feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s

- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230)
- 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合
- 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试
- 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单
- 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用
- 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计
- tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
This commit is contained in:
2026-09-17 16:39:26 +08:00
parent 70e6b186df
commit d52be16802
54 changed files with 5164 additions and 97 deletions

View File

@@ -0,0 +1,226 @@
package assetautorenewal
import (
"context"
"sort"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// ConfigView 是自动续费配置的读取视图。
type ConfigView struct {
Enabled int `json:"enabled"`
Scope string `json:"scope"`
PackageIDs []uint `json:"package_ids"`
DaysBeforeExpiry int `json:"days_before_expiry"`
ConfigVersion int64 `json:"config_version"`
Updater uint `json:"updater"`
UpdatedAt time.Time `json:"updated_at"`
}
// ConfigRequest 是保存自动续费配置的请求。
type ConfigRequest struct {
Enabled int `json:"enabled"`
Scope string `json:"scope"`
PackageIDs []uint `json:"package_ids"`
DaysBeforeExpiry int `json:"days_before_expiry"`
}
// GetConfig 读取唯一的自动续费配置;仅超级管理员与平台账号可见。
func (s *Service) GetConfig(ctx context.Context) (*ConfigView, error) {
if _, err := requirePlatformOperator(ctx); err != nil {
return nil, err
}
config, err := s.configStore.Get(ctx)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "自动续费配置不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败")
}
return toConfigView(config), nil
}
// SaveConfig 保存自动续费配置:单行事务锁串行化、事务内自增配置版本,并与审计同事务写入。
//
// 保存只影响后续扫描:已产生的尝试记录保留触发时的配置版本快照,不重算。
func (s *Service) SaveConfig(ctx context.Context, request ConfigRequest) (*ConfigView, error) {
operatorID, err := requirePlatformOperator(ctx)
if err != nil {
return nil, err
}
packageIDs, err := normalizeConfigRequest(&request)
if err != nil {
return nil, err
}
if len(packageIDs) > 0 {
if err := s.validateSellableMainPackages(ctx, packageIDs); err != nil {
return nil, err
}
}
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "自动续费配置审计接缝未配置")
}
saved := &model.AssetAutoRenewalConfig{}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
current, lockErr := s.configStore.LockInTx(ctx, tx)
if lockErr != nil {
if lockErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "自动续费配置不存在")
}
return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定自动续费配置失败")
}
before := configSnapshot(current)
saved.Enabled = request.Enabled
saved.Scope = request.Scope
saved.PackageIDs = model.UintJSONBArray(packageIDs)
saved.DaysBeforeExpiry = request.DaysBeforeExpiry
saved.ConfigVersion = current.ConfigVersion + 1
saved.Creator = current.Creator
saved.Updater = operatorID
if saveErr := s.configStore.SaveInTx(ctx, tx, saved, operatorID); saveErr != nil {
return errors.Wrap(errors.CodeDatabaseError, saveErr, "保存自动续费配置失败")
}
if auditErr := s.auditWriter.WriteAssetAutoRenewalConfigChange(ctx, tx, audit.AssetAutoRenewalConfigAudit{
OperatorID: operatorID,
OperationType: constants.AuditOperationAssetAutoRenewalConfigUpdate,
Description: "保存资产钱包自动续费配置",
BeforeData: before,
AfterData: configSnapshot(saved),
RequestID: derefString(middleware.GetRequestIDFromContext(ctx)),
CorrelationID: derefString(middleware.GetRequestIDFromContext(ctx)),
}); auditErr != nil {
return auditErr
}
return nil
})
if err != nil {
return nil, err
}
view := &ConfigView{
Enabled: saved.Enabled, Scope: saved.Scope, PackageIDs: packageIDs,
DaysBeforeExpiry: saved.DaysBeforeExpiry, ConfigVersion: saved.ConfigVersion,
Updater: saved.Updater, UpdatedAt: s.now(),
}
s.logger.Info("资产钱包自动续费配置已保存",
zap.Int("enabled", view.Enabled), zap.String("scope", view.Scope),
zap.Int("days_before_expiry", view.DaysBeforeExpiry), zap.Int64("config_version", view.ConfigVersion))
return view, nil
}
// derefString 安全解引用可空字符串,供审计上下文可选字段复用。
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
// requirePlatformOperator 复核调用者仅限超级管理员与平台账号,并返回其账号 ID。
//
// 路由组已做粗粒度门禁这里在业务边界再复核一次账号类型ENG-AUTHZ-001
// 代理、企业与个人客户一律按「无权限或不存在」统一拒绝,不形成可枚举差异。
func requirePlatformOperator(ctx context.Context) (uint, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return operatorID, nil
}
// normalizeConfigRequest 归一化并校验保存请求,返回去重升序的指定套餐集合。
func normalizeConfigRequest(request *ConfigRequest) ([]uint, error) {
if request.Enabled != constants.AssetAutoRenewalConfigEnabledOff &&
request.Enabled != constants.AssetAutoRenewalConfigEnabledOn {
return nil, errors.New(errors.CodeInvalidParam, "自动续费总开关取值非法")
}
if request.Scope != constants.AssetAutoRenewalScopeAll && request.Scope != constants.AssetAutoRenewalScopeSpecified {
return nil, errors.New(errors.CodeInvalidParam, "自动续费适用范围取值非法")
}
if request.DaysBeforeExpiry < constants.AssetAutoRenewalMinDaysBeforeExpiry ||
request.DaysBeforeExpiry > constants.AssetAutoRenewalMaxDaysBeforeExpiry {
return nil, errors.New(errors.CodeInvalidParam, "自动续费到期前天数必须在 1 至 90 之间")
}
seen := make(map[uint]struct{}, len(request.PackageIDs))
packageIDs := make([]uint, 0, len(request.PackageIDs))
for _, packageID := range request.PackageIDs {
if packageID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "自动续费指定套餐包含无效 ID")
}
if _, exists := seen[packageID]; exists {
continue
}
seen[packageID] = struct{}{}
packageIDs = append(packageIDs, packageID)
}
sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] })
if request.Scope == constants.AssetAutoRenewalScopeSpecified && len(packageIDs) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "指定范围必须至少选择一个主套餐")
}
if request.Scope == constants.AssetAutoRenewalScopeAll {
packageIDs = nil
}
return packageIDs, nil
}
// validateSellableMainPackages 校验指定集合只能选择当前可售主套餐。
//
// 可售口径与购买校验的平台分支一致:套餐为正式套餐、全局启用且上架。
// 运行时不因后来下架而拒绝(交由续费豁免判定),因此下架只在此处拦截配置保存。
func (s *Service) validateSellableMainPackages(ctx context.Context, packageIDs []uint) error {
packages, err := s.loadPackagesByIDs(ctx, packageIDs)
if err != nil {
return err
}
for _, packageID := range packageIDs {
pkg, exists := packages[packageID]
if !exists {
return errors.New(errors.CodeInvalidParam, "指定套餐不存在")
}
if pkg.PackageType != constants.PackageTypeFormal {
return errors.New(errors.CodeInvalidParam, "指定范围只能选择主套餐")
}
if pkg.Status != constants.StatusEnabled {
return errors.New(errors.CodeInvalidParam, "指定套餐已禁用")
}
if pkg.ShelfStatus != constants.ShelfStatusOn {
return errors.New(errors.CodeInvalidParam, "指定套餐已下架")
}
}
return nil
}
// configSnapshot 生成配置前后值快照,字段口径固定,便于审计比对。
func configSnapshot(config *model.AssetAutoRenewalConfig) map[string]any {
return map[string]any{
"enabled": config.Enabled,
"scope": config.Scope,
"package_ids": []uint(config.PackageIDs),
"days_before_expiry": config.DaysBeforeExpiry,
"config_version": config.ConfigVersion,
}
}
func toConfigView(config *model.AssetAutoRenewalConfig) *ConfigView {
return &ConfigView{
Enabled: config.Enabled,
Scope: config.Scope,
PackageIDs: []uint(config.PackageIDs),
DaysBeforeExpiry: config.DaysBeforeExpiry,
ConfigVersion: config.ConfigVersion,
Updater: config.Updater,
UpdatedAt: config.UpdatedAt,
}
}

View File

@@ -0,0 +1,222 @@
package assetautorenewal
import (
"context"
"strconv"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// assetAutoRenewalResumePayloadVersion 是自动续费复机事件的载荷版本。
const assetAutoRenewalResumePayloadVersion = 1
// resumePayload 是自动续费复机事件的载荷,只携带尝试与资产标识,消费者按尝试 ID 认领执行权。
type resumePayload struct {
AttemptID uint `json:"attempt_id"`
AssetType string `json:"asset_type"`
AssetID uint `json:"asset_id"`
}
// AppendResumeRequested 在续费事务内幂等写入复机事件。
//
// 事件 ID 由尝试记录 ID 派生:续费成功与复机状态同事务写入,重复投递不会创建第二个事件
// ENG-OUTBOX-001。调用方必须已确认可复机条件成立本函数不做条件判定。
func AppendResumeRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, attempt *model.AssetAutoRenewalAttempt) error {
if repository == nil {
return gorm.ErrInvalidDB
}
if attempt == nil || attempt.ID == 0 {
return gorm.ErrInvalidData
}
value := strconv.FormatUint(uint64(attempt.ID), 10)
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: outboxid.Stable(constants.OutboxEventTypeAssetAutoRenewalResumeRequested+":", value),
EventType: constants.OutboxEventTypeAssetAutoRenewalResumeRequested,
PayloadVersion: assetAutoRenewalResumePayloadVersion,
AggregateType: "asset_auto_renewal_attempt",
AggregateID: value,
ResourceType: attempt.AssetType,
ResourceID: strconv.FormatUint(uint64(attempt.AssetID), 10),
BusinessKey: constants.OutboxEventTypeAssetAutoRenewalResumeRequested + ":" + value,
Payload: resumePayload{
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
},
})
return err
}
// ResumeConsumer 把自动续费复机事件转成一次复机动作。
type ResumeConsumer struct {
service *Service
}
// NewResumeConsumer 创建自动续费复机事件消费者。
func NewResumeConsumer(service *Service) *ResumeConsumer {
return &ResumeConsumer{service: service}
}
// Consume 按尝试记录认领执行权后执行复机;重复投递由认领字段兜住,不会产生第二次外部调用。
func (c *ResumeConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.service == nil {
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行能力未配置")
}
if envelope.EventType != constants.OutboxEventTypeAssetAutoRenewalResumeRequested {
return outbox.Permanent(gorm.ErrInvalidData)
}
if envelope.PayloadVersion != assetAutoRenewalResumePayloadVersion {
return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷版本不受支持"))
}
var payload resumePayload
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return outbox.Permanent(errors.Wrap(errors.CodeInvalidParam, err, "自动续费复机事件载荷格式错误"))
}
if payload.AttemptID == 0 {
return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷不完整"))
}
// 消费者不经过计划任务入口必须自带操作者与来源否则失败审计会因审计上下文缺失被拒fail-closed
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.OutboxEventTypeAssetAutoRenewalResumeRequested,
ActorName: "资产钱包自动续费复机结果消费者", Source: constants.AuditSourceWorker,
CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID,
})
return c.service.ExecuteResume(ctx, payload.AttemptID)
}
// ExecuteResume 认领并执行一次自动续费复机,回写尝试记录的复机状态、外部交互号与失败原因。
//
// 「回写复机终态 + 投递失败通知 + 失败审计」在同一个短事务内闭合:任一失败整体回滚,
// 记录退回「已投递且已提交」,由恢复扫描按只读查询继续收敛,因此通知不会因一次写入抖动而永久丢失。
// 复机失败或结果未知时绝不回滚续费事实:订单、套餐生效与钱包扣款保持已提交状态。
func (s *Service) ExecuteResume(ctx context.Context, attemptID uint) error {
if s.resume == nil {
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
}
attempt, err := s.attemptStore.Load(ctx, attemptID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费尝试记录失败")
}
if attempt.ResumeStatus != constants.AssetAutoRenewalResumeStatusRequested {
// 已收敛或未投递复机:重复投递与非复机尝试都按幂等结束。
return nil
}
claimed, err := s.attemptStore.ClaimResumeSubmission(ctx, attemptID, s.now())
if err != nil {
return err
}
if !claimed {
// 认领已被占用:可能是并发重复投递,也可能是上次「已调用但未回写」的进程中断。
// 两种情况都不得再次调用运营商,留给恢复扫描按只读查询收敛。
s.logger.Info("自动续费复机已被并发执行,跳过重复调用", zap.Uint("attempt_id", attemptID))
return nil
}
outcome, resumeErr := s.resume.ResumeAssetForAutoRenewal(ctx, attempt.AssetType, attempt.AssetID)
status := constants.AssetAutoRenewalResumeStatusUnknown
reason := outcome.SafeReason
switch {
case !outcome.Applied:
// 判定在执行时已不成立:按跳过记录,不通知,也不改写任何续费事实。
status = constants.AssetAutoRenewalResumeStatusSkipped
reason = ""
case outcome.Result == constants.AuditResultSuccess:
status = constants.AssetAutoRenewalResumeStatusSucceeded
reason = ""
case outcome.Result == constants.AuditResultFailed:
status = constants.AssetAutoRenewalResumeStatusFailed
reason = resumeFailureDetail(outcome.SafeReason)
default:
status = constants.AssetAutoRenewalResumeStatusUnknown
reason = resumeFailureDetail(outcome.SafeReason)
}
if err := s.finalizeResumeOutcome(ctx, attempt, status, reason, outcome.IntegrationID); err != nil {
return err
}
if resumeErr != nil {
s.logger.Warn("自动续费复机执行未确认完成",
zap.Uint("attempt_id", attemptID), zap.String("result", outcome.Result), zap.Error(resumeErr))
}
return nil
}
// finalizeResumeOutcome 在同一短事务内回写复机终态;确认失败时同事务投递通知并写失败审计。
func (s *Service) finalizeResumeOutcome(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, status int, reason, integrationID string) error {
expected := []int{constants.AssetAutoRenewalResumeStatusRequested}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updated, err := s.attemptStore.MarkResumeOutcomeInTx(ctx, tx, attempt.ID, expected, status, integrationID, reason)
if err != nil {
return err
}
if !updated {
// 已被并发收敛:不重复投递通知与审计。
s.logger.Info("自动续费复机结果已被并发收敛,跳过通知与审计", zap.Uint("attempt_id", attempt.ID))
return nil
}
if status != constants.AssetAutoRenewalResumeStatusFailed {
return nil
}
if err := s.appendFailureNotifications(ctx, tx, failureNotification{
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
Identifier: s.assetIdentifier(ctx, attempt.AssetType, attempt.AssetID),
ShopID: attempt.ShopID, CustomerID: attempt.CustomerID,
TriggerDate: attempt.TriggerDate, Reason: constants.AssetAutoRenewalFailureResumeFailed,
PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt,
}); err != nil {
return err
}
return s.appendResumeFailureAudit(ctx, tx, attempt, reason)
})
}
// appendResumeFailureAudit 在复机失败终态事务内写统一审计,主资源为本次尝试记录。
func (s *Service) appendResumeFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason string) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
}
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费复机失败",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed,
ErrorSummary: reason,
CorrelationID: attemptID,
Metadata: map[string]any{
"asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
"failure_kind": constants.AssetAutoRenewalFailureResumeFailed,
},
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
DisplayName: "自动续费尝试 " + attemptID,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
"resume_status": constants.AssetAutoRenewalResumeStatusFailed,
"failure_reason": constants.AssetAutoRenewalFailureResumeFailed,
},
BeforeData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusRequested},
AfterData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusFailed},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
}
// resumeFailureDetail 组装可安全展示的复机失败原因,不写渠道报文原文。
func resumeFailureDetail(safeReason string) string {
if safeReason == "" {
return "复机结果确认为失败"
}
return safeReason
}

View File

