Files
junhong_cmp_fiber/internal/application/packagesilence/resolve.go
邹进 909f4d0ec2
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
feat: 实名即生效套餐沉默期、延期办理与资产出库时间
- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:32:58 +09:00

329 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package packagesilence
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Activator 是沉默期结束后按现有逻辑激活(或继续排队)套餐的接缝,由旧激活服务实现。
type Activator interface {
ActivateSpecificPackage(ctx context.Context, packageUsageID uint) error
}
// Resolver 是沉默期统一判定用例 ResolveUsageSilence(技术方案 §5.2)。
// 所有入口(实名、绑卡、排队、指定激活、孤儿扫描、延期、调整审批、扫描)只调用它,不自写判定。
type Resolver struct {
db *gorm.DB
sw *Switch
audit *audit.Writer
activator Activator
logger *zap.Logger
now func() time.Time
}
// NewResolver 创建沉默期判定用例。
func NewResolver(db *gorm.DB, sw *Switch, writer *audit.Writer, logger *zap.Logger) *Resolver {
if logger == nil {
logger = zap.NewNop()
}
return &Resolver{db: db, sw: sw, audit: writer, logger: logger, now: time.Now}
}
// SetActivator 注入激活接缝(避免与旧激活服务循环构造)。
func (r *Resolver) SetActivator(activator Activator) {
r.activator = activator
}
// Switch 返回总开关读取器。
func (r *Resolver) Switch() *Switch {
return r.sw
}
// ResolveResult 是一次判定的结果。
type ResolveResult struct {
Decision packagedomain.SilenceDecision
Changed bool // 本次是否写入了状态变化
Status string // 判定后的 silence_status(空表示不适用)
}
// Blocked 表示套餐沉默期已过期,不能激活。
func (r ResolveResult) Blocked() bool {
return r.Status == constants.SilenceStatusExpired
}
// ResolveOptions 控制判定行为。
type ResolveOptions struct {
Trigger string
// AllowExpire 为 false 时只记录"期内实名结束",不判过期(总开关关闭时)。
AllowExpire bool
}
// ResolveInTx 在调用方事务内对一笔套餐记录执行判定:加锁、读实名事实、条件更新、写流水与审计。
func (r *Resolver) ResolveInTx(ctx context.Context, tx *gorm.DB, usageID uint, options ResolveOptions) (ResolveResult, error) {
var usage model.PackageUsage
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", usageID).First(&usage).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return ResolveResult{Decision: packagedomain.SilenceDecisionNoop}, nil
}
return ResolveResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐记录失败")
}
return r.resolveLocked(ctx, tx, &usage, options)
}
// Resolve 用独立事务执行判定;结束沉默期时提交后按现有逻辑激活或排队。
func (r *Resolver) Resolve(ctx context.Context, usageID uint, options ResolveOptions) (ResolveResult, error) {
var result ResolveResult
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var resolveErr error
result, resolveErr = r.ResolveInTx(ctx, tx, usageID, options)
return resolveErr
})
if err != nil {
return result, err
}
r.activateIfEnded(ctx, usageID, result)
return result, nil
}
// ResolveAndActivate 判定后,若沉默期已结束(期内实名)则调用激活接缝(排队中的保持排队)。
// 用于扫描、延期生效、调整审批结束等"判定后立即激活"的入口。
func (r *Resolver) ResolveAndActivate(ctx context.Context, usageID uint, options ResolveOptions) (ResolveResult, error) {
result, err := r.Resolve(ctx, usageID, options)
if err != nil {
return result, err
}
if result.Status == constants.SilenceStatusEnded && !result.Changed {
r.activate(ctx, usageID)
}
return result, nil
}
func (r *Resolver) activateIfEnded(ctx context.Context, usageID uint, result ResolveResult) {
if result.Changed && result.Status == constants.SilenceStatusEnded {
r.activate(ctx, usageID)
}
}
func (r *Resolver) activate(ctx context.Context, usageID uint) {
if r.activator == nil {
return
}
if err := r.activator.ActivateSpecificPackage(ctx, usageID); err != nil {
r.logger.Warn("沉默期结束后激活套餐失败,等待后续实名、排队或孤儿扫描接续",
zap.Uint("usage_id", usageID), zap.Error(err))
}
}
func (r *Resolver) resolveLocked(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, options ResolveOptions) (ResolveResult, error) {
status := ""
if usage.SilenceStatus != nil {
status = *usage.SilenceStatus
}
if status != constants.SilenceStatusWaiting || usage.Status != constants.PackageUsageStatusPending {
return ResolveResult{Decision: packagedomain.SilenceDecisionNoop, Status: status}, nil
}
now := r.now()
facts, err := loadRealnameFacts(ctx, tx, usage)
if err != nil {
return ResolveResult{}, err
}
judgement := packagedomain.JudgeSilence(packagedomain.SilenceFacts{
Status: status, DeadlineAt: usage.SilenceDeadlineAt, Realnamed: facts.realnamed,
RealnameAt: facts.realnameAt, AdjustPending: usage.SilenceAdjustPending, Now: now,
})
switch judgement.Decision {
case packagedomain.SilenceDecisionEnd:
return r.applyEnd(ctx, tx, usage, facts, judgement.BackfillRealnameAt, options.Trigger, now)
case packagedomain.SilenceDecisionExpire:
if !options.AllowExpire {
return ResolveResult{Decision: packagedomain.SilenceDecisionWait, Status: status}, nil
}
return r.applyExpire(ctx, tx, usage, options.Trigger, now)
default:
return ResolveResult{Decision: judgement.Decision, Status: status}, nil
}
}
func (r *Resolver) applyEnd(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, facts realnameFacts, backfill bool, trigger string, now time.Time) (ResolveResult, error) {
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where("id = ? AND silence_status = ? AND status = ?", usage.ID, constants.SilenceStatusWaiting, constants.PackageUsageStatusPending).
Updates(map[string]any{"silence_status": constants.SilenceStatusEnded, "updated_at": now})
if result.Error != nil {
return ResolveResult{}, errors.Wrap(errors.CodeDatabaseError, result.Error, "结束套餐沉默期失败")
}
if result.RowsAffected == 0 {
return ResolveResult{Decision: packagedomain.SilenceDecisionNoop, Status: constants.SilenceStatusWaiting}, nil
}
before := copyStringPtr(usage.SilenceStatus)
usage.SilenceStatus = stringPtr(constants.SilenceStatusEnded)
realnameAt := facts.realnameAt
if backfill || len(facts.backfillCardIDs) > 0 {
backfilled, err := backfillRealnameAt(ctx, tx, facts, now)
if err != nil {
return ResolveResult{}, err
}
if backfilled > 0 {
if err := writeRecord(ctx, tx, r.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventRealnameBackfill, Trigger: trigger,
BeforeStatus: before, BeforeDeadline: usage.SilenceDeadlineAt, RealnameAt: &now,
Reason: "资产已实名但实名时间为空,视为期内实名并补写实名时间",
Extra: map[string]any{"backfilled_card_count": backfilled},
}); err != nil {
return ResolveResult{}, err
}
}
if realnameAt == nil {
realnameAt = &now
}
}
if err := writeRecord(ctx, tx, r.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventEnded, Trigger: trigger,
BeforeStatus: before, BeforeDeadline: usage.SilenceDeadlineAt, RealnameAt: realnameAt,
Reason: "截止前已实名,沉默期结束", AuditAction: constants.AuditActionUsageSilenceEnded,
AuditSummary: "套餐沉默期结束(期内实名)",
}); err != nil {
return ResolveResult{}, err
}
return ResolveResult{Decision: packagedomain.SilenceDecisionEnd, Changed: true, Status: constants.SilenceStatusEnded}, nil
}
func (r *Resolver) applyExpire(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, trigger string, now time.Time) (ResolveResult, error) {
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where("id = ? AND silence_status = ? AND status = ? AND silence_adjust_pending = ? AND silence_deadline_at < ?",
usage.ID, constants.SilenceStatusWaiting, constants.PackageUsageStatusPending, false, now).
Updates(map[string]any{"silence_status": constants.SilenceStatusExpired, "silence_expired_at": now, "updated_at": now})
if result.Error != nil {
return ResolveResult{}, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记套餐沉默期过期失败")
}
if result.RowsAffected == 0 {
return ResolveResult{Decision: packagedomain.SilenceDecisionNoop, Status: constants.SilenceStatusWaiting}, nil
}
before := copyStringPtr(usage.SilenceStatus)
usage.SilenceStatus = stringPtr(constants.SilenceStatusExpired)
usage.SilenceExpiredAt = &now
if err := writeRecord(ctx, tx, r.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventExpired, Trigger: trigger,
BeforeStatus: before, BeforeDeadline: usage.SilenceDeadlineAt,
Reason: "沉默期截止仍未实名,套餐失效(沉默期已过期)", AuditAction: constants.AuditActionUsageSilenceExpired,
AuditSummary: "套餐沉默期过期",
}); err != nil {
return ResolveResult{}, err
}
return ResolveResult{Decision: packagedomain.SilenceDecisionExpire, Changed: true, Status: constants.SilenceStatusExpired}, nil
}
// realnameFacts 是判定所需的资产实名事实(技术方案 §5.1)。
type realnameFacts struct {
realnamed bool
realnameAt *time.Time
backfillCardIDs []uint // 已实名但实名时间为空、需要补写的卡
}
type boundCardRow struct {
CardID uint `gorm:"column:card_id"`
RealNameStatus int `gorm:"column:real_name_status"`
FirstRealnameAt *time.Time `gorm:"column:first_realname_at"`
BindTime *time.Time `gorm:"column:bind_time"`
BindCreatedAt time.Time `gorm:"column:bind_created_at"`
}
// loadRealnameFacts 读取实名事实:
// - 卡:first_realname_at;已实名但时间为空时视为期内实名并补写。
// - 设备:所有已绑定且已实名卡中 max(卡实名时间, 绑定时间) 的最小值,使截止后才绑定的已实名卡不能倒推为期内实名;
// 卡实名时间为空时按绑定时间计并补写卡的实名时间。
func loadRealnameFacts(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) (realnameFacts, error) {
if usage.DeviceID > 0 {
var rows []boundCardRow
err := tx.WithContext(ctx).Table("tb_device_sim_binding AS b").
Select("c.id AS card_id, c.real_name_status, c.first_realname_at, b.bind_time, b.created_at AS bind_created_at").
Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL").
Where("b.device_id = ? AND b.bind_status = ? AND b.deleted_at IS NULL", usage.DeviceID, constants.BindStatusBound).
Where("c.real_name_status = ?", constants.RealNameStatusVerified).
Scan(&rows).Error
if err != nil {
return realnameFacts{}, errors.Wrap(errors.CodeDatabaseError, err, "查询设备绑定卡实名事实失败")
}
facts := realnameFacts{}
for _, row := range rows {
facts.realnamed = true
bindAt := row.BindCreatedAt
if row.BindTime != nil {
bindAt = *row.BindTime
}
effective := bindAt
if row.FirstRealnameAt == nil {
facts.backfillCardIDs = append(facts.backfillCardIDs, row.CardID)
} else if row.FirstRealnameAt.After(bindAt) {
effective = *row.FirstRealnameAt
}
if facts.realnameAt == nil || effective.Before(*facts.realnameAt) {
value := effective
facts.realnameAt = &value
}
}
return facts, nil
}
if usage.IotCardID == 0 {
return realnameFacts{}, nil
}
var card model.IotCard
if err := tx.WithContext(ctx).Select("id", "real_name_status", "first_realname_at").
Where("id = ?", usage.IotCardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return realnameFacts{}, nil
}
return realnameFacts{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名事实失败")
}
facts := realnameFacts{realnamed: card.RealNameStatus == constants.RealNameStatusVerified, realnameAt: card.FirstRealnameAt}
if facts.realnamed && card.FirstRealnameAt == nil {
facts.backfillCardIDs = []uint{card.ID}
}
return facts, nil
}
// backfillRealnameAt 为已实名但实名时间为空的卡补写 first_realname_at(只写为空的,返回补写数量)。
func backfillRealnameAt(ctx context.Context, tx *gorm.DB, facts realnameFacts, now time.Time) (int64, error) {
if len(facts.backfillCardIDs) == 0 {
return 0, nil
}
result := tx.WithContext(ctx).Model(&model.IotCard{}).
Where("id IN ? AND first_realname_at IS NULL AND real_name_status = ?", facts.backfillCardIDs, constants.RealNameStatusVerified).
Update("first_realname_at", now)
if result.Error != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, result.Error, "补写卡实名时间失败")
}
return result.RowsAffected, nil
}
// AllowActivationInTx 是激活闸门(技术方案 §5.3):在激活事务内、实名判断前先执行沉默期判定。
// 总开关关闭时只记录"期内实名结束",始终放行(行为与现状一致);打开时沉默期已过期的套餐不能激活。
func (r *Resolver) AllowActivationInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) (bool, error) {
if r == nil || usage == nil || usage.SilenceStatus == nil {
return true, nil
}
enabled := r.sw.Enabled(ctx)
result, err := r.ResolveInTx(ctx, tx, usage.ID, ResolveOptions{Trigger: constants.SilenceTriggerActivation, AllowExpire: enabled})
if err != nil {
return false, err
}
if result.Status != "" {
status := result.Status
usage.SilenceStatus = &status
}
if !enabled {
return true, nil
}
return !result.Blocked(), nil
}