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,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")
}