@@ -0,0 +1,136 @@
package assetautorenewal
import (
"context"
"fmt"
"strconv"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// failureNotification 是一条失败通知事件所需冻结的事实。
type failureNotification struct {
AttemptID uint
AssetType string
AssetID uint
Identifier string
ShopID *uint
CustomerID uint
TriggerDate time.Time
Reason string
PackageName string
FinalExpiresAt *time.Time
}
// appendFailureNotifications 在调用方事务内为当前个人客户与资产所属店铺各写一条幂等通知事件。
//
// 每日至多一条由上锁的两个条件推出:同一资产同一自然日至多一次尝试,且幂等键内嵌资产类型与资产 ID、
// 上海自然日、原因类型与接收人。资产所属店铺当时无有效业务员时不阻断:店铺接收人由既有店铺解析
// 在投递期完成,解析为空列表即正常结束,不影响续费事实与尝试记录;资产无店铺归属时只创建客户通知。
// 非通知原因(如占位中断收敛)一律不投递,未登记原因按 fail-closed 处理。
func (s *Service) appendFailureNotifications(ctx context.Context, tx *gorm.DB, request failureNotification) error {
if s.outbox == nil {
return errors.New(errors.CodeInvalidStatus, "自动续费通知 Outbox 未配置")
}
if !constants.IsAssetAutoRenewalNotifiableFailureReason(request.Reason) {
s.logger.Warn("自动续费失败原因不属于通知口径,已跳过通知投递",
zap.Uint("attempt_id", request.AttemptID), zap.String("reason", request.Reason))
return nil
}
templateData := map[string]string{
"asset_identifier": request.Identifier,
"package_name": request.PackageName,
"failure_reason": constants.GetAssetAutoRenewalFailureReasonName(request.Reason),
"expiry_date": formatShanghaiDate(request.FinalExpiresAt, s.now()),
}
assetIDText := strconv.FormatUint(uint64(request.AssetID), 10)
// 资源引用按资产类型选择既有可跳转目标:卡用 iot_card 详情、设备用 device 详情
// (两者都在 internal/query/notification/target.go 的目标定义里idTarget + 可用性复核),
// 使店铺/业务员点开通知能进入对应资产详情,而不是落到无目标类型。
refType := assetRefType(request.AssetType)
expiresAt := request.FinalExpiresAt
if expiresAt == nil {
fallback := s.now().UTC()
expiresAt = &fallback
}
if request.CustomerID > 0 {
eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "c", request.CustomerID)
_, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10),
ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID,
Payload: notificationapp.PersonalCustomerDirectPayload{
RecipientID: request.CustomerID, NotificationType: constants.NotificationTypeAssetAutoRenewalFailed,
TemplateData: templateData, RefType: refType, RefID: assetIDText,
ExpiresAt: expiresAt,
},
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费客户通知事件失败")
}
}
if request.ShopID == nil || *request.ShopID == 0 {
return nil
}
eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "shop", *request.ShopID)
_, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10),
ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID,
Payload: notificationapp.AdminDynamicPayload{
TargetKind: constants.NotificationTargetKindShop, TargetID: *request.ShopID,
NotificationType: constants.NotificationTypeAssetAutoRenewalFailed,
TemplateData: templateData, RefType: refType, RefID: assetIDText,
ExpiresAt: expiresAt,
},
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费店铺通知事件失败")
}
return nil
}
// assetRefType 把资产类型映射为可跳转的通知引用类型(卡片详情 / 设备详情)。
func assetRefType(assetType string) string {
if assetType == constants.AssetWalletResourceTypeDevice {
return constants.NotificationRefTypeDevice
}
return constants.NotificationRefTypeIotCard
}
// failureEventID 构造失败通知的稳定幂等键。
//
// 键内嵌资产类型与资产 ID、上海自然日、原因类型与接收人复机失败沿用该次尝试的日期键
// 因此同一尝试只通知一次且不跨日新增。超长时由 outboxid.Stable 追加稳定摘要,仍保持唯一。
func failureEventID(assetType string, assetID uint, triggerDate time.Time, reason, recipientKind string, recipientID uint) string {
dateKey := triggerDate.In(shanghaiLocation).Format("20060102")
return outboxid.Stable("aar:", fmt.Sprintf("%s:%d:%s:%s:%s:%d",
assetCode(assetType), assetID, dateKey, reason, recipientKind, recipientID))
}
// assetCode 把资产类型压缩为单字母代码,只为把幂等键长度压进 Outbox 预算。
func assetCode(assetType string) string {
if assetType == constants.AssetWalletResourceTypeDevice {
return "d"
}
return "c"
}
// formatShanghaiDate 把业务到期时间格式化为上海自然日文本,供通知模板与展示期使用。
func formatShanghaiDate(value *time.Time, fallback time.Time) string {
target := fallback
if value != nil {
target = *value
}
return target.In(shanghaiLocation).Format("2006-01-02")
}

View File

@@ -0,0 +1,108 @@
package assetautorenewal
import (
"context"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RecoveryResult 是一次复机结果恢复扫描的可观察结果。
//
// Scanned 为扫到的未收敛尝试数Confirmed 为本次回填为已确认结果的尝试数;
// Pending 为结果仍未确认、等待下次扫描的尝试数Anomaly 为超过查询窗口仍不可确认、
// 本次标记转人工的尝试数。
type RecoveryResult struct {
Scanned int
Confirmed int
Pending int
Anomaly int
}
// RecoverResumeResults 扫描未收敛的复机子结果:只查询运营商状态回填,绝不重复发起复机调用。
//
// 收敛口径与既有停复机恢复一致internal/application/carrierthreshold/cycle.go:246-296
// - 查询确认已复机 → 回填成功;
// - 「已知但未复机」或不可判定 → 仍算未确认,等到下一次扫描;
// - 自提交起超过查询窗口仍不可确认 → 标记异常并退出自动扫描转人工核对。
//
// 恢复扫描**不**据此判定「复机失败」:续购后新主套餐多为待生效,卡在此期间本就可能仍处于停机,
// 把「未复机」当失败会发出误报通知。复机失败只由消费者在网关明确返回失败时确认(「仅确认失败才通知」)。
// 单条失败不中断整批,但会作为首个错误返回,交既有任务重试。
func (s *Service) RecoverResumeResults(ctx context.Context) (RecoveryResult, error) {
result := RecoveryResult{}
if s.resume == nil {
return result, errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
}
now := s.now()
attempts, err := s.attemptStore.ScanUnresolvedResumes(ctx, now, constants.AssetAutoRenewalRecoveryBatchSize)
if err != nil {
return result, err
}
result.Scanned = len(attempts)
var firstErr error
for index := range attempts {
if err := s.recoverResumeResult(ctx, &attempts[index], now, &result); err != nil {
s.logger.Warn("自动续费复机结果恢复单条失败",
zap.Uint("attempt_id", attempts[index].ID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
s.logger.Info("自动续费复机结果恢复扫描完成",
zap.Int("scanned", result.Scanned), zap.Int("confirmed", result.Confirmed),
zap.Int("pending", result.Pending), zap.Int("anomaly", result.Anomaly))
return result, firstErr
}
// recoverResumeResult 处理单条未收敛的复机子结果。
func (s *Service) recoverResumeResult(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, now time.Time, result *RecoveryResult) error {
online, known, integrationID, err := s.resume.QueryAutoRenewalResumeState(ctx, attempt.AssetType, attempt.AssetID)
if err != nil || !known || !online {
// 查询失败、状态不可判定、或已知仍未复机:一律按「仍未确认」处理,
// 绝不误判为失败终态,也绝不据此发出失败通知。
result.Pending++
if !expiredResumeQueryWindow(attempt.ResumeSubmittedAt, now) {
return nil
}
marked, markErr := s.attemptStore.MarkResumeAnomaly(ctx, attempt.ID,
"复机结果超过确认窗口仍不可查,请人工核对")
if markErr != nil {
return markErr
}
if marked {
result.Anomaly++
s.logger.Warn("自动续费复机结果超期不可确认,已标记异常转人工",
zap.Uint("attempt_id", attempt.ID), zap.String("asset_type", attempt.AssetType),
zap.Uint("asset_id", attempt.AssetID))
}
return nil
}
expected := []int{
constants.AssetAutoRenewalResumeStatusRequested,
constants.AssetAutoRenewalResumeStatusUnknown,
}
marked, markErr := s.attemptStore.MarkResumeOutcome(ctx, attempt.ID, expected,
constants.AssetAutoRenewalResumeStatusSucceeded, integrationID, "")
if markErr != nil {
return markErr
}
if marked {
result.Confirmed++
}
return nil
}
// expiredResumeQueryWindow 判断复机子任务自提交起是否已超过自动查询窗口。
// 未提交(提交认领时刻为空)表示尚未发起复机,不算超期。
func expiredResumeQueryWindow(submittedAt *time.Time, now time.Time) bool {
if submittedAt == nil {
return false
}
return now.Sub(*submittedAt) >= constants.AssetAutoRenewalResumeQueryWindow
}

View File

@@ -0,0 +1,809 @@
package assetautorenewal
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
"github.com/break/junhong_cmp_fiber/internal/model"
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// renewalHalt 是执行事务内重读资格事实后必须终止本次尝试的可安全记录原因。
//
// 它作为事务闭包的返回错误使已取得行锁的续费事务整体回滚(此时尚未写入任何资金与订单事实),
// 再由调用方在独立短事务中落终态、投递通知与写审计。
// FailureReason 与 SkipReason 恰有一个非空:非空失败原因触发通知,跳过原因不触发通知。
type renewalHalt struct {
FailureReason string
SkipReason string
Detail string
}
// Error 实现 error使事务闭包能把终止信号回传给调用方。
func (h *renewalHalt) Error() string {
if h.SkipReason != "" {
return "自动续费跳过:" + constants.GetAssetAutoRenewalSkipReasonName(h.SkipReason)
}
return "自动续费未执行:" + constants.GetAssetAutoRenewalFailureReasonName(h.FailureReason)
}
// renewalFacts 是一次续费执行成功后用于运行日志的关键事实。
type renewalFacts struct {
RenewPrice int64
OrderID uint
OrderNo string
}
// executeRenewal 在单个事务内闭合一次续购:先锁资产钱包行、后锁资产载体行,锁后重读全部资格事实,
// 再扣可用余额、建订单与明细、写已支付支付记录、写钱包流水、激活套餐、写佣金与观测 Outbox、
// 更新尝试记录为成功并写成功审计。任一步失败整体回滚,不存在部分成功状态。
//
// windowDays 是本次扫描使用的配置窗口,必须传入实际配置值:窗口是触发条件而不是资格不变式,
// 用常量上限会让「人工已把最终到期推远」被误判为失败。
//
// 返回 halt 表示重读后应落失败或跳过终态(事务已回滚且未写入任何事实);返回 err 表示事务失败。
func (s *Service) executeRenewal(ctx context.Context, candidate assetquery.Candidate, attempt *model.AssetAutoRenewalAttempt, windowDays int) (*renewalFacts, *renewalHalt, error) {
if s.outbox == nil {
return nil, nil, errors.New(errors.CodeInvalidStatus, "自动续费 Outbox 未配置")
}
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, candidate.AssetType, candidate.AssetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "资产钱包不存在,无法以可用余额续购",
}, nil
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "读取资产钱包失败")
}
facts := &renewalFacts{}
var halt *renewalHalt
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 锁序固定为「先资产钱包行、后资产载体行」,与人工路径的「先冻结钱包、后激活套餐」一致,
// 避免与人工事务的锁序反转形成死锁。
lockedWallet, lockErr := s.assetWalletStore.LockByIDWithTx(ctx, tx, wallet.ID)
if lockErr != nil {
return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定资产钱包失败")
}
if carrierErr := s.lockCarrier(ctx, tx, candidate.AssetType, candidate.AssetID); carrierErr != nil {
return carrierErr
}
// 锁后重读:最终到期、当前主套餐、待生效主套餐、可售续费价与可用余额都以重读结果为准。
execution, execErr := s.rereadUnderLock(ctx, tx, candidate, lockedWallet, windowDays)
if execErr != nil {
var halted *renewalHalt
if asRenewalHalt(execErr, &halted) {
halt = halted
return execErr
}
return execErr
}
if err := s.writeRenewalFacts(ctx, tx, execution, attempt, facts); err != nil {
return err
}
return nil
})
if err != nil {
if halt != nil {
return nil, halt, nil
}
return nil, nil, err
}
return facts, nil, nil
}
// executionPlan 是锁后重读得到的执行输入。
type executionPlan struct {
candidate assetquery.Candidate
asset *assetSnapshot
wallet *model.AssetWallet
pkg *model.Package
sellerShop *uint
price int64
costPrice int64
}
// assetSnapshot 是执行事务内锁定的资产事实。
type assetSnapshot struct {
assetType string
assetID uint
identifier string
shopID *uint
seriesID *uint
generation int
}
// rereadUnderLock 在行锁内重读全部资格事实,并给出可执行或必须终止的判断。
//
// 判定顺序体现「资格不变式先于触发条件」:
// 1. 先判资格不变式——已存在待生效主套餐即「人工已完成续购 / 不叠加周期」,无论最终到期被推到多远
// 都 MUST 跳过(规格 Requirement 8绝不退化为「不可续费」失败与错误通知
// 2. 再判窗口与推算(触发条件)——不在窗口或推算不再明确,才是「当前条件不允许自动续购」。
//
// 之后依次判在途人工订单、钱包状态、可售续费价与可用余额。
func (s *Service) rereadUnderLock(ctx context.Context, tx *gorm.DB, candidate assetquery.Candidate, wallet *model.AssetWallet, windowDays int) (*executionPlan, error) {
asset, err := s.lockAndSnapshotAsset(ctx, tx, candidate.AssetType, candidate.AssetID)
if err != nil {
return nil, err
}
inTxQuery := s.candidates.WithDB(tx)
// 第 1 步:资格不变式(先于窗口判定)。
state, err := inTxQuery.MainUsageStateOf(ctx, candidate.AssetType, candidate.AssetID)
if err != nil {
return nil, err
}
if state.HasPendingMainPackage {
return nil, &renewalHalt{
SkipReason: constants.AssetAutoRenewalSkipManualRenewed,
Detail: "锁后重读发现该资产已存在待生效主套餐",
}
}
if state.CurrentPackageID == 0 {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "锁后重读未找到当前主套餐商品",
}
}
// 第 2 步:触发条件(窗口与推算口径),窗口取本次扫描的配置值。
current, err := inTxQuery.Candidate(ctx, candidate.AssetType, candidate.AssetID, windowDays)
if err != nil {
return nil, err
}
if current == nil {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "锁后重读最终到期已不在触发窗口或推算结果不再明确",
}
}
inFlight, err := inTxQuery.OpenManualMainPackageOrder(ctx, wallet.ID, candidate.AssetType, candidate.AssetID)
if err != nil {
return nil, err
}
if inFlight {
return nil, &renewalHalt{
SkipReason: constants.AssetAutoRenewalSkipManualOrderPending,
Detail: "锁后重读发现该资产存在未关闭的个人资产钱包主套餐订单",
}
}
if wallet.Status != constants.AssetWalletStatusNormal {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "资产钱包当前不可用于扣款",
}
}
price, pkg, sellerShop, costPrice, err := s.resolveExecutablePrice(ctx, candidate.AssetType, candidate.AssetID, current.CurrentPackageID)
if err != nil {
var halted *renewalHalt
if asRenewalHalt(err, &halted) {
return nil, halted
}
return nil, err
}
if wallet.GetAvailableBalance() < price {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureInsufficientBalance,
Detail: "资产钱包可用余额小于执行时当前可售续费价",
}
}
return &executionPlan{
candidate: *current, asset: asset, wallet: wallet, pkg: pkg,
sellerShop: sellerShop, price: price, costPrice: costPrice,
}, nil
}
// resolveExecutablePrice 复用应用层购买校验与价格策略取得可续费判定与执行时续费价。
//
// 校验入口是个人卡/设备购买校验(含续费豁免下架与生效零售价、成本价比较),
// 绝不依赖 handler 层续费价实现;任何校验失败都归一为「不可续费」并保留可安全记录的说明。
func (s *Service) resolveExecutablePrice(ctx context.Context, assetType string, assetID, renewPackageID uint) (int64, *model.Package, *uint, int64, error) {
if s.purchaseValidation == nil {
return 0, nil, nil, 0, errors.New(errors.CodeServiceUnavailable, "购买校验能力未配置")
}
packageIDs := []uint{renewPackageID}
var result *purchase_validation.PurchaseValidationResult
var err error
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
result, err = s.purchaseValidation.ValidatePersonalCardPurchase(ctx, assetID, packageIDs)
case constants.AssetWalletResourceTypeDevice:
result, err = s.purchaseValidation.ValidatePersonalDevicePurchase(ctx, assetID, packageIDs)
default:
return 0, nil, nil, 0, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
if err != nil {
return 0, nil, nil, 0, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "当前条件不允许自动续购:" + purchaseValidationReason(err),
}
}
if len(result.Packages) == 0 {
return 0, nil, nil, 0, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "当前条件不允许自动续购:未解析到可售续费套餐",
}
}
var sellerShop uint
if result.Card != nil && result.Card.ShopID != nil {
sellerShop = *result.Card.ShopID
}
if result.Device != nil && result.Device.ShopID != nil {
sellerShop = *result.Device.ShopID
}
costPrice := int64(0)
if sellerShop > 0 {
resolved, costErr := s.purchaseValidation.GetCostPrice(ctx, result.Packages[0], sellerShop)
if costErr != nil {
return 0, nil, nil, 0, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "当前条件不允许自动续购:渠道成本价不可读",
}
}
costPrice = resolved
}
if result.TotalPrice <= 0 {
return 0, nil, nil, 0, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "当前条件不允许自动续购:生效续费价异常",
}
}
var sellerShopPtr *uint
if sellerShop > 0 {
sellerShopPtr = &sellerShop
}
return result.TotalPrice, result.Packages[0], sellerShopPtr, costPrice, nil
}
// writeRenewalFacts 在同一事务内闭合扣款、订单、支付、钱包流水、套餐生效、可靠事件、尝试记录与审计。
func (s *Service) writeRenewalFacts(ctx context.Context, tx *gorm.DB, plan *executionPlan, attempt *model.AssetAutoRenewalAttempt, facts *renewalFacts) error {
now := s.now()
wallet := plan.wallet
if err := s.assetWalletStore.DeductBalanceWithTx(ctx, tx, wallet.ID, plan.price, wallet.Version); err != nil {
return errors.Wrap(errors.CodeConflict, err, "资产钱包扣款失败")
}
order, item, err := s.buildRenewalOrder(ctx, tx, plan, now)
if err != nil {
return err
}
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单失败")
}
item.OrderID = order.ID
if err := tx.WithContext(ctx).Create(item).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单明细失败")
}
payment := &model.Payment{
PaymentNo: order.OrderNo,
OrderID: order.ID,
OrderType: model.PaymentOrderTypePackage,
PaymentMethod: model.PaymentByWallet,
Amount: plan.price,
Status: model.PaymentRecordStatusPaid,
}
if err := tx.WithContext(ctx).Create(payment).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费支付记录失败")
}
referenceType := constants.ReferenceTypeOrder
walletTransaction := &model.AssetWalletTransaction{
AssetWalletID: wallet.ID,
ResourceType: wallet.ResourceType,
ResourceID: wallet.ResourceID,
UserID: plan.candidate.CustomerID,
TransactionType: constants.AssetTransactionTypeDeduct,
Amount: -plan.price,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance - plan.price,
Status: constants.TransactionStatusSuccess,
ReferenceType: &referenceType,
ReferenceNo: &order.OrderNo,
Creator: plan.candidate.CustomerID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.walletTransactionStore.CreateWithTx(ctx, tx, walletTransaction); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费钱包流水失败")
}
usage, err := s.activateMainPackage(ctx, tx, order, plan, now)
if err != nil {
return err
}
if err := commissiondelivery.AppendCommissionCalculate(ctx, tx, s.outbox, order.ID); err != nil {
return err
}
if s.observationEvents == nil {
return errors.New(errors.CodeInvalidStatus, "自动续费观测 Outbox 未配置")
}
observationID := "asset-auto-renewal:" + strconv.FormatUint(uint64(attempt.ID), 10)
if err := s.observationEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: outboxEventID(observationID), Scene: constants.CardObservationScenePackageChanged,
ResourceType: observationResourceType(plan.asset.assetType), ResourceID: plan.asset.assetID,
SyncTypes: []string{
constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic,
constants.CardObservationSyncTypeNetwork,
},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now.UTC(),
RequestID: observationID, CorrelationID: observationID,
}); err != nil {
return err
}
updates := map[string]any{
"status": constants.AssetAutoRenewalAttemptStatusSucceeded,
"failure_reason": "",
"failure_detail": "",
"skip_reason": "",
"final_expires_at": plan.candidate.FinalExpiresAt,
"current_usage_id": plan.candidate.CurrentUsageID,
"current_package_id": plan.candidate.CurrentPackageID,
"renew_package_id": plan.pkg.ID,
"renew_price": plan.price,
"wallet_id": wallet.ID,
"wallet_transaction_id": walletTransaction.ID,
"deduct_amount": plan.price,
"balance_before": wallet.Balance,
"balance_after": wallet.Balance - plan.price,
"order_id": order.ID,
"order_no": order.OrderNo,
}
// current_usage_id / current_package_id 保留**触发时**解析到的当前主套餐快照(规格 Requirement 7
// 要求「触发时解析」),不覆盖为本次新生成的套餐使用记录;新记录通过 order_id / order_no 追溯,
// 「续购后处于待生效」也可由 usage_after_success 断言直接观察。
resumeReady, resumeReason, err := s.evaluateResumeGate(ctx, plan)
if err != nil {
return err
}
if resumeReady {
updates["resume_status"] = constants.AssetAutoRenewalResumeStatusRequested
updates["resume_failure_reason"] = ""
} else {
updates["resume_status"] = constants.AssetAutoRenewalResumeStatusSkipped
updates["resume_failure_reason"] = resumeReason
}
updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, updates)
if err != nil {
return err
}
if !updated {
return errors.Wrap(errors.CodeConflict, gorm.ErrInvalidData, "续费尝试已非处理中,拒绝重复成功")
}
facts.RenewPrice = plan.price
facts.OrderID = order.ID
facts.OrderNo = order.OrderNo
if resumeReady {
attempt.ResumeStatus = constants.AssetAutoRenewalResumeStatusRequested
if err := AppendResumeRequested(ctx, tx, s.outbox, attempt); err != nil {
return err
}
}
return s.appendRenewalAudit(ctx, tx, plan, attempt, order, payment, wallet, walletTransaction, usage, updates)
}
// evaluateResumeGate 在同一事务内按可复机判定给出复机去向。
//
// 判定只做数据库读取、不持有任何外部 I/O因此可以安全地留在资金事务闭包内ENG-TX-001
// 它也不对资产钱包行或载体行加锁,因此不会与已持有的行锁形成等待。
func (s *Service) evaluateResumeGate(ctx context.Context, plan *executionPlan) (bool, string, error) {
if s.resume == nil {
return false, "", errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
}
ready, reason, err := s.resume.AutoRenewalResumeReady(ctx, plan.asset.assetType, plan.asset.assetID)
if err != nil {
return false, "", err
}
return ready, reason, nil
}
// buildRenewalOrder 组装续购订单与唯一明细:买家恒为当前个人客户,金额为执行时当前可售续费价。
func (s *Service) buildRenewalOrder(ctx context.Context, tx *gorm.DB, plan *executionPlan, now time.Time) (*model.Order, *model.OrderItem, error) {
orderType := model.OrderTypeSingleCard
var iotCardID, deviceID *uint
if plan.asset.assetType == constants.AssetWalletResourceTypeDevice {
orderType = model.OrderTypeDevice
deviceID = &plan.asset.assetID
} else {
iotCardID = &plan.asset.assetID
}
generation := plan.asset.generation
if generation <= 0 {
generation = 1
}
paidAmount := plan.price
operatorAccountID, operatorAccountName := s.personalCustomerOperatorSnapshot(ctx, plan.candidate.CustomerID)
order := &model.Order{
BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID},
OrderNo: s.orderStore.GenerateOrderNo(), OrderType: orderType,
BuyerType: model.BuyerTypePersonal, BuyerID: plan.candidate.CustomerID,
IotCardID: iotCardID, DeviceID: deviceID, AssetIdentifier: plan.asset.identifier,
TotalAmount: plan.price, PaymentMethod: model.PaymentMethodWallet,
PaymentStatus: model.PaymentStatusPaid, PaidAt: &now,
CommissionStatus: model.CommissionStatusPending, CommissionConfigVersion: 0,
Source: constants.OrderSourceClient, Generation: generation, ActualPaidAmount: &paidAmount,
OperatorAccountID: operatorAccountID, OperatorAccountType: model.OperatorAccountTypePersonalCustomer,
OperatorAccountName: operatorAccountName, SellerShopID: plan.sellerShop,
SeriesID: plan.asset.seriesID, SellerCostPrice: plan.costPrice,
}
item := &model.OrderItem{
BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID},
PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, Quantity: 1,
UnitPrice: plan.price, Amount: plan.price,
PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift,
}
return order, item, nil
}
// personalCustomerOperatorSnapshot 读取个人客户昵称作为订单操作者名称快照。
func (s *Service) personalCustomerOperatorSnapshot(ctx context.Context, customerID uint) (*uint, string) {
if customerID == 0 {
return nil, ""
}
customer, err := s.personalCustomerStore.GetByID(ctx, customerID)
if err != nil {
return &customerID, ""
}
return &customerID, customer.Nickname
}
// activateMainPackage 在同一事务内激活续购的主套餐:按既有排队规则决定待生效或立即生效。
//
// 资格前置保证本次执行前不存在待生效主套餐,因此续购最多领先一个周期;
// 只有当当前主套餐在执行前刚好过期时新记录才立即生效,此时按既有规则追加套餐生效优先轮询请求。
func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, plan *executionPlan, now time.Time) (*model.PackageUsage, error) {
terms, err := packagepkg.ResolveTermsFromTx(ctx, tx, plan.pkg, order.SellerShopID)
if err != nil {
return nil, err
}
hasCurrentMain, err := packagepkg.HasCurrentMainPackageForQueue(tx.WithContext(ctx), plan.asset.assetType, plan.asset.assetID, now)
if err != nil {
return nil, err
}
var status, priority int
var activatedAt, expiresAt time.Time
var nextResetAt *time.Time
pendingRealnameActivation := false
if terms.ExpiryBase == constants.PackageExpiryBaseFromActivation {
realnamed, realnameErr := s.isCarrierRealnamed(ctx, tx, plan.asset.assetType, plan.asset.assetID)
if realnameErr != nil {
return nil, realnameErr
}
pendingRealnameActivation = !realnamed
}
if hasCurrentMain {
status = constants.PackageUsageStatusPending
var maxPriority int
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where(carrierColumn(plan.asset.assetType)+" = ?", plan.asset.assetID).
Select("COALESCE(MAX(priority), 0)").Scan(&maxPriority).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐排队优先级失败")
}
priority = maxPriority + 1
} else {
priority = 1
if pendingRealnameActivation {
status = constants.PackageUsageStatusPending
} else {
status = constants.PackageUsageStatusActive
activatedAt = now
expiresAt = packagepkg.CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
nextResetAt = packagepkg.CalculateNextResetTime(plan.pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
}
}
virtualTotalMB, displayGainRatio, enableVirtualData := model.BuildPackageUsageSnapshotValues(plan.pkg)
retailAmount := order.TotalAmount
usage := &model.PackageUsage{
BaseModel: model.BaseModel{Creator: order.Creator, Updater: order.Creator},
OrderID: order.ID, OrderNo: order.OrderNo,
PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, UsageType: order.OrderType,
DataLimitMB: plan.pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMB, DisplayGainRatioSnapshot: displayGainRatio,
EnableVirtualDataSnapshot: enableVirtualData, Status: status, Priority: priority,
DataResetCycle: plan.pkg.DataResetCycle, PendingRealnameActivation: pendingRealnameActivation,
Generation: order.Generation, PaidAmount: &order.SellerCostPrice, RetailAmount: &retailAmount,
PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift,
}
terms.Apply(usage)
if plan.asset.assetType == constants.AssetWalletResourceTypeIotCard {
usage.IotCardID = plan.asset.assetID
} else {
usage.DeviceID = plan.asset.assetID
}
if status == constants.PackageUsageStatusActive {
usage.ActivatedAt = &activatedAt
usage.ExpiresAt = &expiresAt
usage.NextResetAt = nextResetAt
}
if err := tx.WithContext(ctx).Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "写入续费套餐使用记录失败")
}
if err := tx.WithContext(ctx).Model(usage).Updates(map[string]any{
"status": usage.Status, "pending_realname_activation": usage.PendingRealnameActivation,
}).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "写回续费套餐使用记录状态失败")
}
if status != constants.PackageUsageStatusActive {
return usage, nil
}
triggerType, err := packagepkg.ResolveActivationTriggerType(ctx, tx, plan.asset.assetType, plan.asset.assetID, usage.ID)
if err != nil {
return nil, err
}
if err := packagepkg.AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage,
plan.asset.assetType, plan.asset.assetID, triggerType, activatedAt); err != nil {
return nil, err
}
return usage, nil
}
// isCarrierRealnamed 判断载体是否已满足实名激活条件,口径与既有自动购包一致。
func (s *Service) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (bool, error) {
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
var card model.IotCard
if err := tx.WithContext(ctx).Select("real_name_status").First(&card, assetID).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "读取卡实名状态失败")
}
return card.RealNameStatus == constants.RealNameStatusVerified, nil
case constants.AssetWalletResourceTypeDevice:
var count int64
subQuery := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Select("iot_card_id").Where("device_id = ? AND bind_status = ?", assetID, constants.BindStatusBound)
if err := tx.WithContext(ctx).Model(&model.IotCard{}).
Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified).
Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "统计设备实名卡失败")
}
return count > 0, nil
default:
return false, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
}
// lockCarrier 在事务内按资产类型对载体行加行锁,作为与人工路径共享的序列化点。
func (s *Service) lockCarrier(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
var card model.IotCard
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&card, assetID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败")
}
return nil
case constants.AssetWalletResourceTypeDevice:
var device model.Device
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, assetID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败")
}
return nil
default:
return errors.New(errors.CodeInvalidParam, "资产类型无效")
}
}
// lockAndSnapshotAsset 在已有行锁的事务内读取资产快照。
func (s *Service) lockAndSnapshotAsset(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (*assetSnapshot, error) {
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
var card model.IotCard
if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费卡事实失败")
}
if !card.IsStandalone {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "该卡已绑定设备,独立卡维度不执行自动续费",
}
}
return &assetSnapshot{
assetType: constants.AssetWalletResourceTypeIotCard, assetID: card.ID,
identifier: card.ICCID, shopID: card.ShopID, seriesID: card.SeriesID, generation: card.Generation,
}, nil
case constants.AssetWalletResourceTypeDevice:
var device model.Device
if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费设备事实失败")
}
identifier := device.VirtualNo
if identifier == "" {
identifier = device.IMEI
}
return &assetSnapshot{
assetType: constants.AssetWalletResourceTypeDevice, assetID: device.ID,
identifier: identifier, shopID: device.ShopID, seriesID: device.SeriesID, generation: device.Generation,
}, nil
default:
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
}
// carrierColumn 返回套餐使用记录上的资产外键列名。
func carrierColumn(assetType string) string {
if assetType == constants.AssetWalletResourceTypeDevice {
return "device_id"
}
return "iot_card_id"
}
// observationResourceType 把资产类型映射为观测序列的资源类型。
func observationResourceType(assetType string) string {
if assetType == constants.AssetWalletResourceTypeDevice {
return constants.CardObservationResourceTypeDevice
}
return constants.CardObservationResourceTypeCard
}
// asRenewalHalt 从错误中取出终止信号,非终止信号返回 false。
func asRenewalHalt(err error, target **renewalHalt) bool {
halt, ok := err.(*renewalHalt)
if !ok {
return false
}
*target = halt
return true
}
// appendRenewalAudit 在续费事务内写成功审计,资源覆盖尝试记录、订单、钱包、流水与套餐使用记录。
func (s *Service) appendRenewalAudit(
ctx context.Context,
tx *gorm.DB,
plan *executionPlan,
attempt *model.AssetAutoRenewalAttempt,
order *model.Order,
payment *model.Payment,
wallet *model.AssetWallet,
walletTransaction *model.AssetWalletTransaction,
usage *model.PackageUsage,
updates map[string]any,
) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
}
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
DisplayName: "自动续费尝试 " + attemptID,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
"status": constants.AssetAutoRenewalAttemptStatusSucceeded,
"renew_package_id": plan.pkg.ID, "renew_price": plan.price,
"wallet_id": wallet.ID, "wallet_transaction_id": walletTransaction.ID,
"deduct_amount": plan.price, "balance_before": wallet.Balance,
"balance_after": wallet.Balance - plan.price,
"order_id": order.ID, "order_no": order.OrderNo,
"resume_status": updates["resume_status"],
},
BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing},
AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusSucceeded, "failure_reason": ""},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}}
orderResource := audit.OrderResource(order, constants.AuditResourceRelationAffected, constants.AuditResourceRoleAssetAutoRenewalOrder)
resources = append(resources, orderResource)
walletID := strconv.FormatUint(uint64(wallet.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetWallet, ID: &walletID, Key: walletID, DisplayName: "资产钱包 " + walletID,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAutoRenewalWallet,
IdentitySnapshot: map[string]any{
"id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID,
},
BeforeData: map[string]any{"balance": walletTransaction.BalanceBefore},
AfterData: map[string]any{"balance": walletTransaction.BalanceAfter},
})
walletTxID := strconv.FormatUint(uint64(walletTransaction.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetWalletTransaction, ID: &walletTxID, Key: walletTxID,
DisplayName: "资产钱包流水 " + walletTxID,
Relation: constants.AuditResourceRelationAffected,
Role: constants.AuditResourceRoleAssetAutoRenewalWalletTransaction,
IdentitySnapshot: map[string]any{
"id": walletTransaction.ID, "asset_wallet_id": walletTransaction.AssetWalletID,
"resource_type": walletTransaction.ResourceType, "resource_id": walletTransaction.ResourceID,
"transaction_type": walletTransaction.TransactionType,
"reference_no": walletTransaction.ReferenceNo, "status": walletTransaction.Status,
},
AfterData: map[string]any{
"amount": walletTransaction.Amount, "balance_before": walletTransaction.BalanceBefore,
"balance_after": walletTransaction.BalanceAfter,
},
})
resources = append(resources, audit.PaymentResource(payment, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPayment, nil, nil))
if usage != nil {
resources = append(resources, audit.PackageUsageResource(usage,
constants.AuditResourceRelationAffected, constants.AuditResourceRolePackageUsageTarget, nil, nil))
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionAssetAutoRenewalRenewed, Summary: "资产钱包自动续费完成",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: order.OrderNo,
Metadata: map[string]any{
"asset_type": plan.asset.assetType, "asset_id": plan.asset.assetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
},
Resources: resources,
})
}
// appendFailureAudit 在独立短事务内写失败审计跳过终态不写审计由尝试记录本身承载Domain Ledger
func (s *Service) appendFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
}
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费失败",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed,
ErrorCode: strconv.Itoa(reasonCode(reason)), ErrorSummary: detail,
CorrelationID: attemptID,
Metadata: map[string]any{
"asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), "failure_reason": reason,
},
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
DisplayName: "自动续费尝试 " + attemptID,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
"status": constants.AssetAutoRenewalAttemptStatusFailed,
"failure_reason": reason,
},
BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing},
AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusFailed, "failure_reason": reason},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
}
// reasonCode 把失败归类映射为审计错误码位,便于按原因检索失败事件。
func reasonCode(reason string) int {
switch reason {
case constants.AssetAutoRenewalFailureInsufficientBalance:
return 1
case constants.AssetAutoRenewalFailureNotRenewable:
return 2
case constants.AssetAutoRenewalFailureOrderFailed:
return 3
default:
return 0
}
}
// purchaseValidationReason 从购买校验错误中提取可安全记录的说明,不写底层错误细节。
func purchaseValidationReason(err error) string {
if err == nil {
return ""
}
message := err.Error()
switch {
case strings.Contains(message, "套餐已禁用"):
return "套餐商品被禁用"
case strings.Contains(message, "套餐已下架"):
return "当前渠道下架且不满足续费豁免"
case strings.Contains(message, "价格配置异常"):
return "生效零售价低于成本价"
case strings.Contains(message, "可购买范围"), strings.Contains(message, "未关联套餐系列"),
strings.Contains(message, "绑定设备"):
return "不在可购买范围或资产未关联套餐系列"
case strings.Contains(message, "赠送套餐"):
return "赠送套餐不参与自动续购"
default:
return "当前条件不允许自动续购"
}
}
// outboxEventID 把观测事件标识裁剪进 Outbox 的事件 ID 长度预算。
func outboxEventID(value string) string {
return outboxid.Stable("card-observation:", value)
}

View File

@@ -0,0 +1,245 @@
package assetautorenewal
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ScanResult 汇总一次每日扫描的可观察结果。
type ScanResult struct {
Converged int64
Candidates int
Attempted int
Succeeded int
Failed int
Skipped int
Duplicated int
}
// RunDailyScan 执行一次每日自动续费扫描:先收敛历史非终态尝试,再按资产扫描候选并逐项执行。
//
// 只有扫描级失败(配置读取、候选读取、数据库不可用)返回错误交既有任务重试;单个资产执行失败
// 在用例内捕获并落终态后继续处理其余资产。总开关关闭时不创建任何新尝试与订单,既有记录保留。
func (s *Service) RunDailyScan(ctx context.Context) (ScanResult, error) {
result := ScanResult{}
if s.db == nil {
return result, errors.New(errors.CodeServiceUnavailable, "自动续费用例未配置")
}
converged, err := s.attemptStore.ConvergeUnfinished(ctx, s.today())
if err != nil {
return result, err
}
result.Converged = converged
if converged > 0 {
s.logger.Info("自动续费历史非终态尝试已收敛", zap.Int64("converged", converged))
}
config, err := s.configStore.Get(ctx)
if err != nil {
if err == gorm.ErrRecordNotFound {
return result, errors.New(errors.CodeNotFound, "自动续费配置不存在")
}
return result, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败")
}
if config.Enabled != 1 {
s.logger.Info("自动续费总开关关闭,本次扫描不创建尝试与订单")
return result, nil
}
if config.Scope != constants.AssetAutoRenewalScopeAll && config.Scope != constants.AssetAutoRenewalScopeSpecified {
return result, errors.New(errors.CodeInvalidStatus, "自动续费配置范围取值非法")
}
candidates, err := s.candidates.Candidates(ctx, config.DaysBeforeExpiry)
if err != nil {
return result, err
}
result.Candidates = len(candidates)
scope := newScopeMatcher(config)
for _, candidate := range candidates {
if !scope.matches(candidate.CurrentPackageID) {
continue
}
outcome, processErr := s.processCandidate(ctx, candidate, config)
if processErr != nil {
// 单资产失败已落终态,继续处理其余资产;扫描任务本身不因该资产失败而失败。
s.logger.Error("自动续费单资产执行失败,继续处理其余资产",
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID),
zap.Error(processErr))
result.Failed++
continue
}
switch outcome {
case candidateSucceeded:
result.Attempted++
result.Succeeded++
case candidateFailed:
result.Attempted++
result.Failed++
case candidateSkipped:
result.Attempted++
result.Skipped++
default:
result.Duplicated++
}
}
s.logger.Info("自动续费每日扫描完成",
zap.Int64("converged", result.Converged), zap.Int("candidates", result.Candidates),
zap.Int("succeeded", result.Succeeded), zap.Int("failed", result.Failed),
zap.Int("skipped", result.Skipped), zap.Int("duplicated", result.Duplicated))
return result, nil
}
// scanOutcome 是一次候选处理的终态归属,用于汇总扫描结果。
type scanOutcome int
const (
candidateDuplicated scanOutcome = iota
candidateSucceeded
candidateFailed
candidateSkipped
)
// scopeMatcher 表达配置的适用范围:全部主套餐,或指定主套餐集合。
//
// 运行时只按当前主套餐商品是否在集合内判定,不因后来下架而拒绝——下架交给续费豁免判定。
type scopeMatcher struct {
all bool
packageIDs map[uint]struct{}
}
func newScopeMatcher(config *model.AssetAutoRenewalConfig) scopeMatcher {
if config.Scope == constants.AssetAutoRenewalScopeAll {
return scopeMatcher{all: true}
}
ids := make(map[uint]struct{}, len(config.PackageIDs))
for _, packageID := range config.PackageIDs {
ids[packageID] = struct{}{}
}
return scopeMatcher{packageIDs: ids}
}
func (m scopeMatcher) matches(packageID uint) bool {
if m.all {
return true
}
_, exists := m.packageIDs[packageID]
return exists
}
// processCandidate 处理单个候选:占位写入、执行、落终态与通知。
//
// 占位冲突表示该资产当日已尝试,直接跳过且不重复扣款;执行阶段的失败与跳过各以独立短事务落终态。
func (s *Service) processCandidate(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig) (scanOutcome, error) {
triggerDate := s.today()
attempt, err := s.buildAttempt(ctx, candidate, config, triggerDate)
if err != nil {
return candidateFailed, err
}
created, err := s.attemptStore.CreatePlaceholder(ctx, attempt)
if err != nil {
return candidateFailed, err
}
if !created {
s.logger.Info("该资产当日已存在自动续费尝试,跳过",
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID))
return candidateDuplicated, nil
}
facts, halt, err := s.executeRenewal(ctx, candidate, attempt, config.DaysBeforeExpiry)
if halt != nil {
if halt.SkipReason != "" {
return candidateSkipped, s.finalizeSkip(ctx, attempt, halt)
}
return candidateFailed, s.finalizeFailure(ctx, attempt, halt.FailureReason, halt.Detail)
}
if err != nil {
s.logger.Error("自动续费事务失败并已整体回滚",
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID), zap.Error(err))
return candidateFailed, s.finalizeFailure(ctx, attempt, constants.AssetAutoRenewalFailureOrderFailed,
"续购事务执行失败并已整体回滚,未产生订单、扣款与套餐事实")
}
s.logger.Info("自动续费续购成功",
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID),
zap.Uint("order_id", facts.OrderID), zap.String("order_no", facts.OrderNo),
zap.Int64("renew_price", facts.RenewPrice))
return candidateSucceeded, nil
}
// buildAttempt 组装占位尝试记录,冻结触发时的客户、店铺、配置窗口与套餐快照。
func (s *Service) buildAttempt(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig, triggerDate time.Time) (*model.AssetAutoRenewalAttempt, error) {
sequence, err := s.attemptStore.CountByAsset(ctx, candidate.AssetType, candidate.AssetID, triggerDate)
if err != nil {
return nil, err
}
finalExpiresAt := candidate.FinalExpiresAt
attempt := &model.AssetAutoRenewalAttempt{
AssetType: candidate.AssetType, AssetID: candidate.AssetID, TriggerDate: triggerDate,
Status: constants.AssetAutoRenewalAttemptStatusProcessing,
CustomerID: candidate.CustomerID,
ShopID: candidate.ShopID,
ConfigVersion: config.ConfigVersion, WindowDays: config.DaysBeforeExpiry,
FinalExpiresAt: &finalExpiresAt,
CurrentUsageID: candidate.CurrentUsageID,
CurrentPackageID: candidate.CurrentPackageID,
RenewPackageID: candidate.CurrentPackageID,
OperatorType: constants.AssetAutoRenewalOperatorTypeSystemTask,
OperatorID: constants.TaskTypeAssetAutoRenewalScan,
AttemptSeq: int(sequence) + 1,
}
return attempt, nil
}
// finalizeSkip 以独立短事务落跳过终态:不扣款、不建订单、不发送通知。
func (s *Service) finalizeSkip(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, halt *renewalHalt) error {
_, err := s.attemptStore.Finalize(ctx, attempt.ID, map[string]any{
"status": constants.AssetAutoRenewalAttemptStatusSkipped,
"skip_reason": halt.SkipReason,
"failure_reason": "",
"failure_detail": halt.Detail,
})
if err != nil {
return err
}
s.logger.Info("自动续费跳过该资产",
zap.String("asset_type", attempt.AssetType), zap.Uint("asset_id", attempt.AssetID),
zap.String("skip_reason", halt.SkipReason))
return nil
}
// finalizeFailure 以独立短事务落失败终态,并在同一事务内投递通知与写失败审计。
//
// 该短事务与已回滚的续费事务不共用连接或事务ENG-TX-001 例外),条件更新依据尝试记录仍非终态;
// 已被并发收敛时不再投递通知与审计。中断收敛interrupted不属于通知口径不会被通知。
func (s *Service) finalizeFailure(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, map[string]any{
"status": constants.AssetAutoRenewalAttemptStatusFailed,
"failure_reason": reason,
"failure_detail": detail,
"skip_reason": "",
})
if err != nil {
return err
}
if !updated {
s.logger.Info("自动续费尝试已被并发收敛,跳过通知与审计",
zap.Uint("attempt_id", attempt.ID))
return nil
}
if notifyErr := s.appendFailureNotifications(ctx, tx, failureNotification{
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
Identifier: s.assetIdentifier(ctx, attempt.AssetType, attempt.AssetID),
ShopID: attempt.ShopID, CustomerID: attempt.CustomerID,
TriggerDate: attempt.TriggerDate, Reason: reason,
PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt,
}); notifyErr != nil {
return notifyErr
}
return s.appendFailureAudit(ctx, tx, attempt, reason, detail)
})
}

View File

@@ -0,0 +1,178 @@
// Package assetautorenewal 编排资产钱包自动续费:受控配置维护、每日扫描与尝试、续费事务闭合、
// 失败通知与复机可靠投递。
//
// 本包不调用任何支付渠道或运营商接口:续购价格与可售判定复用应用层购买校验与价格策略,
// 复机执行通过 ResumeCommander 端口复用既有停复机单一事实源,通知与复机都通过公共 Outbox
// 在业务事务内写出事件ENG-OUTBOX-001。资金、订单、套餐与成功审计在同一事务内闭合ENG-TX-001
package assetautorenewal
import (
"context"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// ResumeOutcome 是一次复机调用可安全记录的结果摘要。
//
// Applied 为 false 表示本次未满足可复机条件、没有发起任何复机调用Result 取值
// constants.AuditResultSuccess / Failed / Unknown。
type ResumeOutcome struct {
Applied bool
IntegrationID string
Result string
SafeReason string
}
// ResumeCommander 是自动续费成功后复机的执行边界。
//
// 实现必须复用既有停复机单一事实源重试、Integration Log、统一审计、观测序列与既有
// 套餐/流量/实名/风险判定;本包绝不复制这些规则,也绝不直接调用运营商接口。
type ResumeCommander interface {
// AutoRenewalResumeReady 判断该资产当前是否满足自动复机条件;不满足时返回可安全记录的原因。
AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error)
// ResumeAssetForAutoRenewal 执行复机并返回结果分类。
ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (ResumeOutcome, error)
// QueryAutoRenewalResumeState 只查询运营商状态回填复机结果,绝不重复发起复机。
QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (online bool, known bool, integrationID string, err error)
}
// Dependencies 汇总自动续费用例的装配依赖。
//
// DB 与 Redis 用于构造本用例独占的资产钱包、流水、订单、套餐与资产 Store
// 其余依赖是配置、价格、候选、复机与可靠事件的能力边界。
type Dependencies struct {
DB *gorm.DB
Redis *redis.Client
Logger *zap.Logger
Outbox *outbox.Repository
AuditWriter *audit.Writer
PurchaseValidation *purchase_validation.Service
Candidates *assetquery.Query
Resume ResumeCommander
ObservationEvents cardObservationApp.SeriesEventWriter
PriorityEvents priorityapp.PriorityEventWriter
}
// Service 执行资产钱包自动续费的配置维护、每日扫描、续费事务与终止态收敛。
type Service struct {
db *gorm.DB
configStore *postgres.AssetAutoRenewalConfigStore
attemptStore *postgres.AssetAutoRenewalAttemptStore
assetWalletStore *postgres.AssetWalletStore
walletTransactionStore *postgres.AssetWalletTransactionStore
orderStore *postgres.OrderStore
packageUsageStore *postgres.PackageUsageStore
packageStore *postgres.PackageStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
personalCustomerStore *postgres.PersonalCustomerStore
candidates *assetquery.Query
purchaseValidation *purchase_validation.Service
outbox *outbox.Repository
auditWriter *audit.Writer
resume ResumeCommander
observationEvents cardObservationApp.SeriesEventWriter
priorityEvents priorityapp.PriorityEventWriter
logger *zap.Logger
now func() time.Time
}
// NewService 创建资产钱包自动续费用例。
func NewService(deps Dependencies) *Service {
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
return &Service{
db: deps.DB,
configStore: postgres.NewAssetAutoRenewalConfigStore(deps.DB),
attemptStore: postgres.NewAssetAutoRenewalAttemptStore(deps.DB),
assetWalletStore: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
walletTransactionStore: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
orderStore: postgres.NewOrderStore(deps.DB, deps.Redis),
packageUsageStore: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
packageStore: postgres.NewPackageStore(deps.DB),
iotCardStore: postgres.NewIotCardStore(deps.DB, deps.Redis),
deviceStore: postgres.NewDeviceStore(deps.DB, deps.Redis),
personalCustomerStore: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
candidates: deps.Candidates,
purchaseValidation: deps.PurchaseValidation,
outbox: deps.Outbox,
auditWriter: deps.AuditWriter,
resume: deps.Resume,
observationEvents: deps.ObservationEvents,
priorityEvents: deps.PriorityEvents,
logger: logger,
now: time.Now,
}
}
// today 返回当前上海自然日,作为触发日期与每日唯一键的统一口径。
func (s *Service) today() time.Time {
return assetquery.Today(s.now())
}
// assetIdentifier 读取资产对外的可读标识,取不到时回退为资产 ID 文本。
// 通知模板要求标识非空,因此绝不返回空串。
func (s *Service) assetIdentifier(ctx context.Context, assetType string, assetID uint) string {
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
if card, err := s.iotCardStore.GetByID(ctx, assetID); err == nil && card.ICCID != "" {
return card.ICCID
}
case constants.AssetWalletResourceTypeDevice:
if device, err := s.deviceStore.GetByID(ctx, assetID); err == nil {
if device.VirtualNo != "" {
return device.VirtualNo
}
if device.IMEI != "" {
return device.IMEI
}
}
}
return strconv.FormatUint(uint64(assetID), 10)
}
// packageName 读取套餐商品名称,取不到时回退为套餐 ID 文本。
func (s *Service) packageName(ctx context.Context, packageID uint) string {
if packageID == 0 {
return "未知套餐"
}
if pkg, err := s.packageStore.GetByID(ctx, packageID); err == nil && pkg.PackageName != "" {
return pkg.PackageName
}
return strconv.FormatUint(uint64(packageID), 10)
}
// loadPackagesByIDs 批量读取套餐商品,用于一次性取价与快照。
func (s *Service) loadPackagesByIDs(ctx context.Context, packageIDs []uint) (map[uint]*model.Package, error) {
result := make(map[uint]*model.Package, len(packageIDs))
if len(packageIDs) == 0 {
return result, nil
}
var packages []*model.Package
if err := s.db.WithContext(ctx).Where("id IN ?", packageIDs).Find(&packages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费套餐商品失败")
}
for _, pkg := range packages {
result[pkg.ID] = pkg
}
return result, nil
}

View File

@@ -219,6 +219,7 @@ func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
constants.NotificationTypeExchangeShippingCreated,
constants.NotificationTypeH5PopupRiskExchange,
constants.NotificationTypeH5PopupOperation,
constants.NotificationTypeAssetAutoRenewalFailed,
},
now,
)

View File

@@ -287,6 +287,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate),
AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(svc.AssetAutoRenewal, validate),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),

View File

@@ -7,6 +7,7 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
@@ -28,6 +29,7 @@ import (
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
"github.com/break/junhong_cmp_fiber/internal/polling"
assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
agentOpenAPISvc "github.com/break/junhong_cmp_fiber/internal/service/agent_open_api"
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
@@ -118,6 +120,7 @@ type services struct {
PackageDailyRecord *packageSvc.DailyRecordService
PackageCustomerView *packageSvc.CustomerViewService
PackageTrafficAlertRule *packagetrafficalertapp.RuleService
AssetAutoRenewal *assetAutoRenewalApp.Service
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
ShopSeriesGrant *shopSeriesGrantSvc.Service
@@ -509,6 +512,12 @@ func initServices(s *stores, deps *Dependencies) *services {
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
PackageTrafficAlertRule: packagetrafficalertapp.NewRuleService(deps.DB, s.PackageTrafficAlert, auditWriter),
AssetAutoRenewal: assetAutoRenewalApp.NewService(assetAutoRenewalApp.Dependencies{
DB: deps.DB, Redis: deps.Redis, Logger: deps.Logger,
Outbox: outbox.NewRepository(), AuditWriter: auditWriter,
PurchaseValidation: purchaseValidation,
Candidates: assetAutoRenewalQuery.NewQuery(deps.DB),
}),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, auditWriter),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, auditWriter),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger, auditWriter),

View File

@@ -83,6 +83,7 @@ type Handlers struct {
ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
PackageTrafficAlert *admin.PackageTrafficAlertHandler
AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler

View File

@@ -1,9 +1,11 @@
package bootstrap
import (
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
@@ -11,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
@@ -176,6 +179,16 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
// 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。
carrierThresholdService.SetCommander(carrierThresholdInfra.NewCardCommander(stopResumeService))
// 资产钱包自动续费:复机执行同样复用既有停复机单一事实源,通知与复机都走公共 Outbox。
assetAutoRenewalService := assetAutoRenewalApp.NewService(assetAutoRenewalApp.Dependencies{
DB: deps.DB, Redis: deps.Redis, Logger: deps.Logger,
Outbox: cardObservationOutbox, AuditWriter: auditWriter,
PurchaseValidation: purchaseValidation,
Candidates: assetAutoRenewalQuery.NewQuery(deps.DB),
Resume: assetAutoRenewalInfra.NewCardCommander(stopResumeService),
ObservationEvents: observationSeriesEvents,
PriorityEvents: priorityEvents,
})
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
activationService.SetPriorityEventWriter(priorityEvents)
usageService.SetStopResumeCallback(stopResumeService)
@@ -205,6 +218,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
CleanupService: cleanupService,
StopResumeService: stopResumeService,
CarrierThreshold: carrierThresholdService,
AssetAutoRenewal: assetAutoRenewalService,
OrderExpirer: orderService,
AssetPackageOrderCreator: orderService,
DeviceBatchAllocator: deviceBatchAllocator,

View File

@@ -0,0 +1,82 @@
package admin
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AssetAutoRenewalConfigHandler 资产钱包自动续费配置 Handler。
//
// 路由组已有「仅超级管理员与平台账号」门禁应用层仍会复核账号类型ENG-AUTHZ-001
// 因此代理、企业与个人客户即使绕过路由也无任何入口。
type AssetAutoRenewalConfigHandler struct {
service *assetAutoRenewalApp.Service
validator *validator.Validate
}
// NewAssetAutoRenewalConfigHandler 创建资产钱包自动续费配置 Handler。
func NewAssetAutoRenewalConfigHandler(service *assetAutoRenewalApp.Service, validator *validator.Validate) *AssetAutoRenewalConfigHandler {
return &AssetAutoRenewalConfigHandler{service: service, validator: validator}
}
// GetConfig 读取资产钱包自动续费配置。
// GET /api/admin/asset-auto-renewal-config
func (h *AssetAutoRenewalConfigHandler) GetConfig(c *fiber.Ctx) error {
config, err := h.service.GetConfig(c.UserContext())
if err != nil {
return err
}
return response.Success(c, toAssetAutoRenewalConfigResponse(config))
}
// UpdateConfig 保存资产钱包自动续费配置。
// PUT /api/admin/asset-auto-renewal-config
func (h *AssetAutoRenewalConfigHandler) UpdateConfig(c *fiber.Ctx) error {
var req dto.UpdateAssetAutoRenewalConfigRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("保存自动续费配置参数不合法", &req, err))
}
config, err := h.service.SaveConfig(c.UserContext(), assetAutoRenewalApp.ConfigRequest{
Enabled: req.Enabled,
Scope: req.Scope,
PackageIDs: req.PackageIDs,
DaysBeforeExpiry: req.DaysBeforeExpiry,
})
if err != nil {
return err
}
return response.Success(c, toAssetAutoRenewalConfigResponse(config))
}
// toAssetAutoRenewalConfigResponse 组装配置响应枚举附加中文名称字段ENG-DTO-001
func toAssetAutoRenewalConfigResponse(config *assetAutoRenewalApp.ConfigView) dto.AssetAutoRenewalConfigResponse {
packageIDs := config.PackageIDs
if packageIDs == nil {
packageIDs = []uint{}
}
enabledName := "关闭"
if config.Enabled == constants.AssetAutoRenewalConfigEnabledOn {
enabledName = "开启"
}
return dto.AssetAutoRenewalConfigResponse{
Enabled: config.Enabled,
Scope: config.Scope,
PackageIDs: packageIDs,
DaysBeforeExpiry: config.DaysBeforeExpiry,
ConfigVersion: config.ConfigVersion,
ScopeName: constants.GetAssetAutoRenewalScopeName(config.Scope),
EnabledName: enabledName,
Updater: config.Updater,
UpdatedAt: config.UpdatedAt.Format("2006-01-02 15:04:05"),
}
}

View File

@@ -0,0 +1,56 @@
// Package assetautorenewal 提供资产钱包自动续费的停复机执行适配与异步任务入口。
package assetautorenewal
import (
"context"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CardCommander 把自动续费复机端口转发到既有停复机单一事实源。
//
// 它只做类型与结果分类的转换判定规则、重试、Integration Log、统一审计与观测序列全部由
// internal/service/iot_card 提供,本适配器不复制任何规则,也不直接调用运营商接口。
type CardCommander struct {
service *iotCardSvc.StopResumeService
}
// NewCardCommander 创建自动续费复机执行适配器。
func NewCardCommander(service *iotCardSvc.StopResumeService) *CardCommander {
return &CardCommander{service: service}
}
// AutoRenewalResumeReady 判断该资产是否满足自动复机条件。
func (c *CardCommander) AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error) {
if c == nil || c.service == nil {
return false, "", errCommanderUnavailable()
}
return c.service.AutoRenewalResumeReady(ctx, assetType, assetID)
}
// ResumeAssetForAutoRenewal 执行复机并把结果转换为本用例的结果摘要。
func (c *CardCommander) ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (assetAutoRenewalApp.ResumeOutcome, error) {
if c == nil || c.service == nil {
return assetAutoRenewalApp.ResumeOutcome{}, errCommanderUnavailable()
}
outcome, err := c.service.ResumeAssetForAutoRenewal(ctx, assetType, assetID)
return assetAutoRenewalApp.ResumeOutcome{
Applied: outcome.Applied, IntegrationID: outcome.IntegrationID,
Result: outcome.Result, SafeReason: outcome.SafeReason,
}, err
}
// QueryAutoRenewalResumeState 只查询运营商卡状态,供恢复扫描回填复机结果。
func (c *CardCommander) QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (bool, bool, string, error) {
if c == nil || c.service == nil {
return false, false, "", errCommanderUnavailable()
}
return c.service.QueryAutoRenewalResumeState(ctx, assetType, assetID)
}
// errCommanderUnavailable 返回复机执行端口未配置的稳定错误。
func errCommanderUnavailable() error {
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行能力未配置")
}

View File

@@ -0,0 +1,70 @@
package assetautorenewal
import (
"context"
"github.com/hibiken/asynq"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// DailyScanTaskHandler 执行资产钱包自动续费的每日扫描任务。
//
// 审计上下文固定为计划任务来源,使终态收敛、续费成功与失败事实都可按计划任务维度追溯。
type DailyScanTaskHandler struct {
service *assetAutoRenewalApp.Service
}
// NewDailyScanTaskHandler 创建自动续费每日扫描任务处理器。
func NewDailyScanTaskHandler(service *assetAutoRenewalApp.Service) *DailyScanTaskHandler {
return &DailyScanTaskHandler{service: service}
}
// Handle 执行一次每日扫描;只有扫描级失败才返回错误交既有任务重试。
func (h *DailyScanTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "自动续费扫描任务未配置")
}
taskType := constants.TaskTypeAssetAutoRenewalScan
if task != nil && task.Type() != "" {
taskType = task.Type()
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
ActorName: "资产钱包自动续费每日扫描计划任务", Source: constants.AuditSourceScheduler,
})
_, err := h.service.RunDailyScan(ctx)
return err
}
// RecoveryTaskHandler 执行资产钱包自动续费的复机结果恢复扫描任务。
//
// 只查询运营商状态回填,绝不重复发起复机调用;只有最终确认失败才按通知契约投递通知。
type RecoveryTaskHandler struct {
service *assetAutoRenewalApp.Service
}
// NewRecoveryTaskHandler 创建自动续费复机结果恢复任务处理器。
func NewRecoveryTaskHandler(service *assetAutoRenewalApp.Service) *RecoveryTaskHandler {
return &RecoveryTaskHandler{service: service}
}
// Handle 扫描未收敛的复机子结果并只查询状态回填。
func (h *RecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "自动续费复机结果恢复任务未配置")
}
taskType := constants.TaskTypeAssetAutoRenewalRecovery
if task != nil && task.Type() != "" {
taskType = task.Type()
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
ActorName: "资产钱包自动续费结果恢复计划任务", Source: constants.AuditSourceScheduler,
})
_, err := h.service.RecoverResumeResults(ctx)
return err
}

View File

@@ -0,0 +1,74 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// AssetAutoRenewalConfigAudit 是资产钱包自动续费配置保存的审计事实。
//
// 前后值快照由调用方在保存事务内组装Before 为保存前的开关、范围、集合、天数与版本,
// After 为保存后的对应值。审计写入与配置保存同事务未注册时整体失败fail-closed
type AssetAutoRenewalConfigAudit struct {
OperatorID uint
OperationType string
Description string
BeforeData map[string]any
AfterData map[string]any
Result string
ErrorCode string
ErrorSummary string
RequestID string
CorrelationID string
}
// WriteAssetAutoRenewalConfigChange 将自动续费配置变化转换为统一 Audit Event。
func (w *Writer) WriteAssetAutoRenewalConfigChange(ctx context.Context, tx *gorm.DB, change AssetAutoRenewalConfigAudit) error {
operationType := change.OperationType
if operationType != "" && operationType != constants.AuditOperationAssetAutoRenewalConfigUpdate {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
}
action, ok := w.registry.ActionByOperation(constants.AuditOperationAssetAutoRenewalConfigUpdate)
if !ok {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
}
if change.OperatorID == 0 {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "自动续费配置审计操作者缺失")
}
result := change.Result
if result == "" {
result = constants.AuditResultSuccess
}
summary := change.Description
if summary == "" {
summary = "保存资产钱包自动续费配置"
}
configID := strconv.FormatUint(uint64(constants.AssetAutoRenewalConfigSingletonID), 10)
return w.Append(ctx, tx, AppendInput{
ActionCode: action.Code, Summary: summary,
Actor: ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(change.OperatorID), 10),
Name: middleware.GetUsernameFromContext(ctx),
},
Source: action.Source, RequestPath: contextString(middleware.GetRequestPathFromContext(ctx)),
RequestMethod: contextString(middleware.GetRequestMethodFromContext(ctx)),
IPAddress: contextString(middleware.GetIPFromContext(ctx)), UserAgent: contextString(middleware.GetUserAgentFromContext(ctx)),
ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary,
RequestID: change.RequestID, CorrelationID: change.CorrelationID,
Resources: []ResourceInput{{
Type: constants.AuditResourceAssetAutoRenewalConfig, ID: &configID, Key: configID,
DisplayName: constants.AssetAutoRenewalConfigDisplayName,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleAssetAutoRenewalConfigTarget,
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
}

View File

@@ -436,9 +436,18 @@ func NewRegistry() *Registry {
constants.AuditActionCommissionWithdrawalAttemptRejected, "提现提交被拒绝", constants.AuditResourceShop)
qualificationSubmitRejected := distributionAction(
constants.AuditActionWithdrawalQualificationSubmitRejected, "资格提交被拒绝", constants.AuditResourceShop)
// 资产钱包自动续费:配置保存是运营账号行为,续费与失败是系统任务行为,三者主资源不同。
assetAutoRenewalConfigUpdated := packageConfigAction(
constants.AuditActionAssetAutoRenewalConfigUpdated, "保存资产钱包自动续费配置",
constants.AuditResourceAssetAutoRenewalConfig, constants.AuditRiskHigh)
assetAutoRenewalRenewed := assetAutoRenewalAction(
constants.AuditActionAssetAutoRenewalRenewed, "资产钱包自动续费完成", constants.AuditRiskHigh)
assetAutoRenewalFailed := assetAutoRenewalAction(
constants.AuditActionAssetAutoRenewalFailed, "资产钱包自动续费失败或跳过", constants.AuditRiskNormal)
return &Registry{
actionsByOperation: map[string]ActionDefinition{
constants.AuditOperationSystemConfigUpdate: systemConfigUpdated,
constants.AuditOperationAssetAutoRenewalConfigUpdate: assetAutoRenewalConfigUpdated,
constants.AuditOperationPaymentConfigCreate: paymentConfigCreated,
constants.AuditOperationPaymentConfigUpdate: paymentConfigUpdated,
constants.AuditOperationPaymentConfigDelete: paymentConfigDeleted,
@@ -748,6 +757,9 @@ func NewRegistry() *Registry {
constants.AuditActionWithdrawalQualificationInvalidated: qualificationInvalidated,
constants.AuditActionCommissionWithdrawalAttemptRejected: withdrawalAttemptRejected,
constants.AuditActionWithdrawalQualificationSubmitRejected: qualificationSubmitRejected,
constants.AuditActionAssetAutoRenewalConfigUpdated: assetAutoRenewalConfigUpdated,
constants.AuditActionAssetAutoRenewalRenewed: assetAutoRenewalRenewed,
constants.AuditActionAssetAutoRenewalFailed: assetAutoRenewalFailed,
},
resources: map[string]ResourceDefinition{
constants.AuditResourceAccount: {
@@ -822,6 +834,19 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceLogArchiveMonth, Name: "日志归档自然月",
IdentityFields: []string{"month", "timezone", "range_start", "range_end"},
},
constants.AuditResourceAssetAutoRenewalConfig: {
Type: constants.AuditResourceAssetAutoRenewalConfig, Name: "资产钱包自动续费配置",
IdentityFields: []string{"id", "enabled", "scope", "package_ids", "days_before_expiry", "config_version"},
},
constants.AuditResourceAssetAutoRenewalAttempt: {
Type: constants.AuditResourceAssetAutoRenewalAttempt, Name: "资产钱包自动续费尝试",
IdentityFields: []string{
"id", "asset_type", "asset_id", "trigger_date", "status", "failure_reason", "skip_reason",
"current_usage_id", "current_package_id", "renew_package_id", "renew_price",
"wallet_id", "wallet_transaction_id", "deduct_amount", "balance_before", "balance_after",
"order_id", "order_no", "resume_status", "resume_integration_id", "attempt_seq",
},
},
constants.AuditResourceDeviceBatchTask: {
Type: constants.AuditResourceDeviceBatchTask, Name: "设备批量分配任务",
IdentityFields: []string{"task_no", "operation_type"},
@@ -1168,6 +1193,23 @@ func customerAssetAdminAction(code, name string) ActionDefinition {
}
}
// assetAutoRenewalAction 是资产钱包自动续费系统动作的注册模板。
//
// 续费与失败都由计划任务或 Worker 系统任务产生,主资源是本表新登记的自动续费尝试记录,
// 因此不设 AllowedActor/Source 单一来源,改用 AllowedOrigins 同时接受两个系统入口。
func assetAutoRenewalAction(code, name, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: risk,
PrimaryResource: constants.AuditResourceAssetAutoRenewalAttempt, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
},
}
}
func packageConfigAction(code, name, primaryResource, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryConfiguration, Risk: risk,

View File

@@ -157,6 +157,25 @@ func NewRegistry() *Registry {
constants.NotificationRefTypePackageTrafficAlert: {},
},
},
// 资产钱包自动续费失败:类别沿用 expiry接收人含店铺业务员账号与个人客户
// 资源引用只指向失败资产,正文不含任何 URL 或前端路由。
constants.NotificationTypeAssetAutoRenewalFailed: {
Type: constants.NotificationTypeAssetAutoRenewalFailed, Category: constants.NotificationCategoryExpiry,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "套餐自动续费未完成",
BodyTemplate: "资产 {{.asset_identifier}} 的套餐 {{.package_name}} 自动续费未完成,原因:{{.failure_reason}},最终到期日期:{{.expiry_date}}。",
TemplateFields: map[string]struct{}{
"asset_identifier": {}, "package_name": {}, "failure_reason": {}, "expiry_date": {},
},
RecipientKinds: map[string]struct{}{
constants.NotificationRecipientKindAccount: {},
constants.NotificationRecipientKindPersonalCustomer: {},
},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeIotCard: {},
constants.NotificationRefTypeDevice: {},
},
},
}}
}

View File

@@ -0,0 +1,99 @@
package model
import (
"time"
"gorm.io/gorm"
)
// AssetAutoRenewalConfig 是全局唯一一行自动续费配置的 PostgreSQL 持久化事实。
//
// 主键恒为 1数据库 CHECK 约束保证单行):配置只有一份全局生效值,不按店铺、企业或个人
// 客户分范围。ConfigVersion 是配置版本而非乐观锁,保存事务内递增并供尝试记录冻结快照,
// 已产生的尝试记录保留原版本、不重算。PackageIDs 只在 Scope 为 specified 时非空。
type AssetAutoRenewalConfig struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
Enabled int `gorm:"column:enabled;type:smallint;not null;default:0;comment:总开关 0-关闭 1-开启" json:"enabled"`
Scope string `gorm:"column:scope;type:varchar(16);not null;default:'all';comment:适用范围 all-全部主套餐 specified-指定主套餐" json:"scope"`
PackageIDs UintJSONBArray `gorm:"column:package_ids;type:jsonb;not null;default:'[]';comment:指定主套餐集合(仅 specified 范围非空)" json:"package_ids"`
DaysBeforeExpiry int `gorm:"column:days_before_expiry;type:integer;not null;default:15;comment:统一到期前天数1-90" json:"days_before_expiry"`
ConfigVersion int64 `gorm:"column:config_version;type:bigint;not null;default:1;comment:配置版本,保存事务内自增" json:"config_version"`
BaseModel `gorm:"embedded"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 返回自动续费配置表名。
func (AssetAutoRenewalConfig) TableName() string {
return "tb_asset_auto_renewal_config"
}
// AssetAutoRenewalAttempt 是一次自动续费尝试的 PostgreSQL 持久化事实。
//
// 一行表达一个 (AssetType, AssetID, TriggerDate) 组合,由部分唯一索引保证每项资产每日至多
// 一次尝试Status 为终态(成功/失败/跳过)时该次尝试已收敛,仍为处理中表示占位后进程中断。
// 资金、订单、复机与配置窗口字段全部是触发时冻结的快照,不随后续配置或资产变化重算。
type AssetAutoRenewalAttempt struct {
gorm.Model
// AssetType 取值与资产钱包资源类型一致iot_card / device卡与设备分别计数。
AssetType string `gorm:"column:asset_type;type:varchar(20);not null;comment:资产类型 iot_card-物联网卡 device-设备" json:"asset_type"`
AssetID uint `gorm:"column:asset_id;type:bigint;not null;comment:资产ID" json:"asset_id"`
// TriggerDate 是触发日的上海自然日,与资产类型、资产 ID 共同构成唯一键。
TriggerDate time.Time `gorm:"column:trigger_date;type:date;not null;comment:触发日期(上海自然日)" json:"trigger_date"`
// Status 取值 constants.AssetAutoRenewalAttemptStatus*。
Status int `gorm:"column:status;type:smallint;not null;default:1;comment:尝试状态 1-处理中 2-成功 3-失败 4-跳过" json:"status"`
// FailureReason 取值 constants.AssetAutoRenewalFailure*,成功与跳过时为空。
FailureReason string `gorm:"column:failure_reason;type:varchar(32);not null;default:'';comment:失败原因 insufficient_balance-余额不足 not_renewable-不可续费 order_failed-订单失败 resume_failed-复机失败" json:"failure_reason"`
// FailureDetail 是可安全记录的失败说明,不写渠道报文、凭证或内部错误细节。
FailureDetail string `gorm:"column:failure_detail;type:varchar(500);not null;default:'';comment:可安全展示的失败说明" json:"failure_detail"`
// SkipReason 取值 constants.AssetAutoRenewalSkip*,仅跳过态有值。
SkipReason string `gorm:"column:skip_reason;type:varchar(32);not null;default:'';comment:跳过原因 manual_renewed-人工已完成续购 manual_order_pending-人工订单在途" json:"skip_reason"`
// 触发时冻结的客户与店铺快照。
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;default:0;comment:触发时解析到的当前个人客户ID0-无" json:"customer_id"`
ShopID *uint `gorm:"column:shop_id;type:bigint;comment:触发时资产所属店铺IDNULL-无店铺" json:"shop_id"`
// 触发时冻结的配置与窗口快照。
ConfigVersion int64 `gorm:"column:config_version;type:bigint;not null;default:0;comment:触发时配置版本快照" json:"config_version"`
WindowDays int `gorm:"column:window_days;type:integer;not null;default:0;comment:触发时到期前天数快照" json:"window_days"`
FinalExpiresAt *time.Time `gorm:"column:final_expires_at;type:timestamptz;comment:触发时最终到期时间快照" json:"final_expires_at,omitempty"`
// 当前主套餐与续购对象。
CurrentUsageID uint `gorm:"column:current_usage_id;type:bigint;not null;default:0;comment:触发时当前主套餐使用记录ID" json:"current_usage_id"`
CurrentPackageID uint `gorm:"column:current_package_id;type:bigint;not null;default:0;comment:触发时当前套餐商品ID" json:"current_package_id"`
RenewPackageID uint `gorm:"column:renew_package_id;type:bigint;not null;default:0;comment:待续购套餐商品ID" json:"renew_package_id"`
RenewPrice int64 `gorm:"column:renew_price;type:bigint;not null;default:0;comment:执行时当前可售续费价(分)" json:"renew_price"`
// 资金事实。
WalletID uint `gorm:"column:wallet_id;type:bigint;not null;default:0;comment:扣款资产钱包ID" json:"wallet_id"`
// WalletTransactionID 即规格中的钱包流水号,指向 tb_asset_wallet_transaction.id。
WalletTransactionID uint `gorm:"column:wallet_transaction_id;type:bigint;not null;default:0;comment:资产钱包流水标识tb_asset_wallet_transaction.id" json:"wallet_transaction_id"`
DeductAmount int64 `gorm:"column:deduct_amount;type:bigint;not null;default:0;comment:扣款金额(分)" json:"deduct_amount"`
BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;default:0;comment:扣款前钱包余额(分)" json:"balance_before"`
BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;default:0;comment:扣款后钱包余额(分)" json:"balance_after"`
// 订单事实。
OrderID uint `gorm:"column:order_id;type:bigint;not null;default:0;comment:续费订单ID" json:"order_id"`
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;default:'';comment:续费订单号快照" json:"order_no"`
// 复机事实。
ResumeStatus int `gorm:"column:resume_status;type:smallint;not null;default:0;comment:复机状态 0-未评估 1-跳过 2-已投递 3-成功 4-失败 5-未知" json:"resume_status"`
// ResumeSubmittedAt 是复机执行提交认领时刻:消费者以「为空」条件更新取得至多一次的外部调用权。
ResumeSubmittedAt *time.Time `gorm:"column:resume_submitted_at;type:timestamptz;comment:复机执行提交认领时刻" json:"resume_submitted_at,omitempty"`
// ResumeIntegrationID 记录复机 Gateway 调用的 Integration Log 标识,便于人工核对。
ResumeIntegrationID string `gorm:"column:resume_integration_id;type:varchar(64);not null;default:'';comment:复机外部交互标识Integration Log 标识)" json:"resume_integration_id"`
ResumeFailureReason string `gorm:"column:resume_failure_reason;type:varchar(500);not null;default:'';comment:可安全展示的复机失败原因" json:"resume_failure_reason"`
// ResumeAnomalyFlag 为 1 表示查询窗口超期仍无法确认,退出自动扫描转人工核对。
ResumeAnomalyFlag int `gorm:"column:resume_anomaly_flag;type:smallint;not null;default:0;comment:复机异常标记 0-正常 1-需人工核对" json:"resume_anomaly_flag"`
// 操作者与跨日尝试次数:本能力无人工处理入口,操作者恒为系统任务。
OperatorType string `gorm:"column:operator_type;type:varchar(32);not null;default:'system_task';comment:操作者类型,恒为 system_task" json:"operator_type"`
OperatorID string `gorm:"column:operator_id;type:varchar(64);not null;default:'';comment:操作者标识,系统任务固定为计划任务类型" json:"operator_id"`
// AttemptSeq 是该资产截至本次尝试当日的跨日累计尝试次数。
AttemptSeq int `gorm:"column:attempt_seq;type:integer;not null;default:1;comment:跨日尝试次数" json:"attempt_seq"`
}
// TableName 返回自动续费尝试表名。
func (AssetAutoRenewalAttempt) TableName() string {
return "tb_asset_auto_renewal_attempt"
}

View File

@@ -0,0 +1,39 @@
package dto
// AssetAutoRenewalConfigResponse 是资产钱包自动续费全局配置的读取响应。
type AssetAutoRenewalConfigResponse struct {
Enabled int `json:"enabled" description:"总开关 0-关闭 1-开启"`
// Scope 取值 all-全部主套餐 specified-指定主套餐。
Scope string `json:"scope" description:"适用范围 all-全部主套餐 specified-指定主套餐"`
// PackageIDs 仅在指定范围时非空,元素为可售主套餐商品 ID。
PackageIDs []uint `json:"package_ids" description:"指定主套餐商品ID集合范围为主套餐全部时为空数组"`
// DaysBeforeExpiry 是统一到期前天数,触发窗口为最终到期剩余天数闭区间 0 至该值。
DaysBeforeExpiry int `json:"days_before_expiry" description:"统一到期前天数,取值 1 至 90"`
// ConfigVersion 每次保存递增,尝试记录只保留触发时版本快照。
ConfigVersion int64 `json:"config_version" description:"配置版本,每次保存递增"`
// ScopeName 是适用范围的中文名称。
ScopeName string `json:"scope_name" description:"适用范围中文名称"`
// EnabledName 是总开关的中文名称。
EnabledName string `json:"enabled_name" description:"总开关中文名称"`
// Updater 是最近保存的操作者账号 ID。
Updater uint `json:"updater" description:"最近保存的操作者账号ID"`
// UpdatedAt 是最近保存时间。
UpdatedAt string `json:"updated_at" description:"最近保存时间"`
}
// UpdateAssetAutoRenewalConfigRequest 是保存自动续费全局配置的请求。
//
// required:"true" 与 enum:"..." 是**文档契约标签**(供 OpenAPI 反射,见 pkg/openapi 的 Reflector 与
// internal/model/dto/asset_dto.go:386 的既有用法),不参与运行时校验;运行时校验仍由 validate tag 与
// internal/handler/admin/asset_auto_renewal.go 的 validator.Struct 承担。
// description 一律在首个中文逗号处收住internal/handler/validation 的 fieldDescription 会在此截断,
// 使校验提示只取到字段名(如「适用范围」)而不是整段枚举说明;枚举取值逐字取自 pkg/constants。
type UpdateAssetAutoRenewalConfigRequest struct {
Enabled int `json:"enabled" validate:"oneof=0 1" enum:"0,1" description:"总开关,取值 0-关闭 1-开启"`
// Scope 只能选择全部主套餐或指定主套餐;指定范围时 PackageIDs 必须非空。
Scope string `json:"scope" validate:"required,oneof=all specified" required:"true" enum:"all,specified" description:"适用范围,取值 all-全部主套餐 specified-指定主套餐"`
// PackageIDs 只能选择当前可售主套餐;范围为主套餐全部时必须留空。
PackageIDs []uint `json:"package_ids" description:"指定主套餐商品ID集合仅指定范围时填写"`
// DaysBeforeExpiry 是统一到期前天数,上限 90。
DaysBeforeExpiry int `json:"days_before_expiry" validate:"required,min=1,max=90" required:"true" description:"统一到期前天数,取值 1 至 90"`
}

View File

@@ -30,3 +30,29 @@ func (a *StringJSONBArray) Scan(value any) error {
}
return json.Unmarshal(b, a)
}
// UintJSONBArray 用于将 []uint 与 PostgreSQL jsonb 列互转
// 读写时通过 Scan/Value 完成序列化,空切片序列化为 []
type UintJSONBArray []uint
// Value 写入数据库时序列化为 JSON
func (a UintJSONBArray) Value() (driver.Value, error) {
if a == nil {
return "[]", nil
}
return json.Marshal(a)
}
// Scan 从数据库读取时反序列化
func (a *UintJSONBArray) Scan(value any) error {
if value == nil {
*a = UintJSONBArray{}
return nil
}
b, ok := value.([]byte)
if !ok {
*a = UintJSONBArray{}
return nil
}
return json.Unmarshal(b, a)
}

View File

@@ -0,0 +1,524 @@
// Package assetautorenewal 提供资产钱包自动续费的只读候选与资格事实查询。
//
// 本包只读:候选资产的最终到期推算完全复用 internal/query/packageexpiry 的既有口径
// ResolveBatch/Calculate当前主套餐取法与既有临期列表同序priority、created_at、id 升序取第一条),
// 本包不重新推算到期、也不修改任何状态。
package assetautorenewal
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// Candidate 是一项最终到期进入触发窗口的自动续费候选资产。
type Candidate struct {
AssetType string
AssetID uint
Identifier string
ShopID *uint
// CustomerID 是触发时解析到的当前个人客户 ID0 表示该资产当前没有绑定个人客户。
CustomerID uint
// CurrentUsageID 与 CurrentPackageID 是当前主套餐使用记录与其套餐商品。
CurrentUsageID uint
CurrentPackageID uint
Generation int
// FinalExpiresAt 与 DaysUntilFinalExpiry 来自既有最终到期推算结果(含待生效主套餐顺延)。
FinalExpiresAt time.Time
DaysUntilFinalExpiry int
// HasPendingMainPackage 表示该资产已存在待生效主套餐(未退款、无主套餐归属)。
// 它同时表达「人工已完成续购」与「不叠加周期」两个不变式。
HasPendingMainPackage bool
}
// Query 查询自动续费候选资产与资格事实。
type Query struct {
db *gorm.DB
expiry *packageexpiry.Query
now func() time.Time
}
// NewQuery 创建自动续费候选查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db, expiry: packageexpiry.NewQuery(db), now: time.Now}
}
// WithDB 返回绑定指定事务的只读视图。
//
// 供续费事务在锁内重读资格事实复用:查询口径不变,只把读取句柄换成调用方事务。
func (q *Query) WithDB(db *gorm.DB) *Query {
if db == nil {
return q
}
return &Query{db: db, expiry: packageexpiry.NewQuery(db), now: q.now}
}
type assetCandidate struct {
AssetType string
AssetID uint
Identifier string
ShopID *uint
}
// Candidates 返回最终到期进入 [0, windowDays] 闭区间的候选资产。
//
// 窗口按上海自然日比较只接受推算结果为明确值exact的资产已过期剩余天数为负
// 无有效主套餐、待激活或数据异常的资产一律不进入候选。资产的个人客户与店铺在触发时解析并冻结。
// 候选按主键分批解析(每批 candidateBatchSize使单条 SQL 的参数个数与单批中间结果有界。
func (q *Query) Candidates(ctx context.Context, windowDays int) ([]Candidate, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeInternalError, "自动续费候选查询未配置")
}
if err := validateWindowDays(windowDays); err != nil {
return nil, err
}
results := make([]Candidate, 0)
for _, assetType := range []string{constants.AssetWalletResourceTypeIotCard, constants.AssetWalletResourceTypeDevice} {
lastID := uint(0)
for {
page, err := q.assetCandidatePage(ctx, assetType, windowDays, lastID, candidateBatchSize)
if err != nil {
return nil, err
}
if len(page) == 0 {
break
}
resolved, err := q.resolve(ctx, page, windowDays)
if err != nil {
return nil, err
}
results = append(results, resolved...)
lastID = page[len(page)-1].AssetID
if len(page) < candidateBatchSize {
break
}
}
}
return results, nil
}
// Candidate 读取单项资产的候选资格事实;不在窗口内或资格不足时返回 (nil, nil)。
//
// 供续费事务在锁内重读使用:读取句柄由调用方事务提供,重读结果与后续写入处于同一隔离视图。
// windowDays 必须传入当前配置值——窗口是触发条件而不是资格不变式,用常量上限会让「人工已完成续购
// 把最终到期推远」被误判为「不在窗口」。
func (q *Query) Candidate(ctx context.Context, assetType string, assetID uint, windowDays int) (*Candidate, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeInternalError, "自动续费候选查询未配置")
}
if assetType != constants.AssetWalletResourceTypeIotCard && assetType != constants.AssetWalletResourceTypeDevice {
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
if err := validateWindowDays(windowDays); err != nil {
return nil, err
}
assets, err := q.assetsByIDs(ctx, assetType, []uint{assetID})
if err != nil {
return nil, err
}
resolved, err := q.resolve(ctx, assets, windowDays)
if err != nil {
return nil, err
}
if len(resolved) == 0 {
return nil, nil
}
return &resolved[0], nil
}
// MainUsageState 是一项资产在锁后重读时的主套餐资格事实。
//
// HasPendingMainPackage 是资格不变式(人工已完成续购 / 不叠加周期MustCheckBeforeWindow 的语义是:
// 它必须先于窗口判定被检查,否则「人工已把最终到期推远」会被窗口判定误判为失败。
type MainUsageState struct {
CurrentUsageID uint
CurrentPackageID uint
Generation int
HasPendingMainPackage bool
}
// MainUsageStateOf 读取单项资产的待生效主套餐与当前主套餐事实,不做任何窗口判定。
func (q *Query) MainUsageStateOf(ctx context.Context, assetType string, assetID uint) (MainUsageState, error) {
state := MainUsageState{}
if q == nil || q.db == nil {
return state, errors.New(errors.CodeInternalError, "自动续费候选查询未配置")
}
usages, err := q.mainUsages(ctx, assetType, []uint{assetID})
if err != nil {
return state, err
}
items := usages[assetID]
if len(items) == 0 {
return state, nil
}
state.CurrentUsageID = items[0].ID
state.CurrentPackageID = items[0].PackageID
state.Generation = items[0].Generation
for _, usage := range items {
if usage.Status == constants.PackageUsageStatusPending {
state.HasPendingMainPackage = true
break
}
}
return state, nil
}
// validateWindowDays 校验到期前天数落在配置允许范围内。
func validateWindowDays(windowDays int) error {
if windowDays < constants.AssetAutoRenewalMinDaysBeforeExpiry || windowDays > constants.AssetAutoRenewalMaxDaysBeforeExpiry {
return errors.New(errors.CodeInvalidParam, "自动续费到期前天数超出允许范围")
}
return nil
}
// OpenManualMainPackageOrder 判断该资产是否存在未关闭的个人资产钱包主套餐订单。
//
// 未关闭指订单仍为待支付;主套餐订单指订单存在包类型为正式套餐的明细快照。
// 该查询表达「手动续购优先」的第二个条件:人工订单在途时自动任务必须跳过。
func (q *Query) OpenManualMainPackageOrder(ctx context.Context, walletID uint, assetType string, assetID uint) (bool, error) {
if q == nil || q.db == nil {
return false, errors.New(errors.CodeInternalError, "自动续费候选查询未配置")
}
if walletID == 0 || assetID == 0 {
return false, nil
}
var assetColumn string
var orderType string
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
assetColumn, orderType = "iot_card_id", model.OrderTypeSingleCard
case constants.AssetWalletResourceTypeDevice:
assetColumn, orderType = "device_id", model.OrderTypeDevice
default:
return false, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
var count int64
err := q.db.WithContext(ctx).Model(&model.Order{}).
Where("payment_status = ? AND buyer_type = ? AND order_type = ?", model.PaymentStatusPending, model.BuyerTypePersonal, orderType).
Where("asset_wallet_reservation_wallet_id = ? AND "+assetColumn+" = ?", walletID, assetID).
Where(`EXISTS (
SELECT 1 FROM tb_order_item oi
WHERE oi.order_id = tb_order.id AND oi.deleted_at IS NULL AND oi.package_type = ?
)`, constants.PackageTypeFormal).
Count(&count).Error
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询资产在途人工订单失败")
}
return count > 0, nil
}
// candidateBatchSize 是候选解析的单批上限:与恢复扫描同量级,使 IN 参数个数与单批中间结果有界。
// 该值只约束「一批」,不截断候选结果集——每批按主键升序推进,直到本批不足一批为止。
const candidateBatchSize = constants.AssetAutoRenewalRecoveryBatchSize
// assetCandidatePage 按主键游标取一页候选资产(跨卡与设备统一按 id 升序推进)。
func (q *Query) assetCandidatePage(ctx context.Context, assetType string, windowDays int, afterID uint, limit int) ([]assetCandidate, error) {
cutoff := dateInShanghai(q.now()).AddDate(0, 0, windowDays+1)
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
return q.cardCandidatePage(ctx, cutoff, afterID, limit)
case constants.AssetWalletResourceTypeDevice:
return q.deviceCandidatePage(ctx, cutoff, afterID, limit)
default:
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
}
func (q *Query) cardCandidatePage(ctx context.Context, cutoff time.Time, afterID uint, limit int) ([]assetCandidate, error) {
var rows []model.IotCard
if err := q.db.WithContext(ctx).Model(&model.IotCard{}).
Select("id, iccid, shop_id").
Where("is_standalone = ?", true).
Where("id > ?", afterID).
Where(expiringAssetExistsClause("iot_card_id"), []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, cutoff).
Order("id ASC").Limit(limit).
Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费卡候选失败")
}
results := make([]assetCandidate, 0, len(rows))
for _, row := range rows {
results = append(results, assetCandidate{
AssetType: constants.AssetWalletResourceTypeIotCard, AssetID: row.ID, Identifier: row.ICCID, ShopID: row.ShopID,
})
}
return results, nil
}
func (q *Query) deviceCandidatePage(ctx context.Context, cutoff time.Time, afterID uint, limit int) ([]assetCandidate, error) {
var rows []model.Device
if err := q.db.WithContext(ctx).Model(&model.Device{}).
Select("id, virtual_no, imei, shop_id").
Where("id > ?", afterID).
Where(expiringAssetExistsClause("device_id"), []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, cutoff).
Order("id ASC").Limit(limit).
Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费设备候选失败")
}
results := make([]assetCandidate, 0, len(rows))
for _, row := range rows {
identifier := row.VirtualNo
if identifier == "" {
identifier = row.IMEI
}
results = append(results, assetCandidate{
AssetType: constants.AssetWalletResourceTypeDevice, AssetID: row.ID, Identifier: identifier, ShopID: row.ShopID,
})
}
return results, nil
}
// expiringAssetExistsClause 是候选预筛:资产存在未退款主套餐且已有生效/已用完记录临近窗口。
// 窗口天数可配置,因此不能复用临期列表绑定 15 天常量候选查询;到期口径本身仍由 packageexpiry 推算。
func expiringAssetExistsClause(assetColumn string) string {
return `EXISTS (
SELECT 1 FROM tb_package_usage pu
WHERE pu.` + assetColumn + ` = tb_` + assetTableName(assetColumn) + `.id AND pu.deleted_at IS NULL
AND pu.master_usage_id IS NULL AND pu.refund_id IS NULL
AND pu.status IN ? AND pu.expires_at IS NOT NULL AND pu.expires_at < ?
)`
}
func assetTableName(assetColumn string) string {
if assetColumn == "device_id" {
return "device"
}
return "iot_card"
}
func (q *Query) assetsByIDs(ctx context.Context, assetType string, assetIDs []uint) ([]assetCandidate, error) {
if len(assetIDs) == 0 {
return nil, nil
}
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
var rows []model.IotCard
if err := q.db.WithContext(ctx).Model(&model.IotCard{}).Select("id, iccid, shop_id").
Where("id IN ? AND is_standalone = ?", assetIDs, true).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费卡失败")
}
results := make([]assetCandidate, 0, len(rows))
for _, row := range rows {
results = append(results, assetCandidate{
AssetType: constants.AssetWalletResourceTypeIotCard, AssetID: row.ID, Identifier: row.ICCID, ShopID: row.ShopID,
})
}
return results, nil
case constants.AssetWalletResourceTypeDevice:
var rows []model.Device
if err := q.db.WithContext(ctx).Model(&model.Device{}).Select("id, virtual_no, imei, shop_id").
Where("id IN ?", assetIDs).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费设备失败")
}
results := make([]assetCandidate, 0, len(rows))
for _, row := range rows {
identifier := row.VirtualNo
if identifier == "" {
identifier = row.IMEI
}
results = append(results, assetCandidate{
AssetType: constants.AssetWalletResourceTypeDevice, AssetID: row.ID, Identifier: identifier, ShopID: row.ShopID,
})
}
return results, nil
default:
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
}
func (q *Query) resolve(ctx context.Context, assets []assetCandidate, windowDays int) ([]Candidate, error) {
if len(assets) == 0 {
return nil, nil
}
grouped := map[string][]assetCandidate{
constants.AssetWalletResourceTypeIotCard: {},
constants.AssetWalletResourceTypeDevice: {},
}
for _, asset := range assets {
grouped[asset.AssetType] = append(grouped[asset.AssetType], asset)
}
results := make([]Candidate, 0, len(assets))
for _, assetType := range []string{constants.AssetWalletResourceTypeIotCard, constants.AssetWalletResourceTypeDevice} {
items := grouped[assetType]
if len(items) == 0 {
continue
}
ids := make([]uint, 0, len(items))
identifiers := make(map[uint]assetCandidate, len(items))
for _, item := range items {
ids = append(ids, item.AssetID)
identifiers[item.AssetID] = item
}
estimates, err := q.expiry.ResolveBatch(ctx, assetType, ids)
if err != nil {
return nil, err
}
usages, err := q.mainUsages(ctx, assetType, ids)
if err != nil {
return nil, err
}
customers, err := q.customerBindings(ctx, assetType, ids)
if err != nil {
return nil, err
}
for _, id := range ids {
item := identifiers[id]
candidate, ok := buildCandidate(assetType, item, estimates[id], usages[id], customers[id], windowDays)
if ok {
results = append(results, candidate)
}
}
}
return results, nil
}
// buildCandidate 按窗口与资格口径装配候选;任一条不满足即返回 ok=false。
func buildCandidate(
assetType string,
asset assetCandidate,
estimate dto.PackageExpiryEstimate,
usages []*model.PackageUsage,
customerID uint,
windowDays int,
) (Candidate, bool) {
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact {
return Candidate{}, false
}
if estimate.DaysUntilFinalExpiry == nil || estimate.EstimatedFinalExpiresAt == nil {
return Candidate{}, false
}
days := *estimate.DaysUntilFinalExpiry
if days < 0 || days > windowDays {
return Candidate{}, false
}
if len(usages) == 0 {
return Candidate{}, false
}
current := usages[0]
pending := false
for _, usage := range usages {
if usage.Status == constants.PackageUsageStatusPending {
pending = true
break
}
}
return Candidate{
AssetType: assetType, AssetID: asset.AssetID, Identifier: asset.Identifier, ShopID: asset.ShopID,
CustomerID: customerID, CurrentUsageID: current.ID, CurrentPackageID: current.PackageID,
Generation: current.Generation, FinalExpiresAt: *estimate.EstimatedFinalExpiresAt,
DaysUntilFinalExpiry: days, HasPendingMainPackage: pending,
}, true
}
// mainUsages 按临期口径读取每项资产的未退款主套餐使用记录:仅主套餐(无主套餐归属)、未退款,
// 状态为待生效/生效中/已用完,按 priority、created_at、id 升序,第一条即当前主套餐。
func (q *Query) mainUsages(ctx context.Context, assetType string, assetIDs []uint) (map[uint][]*model.PackageUsage, error) {
results := make(map[uint][]*model.PackageUsage, len(assetIDs))
if len(assetIDs) == 0 {
return results, nil
}
column, ok := assetIDColumn(assetType)
if !ok {
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
var usages []*model.PackageUsage
if err := q.db.WithContext(ctx).
Where(column+" IN ?", assetIDs).
Where("master_usage_id IS NULL AND refund_id IS NULL").
Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费主套餐失败")
}
for _, usage := range usages {
assetID := usage.IotCardID
if assetType == constants.AssetWalletResourceTypeDevice {
assetID = usage.DeviceID
}
results[assetID] = append(results[assetID], usage)
}
return results, nil
}
func assetIDColumn(assetType string) (string, bool) {
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
return "iot_card_id", true
case constants.AssetWalletResourceTypeDevice:
return "device_id", true
default:
return "", false
}
}
// customerBindings 批量解析资产当前绑定的个人客户:卡按虚拟号或 ICCID 关联,
// 设备按虚拟号或 IMEI 关联,口径与既有临期提醒接收人解析一致。
func (q *Query) customerBindings(ctx context.Context, assetType string, assetIDs []uint) (map[uint]uint, error) {
results := make(map[uint]uint, len(assetIDs))
if len(assetIDs) == 0 {
return results, nil
}
var rows []struct {
AssetID uint
CustomerID uint
}
var query *gorm.DB
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
query = q.db.WithContext(ctx).Raw(`
SELECT c.id AS asset_id, b.customer_id
FROM tb_iot_card c
JOIN tb_personal_customer_device b ON c.virtual_no <> '' AND b.virtual_no = c.virtual_no
WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = ?
UNION
SELECT c.id AS asset_id, b.customer_id
FROM tb_iot_card c
JOIN tb_personal_customer_iccid b ON b.iccid IN (c.iccid_19, c.iccid_20)
WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = ?
`, assetIDs, constants.StatusEnabled, assetIDs, constants.StatusEnabled)
case constants.AssetWalletResourceTypeDevice:
query = q.db.WithContext(ctx).Raw(`
SELECT d.id AS asset_id, b.customer_id
FROM tb_device d
JOIN tb_personal_customer_device b ON b.virtual_no = d.virtual_no
WHERE d.id IN ? AND d.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = ?
UNION
SELECT d.id AS asset_id, b.customer_id
FROM tb_device d
JOIN tb_personal_customer_device b ON d.imei <> '' AND b.virtual_no = d.imei
WHERE d.id IN ? AND d.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = ?
`, assetIDs, constants.StatusEnabled, assetIDs, constants.StatusEnabled)
default:
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
if err := query.Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费资产个人客户失败")
}
for _, row := range rows {
if row.CustomerID == 0 {
continue
}
if _, exists := results[row.AssetID]; !exists {
results[row.AssetID] = row.CustomerID
}
}
return results, nil
}
// dateInShanghai 把时间归一到东八区当日零点,与既有最终到期推算使用同一时区口径。
func dateInShanghai(value time.Time) time.Time {
local := value.In(shanghaiLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
}
// Today 返回当前上海自然日,供触发日期与每日幂等键复用。
func Today(now time.Time) time.Time {
return dateInShanghai(now)
}

View File

@@ -197,6 +197,7 @@ func personalNotificationScope(db *gorm.DB, customerID uint, now time.Time) *gor
constants.NotificationTypeExchangeShippingCreated,
constants.NotificationTypeH5PopupRiskExchange,
constants.NotificationTypeH5PopupOperation,
constants.NotificationTypeAssetAutoRenewalFailed,
},
now,
)

View File

@@ -106,6 +106,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.PackageTrafficAlert != nil {
registerPackageTrafficAlertRoutes(authGroup, handlers.PackageTrafficAlert, doc, basePath)
}
if handlers.AssetAutoRenewal != nil {
registerAssetAutoRenewalRoutes(authGroup, handlers.AssetAutoRenewal, doc, basePath)
}
if handlers.ShopPackageBatchAllocation != nil {
registerShopPackageBatchAllocationRoutes(authGroup, handlers.ShopPackageBatchAllocation, doc, basePath)
}

View File

@@ -0,0 +1,43 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerAssetAutoRenewalRoutes 注册资产钱包自动续费配置的读写路由。
// 沿用超管/平台路由组级 gate 先例:代理、企业与个人客户账号一律 403无任何读取或修改入口。
func registerAssetAutoRenewalRoutes(router fiber.Router, handler *admin.AssetAutoRenewalConfigHandler, doc *openapi.Generator, basePath string) {
group := router.Group("", func(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return c.Next()
})
path := basePath + "/asset-auto-renewal-config"
Register(group, doc, path, "GET", "", handler.GetConfig, RouteSpec{
Summary: "查询资产钱包自动续费配置",
Description: "返回唯一一份全局配置的总开关、适用范围、指定主套餐集合、到期前天数与配置版本;仅超级管理员与平台账号可访问",
Tags: []string{"资产钱包"},
Output: new(dto.AssetAutoRenewalConfigResponse),
Auth: true,
})
Register(group, doc, path, "PUT", "", handler.UpdateConfig, RouteSpec{
Summary: "保存资产钱包自动续费配置",
Description: "指定范围时集合必须非空且只能选择当前可售主套餐;保存记录操作者与前后值快照并递增配置版本,只影响后续扫描",
Tags: []string{"资产钱包"},
Body: new(dto.UpdateAssetAutoRenewalConfigRequest),
Output: new(dto.AssetAutoRenewalConfigResponse),
Auth: true,
})
}

View File

@@ -0,0 +1,296 @@
package iot_card
import (
"context"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AutoRenewalResumeOutcome 是资产钱包自动续费成功后一次复机调用可安全记录的结果摘要。
//
// 它只承载可靠任务回填所需的事实是否真的发起过复机、Integration Log 标识、结果分类与可安全
// 展示的原因,不携带渠道报文原文、凭证或内部错误细节。
type AutoRenewalResumeOutcome struct {
// Applied 为 false 表示本次未满足可复机条件,没有发起任何复机调用。
Applied bool
// IntegrationID 是本次复机 Gateway 调用的 Integration Log 标识,未发起时为空。
IntegrationID string
// Result 取值 constants.AuditResultSuccess / Failed / Unknown。
Result string
// SafeReason 是可安全记录的不复机原因或失败原因,成功时为空。
SafeReason string
}
// AutoRenewalResumeReady 判断续费成功后的资产是否满足自动复机条件。
//
// 判定规则全部复用既有单一来源(风险网关扩展、可轮询停因、通道阈值锁、有效主套餐、流量未耗尽、
// 实名),不复制任何规则;本入口与既有「若已停机则复机」入口的区别只在于:它明确区分
// 「条件不成立」与「已发起复机」,因此可作为可靠任务的结果来源。
// 卡资产按该卡自身判定;设备资产按设备下因轮询原因停机的卡逐个判定,任一卡满足即返回可复机。
func (s *StopResumeService) AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error) {
cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID)
if err != nil {
return false, "", err
}
if len(cards) == 0 {
return false, "该资产当前没有可恢复停机的卡", nil
}
reason := ""
for _, card := range cards {
ready, cardReason, readyErr := s.autoRenewalCardReady(ctx, card)
if readyErr != nil {
return false, "", readyErr
}
if ready {
return true, "", nil
}
if reason == "" {
reason = cardReason
}
}
if reason == "" {
reason = "该资产当前没有可恢复停机的卡"
}
return false, reason, nil
}
// ResumeAssetForAutoRenewal 在可复机判定成立时执行复机,并返回结果分类。
//
// 复用既有复机重试、Integration Log、统一审计与观测序列成功时在同一事务写回 network_status=online、
// resumed_at并清除可轮询停因。判定不成立时返回 Applied=false 且不发起任何调用;
// 判定成立但调用失败或结果未知时,续费事实不回滚,结果交可靠任务与恢复扫描收敛。
func (s *StopResumeService) ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (AutoRenewalResumeOutcome, error) {
cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID)
if err != nil {
return AutoRenewalResumeOutcome{}, err
}
reason := "该资产当前没有可恢复停机的卡"
applied := false
outcome := AutoRenewalResumeOutcome{Result: constants.AuditResultSuccess}
for _, card := range cards {
ready, cardReason, readyErr := s.autoRenewalCardReady(ctx, card)
if readyErr != nil {
return AutoRenewalResumeOutcome{}, readyErr
}
if !ready {
if reason == "该资产当前没有可恢复停机的卡" && cardReason != "" {
reason = cardReason
}
continue
}
applied = true
cardOutcome, resumeErr := s.autoRenewalResumeCard(ctx, card)
if cardOutcome.IntegrationID != "" {
outcome.IntegrationID = cardOutcome.IntegrationID
}
switch cardOutcome.Result {
case constants.AuditResultFailed:
outcome.Result = constants.AuditResultFailed
outcome.SafeReason = cardOutcome.SafeReason
case constants.AuditResultUnknown:
if outcome.Result == constants.AuditResultSuccess {
outcome.Result = constants.AuditResultUnknown
outcome.SafeReason = cardOutcome.SafeReason
}
}
if resumeErr != nil {
s.logger.Warn("自动续费成功后复机未完成",
zap.String("asset_type", assetType), zap.Uint("asset_id", assetID),
zap.Uint("card_id", card.ID), zap.Error(resumeErr))
}
if outcome.Result == constants.AuditResultFailed {
break
}
}
if !applied {
return AutoRenewalResumeOutcome{Applied: false, Result: constants.AuditResultSuccess, SafeReason: reason}, nil
}
outcome.Applied = true
return outcome, nil
}
// QueryAutoRenewalResumeState 只查询运营商卡状态,供恢复扫描回填自动续费的复机结果。
//
// 不发起任何复机调用:只做状态查询,并按既有网关状态映射规则给出是否已复机。
// 查询失败把 known 置 false 并返回错误,调用方必须按「仍未确认」处理,不得判为失败终态。
func (s *StopResumeService) QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (bool, bool, string, error) {
cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID)
if err != nil {
return false, false, "", err
}
if len(cards) == 0 {
return false, false, "", errors.New(errors.CodeNotFound, "该资产当前没有可查询的卡")
}
if s.gatewayClient == nil {
return false, false, "", errors.New(errors.CodeInternalError, "Gateway 未配置,无法查询卡状态")
}
integrationID := ""
online := true
for _, card := range cards {
attempt, startErr := s.startCardCommandAttempt(ctx, card, constants.IntegrationOperationGatewayNetwork,
constants.CardObservationSceneAssetAutoRenewalResumeRecovery, cardCommandSeriesKey(ctx), 1)
if startErr != nil {
return false, false, integrationID, startErr
}
integrationID = attempt.log.IntegrationID
response, callErr := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if callErr != nil {
if completeErr := s.completeCardCommandAttempt(ctx, attempt, callErr, false); completeErr != nil {
s.logger.Error("终结自动续费复机状态查询 Integration Log 失败",
zap.String("integration_id", attempt.log.IntegrationID), zap.Error(completeErr))
}
return false, false, integrationID, callErr
}
if completeErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); completeErr != nil {
s.logger.Error("终结自动续费复机状态查询 Integration Log 失败",
zap.String("integration_id", attempt.log.IntegrationID), zap.Error(completeErr))
return false, false, integrationID, completeErr
}
status, known := carddomain.MapGatewayNetworkStatus(response.CardStatus, response.Extend)
if !known {
return false, false, integrationID, nil
}
if status != constants.NetworkStatusOnline {
online = false
}
}
return online, true, integrationID, nil
}
// autoRenewalResumeCards 解析自动续费资产对应的待复机卡:卡资产取自身,设备资产取该设备下
// 因轮询原因停机的绑定卡(复用既有设备卡查询,不另立绑定关系口径)。
func (s *StopResumeService) autoRenewalResumeCards(ctx context.Context, assetType string, assetID uint) ([]*model.IotCard, error) {
if assetID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "自动续费复机资产无效")
}
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
card, err := s.iotCardStore.GetByID(ctx, assetID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费复机卡事实失败")
}
return []*model.IotCard{card}, nil
case constants.AssetWalletResourceTypeDevice:
cards, err := s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, assetID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备待复机卡失败")
}
return cards, nil
default:
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
}
// autoRenewalCardReady 判断单卡是否满足自动复机条件;返回的 reason 是可安全记录的不满足原因。
func (s *StopResumeService) autoRenewalCardReady(ctx context.Context, card *model.IotCard) (bool, string, error) {
if card == nil || card.ID == 0 {
return false, "", errors.New(errors.CodeInvalidParam, "自动续费复机卡无效")
}
if card.NetworkStatus == constants.NetworkStatusOnline {
return false, "卡当前已在线,无需复机", nil
}
if isRiskGatewayExtend(card.GatewayExtend) {
return false, "网关扩展状态为风险停机或已销户,不自动复机", nil
}
if !isPollingStopReason(card.StopReason) {
return false, "停因不属于可轮询复机范围,不自动复机", nil
}
blocked, err := s.channelThresholdBlocked(ctx, card)
if err != nil {
return false, "", err
}
if blocked {
return false, "卡当前计费周期持有通道阈值停机锁,不自动复机", nil
}
hasPackage, err := s.hasValidPackage(ctx, card)
if err != nil {
return false, "", err
}
if !hasPackage {
return false, "无有效主套餐,不自动复机", nil
}
exhausted, err := s.isTrafficExhausted(ctx, card)
if err != nil {
return false, "", err
}
if exhausted {
return false, "套餐流量已耗尽,不自动复机", nil
}
realnameOK, err := s.isRealnameOK(ctx, card)
if err != nil {
return false, "", err
}
if !realnameOK {
return false, "实名要求未满足,不自动复机", nil
}
return true, "", nil
}
// autoRenewalResumeCard 对单卡执行自动续费后的复机并返回结果分类。
//
// 复用既有每卡复机分布式锁constants.RedisCardResumeLockKey与 resumeSingleCard 同一把锁与 TTL
// 认领字段只保证「同一尝试至多一次」,跨链路(轮询/套餐激活/流量重置 vs 自动续费)仍可能同时复机,
// 该锁是既有链路共用的幂等闸门。抢不到锁表示另一条链路正在复机,本次不重复调用运营商,按未发起处理。
func (s *StopResumeService) autoRenewalResumeCard(ctx context.Context, card *model.IotCard) (AutoRenewalResumeOutcome, error) {
if s.redis != nil {
lockKey := constants.RedisCardResumeLockKey(card.ID)
locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if lockErr != nil {
return AutoRenewalResumeOutcome{}, errors.Wrap(errors.CodeRedisError, lockErr, "获取复机分布式锁失败")
}
if !locked {
return AutoRenewalResumeOutcome{
Applied: false, Result: constants.AuditResultSuccess,
SafeReason: "该卡正在被其他链路复机,本次不重复调用",
}, nil
}
defer func() { _ = s.redis.Del(ctx, lockKey) }()
}
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动续费成功后恢复 IoT 卡网络"
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
return AutoRenewalResumeOutcome{
Applied: true, IntegrationID: integrationID, Result: cardCommandAuditResult(err),
SafeReason: "Gateway 复机请求未成功",
}, err
}
fields := map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": time.Now(),
}
// 判定已限定停因属于可轮询复机范围,复机成功后清除停因,与既有轮询复机口径一致。
if isPollingStopReason(card.StopReason) {
fields["stop_reason"] = ""
}
if err := s.updateCardAndAppendNetworkSeries(ctx, card, fields,
constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结自动续费复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err)
// 运营商已成功但本地回写失败:按结果未知交恢复扫描查询确认,续费事实不回滚。
return AutoRenewalResumeOutcome{
Applied: true, IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultUnknown,
SafeReason: "本地状态回写失败,结果待核对",
}, err
}
s.reschedulePolling(ctx, card.ID)
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
return AutoRenewalResumeOutcome{
Applied: true, IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultSuccess,
}, errors.Wrap(errors.CodeDatabaseError, logErr, "终结自动续费复机 Integration Log 失败")
}
s.logger.Info("自动续费成功后复机完成", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID))
return AutoRenewalResumeOutcome{
Applied: true, IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultSuccess,
}, nil
}

View File

@@ -0,0 +1,314 @@
package postgres
import (
"context"
stderrors "errors"
"time"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// autoRenewalAttemptConstraint 是自动续费尝试的部分唯一索引名,用于把 23505 精确识别为「当日已尝试」。
// 部分唯一索引不能与 GORM OnConflict 组合(谓词未声明时无法命中,见 KNOWN-ISSUE-001
// 因此占位写入使用显式插入 + 23505 识别。
const autoRenewalAttemptConstraint = "uq_asset_auto_renewal_attempt_key"
// shanghaiLocation 是自动续费使用的东八区口径:触发日期与跨日比较都以该时区的自然日为准,
// 与候选查询、尝试唯一键的日期来源保持一致。
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// AssetAutoRenewalConfigStore 是单行自动续费配置的数据访问层。
//
// 配置只有一行(主键恒为 1读取按主键取保存必须在调用方事务内先取行锁再写回
// 使并发保存串行化而不是静默覆盖。
type AssetAutoRenewalConfigStore struct {
db *gorm.DB
}
// NewAssetAutoRenewalConfigStore 创建自动续费配置 Store。
func NewAssetAutoRenewalConfigStore(db *gorm.DB) *AssetAutoRenewalConfigStore {
return &AssetAutoRenewalConfigStore{db: db}
}
// Get 读取唯一一行自动续费配置。
func (s *AssetAutoRenewalConfigStore) Get(ctx context.Context) (*model.AssetAutoRenewalConfig, error) {
var config model.AssetAutoRenewalConfig
if err := s.db.WithContext(ctx).First(&config, constants.AssetAutoRenewalConfigSingletonID).Error; err != nil {
return nil, err
}
return &config, nil
}
// LockInTx 在调用方事务内对配置行加锁读取,作为并发保存的串行化点。
func (s *AssetAutoRenewalConfigStore) LockInTx(ctx context.Context, tx *gorm.DB) (*model.AssetAutoRenewalConfig, error) {
var config model.AssetAutoRenewalConfig
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&config, constants.AssetAutoRenewalConfigSingletonID).Error; err != nil {
return nil, err
}
return &config, nil
}
// SaveInTx 在调用方事务内写回配置的开关、范围、集合、天数、版本与操作者。
func (s *AssetAutoRenewalConfigStore) SaveInTx(ctx context.Context, tx *gorm.DB, config *model.AssetAutoRenewalConfig, operatorID uint) error {
result := tx.WithContext(ctx).Model(&model.AssetAutoRenewalConfig{}).
Where("id = ?", constants.AssetAutoRenewalConfigSingletonID).
Updates(map[string]any{
"enabled": config.Enabled,
"scope": config.Scope,
"package_ids": config.PackageIDs,
"days_before_expiry": config.DaysBeforeExpiry,
"config_version": config.ConfigVersion,
"updater": operatorID,
"updated_at": time.Now(),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "自动续费配置行不存在")
}
return nil
}
// AssetAutoRenewalAttemptStore 是自动续费尝试记录的数据访问层。
//
// 占位写入与失败/跳过终态各走独立短事务;成功终态与续费事实同事务更新。当日重复尝试由唯一键
// 冲突表达终态写入一律以记录仍处于允许该终态的状态为条件ENG-CONC-001
type AssetAutoRenewalAttemptStore struct {
db *gorm.DB
}
// NewAssetAutoRenewalAttemptStore 创建自动续费尝试 Store。
func NewAssetAutoRenewalAttemptStore(db *gorm.DB) *AssetAutoRenewalAttemptStore {
return &AssetAutoRenewalAttemptStore{db: db}
}
// Load 按主键读取尝试记录。
func (s *AssetAutoRenewalAttemptStore) Load(ctx context.Context, id uint) (*model.AssetAutoRenewalAttempt, error) {
var attempt model.AssetAutoRenewalAttempt
if err := s.db.WithContext(ctx).First(&attempt, id).Error; err != nil {
return nil, err
}
return &attempt, nil
}
// CountByAsset 统计该资产截至触发日的尝试条数,用于跨日尝试次数。
//
// 触发日期是 DATE 列:比较必须走显式日期参数(?::date不得把 time.Time 直接与东八区零点比较,
// 否则会因时区换算差一天。
func (s *AssetAutoRenewalAttemptStore) CountByAsset(ctx context.Context, assetType string, assetID uint, triggerDate time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}).
Where("asset_type = ? AND asset_id = ? AND trigger_date <= ?::date", assetType, assetID, shanghaiDateValue(triggerDate)).
Count(&count).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计跨日自动续费尝试次数失败")
}
return count, nil
}
// CreatePlaceholder 写入当次尝试占位。
//
// 唯一键冲突(该资产当日已有尝试)返回 (false, nil),由调用方跳过该资产;其余错误原样返回。
// 单条 INSERT 本身就是原子的,因此这里**不使用显式事务包裹**:既有的保存点写法
// internal/application/carrierthreshold/lock_store.go 的 createLockInTx是为了在**已经处于
// 事务中**插入时隔离冲突;本方法要么独立执行、要么由调用方决定事务边界,没有需要隔离的外层事务。
// 反过来,若在事务闭包内吞掉 23505 后 return nilGORM 会去提交一个已被 PostgreSQL 中止的事务
// ErrTxCommitRollback「当日已尝试」这一可观察结果就永远不会成立。
func (s *AssetAutoRenewalAttemptStore) CreatePlaceholder(ctx context.Context, attempt *model.AssetAutoRenewalAttempt) (bool, error) {
result := s.db.WithContext(ctx).Create(attempt)
if result.Error != nil {
if isAutoRenewalAttemptConflict(result.Error) {
return false, nil
}
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "写入自动续费尝试占位失败")
}
return result.RowsAffected == 1, nil
}
// FinalizeInTx 在调用方事务内以「记录仍非终态」为条件写入终态。
// 成功终态与续费事实同事务,因此本方法只接受调用方事务,不自行开启事务。
func (s *AssetAutoRenewalAttemptStore) FinalizeInTx(ctx context.Context, tx *gorm.DB, attemptID uint, updates map[string]any) (bool, error) {
return finalizeAttempt(ctx, tx, attemptID, updates)
}
// Finalize 以独立短事务写入失败/跳过终态。
//
// 该短事务与已回滚的续费事务不共用连接或事务ENG-TX-001 例外),条件更新依据尝试记录仍非终态。
func (s *AssetAutoRenewalAttemptStore) Finalize(ctx context.Context, attemptID uint, updates map[string]any) (bool, error) {
updated := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result, err := finalizeAttempt(ctx, tx, attemptID, updates)
if err != nil {
return err
}
updated = result
return nil
})
if err != nil {
return false, err
}
return updated, nil
}
func finalizeAttempt(ctx context.Context, tx *gorm.DB, attemptID uint, updates map[string]any) (bool, error) {
if len(updates) == 0 {
return false, errors.New(errors.CodeInvalidParam, "自动续费终态更新内容为空")
}
updates["updated_at"] = time.Now()
result := tx.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}).
Where("id = ? AND status = ?", attemptID, constants.AssetAutoRenewalAttemptStatusProcessing).
Updates(updates)
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "写入自动续费终态失败")
}
return result.RowsAffected == 1, nil
}
// ConvergeUnfinished 收敛触发日期早于指定自然日且仍未终态的尝试记录。
//
// 占位后进程中断会让记录停在处理中;该记录既不能被当日重试,也不能永久悬空,
// 因此由后续扫描收敛为「失败 + interrupted」failure_reason=interrupted 不属于四类通知原因,
// 收敛过程不发送任何通知(一个从未进入资金事务的占位行不该产生「订单失败」通知),
// 也不回写任何资金、订单与复机字段。触发日期是 DATE 列,比较走显式日期参数(?::date
// 与「当日不重试」使用同一日期口径。返回收敛条数。
func (s *AssetAutoRenewalAttemptStore) ConvergeUnfinished(ctx context.Context, before time.Time) (int64, error) {
result := s.db.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}).
Where("status = ? AND trigger_date < ?::date", constants.AssetAutoRenewalAttemptStatusProcessing, shanghaiDateValue(before)).
Updates(map[string]any{
"status": constants.AssetAutoRenewalAttemptStatusFailed,
"failure_reason": constants.AssetAutoRenewalFailureInterrupted,
"failure_detail": "上次执行在占位后中断,未进入资金事务即结束;本记录仅作中断收敛,当日不重试",
"updated_at": time.Now(),
})
if result.Error != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, result.Error, "收敛自动续费非终态尝试失败")
}
return result.RowsAffected, nil
}
// shanghaiDateValue 把时间格式化为东八区自然日字符串,供 DATE 列比较使用。
func shanghaiDateValue(value time.Time) string {
return value.In(shanghaiLocation).Format("2006-01-02")
}
// ClaimResumeSubmission 以「已投递且尚未提交」为条件认领复机执行权。
//
// 认领成功即取得至多一次的外部调用权;重复投递的 Outbox 事件不会产生第二次调用。
func (s *AssetAutoRenewalAttemptStore) ClaimResumeSubmission(ctx context.Context, attemptID uint, now time.Time) (bool, error) {
result := s.db.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}).
Where("id = ? AND resume_status = ? AND resume_submitted_at IS NULL",
attemptID, constants.AssetAutoRenewalResumeStatusRequested).
Updates(map[string]any{"resume_submitted_at": now, "updated_at": now})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "认领自动续费复机执行权失败")
}
return result.RowsAffected == 1, nil
}
// MarkResumeOutcomeInTx 在调用方事务内条件回写复机结果,只接受记录仍处于期望状态时写入。
//
// 与失败通知、失败审计同事务:三者要么一起提交,要么一起回滚,避免出现「终态已写、通知永久丢失」。
func (s *AssetAutoRenewalAttemptStore) MarkResumeOutcomeInTx(
ctx context.Context,
tx *gorm.DB,
attemptID uint,
expectedStatuses []int,
status int,
integrationID, failureReason string,
) (bool, error) {
return markResumeOutcome(ctx, tx, attemptID, expectedStatuses, status, integrationID, failureReason)
}
// MarkResumeOutcome 条件回写复机结果(单条 UPDATE 自带原子性,无需显式事务)。
//
// 供只需要回填、无需同时投递通知的恢复扫描使用。
func (s *AssetAutoRenewalAttemptStore) MarkResumeOutcome(
ctx context.Context,
attemptID uint,
expectedStatuses []int,
status int,
integrationID, failureReason string,
) (bool, error) {
return markResumeOutcome(ctx, s.db, attemptID, expectedStatuses, status, integrationID, failureReason)
}
// markResumeOutcome 是复机结果条件回写的唯一实现。
// integrationID 与 failureReason 只在非空时覆盖,避免未知结果把已记录的外部交互标识清空。
func markResumeOutcome(
ctx context.Context,
handle *gorm.DB,
attemptID uint,
expectedStatuses []int,
status int,
integrationID, failureReason string,
) (bool, error) {
updates := map[string]any{"resume_status": status, "updated_at": time.Now()}
if integrationID != "" {
updates["resume_integration_id"] = integrationID
}
if failureReason != "" {
updates["resume_failure_reason"] = failureReason
}
if status == constants.AssetAutoRenewalResumeStatusSucceeded {
updates["resume_failure_reason"] = ""
}
result := handle.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}).
Where("id = ? AND resume_status IN ?", attemptID, expectedStatuses).
Updates(updates)
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "回写自动续费复机结果失败")
}
return result.RowsAffected == 1, nil
}
// MarkResumeAnomaly 把超过查询窗口仍无法确认的复机结果标记为需人工核对,退出自动扫描。
func (s *AssetAutoRenewalAttemptStore) MarkResumeAnomaly(ctx context.Context, attemptID uint, reason string) (bool, error) {
result := s.db.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}).
Where("id = ? AND resume_anomaly_flag = ?", attemptID, 0).
Updates(map[string]any{
"resume_anomaly_flag": 1,
"resume_failure_reason": reason,
"updated_at": time.Now(),
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记自动续费复机异常失败")
}
return result.RowsAffected == 1, nil
}
// ScanUnresolvedResumes 扫描仍需按只读查询收敛的复机子结果。
//
// 范围只有两类:结果未知(需查询确认)与已提交但超过查询窗口仍未回写(进程中断)。
// 已标记异常的记录退出扫描,不长期重复查询同一笔无法收敛的结果。
func (s *AssetAutoRenewalAttemptStore) ScanUnresolvedResumes(ctx context.Context, now time.Time, limit int) ([]model.AssetAutoRenewalAttempt, error) {
if limit <= 0 {
limit = constants.AssetAutoRenewalRecoveryBatchSize
}
staleBefore := now.Add(-constants.AssetAutoRenewalResumeQueryWindow)
var attempts []model.AssetAutoRenewalAttempt
if err := s.db.WithContext(ctx).
Where("resume_anomaly_flag = ?", 0).
Where("resume_status = ? OR (resume_status = ? AND resume_submitted_at IS NOT NULL AND resume_submitted_at <= ?)",
constants.AssetAutoRenewalResumeStatusUnknown,
constants.AssetAutoRenewalResumeStatusRequested, staleBefore).
Order("id ASC").Limit(limit).Find(&attempts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描未收敛的自动续费复机结果失败")
}
return attempts, nil
}
// isAutoRenewalAttemptConflict 判断错误是否为尝试唯一键冲突,即「该资产当日已尝试」。
func isAutoRenewalAttemptConflict(err error) bool {
var pgErr *pgconn.PgError
if !stderrors.As(err, &pgErr) {
return false
}
return pgErr.Code == "23505" && pgErr.ConstraintName == autoRenewalAttemptConstraint
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// AssetWalletStore 资产钱包数据访问层
@@ -61,6 +62,18 @@ func (s *AssetWalletStore) CreateWithTx(ctx context.Context, tx *gorm.DB, wallet
return tx.WithContext(ctx).Create(wallet).Error
}
// LockByIDWithTx 在调用方事务内按主键加行锁读取资产钱包。
//
// 供需要在扣款前串行化同一资产钱包的写用例复用:锁定行后重读余额与状态,
// 再走既有 DeductBalanceWithTx 的乐观版本条件更新。本方法只加行锁,不修改任何字段与既有约束。
func (s *AssetWalletStore) LockByIDWithTx(ctx context.Context, tx *gorm.DB, id uint) (*model.AssetWallet, error) {
var wallet model.AssetWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&wallet, id).Error; err != nil {
return nil, err
}
return &wallet, nil
}
// DeductBalanceWithTx 扣款(带事务,使用乐观锁)
func (s *AssetWalletStore) DeductBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64, version int) error {
// 使用乐观锁,检查可用余额是否充足