feat: 实名即生效套餐沉默期、延期办理与资产出库时间
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-27 16:32:58 +09:00
parent d2b0d2f534
commit 909f4d0ec2
112 changed files with 10473 additions and 82 deletions

View File

@@ -319,6 +319,14 @@ func (s *Service) writeRenewalFacts(ctx context.Context, tx *gorm.DB, plan *exec
if err != nil {
return err
}
if s.silence != nil {
if err := s.silence.ApplyOnUsageCreatedInTx(ctx, tx, usage, plan.pkg, now, constants.SilenceTriggerAutoPurchase); err != nil {
return err
}
if err := s.silence.RecordForOrderInTx(ctx, tx, order); err != nil {
return err
}
}
if err := commissiondelivery.AppendCommissionCalculate(ctx, tx, s.outbox, order.ID); err != nil {
return err
}

View File

@@ -92,6 +92,18 @@ type Service struct {
priorityEvents priorityapp.PriorityEventWriter
logger *zap.Logger
now func() time.Time
silence SilenceRecorder
}
// SilenceRecorder 在续购创建套餐记录的同一事务内设置沉默期与出库时间(无人参与路径只用默认值)。
type SilenceRecorder interface {
ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error
RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error
}
// SetSilenceRecorder 注入沉默期与出库时间写入接缝;未注入时不记录。
func (s *Service) SetSilenceRecorder(recorder SilenceRecorder) {
s.silence = recorder
}
// NewService 创建资产钱包自动续费用例。

View File

@@ -0,0 +1,155 @@
package packagesilence
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// 沉默期审批模板"申请类型"控件取值(技术方案 §11.3)。
const (
ApplyTypePurchaseAdjust = "购买时调整沉默期"
ApplyTypeSpecial = "特殊延期"
ApplyTypeOfflinePaid = "线下付费延期"
)
// 沉默期审批业务字段(企微场景配置把模板控件映射到这些字段)。
const (
ApprovalFieldApplyType = constants.ApprovalFieldSilenceApplyType
ApprovalFieldBatchNo = constants.ApprovalFieldSilenceBatchNo
ApprovalFieldAssetIdentifier = constants.ApprovalFieldSilenceAssetIdentifier
ApprovalFieldPackageName = constants.ApprovalFieldSilencePackageName
ApprovalFieldOrderNo = constants.ApprovalFieldSilenceOrderNo
ApprovalFieldDefaultDays = constants.ApprovalFieldSilenceDefaultDays
ApprovalFieldRequestedDays = constants.ApprovalFieldSilenceRequestedDays
ApprovalFieldOriginalDeadline = constants.ApprovalFieldSilenceOriginalDeadline
ApprovalFieldInputDays = constants.ApprovalFieldSilenceInputDays
ApprovalFieldBilledDays = constants.ApprovalFieldSilenceBilledDays
ApprovalFieldNewDeadline = constants.ApprovalFieldSilenceNewDeadline
ApprovalFieldExtensionType = constants.ApprovalFieldSilenceExtensionType
ApprovalFieldItemCount = constants.ApprovalFieldSilenceItemCount
ApprovalFieldAmount = constants.ApprovalFieldSilenceAmount
ApprovalFieldAmountCent = constants.ApprovalFieldSilenceAmountCent
ApprovalFieldReason = constants.ApprovalFieldSilenceReason
ApprovalFieldBasis = constants.ApprovalFieldSilenceBasis
ApprovalFieldEvidenceKeys = constants.ApprovalFieldSilenceEvidenceKeys
ApprovalFieldPaymentMethod = constants.ApprovalFieldSilencePaymentMethod
ApprovalFieldTradeNo = constants.ApprovalFieldSilenceTradeNo
ApprovalFieldVoucherKeys = constants.ApprovalFieldSilenceVoucherKeys
ApprovalFieldSubmitterID = constants.ApprovalFieldSubmitterID
ApprovalFieldSubmitterName = constants.ApprovalFieldSubmitterName
)
// ApprovalFields 返回沉默期审批场景允许映射的业务字段(企微场景配置校验用)。
func ApprovalFields() []string {
return []string{
ApprovalFieldApplyType, ApprovalFieldBatchNo, ApprovalFieldAssetIdentifier, ApprovalFieldPackageName,
ApprovalFieldOrderNo, ApprovalFieldDefaultDays, ApprovalFieldRequestedDays, ApprovalFieldOriginalDeadline,
ApprovalFieldInputDays, ApprovalFieldBilledDays, ApprovalFieldNewDeadline, ApprovalFieldExtensionType,
ApprovalFieldItemCount, ApprovalFieldAmount, ApprovalFieldAmountCent, ApprovalFieldReason, ApprovalFieldBasis,
ApprovalFieldEvidenceKeys, ApprovalFieldPaymentMethod, ApprovalFieldTradeNo, ApprovalFieldVoucherKeys,
ApprovalFieldSubmitterID, ApprovalFieldSubmitterName,
}
}
// approvalSubmitter 是审批发起人快照。
type approvalSubmitter struct {
ID uint
Name string
UserType int
}
func loadSubmitter(ctx context.Context, db *gorm.DB, accountID uint) (approvalSubmitter, error) {
var account model.Account
if err := db.WithContext(ctx).Select("id", "username", "user_type").Where("id = ?", accountID).First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return approvalSubmitter{}, errors.New(errors.CodeForbidden, "审批发起人账号不存在")
}
return approvalSubmitter{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批发起人失败")
}
return approvalSubmitter{ID: account.ID, Name: account.Username, UserType: account.UserType}, nil
}
// prepareApproval 在业务事务前确认企微审批场景与发起人企微成员绑定可用;不可用时拒绝提交。
func prepareApproval(ctx context.Context, port approvalapp.Port, submitterID uint, correlationID string) (approvalapp.Preparation, error) {
if port == nil {
return approvalapp.Preparation{}, errors.New(errors.CodeServiceUnavailable, "沉默期审批能力未配置")
}
return port.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypePackageSilence, SubmitterAccountID: submitterID,
CorrelationID: correlationID,
})
}
// createApprovalInTx 在业务事务内创建审批实例并关联到批次。
func createApprovalInTx(ctx context.Context, tx *gorm.DB, port approvalapp.Port, preparation approvalapp.Preparation,
batch *model.PackageSilenceExtensionBatch, submitter approvalSubmitter, applyType string, fields map[string]any) (uint, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": submitter.ID, "account_name": submitter.Name, "user_type": submitter.UserType,
})
if err != nil {
return 0, errors.Wrap(errors.CodeInternalError, err, "编码沉默期审批发起人快照失败")
}
request := map[string]any{
ApprovalFieldApplyType: applyType, ApprovalFieldBatchNo: batch.BatchNo,
ApprovalFieldSubmitterID: submitter.ID, ApprovalFieldSubmitterName: submitter.Name,
}
for key, value := range fields {
request[key] = value
}
requestSnapshot, err := sonic.Marshal(request)
if err != nil {
return 0, errors.Wrap(errors.CodeInternalError, err, "编码沉默期审批业务快照失败")
}
reference, err := port.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypePackageSilence,
BusinessID: batch.ID, SubmitterAccountID: submitter.ID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, CorrelationID: batch.BatchNo,
})
if err != nil {
return 0, err
}
result := tx.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).
Where("id = ? AND approval_instance_id IS NULL", batch.ID).
Updates(map[string]any{"approval_instance_id": reference.InstanceID, "approval_status": constants.SilenceApprovalStatusPending})
if result.Error != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, result.Error, "关联沉默期审批实例失败")
}
if result.RowsAffected != 1 {
return 0, errors.New(errors.CodeConflict, "沉默期审批实例关联已变化")
}
batch.ApprovalInstanceID = &reference.InstanceID
batch.ApprovalStatus = constants.SilenceApprovalStatusPending
return reference.InstanceID, nil
}
// generateNo 生成带前缀的单号:前缀 + 年月日时分秒 + 6 位随机数。
func generateNo(prefix string, now time.Time) string {
value, err := rand.Int(rand.Reader, big.NewInt(1000000))
random := int64(0)
if err == nil {
random = value.Int64()
}
return fmt.Sprintf("%s%s%06d", prefix, now.In(time.FixedZone("CST", 8*3600)).Format("20060102150405"), random)
}
func formatAmount(cents int64) string {
return fmt.Sprintf("%d.%02d", cents/100, cents%100)
}
func formatDate(t *time.Time) string {
if t == nil {
return ""
}
return t.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02")
}

View File

@@ -0,0 +1,290 @@
package packagesilence
import (
"context"
"sort"
"time"
"gorm.io/gorm"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Backfill 是历史数据回填用例(技术方案 §6、§17.4):沉默期回填与出库时间回填,均先出清单、只处理空值、可重复执行。
// 可在总开关关闭时运行;不给上线宽限,截止早于上线日且期内未实名的,打开总开关后过期。
type Backfill struct {
db *gorm.DB
audit *audit.Writer
outbound *OutboundRecorder
now func() time.Time
}
// NewBackfill 创建回填用例。
func NewBackfill(db *gorm.DB, writer *audit.Writer) *Backfill {
return &Backfill{db: db, audit: writer, outbound: NewOutboundRecorder(writer), now: time.Now}
}
// SilenceBackfillReport 是沉默期回填影响清单。
type SilenceBackfillReport struct {
Candidates int `json:"candidates"` // 待回填的套餐记录数(待实名、实名即生效主套餐、沉默期为空)
UnconfiguredPackages map[string]int `json:"unconfigured_packages"` // 套餐未配置默认沉默期、不回填的记录数(按套餐)
UnconfiguredCount int `json:"unconfigured_count"` // 未配置套餐的记录合计
Backfillable int `json:"backfillable"` // 可回填记录数
EndedInPeriod int `json:"ended_in_period"` // 截止前已实名,记为结束
RealnamedWithoutTime int `json:"realnamed_without_time"` // 已实名但实名时间为空(视为期内实名并补写)
WillExpire int `json:"will_expire"` // 打开总开关后将过期
WillExpireNoPaidExtend int `json:"will_expire_no_paid_extend"` // 将过期且无法付费延期(只能特殊延期)
Waiting int `json:"waiting"` // 回填后仍在期内等待实名
ByShop map[string]int `json:"by_shop"` // 按代理(资产当前店铺)分布
ByPackage map[string]int `json:"by_package"` // 按套餐分布
Written int `json:"written"` // 执行模式:实际写入数
SkippedConcurrentUpdate int `json:"skipped_concurrent_update"` // 执行模式:已被其他入口写入而跳过
}
type silenceCandidate struct {
model.PackageUsage
OrderPaidAt *time.Time `gorm:"column:order_paid_at"`
PkgDays *int `gorm:"column:pkg_days"`
PkgName string `gorm:"column:pkg_name"`
PkgSeries uint `gorm:"column:pkg_series"`
ShopName string `gorm:"column:shop_name"`
}
// SilenceBackfill 生成影响清单;execute 为 true 时按批写入(只处理沉默期为空的记录,可重复执行)。
func (b *Backfill) SilenceBackfill(ctx context.Context, execute bool) (*SilenceBackfillReport, error) {
report := &SilenceBackfillReport{UnconfiguredPackages: map[string]int{}, ByShop: map[string]int{}, ByPackage: map[string]int{}}
now := b.now()
lastID := uint(0)
policyCache := map[uint]PolicyMatch{}
for {
var rows []silenceCandidate
err := b.db.WithContext(ctx).Table("tb_package_usage AS u").
Joins("LEFT JOIN tb_order AS o ON o.id = u.order_id").
Joins("LEFT JOIN tb_package AS p ON p.id = u.package_id").
Joins("LEFT JOIN tb_iot_card AS c ON c.id = u.iot_card_id AND COALESCE(u.device_id, 0) = 0").
Joins("LEFT JOIN tb_device AS d ON d.id = u.device_id AND COALESCE(u.device_id, 0) > 0").
Joins("LEFT JOIN tb_shop AS s ON s.id = COALESCE(d.shop_id, c.shop_id)").
Select("u.*, o.paid_at AS order_paid_at, p.silence_period_days AS pkg_days, COALESCE(p.package_name, u.package_name) AS pkg_name, COALESCE(p.series_id, 0) AS pkg_series, COALESCE(s.shop_name, '未分配店铺') AS shop_name").
Where("u.deleted_at IS NULL AND u.status = ? AND u.pending_realname_activation = ? AND u.master_usage_id IS NULL AND u.expiry_base_snapshot = ? AND u.silence_status IS NULL AND u.id > ?",
constants.PackageUsageStatusPending, true, constants.PackageExpiryBaseFromActivation, lastID).
Order("u.id ASC").Limit(constants.SilenceBackfillBatchSize).Scan(&rows).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待回填沉默期的套餐记录失败")
}
if len(rows) == 0 {
break
}
for index := range rows {
row := &rows[index]
lastID = row.ID
report.Candidates++
if row.PkgDays == nil {
report.UnconfiguredPackages[row.PkgName]++
report.UnconfiguredCount++
continue
}
report.Backfillable++
report.ByShop[row.ShopName]++
report.ByPackage[row.PkgName]++
if err := b.backfillOne(ctx, row, now, execute, report, policyCache); err != nil {
return nil, err
}
}
}
return report, nil
}
func (b *Backfill) backfillOne(ctx context.Context, row *silenceCandidate, now time.Time, execute bool, report *SilenceBackfillReport, policyCache map[uint]PolicyMatch) error {
start := row.CreatedAt
if row.OrderPaidAt != nil {
start = *row.OrderPaidAt
}
days := *row.PkgDays
deadline := packagedomain.SilenceDeadline(start, days)
facts, err := loadRealnameFacts(ctx, b.db, &row.PackageUsage)
if err != nil {
return err
}
waiting := constants.SilenceStatusWaiting
judgement := packagedomain.JudgeSilence(packagedomain.SilenceFacts{
Status: waiting, DeadlineAt: &deadline, Realnamed: facts.realnamed, RealnameAt: facts.realnameAt, Now: now,
})
status := constants.SilenceStatusWaiting
switch judgement.Decision {
case packagedomain.SilenceDecisionEnd:
status = constants.SilenceStatusEnded
report.EndedInPeriod++
if judgement.BackfillRealnameAt || len(facts.backfillCardIDs) > 0 {
report.RealnamedWithoutTime++
}
case packagedomain.SilenceDecisionExpire:
report.WillExpire++
match, ok := policyCache[row.PackageID]
if !ok {
match, err = matchPolicy(ctx, b.db, row.PackageID, row.PkgSeries, now)
if err != nil {
return err
}
policyCache[row.PackageID] = match
}
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: deadline, Now: now, Policy: match.Snapshot, Kind: packagedomain.SilenceQuotePaid,
})
if !quote.CanExtend {
report.WillExpireNoPaidExtend++
}
default:
report.Waiting++
}
if !execute {
return nil
}
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.PackageUsage{}).Where("id = ? AND silence_status IS NULL AND status = ?", row.ID, constants.PackageUsageStatusPending).
Updates(map[string]any{
"silence_status": status, "silence_start_at": start, "silence_period_days": days,
"silence_deadline_at": deadline, "updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "回填套餐沉默期失败")
}
if result.RowsAffected != 1 {
report.SkippedConcurrentUpdate++
return nil
}
if status == constants.SilenceStatusEnded {
if _, err := backfillRealnameAt(ctx, tx, facts, now); err != nil {
return err
}
}
usage := row.PackageUsage
usage.SilenceStatus, usage.SilenceStartAt, usage.SilencePeriodDays, usage.SilenceDeadlineAt = &status, &start, &days, &deadline
report.Written++
return writeRecord(ctx, tx, b.audit, recordInput{
Usage: &usage, EventType: constants.SilenceRecordEventBackfill, Trigger: constants.SilenceTriggerBackfill,
DefaultDays: &days, RealnameAt: facts.realnameAt, Reason: "历史数据回填:按购买时间起算,不给上线宽限",
Extra: map[string]any{"decision": string(judgement.Decision)},
AuditAction: constants.AuditActionUsageSilenceBackfilled, AuditSummary: "回填套餐沉默期",
})
})
}
// OutboundBackfillReport 是出库时间回填清单。
type OutboundBackfillReport struct {
CardBackfillable int `json:"card_backfillable"` // 可回填的卡(当前世代有已支付或已退款的单卡订单,未绑定设备)
CardWithoutOrder int `json:"card_without_order"` // 无订单、保持为空的卡
CardBoundToDevice int `json:"card_bound_to_device"` // 绑定在设备上、不记录的卡
DeviceBackfillable int `json:"device_backfillable"` // 可回填的设备
DeviceWithoutOrder int `json:"device_without_order"` // 无订单、保持为空的设备
CardWritten int `json:"card_written"` // 执行模式:写入的卡
DeviceWritten int `json:"device_written"` // 执行模式:写入的设备
}
type firstOrderRow struct {
AssetID uint `gorm:"column:asset_id"`
Generation int `gorm:"column:generation"`
PaidAt time.Time `gorm:"column:paid_at"`
OrderID uint `gorm:"column:order_id"`
OrderNo string `gorm:"column:order_no"`
}
// OutboundBackfill 按资产当前世代最早一笔已支付或已退款的套餐订单回填出库时间(排除绑定在设备上的卡),
// 插入 backfill 出库记录;只更新为空的,可重复执行。
func (b *Backfill) OutboundBackfill(ctx context.Context, execute bool) (*OutboundBackfillReport, error) {
report := &OutboundBackfillReport{}
db := b.db.WithContext(ctx)
countWithout := func(table, orderType, column string, extra string) (int64, error) {
var count int64
err := db.Table(table+" AS a").Where("a.deleted_at IS NULL AND a.outbound_at IS NULL "+extra).
Where("NOT EXISTS (SELECT 1 FROM tb_order o WHERE o.order_type = ? AND o."+column+" = a.id AND o.generation = a.generation AND o.payment_status IN ? AND o.paid_at IS NOT NULL AND o.deleted_at IS NULL)",
orderType, []int{model.PaymentStatusPaid, model.PaymentStatusRefunded}).Count(&count).Error
return count, err
}
notBound := "AND NOT EXISTS (SELECT 1 FROM tb_device_sim_binding bd WHERE bd.iot_card_id = a.id AND bd.bind_status = 1 AND bd.deleted_at IS NULL)"
cardWithout, err := countWithout("tb_iot_card", model.OrderTypeSingleCard, "iot_card_id", notBound)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计无订单的卡失败")
}
deviceWithout, err := countWithout("tb_device", model.OrderTypeDevice, "device_id", "")
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计无订单的设备失败")
}
var bound int64
if err := db.Table("tb_iot_card AS a").Where("a.deleted_at IS NULL AND a.outbound_at IS NULL AND EXISTS (SELECT 1 FROM tb_device_sim_binding bd WHERE bd.iot_card_id = a.id AND bd.bind_status = 1 AND bd.deleted_at IS NULL)").Count(&bound).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计绑定在设备上的卡失败")
}
report.CardWithoutOrder, report.DeviceWithoutOrder, report.CardBoundToDevice = int(cardWithout), int(deviceWithout), int(bound)
if err := b.outboundBatches(ctx, "tb_iot_card", model.OrderTypeSingleCard, "iot_card_id", notBound, constants.OutboundAssetTypeIotCard, execute, &report.CardBackfillable, &report.CardWritten); err != nil {
return nil, err
}
if err := b.outboundBatches(ctx, "tb_device", model.OrderTypeDevice, "device_id", "", constants.OutboundAssetTypeDevice, execute, &report.DeviceBackfillable, &report.DeviceWritten); err != nil {
return nil, err
}
return report, nil
}
func (b *Backfill) outboundBatches(ctx context.Context, table, orderType, column, extra, assetType string, execute bool, backfillable, written *int) error {
lastID := uint(0)
for {
var rows []firstOrderRow
err := b.db.WithContext(ctx).Table(table+" AS a").
Joins(`JOIN LATERAL (
SELECT o.id AS order_id, o.order_no, o.paid_at FROM tb_order o
WHERE o.order_type = ? AND o.`+column+` = a.id AND o.generation = a.generation
AND o.payment_status IN ? AND o.paid_at IS NOT NULL AND o.deleted_at IS NULL
ORDER BY o.paid_at ASC, o.id ASC LIMIT 1
) AS f ON TRUE`, orderType, []int{model.PaymentStatusPaid, model.PaymentStatusRefunded}).
Select("a.id AS asset_id, a.generation, f.paid_at, f.order_id, f.order_no").
Where("a.deleted_at IS NULL AND a.outbound_at IS NULL AND a.id > ? "+extra, lastID).
Order("a.id ASC").Limit(constants.SilenceOutboundBackfillBatchSize).Scan(&rows).Error
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询待回填出库时间的资产失败")
}
if len(rows) == 0 {
return nil
}
for _, row := range rows {
lastID = row.AssetID
*backfillable++
if !execute {
continue
}
err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
inserted, err := b.outbound.RecordBackfillInTx(ctx, tx, assetType, row.AssetID, row.Generation, row.PaidAt, row.OrderID, row.OrderNo)
if inserted {
*written++
}
return err
})
if err != nil {
return err
}
}
if len(rows) < constants.SilenceOutboundBackfillBatchSize {
return nil
}
}
}
// SortedCounts 把分布按数量倒序输出,便于清单阅读。
func SortedCounts(values map[string]int) []struct {
Name string
Count int
} {
result := make([]struct {
Name string
Count int
}, 0, len(values))
for name, count := range values {
result = append(result, struct {
Name string
Count int
}{name, count})
}
sort.Slice(result, func(i, j int) bool { return result[i].Count > result[j].Count })
return result
}

View File

@@ -0,0 +1,470 @@
package packagesilence
import (
"context"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"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/middleware"
)
// DecisionHandler 消费沉默期审批场景 package_silence_approval 的标准终态(技术方案 §11.3)。
// 审批实例的业务 ID 为批次 ID,按批次路径分发:adjust 购买时调整、offline 线下付费延期、special 特殊延期。
// 回调不依赖表单文本;以批次与明细状态条件更新保证重复事件只处理一次。总开关关闭时照常处理。
type DecisionHandler struct {
extensions *ExtensionService
}
// NewDecisionHandler 创建沉默期审批终态消费者。
func NewDecisionHandler(extensions *ExtensionService) *DecisionHandler {
return &DecisionHandler{extensions: extensions}
}
var _ approvalapp.BusinessDecisionHandler = (*DecisionHandler)(nil)
// Handle 幂等消费标准审批终态。
func (h *DecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.extensions == nil {
return errors.New(errors.CodeInternalError, "沉默期审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypePackageSilence || event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "沉默期审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorExternalSystem, ActorID: "wecom_approval", ActorName: "企业微信审批",
Source: constants.AuditSourceCallback, CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
})
approved := event.Decision == constants.ApprovalDecisionApproved
if event.Decision == constants.ApprovalDecisionRevokedAfterApproved {
// 通过后撤销:延期已按通过执行,不回滚。
return nil
}
s := h.extensions
var batch *model.PackageSilenceExtensionBatch
var postUsageIDs []uint
executeSpecial := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
batch, err = lockBatchForDecision(ctx, tx, event.BusinessID)
if err != nil {
return err
}
if batch.ApprovalInstanceID == nil || *batch.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "沉默期批次关联的审批实例不一致")
}
if batch.Status != constants.SilenceBatchStatusPendingApproval {
return nil
}
now := s.now()
approvalStatus := constants.SilenceApprovalStatusRejected
if approved {
approvalStatus = constants.SilenceApprovalStatusApproved
} else if event.Decision == constants.ApprovalDecisionCancelled || event.Decision == constants.ApprovalDecisionDeleted {
approvalStatus = constants.SilenceApprovalStatusCanceled
}
switch batch.Path {
case constants.SilencePathAdjust:
usageID, err := s.applyAdjustDecisionInTx(ctx, tx, batch, approved, approvalStatus, now)
if usageID > 0 {
postUsageIDs = append(postUsageIDs, usageID)
}
return err
case constants.SilencePathOffline:
usageID, err := s.applyOfflineDecisionInTx(ctx, tx, batch, approved, approvalStatus, now)
if usageID > 0 {
postUsageIDs = append(postUsageIDs, usageID)
}
return err
case constants.SilencePathSpecial:
executeSpecial, err = s.applySpecialDecisionInTx(ctx, tx, batch, approved, approvalStatus, now)
return err
default:
return errors.New(errors.CodeInvalidStatus, "沉默期批次路径不支持审批")
}
})
if err != nil {
return err
}
if executeSpecial {
return s.executeBatch(ctx, batch.ID)
}
if batch != nil && batch.Path == constants.SilencePathAdjust {
for _, id := range postUsageIDs {
if _, err := s.resolver.ResolveAndActivate(ctx, id, ResolveOptions{Trigger: constants.SilenceTriggerAdjustApproval, AllowExpire: s.sw.Enabled(ctx)}); err != nil {
return err
}
}
return nil
}
s.afterExecuted(ctx, postUsageIDs)
return nil
}
// applyAdjustDecisionInTx 处理购买时调整审批终态(技术方案 §4.3):通过按 起算日期 + 审批天数 重算截止日;
// 驳回或撤回保持默认值;两种情况都清除审批中标记,提交后立即重新判定。
func (s *ExtensionService) applyAdjustDecisionInTx(ctx context.Context, tx *gorm.DB, batch *model.PackageSilenceExtensionBatch, approved bool, approvalStatus string, now time.Time) (uint, error) {
if batch.AdjustPackageUsageID == nil {
return 0, errors.New(errors.CodeInvalidStatus, "调整申请缺少套餐记录")
}
var usage model.PackageUsage
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", *batch.AdjustPackageUsageID).First(&usage).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐记录失败")
}
batchStatus := constants.SilenceBatchStatusRejected
values := map[string]any{"silence_adjust_pending": false, "updated_at": now}
beforeDeadline := copyTimePtr(usage.SilenceDeadlineAt)
days := usage.SilencePeriodDays
if approved && batch.AdjustRequestedDays != nil && usage.SilenceStartAt != nil {
deadline := packagedomain.SilenceDeadline(*usage.SilenceStartAt, *batch.AdjustRequestedDays)
values["silence_period_days"] = *batch.AdjustRequestedDays
values["silence_deadline_at"] = deadline
usage.SilenceDeadlineAt = &deadline
days = batch.AdjustRequestedDays
batchStatus = constants.SilenceBatchStatusCompleted
}
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id = ? AND silence_adjust_pending = ?", usage.ID, true).Updates(values).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐沉默期调整结果失败")
}
usage.SilenceAdjustPending = false
usage.SilencePeriodDays = days
if _, err := postgres.NewPackageSilenceBatchStore(tx).TransitionStatus(ctx, batch.ID, []string{constants.SilenceBatchStatusPendingApproval}, batchStatus, map[string]any{
"approval_status": approvalStatus, "approval_finished_at": now, "completed_at": now,
"succeeded_count": boolToInt(approved), "failed_count": boolToInt(!approved),
}); err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新调整申请状态失败")
}
action, summary := constants.AuditActionUsageSilenceAdjustRejected, "购买时调整沉默期审批未通过,保持默认值"
if approved {
action, summary = constants.AuditActionUsageSilenceAdjustApproved, "购买时调整沉默期审批通过,重算截止日"
}
approvalID, batchID := *batch.ApprovalInstanceID, batch.ID
changeDays := 0
if batch.AdjustRequestedDays != nil && batch.AdjustDefaultDays != nil {
changeDays = *batch.AdjustRequestedDays - *batch.AdjustDefaultDays
}
err := writeRecord(ctx, tx, s.audit, recordInput{
Usage: &usage, EventType: constants.SilenceRecordEventAdjust, Trigger: constants.SilenceTriggerAdjustApproval,
BeforeStatus: copyStringPtr(usage.SilenceStatus), BeforeDeadline: beforeDeadline, DefaultDays: batch.AdjustDefaultDays,
IsAdjusted: approved, ChangeDays: changeDays, Reason: batch.Reason, ApprovalID: &approvalID, BatchID: &batchID,
Extra: map[string]any{"stage": approvalStatus, "requested_days": batch.AdjustRequestedDays},
AuditAction: action, AuditSummary: summary,
})
return usage.ID, err
}
// applyOfflineDecisionInTx 处理线下付费延期审批:通过执行延期,驳回或撤回订单 rejected、不延期、系统不退款。
func (s *ExtensionService) applyOfflineDecisionInTx(ctx context.Context, tx *gorm.DB, batch *model.PackageSilenceExtensionBatch, approved bool, approvalStatus string, now time.Time) (uint, error) {
var order model.PackageSilenceExtensionOrder
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("batch_id = ?", batch.ID).First(&order).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "锁定线下延期订单失败")
}
if _, err := postgres.NewPackageSilenceBatchStore(tx).TransitionStatus(ctx, batch.ID, []string{constants.SilenceBatchStatusPendingApproval},
map[bool]string{true: constants.SilenceBatchStatusExecuting, false: constants.SilenceBatchStatusRejected}[approved],
map[string]any{"approval_status": approvalStatus, "approval_finished_at": now}); err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新线下延期批次失败")
}
var usage model.PackageUsage
_ = tx.WithContext(ctx).Where("id = ?", order.PackageUsageID).First(&usage).Error
if !approved {
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, []string{constants.SilenceOrderStatusPendingApproval}, constants.SilenceOrderStatusRejected, nil); err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新线下延期订单为已驳回失败")
}
if err := tx.Model(&model.PackageSilenceExtension{}).Where("batch_id = ? AND status = ?", batch.ID, constants.SilenceExtensionStatusPendingApproval).
Updates(map[string]any{"status": constants.SilenceExtensionStatusRejected, "updated_at": now}).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新线下延期明细为已驳回失败")
}
if err := tx.Model(&model.PackageSilenceExtensionBatch{}).Where("id = ?", batch.ID).Updates(map[string]any{"failed_count": 1, "completed_at": now}).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新线下延期批次计数失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusRejected
if usage.ID > 0 && s.audit != nil {
_ = s.audit.AppendSilenceUsage(ctx, tx, extensionAuditInput(&usage, batch, constants.AuditActionUsageSilenceExtensionRejected, "线下付费延期审批未通过"))
}
return 0, s.writeOrderAudit(ctx, tx, &order, before, "线下付费延期审批未通过,订单已驳回")
}
if usage.ID > 0 && s.audit != nil {
_ = s.audit.AppendSilenceUsage(ctx, tx, extensionAuditInput(&usage, batch, constants.AuditActionUsageSilenceExtensionApproved, "线下付费延期审批通过"))
}
ok, err := s.executeOrderInTx(ctx, tx, &order)
if err != nil || !ok {
return 0, err
}
return order.PackageUsageID, nil
}
// applySpecialDecisionInTx 处理特殊延期审批:通过时明细置为已批准、提交后逐条执行;驳回或撤回不产生任何变化。
func (s *ExtensionService) applySpecialDecisionInTx(ctx context.Context, tx *gorm.DB, batch *model.PackageSilenceExtensionBatch, approved bool, approvalStatus string, now time.Time) (bool, error) {
batchStatus, detailStatus := constants.SilenceBatchStatusRejected, constants.SilenceExtensionStatusRejected
action, summary := constants.AuditActionUsageSilenceExtensionRejected, "特殊延期审批未通过"
if approved {
batchStatus, detailStatus = constants.SilenceBatchStatusExecuting, constants.SilenceExtensionStatusApproved
action, summary = constants.AuditActionUsageSilenceExtensionApproved, "特殊延期审批通过"
}
if _, err := postgres.NewPackageSilenceBatchStore(tx).TransitionStatus(ctx, batch.ID, []string{constants.SilenceBatchStatusPendingApproval}, batchStatus,
map[string]any{"approval_status": approvalStatus, "approval_finished_at": now}); err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新特殊延期批次失败")
}
var extensions []model.PackageSilenceExtension
if err := tx.WithContext(ctx).Where("batch_id = ? AND status = ?", batch.ID, constants.SilenceExtensionStatusPendingApproval).Find(&extensions).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询特殊延期明细失败")
}
if err := tx.Model(&model.PackageSilenceExtension{}).Where("batch_id = ? AND status = ?", batch.ID, constants.SilenceExtensionStatusPendingApproval).
Updates(map[string]any{"status": detailStatus, "updated_at": now}).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新特殊延期明细失败")
}
if !approved {
if err := tx.Model(&model.PackageSilenceExtensionBatch{}).Where("id = ?", batch.ID).
Updates(map[string]any{"failed_count": len(extensions), "completed_at": now}).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新特殊延期批次计数失败")
}
}
for index := range extensions {
var usage model.PackageUsage
if err := tx.WithContext(ctx).Where("id = ?", extensions[index].PackageUsageID).First(&usage).Error; err == nil && s.audit != nil {
_ = s.audit.AppendSilenceUsage(ctx, tx, extensionAuditInput(&usage, batch, action, summary))
}
}
return approved, nil
}
// HandleException 后台处理异常明细(权限 package_silence:exception):重试执行或关闭;不得直接改库时间。
// 在线订单关闭时按冻结商户原路退款。
func (s *ExtensionService) HandleException(ctx context.Context, extensionID uint, req dto.SilenceExceptionHandleRequest) (*dto.SilenceExtensionResponse, error) {
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceException); err != nil {
return nil, err
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
return nil, errors.New(errors.CodeInvalidParam, "处理原因不能为空")
}
extension, err := postgres.NewPackageSilenceExtensionStore(s.db).GetByID(ctx, extensionID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "延期明细不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
}
if _, err := s.loadUsage(ctx, s.db, extension.PackageUsageID, Scope{}); err != nil {
return nil, err
}
if extension.Status != constants.SilenceExtensionStatusException {
return nil, errors.New(errors.CodeInvalidStatus, "只能处理执行异常的明细")
}
operatorID := middleware.GetUserIDFromContext(ctx)
now := s.now()
executed, refundOrderID := false, uint(0)
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
locked, err := postgres.NewPackageSilenceExtensionStore(tx).LockByID(ctx, extensionID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期明细失败")
}
if locked.Status != constants.SilenceExtensionStatusException {
return errors.New(errors.CodeInvalidStatus, "只能处理执行异常的明细")
}
var order *model.PackageSilenceExtensionOrder
if locked.ExtensionOrderID != nil {
if order, err = postgres.NewPackageSilenceOrderStore(tx).LockByID(ctx, *locked.ExtensionOrderID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期订单失败")
}
}
if err := tx.Model(&model.PackageSilenceExtension{}).Where("id = ?", locked.ID).
Updates(map[string]any{"handled_by": operatorID, "handled_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "记录异常处理人失败")
}
if req.Action == "close" {
if _, err := postgres.NewPackageSilenceExtensionStore(tx).TransitionStatus(ctx, locked.ID, []string{constants.SilenceExtensionStatusException},
constants.SilenceExtensionStatusClosed, map[string]any{"failure_summary": truncate("人工关闭:"+reason, 500)}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭异常明细失败")
}
if order != nil && order.PaymentMethod != model.PaymentByOffline && order.Status == constants.SilenceOrderStatusException {
refundOrderID = order.ID
}
if order != nil && order.PaymentMethod == model.PaymentByOffline && order.Status == constants.SilenceOrderStatusException {
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, []string{constants.SilenceOrderStatusException},
constants.SilenceOrderStatusClosed, map[string]any{"closed_at": now, "close_reason": truncate("人工关闭:"+reason, 200)}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭线下延期订单失败")
}
}
return refreshBatchCountsInTx(ctx, tx, locked.BatchID, now)
}
// 重试:按原明细与快照重新执行
batch, err := postgres.NewPackageSilenceBatchStore(tx).GetByID(ctx, locked.BatchID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败")
}
if order != nil {
if order.Status != constants.SilenceOrderStatusException {
return errors.New(errors.CodeInvalidStatus, "延期订单不是异常状态,不能重试")
}
if order.RefundNo != nil {
return errors.New(errors.CodeInvalidStatus, "延期订单已发起原路退款,不能重试")
}
ok, err := s.executeOrderInTx(ctx, tx, order)
executed = ok
return err
}
from := []string{constants.SilenceExtensionStatusException}
outcome, err := s.executeExtensionInTx(ctx, tx, locked, executeKind{free: batch.ExtensionType == constants.SilenceExtensionTypeFree},
paidSnapshot{freeLimit: batch.FreeExtensionLimitSnapshot}, from)
if err != nil {
return err
}
if !outcome.executed {
return errors.NewWithData(errors.CodeInvalidStatus, map[string]any{"failure_code": outcome.failureCode}, "重试仍不满足延期条件:"+outcome.failureText)
}
executed = true
return refreshBatchCountsInTx(ctx, tx, locked.BatchID, now)
})
if err != nil {
return nil, err
}
if executed {
s.afterExecuted(ctx, []uint{extension.PackageUsageID})
}
if refundOrderID > 0 {
if err := s.refundOrder(ctx, refundOrderID, "异常明细人工关闭,原路退款:"+reason); err != nil {
return nil, err
}
}
current, err := postgres.NewPackageSilenceExtensionStore(s.db).GetByID(ctx, extensionID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
}
resp := extensionDTO(current)
return &resp, nil
}
// BatchDetail 查询延期批次与明细。
func (s *ExtensionService) BatchDetail(ctx context.Context, batchID uint) (*dto.SilenceBatchResponse, error) {
batch, err := postgres.NewPackageSilenceBatchStore(s.db).GetByID(ctx, batchID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "延期批次不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败")
}
extensions, err := postgres.NewPackageSilenceExtensionStore(s.db).ListByBatch(ctx, batchID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
}
if len(extensions) > 0 {
if _, err := s.loadUsage(ctx, s.db, extensions[0].PackageUsageID, Scope{}); err != nil {
return nil, err
}
} else if batch.AdjustPackageUsageID != nil {
if _, err := s.loadUsage(ctx, s.db, *batch.AdjustPackageUsageID, Scope{}); err != nil {
return nil, err
}
}
resp := BatchDTO(batch)
for index := range extensions {
resp.Items = append(resp.Items, extensionDTO(&extensions[index]))
}
return resp, nil
}
// Records 按套餐记录查询沉默期流水(后台与 H5)。
func (s *ExtensionService) Records(ctx context.Context, usageID uint, scope Scope) ([]dto.SilenceRecordResponse, error) {
if _, err := s.loadUsage(ctx, s.db, usageID, scope); err != nil {
return nil, err
}
records, err := postgres.NewPackageSilenceRecordStore(s.db).ListByUsage(ctx, usageID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询沉默期流水失败")
}
items := make([]dto.SilenceRecordResponse, 0, len(records))
for index := range records {
record := &records[index]
items = append(items, dto.SilenceRecordResponse{
ID: record.ID, PackageUsageID: record.PackageUsageID, EventType: record.EventType,
EventName: RecordEventName(record.EventType), TriggerSource: record.TriggerSource, AssetType: record.AssetType,
AssetID: record.AssetID, BeforeStatus: record.BeforeStatus, AfterStatus: record.AfterStatus,
BeforeDeadlineAt: record.BeforeDeadlineAt, AfterDeadlineAt: record.AfterDeadlineAt, PeriodDays: record.PeriodDays,
DefaultDays: record.DefaultDaysSnapshot, IsAdjusted: record.IsAdjusted, ChangeDays: record.ChangeDays,
OperatorID: record.OperatorID, OperatorType: record.OperatorType, Reason: record.Reason,
ApprovalInstanceID: record.ApprovalInstanceID, BatchID: record.BatchID, ExtensionID: record.ExtensionID,
ExchangeNo: record.ExchangeNo, CreatedAt: record.CreatedAt,
})
}
return items, nil
}
// RecordEventName 返回流水事件名称。
func RecordEventName(event string) string {
switch event {
case constants.SilenceRecordEventSet:
return "设置沉默期"
case constants.SilenceRecordEventAdjust:
return "购买时调整"
case constants.SilenceRecordEventExpired:
return "沉默期过期"
case constants.SilenceRecordEventEnded:
return "期内实名结束"
case constants.SilenceRecordEventExtend:
return "延期"
case constants.SilenceRecordEventExchange:
return "换货迁移"
case constants.SilenceRecordEventBackfill:
return "历史回填"
case constants.SilenceRecordEventRealnameBackfill:
return "补写实名时间"
default:
return event
}
}
// BatchDTO 构造批次响应(不含明细)。
func BatchDTO(batch *model.PackageSilenceExtensionBatch) *dto.SilenceBatchResponse {
return &dto.SilenceBatchResponse{
ID: batch.ID, BatchNo: batch.BatchNo, Path: batch.Path, ExtensionType: batch.ExtensionType,
ApprovalRequired: batch.ApprovalRequired, ApprovalInstanceID: batch.ApprovalInstanceID,
ApprovalStatus: batch.ApprovalStatus, Status: batch.Status, Reason: batch.Reason, Basis: batch.Basis,
EvidenceRefs: nonNil([]string(batch.EvidenceRefs)), Amount: batch.Amount, RequestedBy: batch.RequestedBy,
RequestedAt: batch.RequestedAt, RequestedCount: batch.RequestedCount, SucceededCount: batch.SucceededCount,
FailedCount: batch.FailedCount, CompletedAt: batch.CompletedAt,
}
}
func extensionDTO(extension *model.PackageSilenceExtension) dto.SilenceExtensionResponse {
return dto.SilenceExtensionResponse{
ID: extension.ID, BatchID: extension.BatchID, RowNo: extension.RowNo, PackageUsageID: extension.PackageUsageID,
PackageName: extension.PackageNameSnapshot, AssetType: extension.AssetType, AssetIdentifier: extension.AssetIdentifierSnapshot,
ExtensionType: extension.ExtensionType, InputDays: extension.InputDays, Months: extension.Months,
ExtensionDays: extension.ExtensionDays, Amount: extension.Amount, ExtensionOrderID: extension.ExtensionOrderID,
BeforeDeadlineAt: extension.BeforeDeadlineAt, AfterDeadlineAt: extension.AfterDeadlineAt, Status: extension.Status,
FailureCode: extension.FailureCode, FailureSummary: extension.FailureSummary, ExecutedAt: extension.ExecutedAt,
}
}
func extensionAuditInput(usage *model.PackageUsage, batch *model.PackageSilenceExtensionBatch, action, summary string) audit.SilenceAuditInput {
return audit.SilenceAuditInput{
ActionCode: action, Summary: summary, Usage: usage,
AfterData: map[string]any{"batch_status": batch.Status},
Metadata: map[string]any{
"batch_no": batch.BatchNo, "batch_id": batch.ID, "path": batch.Path, "extension_type": batch.ExtensionType,
"approval_required": batch.ApprovalRequired, "approval_instance_id": batch.ApprovalInstanceID,
"amount": batch.Amount, "reason": truncate(batch.Reason, 200), "evidence_refs": []string(batch.EvidenceRefs),
"requested_by": batch.RequestedBy,
},
}
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}

View File

@@ -0,0 +1,78 @@
package packagesilence
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ExchangeHook 是换货流程中的沉默期与出库时间接缝(技术方案 §9、§17.3.2)。
type ExchangeHook struct {
setter *Setter
outbound *OutboundRecorder
}
// NewExchangeHook 创建换货接缝。
func NewExchangeHook(setter *Setter, outbound *OutboundRecorder) *ExchangeHook {
return &ExchangeHook{setter: setter, outbound: outbound}
}
// OnUsagesMigratedInTx 换货迁移套餐记录后写沉默期流水(exchange):沉默期字段随记录迁移,不重新起算,截止时间不变;
// 过期记录仍为过期,可在新资产上延期。
func (h *ExchangeHook) OnUsagesMigratedInTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, usageIDs []uint) error {
if h == nil || len(usageIDs) == 0 {
return nil
}
var usages []model.PackageUsage
if err := tx.WithContext(ctx).Where("id IN ? AND silence_status IS NOT NULL", usageIDs).Find(&usages).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询迁移的沉默期套餐记录失败")
}
var writer = h.setter.audit
for index := range usages {
usage := &usages[index]
exchangeID := order.ID
if err := writeRecord(ctx, tx, writer, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventExchange, Trigger: constants.SilenceTriggerExchange,
BeforeStatus: copyStringPtr(usage.SilenceStatus), BeforeDeadline: copyTimePtr(usage.SilenceDeadlineAt),
ExchangeID: &exchangeID, ExchangeNo: order.ExchangeNo, Reason: "换货迁移套餐记录,沉默期随套餐迁移、不重新起算",
Extra: map[string]any{
"old_asset_type": order.OldAssetType, "old_asset_id": order.OldAssetID,
"new_asset_type": order.NewAssetType, "new_asset_id": order.NewAssetID,
},
AuditAction: constants.AuditActionUsageSilenceMigratedOnExchange, AuditSummary: "换货迁移套餐沉默期",
}); err != nil {
return err
}
}
return nil
}
// OnNewAssetInTx 换货完成时为新资产写出库时间(换货完成时间)与 exchange 出库记录;已有出库时间的不覆盖。
func (h *ExchangeHook) OnNewAssetInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, order *model.ExchangeOrder, completedAt time.Time) error {
if h == nil || h.outbound == nil {
return nil
}
return h.outbound.RecordForExchangeInTx(ctx, tx, normalizeOutboundAssetType(assetType), assetID, order.ID, order.ExchangeNo, completedAt)
}
// OnOldAssetRenewedInTx 旧资产换货回库、世代 +1 的同一事务内清空出库时间并结束旧世代出库记录。
func (h *ExchangeHook) OnOldAssetRenewedInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, oldGeneration int, at time.Time) error {
if h == nil || h.outbound == nil {
return nil
}
return h.outbound.EndGenerationInTx(ctx, tx, normalizeOutboundAssetType(assetType), assetID, oldGeneration, at)
}
func normalizeOutboundAssetType(assetType string) string {
switch assetType {
case "card", constants.OutboundAssetTypeIotCard:
return constants.OutboundAssetTypeIotCard
default:
return constants.OutboundAssetTypeDevice
}
}

View File

@@ -0,0 +1,231 @@
package packagesilence
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
"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"
)
// executeKind 描述延期执行时的计数口径。
type executeKind struct {
paid bool // 付费延期(在线与线下):受政策快照上限约束并计入付费累计天数
free bool // 免费延期:受免费次数约束并计数
}
// executeOutcome 是一次延期执行结果。
type executeOutcome struct {
executed bool
failureCode string
failureText string
afterAt time.Time
}
// paidSnapshot 是付费延期执行时使用的政策快照上限。
type paidSnapshot struct {
paidMaxMonths int
singleMaxMonths *int
freeLimit int
}
// executeExtensionInTx 执行一条延期明细(技术方案 §8.6),调用方在同一事务内:
// 1. 锁载体、锁套餐记录并重读;2. 校验仍为过期、新截止晚于当天,付费另校验上限、免费校验次数;
// 3. 更新套餐记录沉默期字段;4. 写明细状态、流水与审计。
//
// 校验不通过时不改套餐记录,返回失败码由调用方把明细置为 exception(付费)或 failed(批量特殊延期)。
// 事务内不调用支付渠道、Gateway 或企微接口;以明细状态条件更新保证幂等。
func (s *ExtensionService) executeExtensionInTx(ctx context.Context, tx *gorm.DB, extension *model.PackageSilenceExtension, kind executeKind, snapshot paidSnapshot, fromStatuses []string) (executeOutcome, error) {
now := s.now()
var usage model.PackageUsage
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", extension.PackageUsageID).First(&usage).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return executeOutcome{failureCode: constants.SilenceFailureUsageInvalid, failureText: "套餐记录不存在"}, nil
}
return executeOutcome{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐记录失败")
}
if err := lockCarrier(ctx, tx, &usage); err != nil {
return executeOutcome{}, err
}
status := ""
if usage.SilenceStatus != nil {
status = *usage.SilenceStatus
}
if usage.Status != constants.PackageUsageStatusPending || usage.SilenceDeadlineAt == nil ||
!packagedomain.IsSilenceEffectivelyExpired(status, usage.SilenceDeadlineAt, usage.SilenceAdjustPending, now) {
return executeOutcome{failureCode: constants.SilenceFailureNotExpired, failureText: "套餐不是沉默期已过期状态(已激活、已退款或未到期)"}, nil
}
before := *usage.SilenceDeadlineAt
after := packagedomain.SilenceDeadline(before, extension.ExtensionDays)
if !after.After(packagedomain.SilenceDeadline(now, 0)) {
return executeOutcome{failureCode: constants.SilenceFailureDeadlineNotAfter, failureText: "延期后的截止日期不晚于今天"}, nil
}
if kind.paid {
if snapshot.singleMaxMonths != nil && extension.Months > *snapshot.singleMaxMonths {
return executeOutcome{failureCode: constants.SilenceFailureExceedsLimit, failureText: "超过单次付费延期月数上限"}, nil
}
if usage.SilencePaidExtendedDays/constants.SilenceDaysPerMonth+extension.Months > snapshot.paidMaxMonths {
return executeOutcome{failureCode: constants.SilenceFailureExceedsLimit, failureText: "超过付费延期累计月数上限"}, nil
}
}
if kind.free && usage.SilenceFreeExtensionUsed >= snapshot.freeLimit {
return executeOutcome{failureCode: constants.SilenceFailureFreeUsedUp, failureText: "免费延期次数已用完"}, nil
}
updates := map[string]any{
"silence_status": constants.SilenceStatusWaiting, "silence_deadline_at": after,
"silence_extended_days": gorm.Expr("silence_extended_days + ?", extension.ExtensionDays),
"silence_extension_count": gorm.Expr("silence_extension_count + 1"),
"updated_at": now,
}
if kind.paid {
updates["silence_paid_extended_days"] = gorm.Expr("silence_paid_extended_days + ?", extension.ExtensionDays)
}
if kind.free {
updates["silence_free_extension_used"] = gorm.Expr("silence_free_extension_used + 1")
}
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where("id = ? AND status = ? AND silence_deadline_at = ?", usage.ID, constants.PackageUsageStatusPending, before).
Updates(updates)
if result.Error != nil {
return executeOutcome{}, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新套餐沉默期失败")
}
if result.RowsAffected != 1 {
return executeOutcome{failureCode: constants.SilenceFailureUsageInvalid, failureText: "套餐沉默期已变化"}, nil
}
affected, err := postgres.NewPackageSilenceExtensionStore(tx).TransitionStatus(ctx, extension.ID, fromStatuses, constants.SilenceExtensionStatusCompleted, map[string]any{
"before_deadline_at": before, "after_deadline_at": after, "executed_at": now, "failure_code": "", "failure_summary": "",
})
if err != nil {
return executeOutcome{}, errors.Wrap(errors.CodeDatabaseError, err, "更新延期明细失败")
}
if affected != 1 {
// 明细已被其他事件执行:回滚本次对套餐记录的更新,保证只执行一次。
return executeOutcome{}, errAlreadyExecuted
}
extension.Status = constants.SilenceExtensionStatusCompleted
extension.BeforeDeadlineAt, extension.AfterDeadlineAt, extension.ExecutedAt = &before, &after, &now
beforeStatus := stringPtr(status)
usage.SilenceStatus = stringPtr(constants.SilenceStatusWaiting)
usage.SilenceDeadlineAt = &after
usage.SilenceExtendedDays += extension.ExtensionDays
usage.SilenceExtensionCount++
if kind.paid {
usage.SilencePaidExtendedDays += extension.ExtensionDays
}
if kind.free {
usage.SilenceFreeExtensionUsed++
}
batchID, extensionID := extension.BatchID, extension.ID
if err := writeRecord(ctx, tx, s.audit, recordInput{
Usage: &usage, EventType: constants.SilenceRecordEventExtend, Trigger: constants.SilenceTriggerExtension,
BeforeStatus: beforeStatus, BeforeDeadline: &before, ChangeDays: extension.ExtensionDays,
BatchID: &batchID, ExtensionID: &extensionID, Reason: "沉默期延期生效",
Extra: map[string]any{
"extension_type": extension.ExtensionType, "months": extension.Months, "amount": extension.Amount,
"extension_order_id": extension.ExtensionOrderID, "paid": kind.paid, "free": kind.free,
},
AuditAction: constants.AuditActionUsageSilenceExtensionApplied, AuditSummary: "沉默期延期生效",
}); err != nil {
return executeOutcome{}, err
}
return executeOutcome{executed: true, afterAt: after}, nil
}
// errAlreadyExecuted 表示明细已被并发事件执行,调用方按幂等成功处理。
var errAlreadyExecuted = errors.New(errors.CodeConflict, "延期明细已执行")
func isAlreadyExecuted(err error) bool {
return err == errAlreadyExecuted
}
// markExtensionFailedInTx 把明细置为 exception / failed 并写审计(不改套餐记录)。
func (s *ExtensionService) markExtensionFailedInTx(ctx context.Context, tx *gorm.DB, extension *model.PackageSilenceExtension, to string, fromStatuses []string, code, text string) error {
affected, err := postgres.NewPackageSilenceExtensionStore(tx).TransitionStatus(ctx, extension.ID, fromStatuses, to, map[string]any{
"failure_code": code, "failure_summary": truncate(text, 500),
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期明细失败状态失败")
}
if affected != 1 {
return nil
}
extension.Status, extension.FailureCode, extension.FailureSummary = to, code, text
if s.audit == nil {
return nil
}
var usage model.PackageUsage
if err := tx.WithContext(ctx).Where("id = ?", extension.PackageUsageID).First(&usage).Error; err != nil {
return nil
}
return s.audit.AppendSilenceUsage(ctx, tx, auditInputForException(&usage, extension, code, text))
}
// afterExecuted 在事务提交后按延期结果判定沉默期:资产在新截止内已实名则立即激活(或排队)。
func (s *ExtensionService) afterExecuted(ctx context.Context, usageIDs []uint) {
for _, id := range usageIDs {
if _, err := s.resolver.ResolveAndActivate(ctx, id, ResolveOptions{Trigger: constants.SilenceTriggerExtension, AllowExpire: s.sw.Enabled(ctx)}); err != nil {
s.logger.Warn("延期生效后判定沉默期失败,等待扫描或实名接续")
}
}
}
// lockCarrier 锁载体行,避免与换货、实名、激活并发改变载体关系。
func lockCarrier(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) error {
assetType, assetID := usageAsset(usage)
if assetID == 0 {
return nil
}
var id uint
if err := tx.WithContext(ctx).Table(outboundTable(assetType)).Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").Where("id = ?", assetID).Scan(&id).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐载体失败")
}
return nil
}
// refreshBatchCountsInTx 按明细结果刷新批次计数与状态(部分失败不影响其他明细)。
func refreshBatchCountsInTx(ctx context.Context, tx *gorm.DB, batchID uint, now time.Time) error {
type countRow struct {
Status string
Count int
}
var rows []countRow
if err := tx.WithContext(ctx).Model(&model.PackageSilenceExtension{}).Select("status, COUNT(*) AS count").
Where("batch_id = ?", batchID).Group("status").Scan(&rows).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "统计延期明细失败")
}
total, succeeded, failed, open := 0, 0, 0, 0
for _, row := range rows {
total += row.Count
switch row.Status {
case constants.SilenceExtensionStatusCompleted:
succeeded += row.Count
case constants.SilenceExtensionStatusFailed, constants.SilenceExtensionStatusException,
constants.SilenceExtensionStatusClosed, constants.SilenceExtensionStatusRejected:
failed += row.Count
default:
open += row.Count
}
}
values := map[string]any{"requested_count": total, "succeeded_count": succeeded, "failed_count": failed, "updated_at": now}
if open == 0 {
switch {
case succeeded == total && total > 0:
values["status"] = constants.SilenceBatchStatusCompleted
case succeeded > 0:
values["status"] = constants.SilenceBatchStatusPartiallyCompleted
default:
values["status"] = constants.SilenceBatchStatusFailed
}
values["completed_at"] = now
}
return tx.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).
Where("id = ? AND status NOT IN ?", batchID, []string{constants.SilenceBatchStatusRejected, constants.SilenceBatchStatusClosed}).
Updates(values).Error
}

View File

@@ -0,0 +1,344 @@
package packagesilence
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// OwnershipChecker 校验 H5 客户是否拥有资产(由客户绑定服务实现)。
type OwnershipChecker interface {
OwnsAsset(ctx context.Context, customerID uint, assetType string, assetID uint) (bool, error)
}
// PaymentPorts 是在线支付渠道 Adapter(与代理在线充值同一套)。
type PaymentPorts struct {
Wechat agentrechargeApp.OnlinePaymentPort
Alipay agentrechargeApp.OnlinePaymentPort
Fuiou agentrechargeApp.OnlinePaymentPort
}
// ExtensionService 是沉默期延期用例集合(技术方案 §7、§8):资格、在线下单、支付确认、线下登记、特殊延期、执行与异常。
type ExtensionService struct {
db *gorm.DB
sw *Switch
resolver *Resolver
audit *audit.Writer
approval approvalapp.Port
perms PermissionChecker
ownership OwnershipChecker
runtime *merchantpayment.RuntimeLoader
payments PaymentPorts
refunder refundchannel.Refunder
logger *zap.Logger
now func() time.Time
}
// NewExtensionService 创建延期用例。
func NewExtensionService(db *gorm.DB, resolver *Resolver, writer *audit.Writer, logger *zap.Logger) *ExtensionService {
if logger == nil {
logger = zap.NewNop()
}
return &ExtensionService{db: db, sw: resolver.Switch(), resolver: resolver, audit: writer, logger: logger, now: time.Now}
}
// SetApprovalPort 注入审批创建接缝。
func (s *ExtensionService) SetApprovalPort(port approvalapp.Port) { s.approval = port }
// SetPermissionChecker 注入权限校验。
func (s *ExtensionService) SetPermissionChecker(checker PermissionChecker) { s.perms = checker }
// SetOwnershipChecker 注入 H5 资产归属校验。
func (s *ExtensionService) SetOwnershipChecker(checker OwnershipChecker) { s.ownership = checker }
// SetPayment 注入商户池路由、在线支付渠道与原路退款渠道。
func (s *ExtensionService) SetPayment(runtime *merchantpayment.RuntimeLoader, ports PaymentPorts, refunder refundchannel.Refunder) {
s.runtime = runtime
s.payments = ports
s.refunder = refunder
}
// Scope 描述调用方身份:CustomerID 为 0 表示后台账号(叠加店铺数据范围),否则为 H5 客户(校验资产归属)。
type Scope struct {
CustomerID uint
}
// requireEnabled 在总开关关闭时拒绝新的延期申请(silence_feature_disabled);已发生的回调不调用它。
func (s *ExtensionService) requireEnabled(ctx context.Context) error {
if !s.sw.Enabled(ctx) {
return errors.New(errors.CodeSilenceFeatureDisabled)
}
return nil
}
func (s *ExtensionService) requirePermission(ctx context.Context, code string) error {
return requirePermission(ctx, s.perms, code)
}
// requirePermission 校验后台账号权限点;超级管理员不受限。
func requirePermission(ctx context.Context, perms PermissionChecker, code string) error {
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin {
return nil
}
if perms == nil {
return errors.New(errors.CodeForbidden)
}
allowed, err := perms.CheckPermission(ctx, middleware.GetUserIDFromContext(ctx), code, constants.PlatformWeb)
if err != nil {
return err
}
if !allowed {
return errors.New(errors.CodeForbidden, "缺少权限:"+code)
}
return nil
}
// loadUsage 读取套餐记录并校验调用方可见:后台叠加店铺数据范围,H5 校验资产归属。
func (s *ExtensionService) loadUsage(ctx context.Context, db *gorm.DB, usageID uint, scope Scope) (*model.PackageUsage, error) {
var usage model.PackageUsage
if err := db.WithContext(ctx).Where("id = ?", usageID).First(&usage).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "套餐记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐记录失败")
}
assetType, assetID := usageAsset(&usage)
if scope.CustomerID > 0 {
if s.ownership == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "资产归属校验未配置")
}
owned, err := s.ownership.OwnsAsset(ctx, scope.CustomerID, assetType, assetID)
if err != nil {
return nil, err
}
if !owned {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
return &usage, nil
}
var count int64
query := db.WithContext(ctx).Table(outboundTable(assetType)).Where("id = ? AND deleted_at IS NULL", assetID)
if err := middleware.ApplyStrictShopFilter(ctx, query).Count(&count).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验资产数据范围失败")
}
if count == 0 {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return &usage, nil
}
// eligibility 是资格判定的中间事实。
type eligibility struct {
usage *model.PackageUsage
pkg *model.Package
match PolicyMatch
paid packagedomain.SilenceQuote
special packagedomain.SilenceQuote
effectiveStatus string
expired bool
reason string
specialAvailable bool
freeAvailable bool
openOrder *model.PackageSilenceExtensionOrder
openDetail bool
}
// computeEligibility 计算付费与特殊延期资格(技术方案 §8.1、§8.2)。
func (s *ExtensionService) computeEligibility(ctx context.Context, db *gorm.DB, usage *model.PackageUsage, now time.Time) (*eligibility, error) {
result := &eligibility{usage: usage}
if usage.SilenceStatus == nil || *usage.SilenceStatus == "" {
result.reason = constants.SilenceReasonNoSilence
return result, nil
}
result.effectiveStatus = *usage.SilenceStatus
if usage.Status == constants.PackageUsageStatusInvalidated {
result.reason = constants.SilenceReasonRefunded
return result, nil
}
if usage.Status != constants.PackageUsageStatusPending || result.effectiveStatus == constants.SilenceStatusEnded {
result.reason = constants.SilenceReasonActivated
return result, nil
}
result.expired = packagedomain.IsSilenceEffectivelyExpired(result.effectiveStatus, usage.SilenceDeadlineAt, usage.SilenceAdjustPending, now)
if result.expired {
result.effectiveStatus = constants.SilenceStatusExpired
}
if !result.expired {
result.reason = constants.SilenceReasonNotExpired
if usage.SilenceAdjustPending {
result.reason = constants.SilenceReasonAdjustPending
}
return result, nil
}
match, pkg, err := matchPolicyForPackage(ctx, db, usage.PackageID, now)
if err != nil {
return nil, err
}
result.match, result.pkg = match, pkg
order, err := postgres.NewPackageSilenceOrderStore(db).FindOpenByUsage(ctx, usage.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询未结束延期订单失败")
}
result.openOrder = order
openDetail, err := postgres.NewPackageSilenceExtensionStore(db).HasOpenByUsage(ctx, usage.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询未结束延期明细失败")
}
result.openDetail = openDetail
deadline := *usage.SilenceDeadlineAt
result.paid = packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: deadline, Now: now, Policy: match.Snapshot, PaidExtendedDays: usage.SilencePaidExtendedDays,
Kind: packagedomain.SilenceQuotePaid,
})
result.special = packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: deadline, Now: now, Policy: match.Snapshot, Kind: packagedomain.SilenceQuoteSpecial,
})
switch {
case openDetail || order != nil:
result.reason = constants.SilenceReasonOrderPending
case !pkg.SilenceExtensionEnabled:
result.reason = constants.SilenceReasonExtensionDisabled
case match.Snapshot == nil:
result.reason = constants.SilenceReasonNoPolicy
case !result.paid.CanExtend:
result.reason = constants.SilenceReasonExceedsLimit
}
result.specialAvailable = !openDetail && order == nil
result.freeAvailable = result.specialAvailable && usage.SilenceFreeExtensionUsed < match.FreeExtensionLimit()
return result, nil
}
// canPay 表示可以办理付费延期。
func (e *eligibility) canPay() bool {
return e.expired && e.reason == ""
}
// paidError 把不能付费延期的原因转换为错误码。
func (e *eligibility) paidError() error {
switch e.reason {
case "":
return nil
case constants.SilenceReasonNotExpired, constants.SilenceReasonNoSilence, constants.SilenceReasonActivated,
constants.SilenceReasonRefunded, constants.SilenceReasonAdjustPending:
return errors.NewWithData(errors.CodeSilenceExtensionNotExpired, map[string]any{"reason": e.reason})
case constants.SilenceReasonOrderPending:
return errors.New(errors.CodeSilenceExtensionOrderPending)
case constants.SilenceReasonExtensionDisabled:
return errors.New(errors.CodeSilenceExtensionDisabled)
case constants.SilenceReasonNoPolicy:
return errors.New(errors.CodeSilenceExtensionNoPolicy)
case constants.SilenceReasonExceedsLimit:
return errors.NewWithData(errors.CodeSilenceExtensionExceedsLimit, map[string]any{"min_months": e.paid.MinMonths, "max_months": e.paid.MaxMonths})
default:
return errors.New(errors.CodeSilenceExtensionNotExpired)
}
}
// Eligibility 查询延期资格(后台与 H5 同语义);waiting 但已到期的先判定为过期。
func (s *ExtensionService) Eligibility(ctx context.Context, usageID uint, scope Scope) (*dto.SilenceEligibilityResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
if _, err := s.loadUsage(ctx, s.db, usageID, scope); err != nil {
return nil, err
}
if _, err := s.resolver.Resolve(ctx, usageID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
return nil, err
}
usage, err := s.loadUsage(ctx, s.db, usageID, Scope{CustomerID: scope.CustomerID})
if err != nil {
return nil, err
}
now := s.now()
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, err
}
return eligibilityResponse(facts), nil
}
// Quote 按输入天数试算付费延期。
func (s *ExtensionService) Quote(ctx context.Context, usageID uint, days int, scope Scope) (*dto.SilenceQuoteResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
usage, err := s.loadUsage(ctx, s.db, usageID, scope)
if err != nil {
return nil, err
}
now := s.now()
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, err
}
if !facts.expired || usage.SilenceDeadlineAt == nil {
return nil, facts.paidError()
}
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: *usage.SilenceDeadlineAt, Now: now, InputDays: days, Policy: facts.match.Snapshot,
PaidExtendedDays: usage.SilencePaidExtendedDays, Kind: packagedomain.SilenceQuotePaid,
})
deadline := quote.NewDeadline
return &dto.SilenceQuoteResponse{
InputDays: days, Months: quote.Months, BilledDays: quote.BilledDays, Amount: quote.Amount,
NewDeadline: formatDate(&deadline), Valid: quote.InputValid && facts.canPay(),
}, nil
}
func eligibilityResponse(facts *eligibility) *dto.SilenceEligibilityResponse {
usage := facts.usage
resp := &dto.SilenceEligibilityResponse{
PackageUsageID: usage.ID, SilenceStatus: facts.effectiveStatus, CanExtend: facts.canPay(),
SpecialExtensionAvailable: facts.expired && facts.specialAvailable, Reason: facts.reason,
ExtendedDays: usage.SilenceExtendedDays, PaidExtendedDays: usage.SilencePaidExtendedDays,
FreeExtensionUsed: usage.SilenceFreeExtensionUsed, FreeExtensionLimit: facts.match.FreeExtensionLimit(),
FreeExtensionAvailable: facts.expired && facts.freeAvailable, Options: []dto.SilenceExtensionOption{},
}
if usage.SilenceDeadlineAt != nil {
resp.OriginalDeadline = formatDate(usage.SilenceDeadlineAt)
}
if facts.pkg != nil {
resp.ExtensionEnabled = facts.pkg.SilenceExtensionEnabled
}
if facts.match.Policy != nil {
id := facts.match.Policy.ID
resp.PolicyID = &id
resp.PolicyName = facts.match.Policy.Name
resp.UnitPrice = facts.match.Policy.UnitPrice
}
if facts.openOrder != nil {
resp.PendingOrderNo = facts.openOrder.OrderNo
}
if !facts.expired {
return resp
}
resp.OverdueDays = facts.paid.OverdueDays
resp.MinDays = facts.paid.MinDays
resp.MinMonths = facts.paid.MinMonths
resp.MaxMonths = facts.paid.MaxMonths
resp.MaxDays = facts.paid.MaxDays
if facts.canPay() && usage.SilenceDeadlineAt != nil {
for _, option := range packagedomain.SilenceQuoteOptions(facts.paid, *usage.SilenceDeadlineAt, constants.SilenceExtensionOptionsMax) {
deadline := option.NewDeadline
resp.Options = append(resp.Options, dto.SilenceExtensionOption{
Months: option.Months, BilledDays: option.BilledDays, Amount: option.Amount, NewDeadline: formatDate(&deadline),
})
}
}
return resp
}

View File

@@ -0,0 +1,548 @@
package packagesilence
import (
"context"
"fmt"
"slices"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// RegisterOffline 登记线下付费延期(技术方案 §8.4a):实收金额必须等于计算金额,同一收款方式下交易单号唯一;
// 政策快照未勾选 offline_paid 时提交企微审批,勾选时登记即执行。
func (s *ExtensionService) RegisterOffline(ctx context.Context, req dto.SilenceOfflineOrderRequest) (*dto.SilenceExtensionOrderResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceOffline); err != nil {
return nil, err
}
operatorID := middleware.GetUserIDFromContext(ctx)
requestKey := scopedRequestKey(constants.SilenceOrderSourceOffline, 0, &operatorID, req.RequestID)
if existing, err := postgres.NewPackageSilenceOrderStore(s.db).FindByRequestID(ctx, requestKey); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单幂等记录失败")
} else if existing != nil {
return s.orderResponse(ctx, existing, false)
}
tradeNo := strings.TrimSpace(req.OfflineTradeNo)
if tradeNo == "" {
return nil, errors.New(errors.CodeInvalidParam, "线下交易单号不能为空")
}
if len(req.PaymentVoucherKeys) == 0 || len(req.PaymentVoucherKeys) > constants.SilenceExtensionVoucherMaxCount {
return nil, errors.New(errors.CodeInvalidParam, "线下付费延期必须上传 1 至 5 个收款凭证")
}
usage, err := s.loadUsage(ctx, s.db, req.PackageUsageID, Scope{})
if err != nil {
return nil, err
}
if _, err := s.resolver.Resolve(ctx, usage.ID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
return nil, err
}
if replaced, err := s.settleOpenOrder(ctx, usage.ID); err != nil {
return nil, err
} else if replaced != nil {
return nil, errors.New(errors.CodeSilenceExtensionOrderPending, "该套餐已有已支付的在线延期订单,不能再登记线下付款")
}
if usage, err = s.loadUsage(ctx, s.db, req.PackageUsageID, Scope{}); err != nil {
return nil, err
}
now := s.now()
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, err
}
if !facts.canPay() {
return nil, facts.paidError()
}
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: *usage.SilenceDeadlineAt, Now: now, InputDays: req.Days, Policy: facts.match.Snapshot,
PaidExtendedDays: usage.SilencePaidExtendedDays, Kind: packagedomain.SilenceQuotePaid,
})
if !quote.InputValid {
return nil, errors.NewWithData(errors.CodeSilenceExtensionMonthsInvalid, map[string]any{"min_days": quote.MinDays, "max_days": quote.MaxDays})
}
if req.PaidAmount != quote.Amount {
return nil, errors.NewWithData(errors.CodeSilenceOfflineAmountMismatch, map[string]any{"expected_amount": quote.Amount})
}
method, err := loadOfflinePaymentMethod(ctx, s.db, req.OfflinePaymentMethodID)
if err != nil {
return nil, err
}
if err := ensureTradeNoUnused(ctx, s.db, method.ID, tradeNo); err != nil {
return nil, err
}
snapshot := facts.match.Snapshot
approvalRequired := !packagedomain.IsSilenceApprovalExempt(snapshot.ApprovalExemptTypes, constants.SilenceExtensionTypeOfflinePaid)
var preparation approvalapp.Preparation
var submitter approvalSubmitter
if approvalRequired {
if submitter, err = loadSubmitter(ctx, s.db, operatorID); err != nil {
return nil, err
}
if preparation, err = prepareApproval(ctx, s.approval, operatorID, "silence-offline-"+requestKey[:16]); err != nil {
return nil, err
}
}
identifier, err := assetIdentifier(ctx, s.db, usage)
if err != nil {
return nil, err
}
shopID := assetShopID(ctx, s.db, usage)
var order *model.PackageSilenceExtensionOrder
executed := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockCarrier(ctx, tx, usage); err != nil {
return err
}
batchStatus, orderStatus, detailStatus := constants.SilenceBatchStatusExecuting, constants.SilenceOrderStatusPaid, constants.SilenceExtensionStatusExecuting
if approvalRequired {
batchStatus, orderStatus, detailStatus = constants.SilenceBatchStatusPendingApproval, constants.SilenceOrderStatusPendingApproval, constants.SilenceExtensionStatusPendingApproval
}
paidAt, paidAmount := req.PaidAt, req.PaidAmount
batch := &model.PackageSilenceExtensionBatch{
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathOffline,
ExtensionType: constants.SilenceExtensionTypeOfflinePaid, ApprovalRequired: approvalRequired,
EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), PolicyID: &snapshot.PolicyID,
PolicyNameSnapshot: snapshot.Name, UnitPriceSnapshot: snapshot.UnitPrice,
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
PaymentVoucherKeys: model.StringJSONBArray(req.PaymentVoucherKeys), RequestedBy: operatorID,
RequestedByType: constants.SilenceOperatorAccount, RequestedAt: now, Status: batchStatus, RequestedCount: 1, ShopIDTag: shopID,
}
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期批次失败")
}
order = &model.PackageSilenceExtensionOrder{
OrderNo: generateNo(constants.SilenceExtensionOrderNoPrefix, now), BatchID: batch.ID,
PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
AssetIdentifierSnapshot: identifier, ShopID: shopID, Source: constants.SilenceOrderSourceOffline,
CreatedBy: &operatorID, EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), InputDays: req.Days,
Months: quote.Months, ExtensionDays: quote.BilledDays, PolicyID: &snapshot.PolicyID,
PolicyNameSnapshot: snapshot.Name, UnitPrice: snapshot.UnitPrice,
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
BeforeDeadlineAt: *usage.SilenceDeadlineAt, ExpectedDeadlineAt: quote.NewDeadline, Status: orderStatus,
PaymentMethod: model.PaymentByOffline, OfflinePaymentMethodID: &method.ID, OfflinePaymentMethodCode: &method.Code,
OfflinePaymentMethodName: &method.Name, OfflineTradeNo: &tradeNo,
PaymentVoucherKeys: model.StringJSONBArray(req.PaymentVoucherKeys), ApprovalRequired: approvalRequired,
PaidAmount: &paidAmount, PaidAt: &paidAt, RequestID: &requestKey, ShopIDTag: shopID,
}
order.AssetType, order.AssetID = usageAsset(usage)
if err := postgres.NewPackageSilenceOrderStore(tx).Create(ctx, order); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceOfflineTradeNoDuplicate, "线下交易单号已登记或该套餐已有未结束的延期订单")
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建线下延期订单失败")
}
extension := &model.PackageSilenceExtension{
BatchID: batch.ID, PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
AssetType: order.AssetType, AssetID: order.AssetID, AssetIdentifierSnapshot: identifier,
ExtensionType: constants.SilenceExtensionTypeOfflinePaid, InputDays: req.Days, Months: quote.Months,
ExtensionDays: quote.BilledDays, UnitPriceSnapshot: snapshot.UnitPrice, Amount: quote.Amount,
ExtensionOrderID: &order.ID, BeforeDeadlineAt: usage.SilenceDeadlineAt, Status: detailStatus,
}
if err := postgres.NewPackageSilenceExtensionStore(tx).Create(ctx, extension); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceExtensionOrderPending)
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期明细失败")
}
if approvalRequired {
instanceID, err := createApprovalInTx(ctx, tx, s.approval, preparation, batch, submitter, ApplyTypeOfflinePaid, map[string]any{
ApprovalFieldAssetIdentifier: identifier, ApprovalFieldPackageName: usage.PackageName,
ApprovalFieldOrderNo: order.OrderNo, ApprovalFieldOriginalDeadline: formatDate(usage.SilenceDeadlineAt),
ApprovalFieldInputDays: req.Days, ApprovalFieldBilledDays: quote.BilledDays,
ApprovalFieldNewDeadline: formatDate(&quote.NewDeadline), ApprovalFieldAmount: formatAmount(quote.Amount),
ApprovalFieldAmountCent: quote.Amount, ApprovalFieldPaymentMethod: method.Name, ApprovalFieldTradeNo: tradeNo,
ApprovalFieldVoucherKeys: req.PaymentVoucherKeys, ApprovalFieldEvidenceKeys: nonNil(req.EvidenceRefs),
})
if err != nil {
return err
}
if err := tx.Model(&model.PackageSilenceExtensionOrder{}).Where("id = ?", order.ID).Update("approval_instance_id", instanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关联线下延期审批实例失败")
}
order.ApprovalInstanceID = &instanceID
if err := s.writeRequestAudit(ctx, tx, usage, batch, "提交线下付费延期审批"); err != nil {
return err
}
return s.writeOrderAudit(ctx, tx, order, "", "登记线下付费延期,待审批")
}
if err := s.writeOrderAudit(ctx, tx, order, "", "登记线下付费延期(免审批)"); err != nil {
return err
}
ok, err := s.executeOrderInTx(ctx, tx, order)
executed = ok
return err
})
if err != nil {
return nil, err
}
if executed {
s.afterExecuted(ctx, []uint{usage.ID})
}
current, err := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, order.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
}
return s.orderResponse(ctx, current, false)
}
// SubmitSpecial 提交特殊延期(技术方案 §8.5):单个或批量;免费次数受政策约束;不受付费上限与"是否支持延期"约束。
// 政策快照未勾选对应免审批场景(批量看 batch,否则看延期类型)时提交企微审批,通过后自动执行;勾选时提交即逐条执行。
func (s *ExtensionService) SubmitSpecial(ctx context.Context, req dto.SilenceSpecialRequest) (*dto.SilenceBatchResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
if !slices.Contains(constants.SilenceSpecialExtensionTypes, req.ExtensionType) {
return nil, errors.New(errors.CodeInvalidParam, "特殊延期类型非法")
}
isBatch := len(req.Items) > 1 || strings.TrimSpace(req.ImportFileKey) != ""
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceExtend); err != nil {
return nil, err
}
if isBatch {
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceExtendBatch); err != nil {
return nil, err
}
}
if len(req.Items) == 0 || len(req.Items) > constants.SilenceBatchImportMaxRows {
return nil, errors.New(errors.CodeInvalidParam, "特殊延期明细数量需在 1~1000 之间")
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
return nil, errors.New(errors.CodeInvalidParam, "特殊延期必须填写原因")
}
free := req.ExtensionType == constants.SilenceExtensionTypeFree
if free && req.Amount != 0 {
return nil, errors.New(errors.CodeInvalidParam, "免费延期费用必须为 0")
}
operatorID := middleware.GetUserIDFromContext(ctx)
now := s.now()
rows, firstMatch, exempt, err := s.validateSpecialItems(ctx, req, free, isBatch, now)
if err != nil {
return nil, err
}
var method *model.EmployeeCollectionPaymentMethod
tradeNo := strings.TrimSpace(req.OfflineTradeNo)
if tradeNo != "" {
if req.OfflinePaymentMethodID == nil || len(req.PaymentVoucherKeys) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "优惠价有收款时需填写收款方式与收款凭证")
}
if method, err = loadOfflinePaymentMethod(ctx, s.db, *req.OfflinePaymentMethodID); err != nil {
return nil, err
}
if err := ensureTradeNoUnused(ctx, s.db, method.ID, tradeNo); err != nil {
return nil, err
}
}
approvalRequired := !exempt
var preparation approvalapp.Preparation
var submitter approvalSubmitter
if approvalRequired {
if submitter, err = loadSubmitter(ctx, s.db, operatorID); err != nil {
return nil, err
}
if preparation, err = prepareApproval(ctx, s.approval, operatorID, fmt.Sprintf("silence-special-%d-%d", operatorID, now.UnixNano())); err != nil {
return nil, err
}
}
snapshot := firstMatch.Snapshot
var batch *model.PackageSilenceExtensionBatch
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
batchStatus, detailStatus := constants.SilenceBatchStatusExecuting, constants.SilenceExtensionStatusApproved
if approvalRequired {
batchStatus, detailStatus = constants.SilenceBatchStatusPendingApproval, constants.SilenceExtensionStatusPendingApproval
}
extensionType := req.ExtensionType
batch = &model.PackageSilenceExtensionBatch{
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathSpecial,
ExtensionType: extensionType, ApprovalRequired: approvalRequired, Reason: truncate(reason, 1000),
EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), Basis: truncate(strings.TrimSpace(req.Basis), 1000),
ImportFileKey: strings.TrimSpace(req.ImportFileKey), FreeExtensionLimitSnapshot: firstMatch.FreeExtensionLimit(),
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(firstMatch.ExemptTypes())), Amount: req.Amount,
PaymentVoucherKeys: model.StringJSONBArray(nonNil(req.PaymentVoucherKeys)), RequestedBy: operatorID,
RequestedByType: constants.SilenceOperatorAccount, RequestedAt: now, Status: batchStatus, RequestedCount: len(rows),
}
if snapshot != nil {
batch.PolicyID, batch.PolicyNameSnapshot, batch.UnitPriceSnapshot = &snapshot.PolicyID, snapshot.Name, snapshot.UnitPrice
batch.PaidMaxMonthsSnapshot, batch.SingleMaxMonthsSnapshot = snapshot.PaidMaxMonths, snapshot.SingleMaxMonths
}
if method != nil {
paidAmount := req.Amount
batch.OfflinePaymentMethodID, batch.OfflinePaymentMethodCode, batch.OfflinePaymentMethodName = &method.ID, &method.Code, &method.Name
batch.OfflineTradeNo, batch.OfflinePaidAmount, batch.OfflinePaidAt = &tradeNo, &paidAmount, req.OfflinePaidAt
}
if len(rows) == 1 {
batch.ShopIDTag = rows[0].shopID
}
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceOfflineTradeNoDuplicate)
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建特殊延期批次失败")
}
perRowAmount := int64(0)
if !free && len(rows) > 0 {
perRowAmount = req.Amount / int64(len(rows))
}
for index, row := range rows {
extension := &model.PackageSilenceExtension{
BatchID: batch.ID, RowNo: index + 1, PackageUsageID: row.usage.ID, PackageID: row.usage.PackageID,
PackageNameSnapshot: row.usage.PackageName, AssetType: row.assetType, AssetID: row.assetID,
AssetIdentifierSnapshot: row.identifier, ExtensionType: extensionType, InputDays: row.days,
Months: row.months, ExtensionDays: row.months * constants.SilenceDaysPerMonth, Amount: perRowAmount,
BeforeDeadlineAt: row.usage.SilenceDeadlineAt, Status: detailStatus,
}
if err := postgres.NewPackageSilenceExtensionStore(tx).Create(ctx, extension); err != nil {
if isUniqueViolation(err) {
return errors.NewWithData(errors.CodeSilenceExtensionOrderPending, map[string]any{"row_no": index + 1, "package_usage_id": row.usage.ID})
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建特殊延期明细失败")
}
if err := s.writeRequestAudit(ctx, tx, row.usage, batch, "提交特殊延期"); err != nil {
return err
}
}
if !approvalRequired {
return nil
}
first := rows[0]
_, err := createApprovalInTx(ctx, tx, s.approval, preparation, batch, submitter, ApplyTypeSpecial, map[string]any{
ApprovalFieldAssetIdentifier: first.identifier, ApprovalFieldPackageName: first.usage.PackageName,
ApprovalFieldOrderNo: first.usage.OrderNo, ApprovalFieldOriginalDeadline: formatDate(first.usage.SilenceDeadlineAt),
ApprovalFieldInputDays: first.days, ApprovalFieldBilledDays: first.months * constants.SilenceDaysPerMonth,
ApprovalFieldExtensionType: specialTypeName(extensionType), ApprovalFieldItemCount: len(rows),
ApprovalFieldAmount: formatAmount(req.Amount), ApprovalFieldAmountCent: req.Amount,
ApprovalFieldReason: reason, ApprovalFieldBasis: strings.TrimSpace(req.Basis),
ApprovalFieldEvidenceKeys: nonNil(req.EvidenceRefs), ApprovalFieldTradeNo: tradeNo,
ApprovalFieldVoucherKeys: nonNil(req.PaymentVoucherKeys),
})
return err
})
if err != nil {
return nil, err
}
if !approvalRequired {
if err := s.executeBatch(ctx, batch.ID); err != nil {
return nil, err
}
}
return s.BatchDetail(ctx, batch.ID)
}
// specialRow 是一条已校验的特殊延期明细。
type specialRow struct {
usage *model.PackageUsage
days int
months int
identifier string
assetType string
assetID uint
shopID uint
}
// validateSpecialItems 逐行校验特殊延期明细,任一行不满足时拒绝并返回行号与原因。
// 返回首行匹配的政策(批次快照)以及是否全部明细的政策都勾选了对应免审批场景。
func (s *ExtensionService) validateSpecialItems(ctx context.Context, req dto.SilenceSpecialRequest, free, isBatch bool, now time.Time) ([]specialRow, PolicyMatch, bool, error) {
scene := packagedomain.SilenceSpecialApprovalScene(req.ExtensionType, isBatch)
rows := make([]specialRow, 0, len(req.Items))
seen := map[uint]bool{}
var first PolicyMatch
exempt := true
rowErrors := make([]map[string]any, 0)
for index, item := range req.Items {
rowNo := index + 1
fail := func(reason string) {
rowErrors = append(rowErrors, map[string]any{"row_no": rowNo, "package_usage_id": item.PackageUsageID, "reason": reason})
}
if seen[item.PackageUsageID] {
fail("同一套餐记录重复")
continue
}
seen[item.PackageUsageID] = true
if _, err := s.resolver.Resolve(ctx, item.PackageUsageID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
return nil, first, false, err
}
usage, err := s.loadUsage(ctx, s.db, item.PackageUsageID, Scope{})
if err != nil {
fail("套餐记录不存在或无权限")
continue
}
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, first, false, err
}
if !facts.expired {
fail("套餐不是沉默期已过期状态")
continue
}
if !facts.specialAvailable {
fail("该套餐已有未结束的延期订单或申请")
continue
}
months, _ := packagedomain.SilenceMonthsForDays(item.Days)
if months < facts.special.MinMonths {
fail(fmt.Sprintf("延期天数不足,至少 %d 天", facts.special.MinDays))
continue
}
if free && !facts.freeAvailable {
if !isBatch && len(req.Items) == 1 {
return nil, first, false, errors.New(errors.CodeSilenceFreeExtensionUsedUp)
}
fail("免费延期次数已用完")
continue
}
if index == 0 || first.Policy == nil {
first = facts.match
}
if !packagedomain.IsSilenceApprovalExempt(facts.match.ExemptTypes(), scene) {
exempt = false
}
identifier, err := assetIdentifier(ctx, s.db, usage)
if err != nil {
return nil, first, false, err
}
assetType, assetID := usageAsset(usage)
rows = append(rows, specialRow{usage: usage, days: item.Days, months: months, identifier: identifier,
assetType: assetType, assetID: assetID, shopID: assetShopID(ctx, s.db, usage)})
}
if len(rowErrors) > 0 {
if len(req.Items) == 1 {
return nil, first, false, errors.NewWithData(errors.CodeSilenceExtensionNotExpired, map[string]any{"rows": rowErrors}, fmt.Sprint(rowErrors[0]["reason"]))
}
return nil, first, false, errors.NewWithData(errors.CodeInvalidParam, map[string]any{"rows": rowErrors}, "批量特殊延期明细校验未通过")
}
return rows, first, exempt, nil
}
// executeBatch 逐条执行已批准的特殊延期明细:每条独立事务,部分失败不影响其他行。
func (s *ExtensionService) executeBatch(ctx context.Context, batchID uint) error {
batch, err := postgres.NewPackageSilenceBatchStore(s.db).GetByID(ctx, batchID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败")
}
extensions, err := postgres.NewPackageSilenceExtensionStore(s.db).ListByBatch(ctx, batchID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
}
free := batch.ExtensionType == constants.SilenceExtensionTypeFree
succeeded := make([]uint, 0, len(extensions))
for index := range extensions {
extension := &extensions[index]
if extension.Status != constants.SilenceExtensionStatusApproved {
continue
}
executed := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
from := []string{constants.SilenceExtensionStatusApproved}
outcome, err := s.executeExtensionInTx(ctx, tx, extension, executeKind{free: free}, paidSnapshot{freeLimit: batch.FreeExtensionLimitSnapshot}, from)
if err != nil {
if isAlreadyExecuted(err) {
return nil
}
return err
}
if !outcome.executed {
return s.markExtensionFailedInTx(ctx, tx, extension, constants.SilenceExtensionStatusFailed, from, outcome.failureCode, outcome.failureText)
}
executed = true
return nil
})
if err != nil {
return err
}
if executed {
succeeded = append(succeeded, extension.PackageUsageID)
}
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return refreshBatchCountsInTx(ctx, tx, batchID, s.now())
}); err != nil {
return err
}
s.afterExecuted(ctx, succeeded)
return nil
}
func (s *ExtensionService) writeRequestAudit(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, batch *model.PackageSilenceExtensionBatch, summary string) error {
if s.audit == nil {
return nil
}
return s.audit.AppendSilenceUsage(ctx, tx, extensionAuditInput(usage, batch, constants.AuditActionUsageSilenceExtensionRequested, summary))
}
func loadOfflinePaymentMethod(ctx context.Context, db *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
var method model.EmployeeCollectionPaymentMethod
if err := db.WithContext(ctx).Where("id = ?", id).First(&method).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
if method.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
}
return &method, nil
}
// ensureTradeNoUnused 同一收款方式下交易单号只能登记一次(延期订单与特殊延期的优惠收款共同校验)。
func ensureTradeNoUnused(ctx context.Context, db *gorm.DB, methodID uint, tradeNo string) error {
var orders, batches int64
if err := db.WithContext(ctx).Model(&model.PackageSilenceExtensionOrder{}).
Where("offline_payment_method_id = ? AND offline_trade_no = ?", methodID, tradeNo).Count(&orders).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验线下交易单号失败")
}
if err := db.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).
Where("offline_payment_method_id = ? AND offline_trade_no = ? AND status NOT IN ?", methodID, tradeNo,
[]string{constants.SilenceBatchStatusRejected, constants.SilenceBatchStatusClosed}).Count(&batches).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验线下交易单号失败")
}
if orders+batches > 0 {
return errors.New(errors.CodeSilenceOfflineTradeNoDuplicate)
}
return nil
}
func specialTypeName(extensionType string) string {
switch extensionType {
case constants.SilenceExtensionTypeFree:
return "免费延期"
case constants.SilenceExtensionTypeDiscount:
return "优惠价延期"
case constants.SilenceExtensionTypeComplaintCompensation:
return "客诉补偿"
case constants.SilenceExtensionTypeSystemCompensation:
return "系统异常补偿"
case constants.SilenceExtensionTypeSpecialCustomer:
return "特殊客户政策"
default:
return extensionType
}
}
// lockBatchForDecision 锁定审批关联的批次。
func lockBatchForDecision(ctx context.Context, tx *gorm.DB, batchID uint) (*model.PackageSilenceExtensionBatch, error) {
var batch model.PackageSilenceExtensionBatch
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", batchID).First(&batch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "沉默期审批关联的批次不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定沉默期批次失败")
}
return &batch, nil
}

View File

@@ -0,0 +1,679 @@
package packagesilence
import (
"context"
"crypto/sha256"
"encoding/hex"
stderrors "errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// OnlineOrderCommand 是在线付费延期下单命令(H5 客户自助或运营后台生成二维码)。
type OnlineOrderCommand struct {
Request dto.SilenceOnlineOrderRequest
Scope Scope
PayerClientIP string
}
// CreateOnlineOrder 创建在线付费延期订单与支付单(技术方案 §8.4):
// 冻结月数、单价、金额与政策快照;商户按支付方式经商户池路由并冻结;事务外调用渠道生成支付内容。
// 同一套餐已有待支付订单时先查单:未支付则关闭旧单再新建,已支付则按已支付处理并返回旧单。
func (s *ExtensionService) CreateOnlineOrder(ctx context.Context, command OnlineOrderCommand) (*dto.SilenceExtensionOrderResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
if s.runtime == nil || s.payments.Wechat == nil || s.payments.Alipay == nil || s.payments.Fuiou == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "沉默期在线支付能力未配置")
}
req := command.Request
source := constants.SilenceOrderSourceClient
var createdBy *uint
if command.Scope.CustomerID == 0 {
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceExtend); err != nil {
return nil, err
}
source = constants.SilenceOrderSourceAdmin
operatorID := middleware.GetUserIDFromContext(ctx)
createdBy = &operatorID
}
requestKey := scopedRequestKey(source, command.Scope.CustomerID, createdBy, req.RequestID)
if existing, err := postgres.NewPackageSilenceOrderStore(s.db).FindByRequestID(ctx, requestKey); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单幂等记录失败")
} else if existing != nil {
if existing.PackageUsageID != req.PackageUsageID || existing.InputDays != req.Days || existing.PaymentMethod != req.PaymentMethod {
return nil, errors.New(errors.CodeConflict, "同一请求标识对应的延期内容不一致")
}
return s.orderResponse(ctx, existing, true)
}
usage, err := s.loadUsage(ctx, s.db, req.PackageUsageID, command.Scope)
if err != nil {
return nil, err
}
if _, err := s.resolver.Resolve(ctx, usage.ID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
return nil, err
}
if replaced, err := s.settleOpenOrder(ctx, usage.ID); err != nil {
return nil, err
} else if replaced != nil {
return s.orderResponse(ctx, replaced, false)
}
usage, err = s.loadUsage(ctx, s.db, req.PackageUsageID, command.Scope)
if err != nil {
return nil, err
}
now := s.now()
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, err
}
if !facts.canPay() {
return nil, facts.paidError()
}
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: *usage.SilenceDeadlineAt, Now: now, InputDays: req.Days, Policy: facts.match.Snapshot,
PaidExtendedDays: usage.SilencePaidExtendedDays, Kind: packagedomain.SilenceQuotePaid,
})
if !quote.InputValid {
return nil, errors.NewWithData(errors.CodeSilenceExtensionMonthsInvalid, map[string]any{
"min_days": quote.MinDays, "max_days": quote.MaxDays, "months": quote.Months,
})
}
order, payment, config, adapter, err := s.createOnlineFacts(ctx, usage, facts, quote, req, source, command.Scope.CustomerID, createdBy, requestKey, now)
if err != nil {
if existing, findErr := postgres.NewPackageSilenceOrderStore(s.db).FindByRequestID(ctx, requestKey); findErr == nil && existing != nil {
return s.orderResponse(ctx, existing, true)
}
return nil, err
}
paymentResult, err := adapter.CreatePaymentURL(ctx, agentrechargeApp.OnlinePaymentRequest{
PaymentID: payment.ID, PaymentNo: payment.PaymentNo, CorrelationID: payment.PaymentNo,
Description: "沉默期延期", Amount: payment.Amount, ExpireAt: *payment.ExpireAt,
PayerClientIP: command.PayerClientIP, Config: config,
})
if err != nil {
if merchantpayment.ShouldDisableMerchant(err) {
if disableErr := merchantpayment.DisableMerchantAfterExplicitFailure(ctx, s.db, payment, "payment_precreate_failed", time.Now()); disableErr != nil {
return nil, disableErr
}
}
if !isUnknownPayment(err) {
_ = s.closeOrder(ctx, order.ID, "支付内容生成失败")
}
return nil, err
}
if strings.TrimSpace(paymentResult.QRContent) == "" {
_ = s.closeOrder(ctx, order.ID, "支付渠道未返回支付内容")
return nil, errors.New(errors.CodeServiceUnavailable, "支付渠道未返回支付内容")
}
if err := s.db.WithContext(ctx).Model(&model.Payment{}).
Where("id = ? AND status = ? AND (qr_content IS NULL OR qr_content = '')", payment.ID, model.PaymentRecordStatusPending).
Update("qr_content", paymentResult.QRContent).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存支付内容失败")
}
return s.orderResponse(ctx, order, true)
}
func (s *ExtensionService) createOnlineFacts(ctx context.Context, usage *model.PackageUsage, facts *eligibility, quote packagedomain.SilenceQuote,
req dto.SilenceOnlineOrderRequest, source string, customerID uint, createdBy *uint, requestKey string, now time.Time,
) (*model.PackageSilenceExtensionOrder, *model.Payment, *model.WechatConfig, agentrechargeApp.OnlinePaymentPort, error) {
identifier, err := assetIdentifier(ctx, s.db, usage)
if err != nil {
return nil, nil, nil, nil, err
}
shopID := assetShopID(ctx, s.db, usage)
var order *model.PackageSilenceExtensionOrder
var payment *model.Payment
var config *model.WechatConfig
var adapter agentrechargeApp.OnlinePaymentPort
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockCarrier(ctx, tx, usage); err != nil {
return err
}
route, err := s.runtime.SelectForNewPaymentWithTx(ctx, tx, req.PaymentMethod, now)
if err != nil {
return err
}
config, err = s.runtime.MerchantConfigWithAuthorization(ctx, route.Merchant)
if err != nil {
return err
}
adapter = s.adapter(req.PaymentMethod, config)
if adapter == nil || !adapter.Available(config) {
return errors.New(errors.CodeNoPaymentConfig)
}
expireAt := onlineExpireAt(config, now)
snapshot := facts.match.Snapshot
batch := &model.PackageSilenceExtensionBatch{
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathOnline,
ExtensionType: constants.SilenceExtensionTypeStandardPaid, EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)),
PolicyID: &snapshot.PolicyID, PolicyNameSnapshot: snapshot.Name, UnitPriceSnapshot: snapshot.UnitPrice,
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
RequestedBy: requesterID(customerID, createdBy), RequestedByType: requesterType(customerID),
RequestedAt: now, Status: constants.SilenceBatchStatusPendingPayment, RequestedCount: 1,
PaymentVoucherKeys: model.StringJSONBArray{}, ShopIDTag: shopID,
}
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期批次失败")
}
order = &model.PackageSilenceExtensionOrder{
OrderNo: generateNo(constants.SilenceExtensionOrderNoPrefix, now), BatchID: batch.ID,
PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
AssetIdentifierSnapshot: identifier, ShopID: shopID, Source: source, CreatedBy: createdBy,
EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), InputDays: req.Days,
Months: quote.Months, ExtensionDays: quote.BilledDays, PolicyID: &snapshot.PolicyID,
PolicyNameSnapshot: snapshot.Name, UnitPrice: snapshot.UnitPrice,
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
BeforeDeadlineAt: *usage.SilenceDeadlineAt, ExpectedDeadlineAt: quote.NewDeadline,
Status: constants.SilenceOrderStatusPendingPayment, PaymentMethod: req.PaymentMethod,
PaymentVoucherKeys: model.StringJSONBArray{}, ExpireAt: &expireAt, RequestID: &requestKey, ShopIDTag: shopID,
}
order.AssetType, order.AssetID = usageAsset(usage)
if customerID > 0 {
order.CustomerID = &customerID
}
if err := postgres.NewPackageSilenceOrderStore(tx).Create(ctx, order); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceExtensionOrderPending)
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期订单失败")
}
extension := &model.PackageSilenceExtension{
BatchID: batch.ID, PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
AssetType: order.AssetType, AssetID: order.AssetID, AssetIdentifierSnapshot: identifier,
ExtensionType: constants.SilenceExtensionTypeStandardPaid, InputDays: req.Days, Months: quote.Months,
ExtensionDays: quote.BilledDays, UnitPriceSnapshot: snapshot.UnitPrice, Amount: quote.Amount,
ExtensionOrderID: &order.ID, BeforeDeadlineAt: usage.SilenceDeadlineAt,
Status: constants.SilenceExtensionStatusPendingPayment,
}
if err := postgres.NewPackageSilenceExtensionStore(tx).Create(ctx, extension); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceExtensionOrderPending)
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期明细失败")
}
paymentNo := generateNo("PAY", now)
payment = &model.Payment{
PaymentNo: paymentNo, OrderID: order.ID, OrderType: constants.PaymentOrderTypeSilenceExtension,
PaymentMethod: req.PaymentMethod, Amount: quote.Amount, Status: model.PaymentRecordStatusPending, ExpireAt: &expireAt,
}
merchantpayment.FreezeRoute(payment, route)
if err := tx.Create(payment).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期支付单失败")
}
if err := merchantpayment.RecordRoutingAttempt(ctx, tx, payment); err != nil {
return err
}
return s.writeOrderAudit(ctx, tx, order, "", "创建在线付费延期订单")
})
if err != nil {
return nil, nil, nil, nil, err
}
return order, payment, config, adapter, nil
}
// settleOpenOrder 处理同一套餐的未结束订单:待支付订单先查单,未支付关闭、已支付按已支付处理并返回该订单。
func (s *ExtensionService) settleOpenOrder(ctx context.Context, usageID uint) (*model.PackageSilenceExtensionOrder, error) {
open, err := postgres.NewPackageSilenceOrderStore(s.db).FindOpenByUsage(ctx, usageID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询未结束延期订单失败")
}
if open == nil {
return nil, nil
}
if open.Status == constants.SilenceOrderStatusPendingApproval {
return nil, errors.New(errors.CodeSilenceExtensionOrderPending, "该套餐有待审批的线下延期订单")
}
paid, err := s.queryAndConfirm(ctx, open)
if err != nil {
return nil, err
}
if paid {
current, getErr := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, open.ID)
if getErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, getErr, "查询延期订单失败")
}
return current, nil
}
if err := s.closeOrder(ctx, open.ID, constants.SilenceExtensionOrderCloseReasonSuperseded); err != nil {
return nil, err
}
return nil, nil
}
// queryAndConfirm 对订单的待支付支付单查单;已支付时走支付确认,返回是否已支付。
// 渠道无法确认时保守返回错误,避免旧单已付又新建。
func (s *ExtensionService) queryAndConfirm(ctx context.Context, order *model.PackageSilenceExtensionOrder) (bool, error) {
var payments []model.Payment
if err := s.db.WithContext(ctx).Where("order_type = ? AND order_id = ?", constants.PaymentOrderTypeSilenceExtension, order.ID).
Order("id DESC").Find(&payments).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
for index := range payments {
payment := &payments[index]
if payment.Status == model.PaymentRecordStatusPaid {
return true, nil
}
if payment.Status != model.PaymentRecordStatusPending {
continue
}
state, result, err := s.queryPayment(ctx, payment)
if err != nil {
return false, err
}
switch state {
case agentrechargeApp.OnlinePaymentStatePaid:
paidAt := s.now()
if result.PaidAt != nil {
paidAt = *result.PaidAt
}
if err := s.ConfirmPayment(ctx, PaymentConfirmCommand{
PaymentNo: payment.PaymentNo, ThirdPartyTradeNo: result.ThirdPartyTradeNo, Amount: result.Amount, PaidAt: paidAt,
}); err != nil {
return false, err
}
return true, nil
case agentrechargeApp.OnlinePaymentStatePending, agentrechargeApp.OnlinePaymentStateUnknown:
if payment.QRContent == "" && state == agentrechargeApp.OnlinePaymentStateUnknown {
// 支付内容从未生成成功:客户无法付款,可以安全关闭。
continue
}
if state == agentrechargeApp.OnlinePaymentStateUnknown {
return false, errors.New(errors.CodeServiceUnavailable, "原待支付订单状态暂时无法确认,请稍后重试")
}
}
}
return false, nil
}
func (s *ExtensionService) queryPayment(ctx context.Context, payment *model.Payment) (string, agentrechargeApp.OnlinePaymentQueryResult, error) {
config, err := s.paymentConfig(ctx, payment)
if err != nil {
return "", agentrechargeApp.OnlinePaymentQueryResult{}, err
}
adapter := s.adapter(payment.PaymentMethod, config)
if adapter == nil {
return "", agentrechargeApp.OnlinePaymentQueryResult{}, errors.New(errors.CodeNoPaymentConfig)
}
result, err := adapter.Query(ctx, agentrechargeApp.OnlinePaymentRequest{
PaymentID: payment.ID, PaymentNo: payment.PaymentNo, CorrelationID: payment.PaymentNo, Config: config,
})
if err != nil {
return agentrechargeApp.OnlinePaymentStateUnknown, result, nil
}
return result.State, result, nil
}
// paymentConfig 按支付单冻结的商户加载凭证,不按当前商户池推断。
func (s *ExtensionService) paymentConfig(ctx context.Context, payment *model.Payment) (*model.WechatConfig, error) {
if s.runtime == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "商户池运行时未配置")
}
if payment.MerchantID == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "延期支付单缺少冻结商户")
}
merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return nil, err
}
return s.runtime.MerchantConfigWithAuthorization(ctx, merchant)
}
func (s *ExtensionService) adapter(paymentMethod string, config *model.WechatConfig) agentrechargeApp.OnlinePaymentPort {
switch paymentMethod {
case model.PaymentByWechat:
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
return s.payments.Fuiou
}
return s.payments.Wechat
case model.PaymentByAlipay:
return s.payments.Alipay
default:
return nil
}
}
// PaymentConfirmCommand 是支付回调或查单确认的支付事实(已验签、已按冻结商户核对)。
type PaymentConfirmCommand struct {
PaymentNo string
ThirdPartyTradeNo string
Amount int64
PaidAt time.Time
}
// ConfirmPayment 处理延期支付成功(回调或查单):pending_payment → paid → completed,重复回调幂等。
// 金额不一致或执行校验不通过时进入 exception,并在提交后按冻结商户原路退款。
// 总开关关闭时照常处理(避免收钱不延期)。
func (s *ExtensionService) ConfirmPayment(ctx context.Context, command PaymentConfirmCommand) error {
var orderID uint
var usageID uint
completed, needRefund := false, false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var payment model.Payment
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("payment_no = ? AND order_type = ?", command.PaymentNo, constants.PaymentOrderTypeSilenceExtension).
First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "延期支付单不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期支付单失败")
}
if payment.Status == model.PaymentRecordStatusPaid || payment.Status == model.PaymentRecordStatusRefunded {
return nil
}
paidAt := command.PaidAt
if paidAt.IsZero() {
paidAt = s.now()
}
if err := tx.Model(&model.Payment{}).Where("id = ? AND status IN ?", payment.ID, []int{model.PaymentRecordStatusPending, model.PaymentRecordStatusFailed}).
Updates(map[string]any{"status": model.PaymentRecordStatusPaid, "third_party_trade_no": command.ThirdPartyTradeNo, "paid_at": paidAt, "updated_at": s.now()}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期支付单失败")
}
order, err := postgres.NewPackageSilenceOrderStore(tx).LockByID(ctx, payment.OrderID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期订单失败")
}
orderID, usageID = order.ID, order.PackageUsageID
paidValues := map[string]any{"paid_payment_id": payment.ID, "paid_amount": command.Amount, "paid_at": paidAt}
if order.Status != constants.SilenceOrderStatusPendingPayment {
// 订单已关闭(超时或被新订单替换)后才到达的成功支付:收到钱但不能延期,进入异常并原路退款。
needRefund = true
return s.orderToException(ctx, tx, order, []string{order.Status}, paidValues, constants.SilenceFailureUsageInvalid, "订单已关闭后收到支付")
}
if command.Amount != order.Amount {
needRefund = true
return s.orderToException(ctx, tx, order, []string{constants.SilenceOrderStatusPendingPayment}, paidValues, constants.SilenceFailureAmountMismatch, "回调金额与订单金额不一致")
}
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, []string{constants.SilenceOrderStatusPendingPayment}, constants.SilenceOrderStatusPaid, paidValues); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为已支付失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusPaid
if err := s.writeOrderAudit(ctx, tx, order, before, "延期订单支付成功"); err != nil {
return err
}
ok, err := s.executeOrderInTx(ctx, tx, order)
if err != nil {
return err
}
completed, needRefund = ok, !ok
return nil
})
if err != nil {
return err
}
if completed {
s.afterExecuted(ctx, []uint{usageID})
}
if needRefund && orderID > 0 {
if refundErr := s.refundOrder(ctx, orderID, "延期执行异常原路退款"); refundErr != nil {
s.logger.Warn("延期异常订单原路退款失败,等待恢复任务重试", zap.Uint("order_id", orderID), zap.Error(refundErr))
}
}
return nil
}
// executeOrderInTx 执行已支付订单对应的明细;成功订单 completed,失败订单与明细进入 exception。
func (s *ExtensionService) executeOrderInTx(ctx context.Context, tx *gorm.DB, order *model.PackageSilenceExtensionOrder) (bool, error) {
var extension model.PackageSilenceExtension
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("extension_order_id = ?", order.ID).First(&extension).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
}
if extension.Status == constants.SilenceExtensionStatusCompleted {
return true, nil
}
from := []string{constants.SilenceExtensionStatusPendingPayment, constants.SilenceExtensionStatusPendingApproval,
constants.SilenceExtensionStatusApproved, constants.SilenceExtensionStatusExecuting, constants.SilenceExtensionStatusException}
outcome, err := s.executeExtensionInTx(ctx, tx, &extension, executeKind{paid: true}, paidSnapshot{
paidMaxMonths: order.PaidMaxMonthsSnapshot, singleMaxMonths: order.SingleMaxMonthsSnapshot, freeLimit: order.FreeExtensionLimitSnapshot,
}, from)
if err != nil {
if isAlreadyExecuted(err) {
return true, nil
}
return false, err
}
now := s.now()
if !outcome.executed {
if err := s.markExtensionFailedInTx(ctx, tx, &extension, constants.SilenceExtensionStatusException, from, outcome.failureCode, outcome.failureText); err != nil {
return false, err
}
if err := s.orderToException(ctx, tx, order, []string{constants.SilenceOrderStatusPaid, constants.SilenceOrderStatusPendingApproval}, nil, outcome.failureCode, outcome.failureText); err != nil {
return false, err
}
return false, refreshBatchCountsInTx(ctx, tx, order.BatchID, now)
}
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID,
[]string{constants.SilenceOrderStatusPaid, constants.SilenceOrderStatusPendingApproval, constants.SilenceOrderStatusException},
constants.SilenceOrderStatusCompleted, map[string]any{"extension_id": extension.ID, "completed_at": now, "exception_code": "", "exception_summary": ""}); err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为已完成失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusCompleted
if err := s.writeOrderAudit(ctx, tx, order, before, "延期订单执行完成"); err != nil {
return false, err
}
return true, refreshBatchCountsInTx(ctx, tx, order.BatchID, now)
}
func (s *ExtensionService) orderToException(ctx context.Context, tx *gorm.DB, order *model.PackageSilenceExtensionOrder, from []string, values map[string]any, code, text string) error {
updates := map[string]any{"exception_code": code, "exception_summary": truncate(text, 500)}
for key, value := range values {
updates[key] = value
}
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, from, constants.SilenceOrderStatusException, updates); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为异常失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusException
order.ExceptionCode, order.ExceptionSummary = code, text
return s.writeOrderAudit(ctx, tx, order, before, "延期订单执行异常:"+text)
}
// closeOrder 关闭待支付订单(超时、被替换或支付内容生成失败):订单 closed、支付单失败、明细与批次关闭。
func (s *ExtensionService) closeOrder(ctx context.Context, orderID uint, reason string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
order, err := postgres.NewPackageSilenceOrderStore(tx).LockByID(ctx, orderID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期订单失败")
}
if order.Status != constants.SilenceOrderStatusPendingPayment {
return nil
}
now := s.now()
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, []string{constants.SilenceOrderStatusPendingPayment},
constants.SilenceOrderStatusClosed, map[string]any{"closed_at": now, "close_reason": truncate(reason, 200)}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期订单失败")
}
if err := tx.Model(&model.Payment{}).Where("order_type = ? AND order_id = ? AND status = ?", constants.PaymentOrderTypeSilenceExtension, order.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期支付单失败")
}
if err := tx.Model(&model.PackageSilenceExtension{}).Where("extension_order_id = ? AND status = ?", order.ID, constants.SilenceExtensionStatusPendingPayment).
Updates(map[string]any{"status": constants.SilenceExtensionStatusClosed, "failure_summary": truncate(reason, 500), "updated_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期明细失败")
}
if _, err := postgres.NewPackageSilenceBatchStore(tx).TransitionStatus(ctx, order.BatchID, []string{constants.SilenceBatchStatusPendingPayment},
constants.SilenceBatchStatusClosed, map[string]any{"completed_at": now}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期批次失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusClosed
return s.writeOrderAudit(ctx, tx, order, before, "关闭延期订单:"+reason)
})
}
// refundOrder 对异常订单按支付单冻结商户原路退款(延期费正常不退,只在执行异常时退)。
func (s *ExtensionService) refundOrder(ctx context.Context, orderID uint, reason string) error {
if s.refunder == nil || s.runtime == nil {
return errors.New(errors.CodeServiceUnavailable, "原路退款能力未配置")
}
order, err := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, orderID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
}
if order.Status != constants.SilenceOrderStatusException || order.PaidPaymentID == nil || order.PaymentMethod == model.PaymentByOffline {
return nil
}
refundNo := ""
if order.RefundNo != nil {
refundNo = *order.RefundNo
} else {
refundNo = generateNo("SR", s.now())
result := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionOrder{}).Where("id = ? AND refund_no IS NULL", order.ID).
Updates(map[string]any{"refund_no": refundNo, "refund_reason": truncate(reason, 500), "updated_at": s.now()})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "登记延期订单退款单号失败")
}
if result.RowsAffected == 0 {
return nil
}
}
var payment model.Payment
if err := s.db.WithContext(ctx).Where("id = ?", *order.PaidPaymentID).First(&payment).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
config, err := s.paymentConfig(ctx, &payment)
if err != nil {
return err
}
merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return err
}
amount := payment.Amount
if order.PaidAmount != nil && *order.PaidAmount > 0 {
amount = *order.PaidAmount
}
target := refundTarget(order, &payment, config, merchant.ProviderType, refundNo, amount)
result, err := s.refunder.Refund(ctx, target)
if err != nil {
return err
}
if result.State != "success" {
return errors.New(errors.CodeServiceUnavailable, "原路退款未确认成功:"+result.FailureMessage)
}
return s.markRefunded(ctx, order.ID, payment.ID, amount)
}
func (s *ExtensionService) markRefunded(ctx context.Context, orderID, paymentID uint, amount int64) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
order, err := postgres.NewPackageSilenceOrderStore(tx).LockByID(ctx, orderID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期订单失败")
}
now := s.now()
affected, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, orderID, []string{constants.SilenceOrderStatusException},
constants.SilenceOrderStatusRefunded, map[string]any{"refund_amount": amount, "refunded_at": now})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为已退款失败")
}
if affected == 0 {
return nil
}
if err := tx.Model(&model.Payment{}).Where("id = ? AND status = ?", paymentID, model.PaymentRecordStatusPaid).
Update("status", model.PaymentRecordStatusRefunded).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期支付单为已退款失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusRefunded
return s.writeOrderAudit(ctx, tx, order, before, "延期异常订单已原路退款")
})
}
// writeOrderAudit 写延期订单状态变化审计(主资源为套餐记录)。
func (s *ExtensionService) writeOrderAudit(ctx context.Context, tx *gorm.DB, order *model.PackageSilenceExtensionOrder, before, summary string) error {
if s.audit == nil {
return nil
}
var usage model.PackageUsage
if err := tx.WithContext(ctx).Where("id = ?", order.PackageUsageID).First(&usage).Error; err != nil {
return nil
}
return s.audit.AppendSilenceUsage(ctx, tx, orderAuditInput(&usage, order, before, summary))
}
func onlineExpireAt(config *model.WechatConfig, now time.Time) time.Time {
minutes := model.DefaultAliPayExpireMinutes
if config != nil && config.AliPayExpireMinutes > 0 {
minutes = config.AliPayExpireMinutes
}
expireAt := now.Add(time.Duration(minutes) * time.Minute)
// 支付截止不跨天:降低跨天支付导致新截止不再晚于当天的概率(技术方案 Risks)。
if endOfDay := packagedomain.SilenceDeadline(now, 0); expireAt.After(endOfDay) {
expireAt = endOfDay
}
return expireAt
}
func scopedRequestKey(source string, customerID uint, createdBy *uint, requestID string) string {
actor := uint(0)
if createdBy != nil {
actor = *createdBy
}
digest := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d:%s", source, customerID, actor, strings.TrimSpace(requestID))))
return hex.EncodeToString(digest[:])
}
func requesterID(customerID uint, createdBy *uint) uint {
if customerID > 0 {
return customerID
}
if createdBy != nil {
return *createdBy
}
return 0
}
func requesterType(customerID uint) string {
if customerID > 0 {
return constants.SilenceOperatorCustomer
}
return constants.SilenceOperatorAccount
}
func nonNil(values []string) []string {
if values == nil {
return []string{}
}
return values
}
func isUnknownPayment(err error) bool {
var appErr *errors.AppError
return stderrors.As(err, &appErr) && appErr.Code == errors.CodeTimeout
}
func isUniqueViolation(err error) bool {
return err != nil && (strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "duplicate key"))
}
// assetShopID 返回套餐载体当前所属店铺(数据范围与对账用)。
func assetShopID(ctx context.Context, db *gorm.DB, usage *model.PackageUsage) uint {
assetType, assetID := usageAsset(usage)
var shopID *uint
_ = db.WithContext(ctx).Table(outboundTable(assetType)).Select("shop_id").Where("id = ?", assetID).Scan(&shopID).Error
if shopID == nil {
return 0
}
return *shopID
}

View File

@@ -0,0 +1,145 @@
package packagesilence
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"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"
)
func refundTarget(order *model.PackageSilenceExtensionOrder, payment *model.Payment, config *model.WechatConfig, providerType, refundNo string, amount int64) refundchannel.Target {
target := refundchannel.Target{
RefundNo: refundNo, OrderID: order.ID, OrderNo: order.OrderNo, ProviderType: providerType, Config: config,
PaymentNo: payment.PaymentNo, ChannelTradeNo: payment.ThirdPartyTradeNo, PaidAt: payment.PaidAt,
PaidAmount: payment.Amount, RefundAmount: amount, FrozenActualReceivedAmount: amount, ChannelRefundRequestNo: refundNo,
}
if providerType == model.ProviderTypeFuiou {
target.ChannelOrderType = "WECHAT"
}
return target
}
func orderAuditInput(usage *model.PackageUsage, order *model.PackageSilenceExtensionOrder, before, summary string) audit.SilenceAuditInput {
return audit.SilenceAuditInput{
ActionCode: constants.AuditActionUsageSilenceExtensionOrderState, Summary: summary, Usage: usage,
BeforeData: map[string]any{"order_status": before},
AfterData: map[string]any{"order_status": order.Status},
Metadata: map[string]any{
"order_no": order.OrderNo, "batch_id": order.BatchID, "source": order.Source, "payment_method": order.PaymentMethod,
"months": order.Months, "extension_days": order.ExtensionDays, "amount": order.Amount,
"paid_payment_id": order.PaidPaymentID, "exception_code": order.ExceptionCode,
"offline_trade_no": order.OfflineTradeNo, "approval_instance_id": order.ApprovalInstanceID,
"before_deadline_at": order.BeforeDeadlineAt, "expected_deadline_at": order.ExpectedDeadlineAt,
},
}
}
func auditInputForException(usage *model.PackageUsage, extension *model.PackageSilenceExtension, code, text string) audit.SilenceAuditInput {
return audit.SilenceAuditInput{
ActionCode: constants.AuditActionUsageSilenceExtensionException, Summary: "沉默期延期执行异常:" + text, Usage: usage,
AfterData: map[string]any{"extension_status": extension.Status},
Metadata: map[string]any{
"batch_id": extension.BatchID, "extension_id": extension.ID, "extension_type": extension.ExtensionType,
"months": extension.Months, "extension_days": extension.ExtensionDays, "amount": extension.Amount,
"extension_order_id": extension.ExtensionOrderID, "failure_code": code,
},
}
}
// orderResponse 构造延期订单响应:支付方式、收款商户、交易流水号取已支付或已退款支付单快照;待支付时返回支付内容。
func (s *ExtensionService) orderResponse(ctx context.Context, order *model.PackageSilenceExtensionOrder, includeQR bool) (*dto.SilenceExtensionOrderResponse, error) {
resp := OrderDTO(order)
var payments []model.Payment
if order.PaymentMethod != model.PaymentByOffline {
if err := s.db.WithContext(ctx).Where("order_type = ? AND order_id = ?", constants.PaymentOrderTypeSilenceExtension, order.ID).
Order("id DESC").Find(&payments).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
}
for index := range payments {
payment := &payments[index]
if order.PaidPaymentID != nil && payment.ID == *order.PaidPaymentID {
resp.PaymentMerchantName = payment.MerchantNameSnapshot
resp.ThirdPartyTradeNo = payment.ThirdPartyTradeNo
resp.PaymentNo = payment.PaymentNo
}
if includeQR && order.Status == constants.SilenceOrderStatusPendingPayment && payment.Status == model.PaymentRecordStatusPending && resp.QRContent == "" {
resp.QRContent = payment.QRContent
resp.PaymentNo = payment.PaymentNo
}
}
var batch model.PackageSilenceExtensionBatch
if err := s.db.WithContext(ctx).Select("id", "batch_no").Where("id = ?", order.BatchID).First(&batch).Error; err == nil {
resp.BatchNo = batch.BatchNo
}
return resp, nil
}
// OrderDTO 构造延期订单基础响应(不含支付单快照)。
func OrderDTO(order *model.PackageSilenceExtensionOrder) *dto.SilenceExtensionOrderResponse {
resp := &dto.SilenceExtensionOrderResponse{
ID: order.ID, OrderNo: order.OrderNo, PackageUsageID: order.PackageUsageID, PackageID: order.PackageID,
PackageName: order.PackageNameSnapshot, AssetType: order.AssetType, AssetID: order.AssetID,
AssetIdentifier: order.AssetIdentifierSnapshot, ShopID: order.ShopID, CustomerID: order.CustomerID,
Source: order.Source, SourceName: orderSourceName(order.Source), InputDays: order.InputDays, Months: order.Months,
ExtensionDays: order.ExtensionDays, UnitPrice: order.UnitPrice, Amount: order.Amount, PolicyName: order.PolicyNameSnapshot,
BeforeDeadlineAt: order.BeforeDeadlineAt, ExpectedDeadlineAt: order.ExpectedDeadlineAt, Status: order.Status,
StatusName: OrderStatusName(order.Status), PaymentMethod: order.PaymentMethod,
PaymentVoucherKeys: []string(order.PaymentVoucherKeys), EvidenceRefs: []string(order.EvidenceRefs),
ApprovalRequired: order.ApprovalRequired, ApprovalInstanceID: order.ApprovalInstanceID, PaidAmount: order.PaidAmount,
PaidAt: order.PaidAt, ExpireAt: order.ExpireAt, CompletedAt: order.CompletedAt, ExceptionCode: order.ExceptionCode,
ExceptionSummary: order.ExceptionSummary, RefundAmount: order.RefundAmount, RefundedAt: order.RefundedAt,
CreatedBy: order.CreatedBy, CreatedAt: order.CreatedAt,
}
if order.OfflinePaymentMethodName != nil {
resp.OfflinePaymentMethodName = *order.OfflinePaymentMethodName
}
if order.OfflineTradeNo != nil {
resp.OfflineTradeNo = *order.OfflineTradeNo
}
if order.RefundNo != nil {
resp.RefundNo = *order.RefundNo
}
return resp
}
// OrderStatusName 返回延期订单状态名称。
func OrderStatusName(status string) string {
switch status {
case constants.SilenceOrderStatusPendingPayment:
return "待支付"
case constants.SilenceOrderStatusPendingApproval:
return "待审批"
case constants.SilenceOrderStatusRejected:
return "已驳回"
case constants.SilenceOrderStatusPaid:
return "已支付"
case constants.SilenceOrderStatusCompleted:
return "已延期"
case constants.SilenceOrderStatusClosed:
return "已关闭"
case constants.SilenceOrderStatusException:
return "执行异常"
case constants.SilenceOrderStatusRefunded:
return "已退款"
default:
return ""
}
}
func orderSourceName(source string) string {
switch source {
case constants.SilenceOrderSourceClient:
return "客户 H5 下单"
case constants.SilenceOrderSourceAdmin:
return "后台二维码"
case constants.SilenceOrderSourceOffline:
return "线下登记"
default:
return ""
}
}

View File

@@ -0,0 +1,223 @@
package packagesilence
import (
"context"
"time"
"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/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// OutboundRecorder 写资产出库时间与出库记录(技术方案 §17)。
// 出库时间 = 资产当前世代第 1 次购买套餐的支付时间;只做记录展示,不受沉默期总开关影响。
type OutboundRecorder struct {
audit *audit.Writer
}
// NewOutboundRecorder 创建出库时间写入用例。
func NewOutboundRecorder(writer *audit.Writer) *OutboundRecorder {
return &OutboundRecorder{audit: writer}
}
// RecordForOrderInTx 在订单发放套餐的事务内写出库时间:只在为空且世代与订单一致时写入,
// 影响行数为 1 时插入出库记录;当前绑定在设备上的卡不记录(套餐在设备上)。
func (r *OutboundRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error {
if r == nil || order == nil {
return nil
}
paidAt := time.Now()
if order.PaidAt != nil {
paidAt = *order.PaidAt
}
generation := order.Generation
if generation <= 0 {
generation = 1
}
orderID := order.ID
switch {
case order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil:
bound, err := cardBoundToDevice(ctx, tx, *order.IotCardID)
if err != nil || bound {
return err
}
return r.write(ctx, tx, constants.OutboundAssetTypeIotCard, *order.IotCardID, generation, paidAt, &model.AssetOutboundRecord{
Source: constants.OutboundSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo,
})
case order.OrderType == model.OrderTypeDevice && order.DeviceID != nil:
return r.write(ctx, tx, constants.OutboundAssetTypeDevice, *order.DeviceID, generation, paidAt, &model.AssetOutboundRecord{
Source: constants.OutboundSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo,
})
default:
return nil
}
}
// EndGenerationInTx 在换货旧资产世代 +1 的同一事务内清空出库时间并结束旧世代记录。
// oldGeneration 为换货前的世代。
func (r *OutboundRecorder) EndGenerationInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, oldGeneration int, endedAt time.Time) error {
if r == nil {
return nil
}
table := outboundTable(assetType)
if table == "" {
return nil
}
if err := tx.WithContext(ctx).Table(table).Where("id = ?", assetID).Update("outbound_at", nil).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清空旧资产出库时间失败")
}
affected, err := postgres.NewAssetOutboundRecordStore(tx).EndGeneration(ctx, assetType, assetID, oldGeneration, endedAt, constants.OutboundEndReasonExchange)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "结束旧世代出库记录失败")
}
if affected > 0 && r.audit != nil {
record := &model.AssetOutboundRecord{AssetType: assetType, AssetID: assetID, Generation: oldGeneration, Source: constants.OutboundSourceExchange}
return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetOutboundEnded, "换货回库结束出库记录",
audit.AssetOutboundResource(record, map[string]any{"ended_at": endedAt, "end_reason": constants.OutboundEndReasonExchange}), nil)
}
return nil
}
// RecordForExchangeInTx 为换货后的新资产写出库时间(换货完成时间)并插入 exchange 记录;已有出库时间的不覆盖。
func (r *OutboundRecorder) RecordForExchangeInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, exchangeOrderID uint, exchangeNo string, completedAt time.Time) error {
if r == nil {
return nil
}
if assetType == constants.OutboundAssetTypeIotCard {
bound, err := cardBoundToDevice(ctx, tx, assetID)
if err != nil || bound {
return err
}
}
generation, err := currentGeneration(ctx, tx, assetType, assetID)
if err != nil {
return err
}
id := exchangeOrderID
return r.write(ctx, tx, assetType, assetID, generation, completedAt, &model.AssetOutboundRecord{
Source: constants.OutboundSourceExchange, ExchangeOrderID: &id, ExchangeNo: exchangeNo,
})
}
// RecordBackfillInTx 历史回填:只更新为空的出库时间并插入 backfill 记录,返回是否写入。
func (r *OutboundRecorder) RecordBackfillInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, outboundAt time.Time, orderID uint, orderNo string) (bool, error) {
id := orderID
before, err := r.countRecords(ctx, tx, assetType, assetID, generation)
if err != nil {
return false, err
}
if err := r.write(ctx, tx, assetType, assetID, generation, outboundAt, &model.AssetOutboundRecord{
Source: constants.OutboundSourceBackfill, OrderID: &id, OrderNo: orderNo,
}); err != nil {
return false, err
}
after, err := r.countRecords(ctx, tx, assetType, assetID, generation)
return after > before, err
}
func (r *OutboundRecorder) countRecords(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int) (int64, error) {
var count int64
err := tx.WithContext(ctx).Model(&model.AssetOutboundRecord{}).
Where("asset_type = ? AND asset_id = ? AND generation = ?", assetType, assetID, generation).Count(&count).Error
return count, err
}
func (r *OutboundRecorder) write(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, outboundAt time.Time, record *model.AssetOutboundRecord) error {
table := outboundTable(assetType)
result := tx.WithContext(ctx).Table(table).
Where("id = ? AND generation = ? AND outbound_at IS NULL AND deleted_at IS NULL", assetID, generation).
Update("outbound_at", outboundAt)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入资产出库时间失败")
}
if result.RowsAffected != 1 {
return nil
}
identifier, shopID, err := outboundAssetSnapshot(ctx, tx, assetType, assetID)
if err != nil {
return err
}
record.AssetType = assetType
record.AssetID = assetID
record.AssetIdentifierSnapshot = identifier
record.Generation = generation
record.OutboundAt = outboundAt
record.ShopID = shopID
record.CreatedAt = time.Now()
inserted, err := postgres.NewAssetOutboundRecordStore(tx).CreateIfAbsent(ctx, record)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入资产出库记录失败")
}
if inserted && r.audit != nil {
return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetOutboundRecorded, "记录资产出库时间",
audit.AssetOutboundResource(record, map[string]any{"outbound_at": outboundAt, "source": record.Source, "order_no": record.OrderNo, "exchange_no": record.ExchangeNo}), nil)
}
return nil
}
func outboundTable(assetType string) string {
switch assetType {
case constants.OutboundAssetTypeIotCard:
return "tb_iot_card"
case constants.OutboundAssetTypeDevice:
return "tb_device"
default:
return ""
}
}
func cardBoundToDevice(ctx context.Context, tx *gorm.DB, cardID uint) (bool, error) {
var count int64
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("iot_card_id = ? AND bind_status = ?", cardID, constants.BindStatusBound).Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败")
}
return count > 0, nil
}
func currentGeneration(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (int, error) {
var generation int
if err := tx.WithContext(ctx).Table(outboundTable(assetType)).Select("generation").Where("id = ?", assetID).Scan(&generation).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产世代失败")
}
return generation, nil
}
func outboundAssetSnapshot(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, *uint, error) {
if assetType == constants.OutboundAssetTypeDevice {
var device model.Device
if err := tx.WithContext(ctx).Select("id", "virtual_no", "imei", "shop_id").Where("id = ?", assetID).First(&device).Error; err != nil {
return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备快照失败")
}
identifier := device.VirtualNo
if identifier == "" {
identifier = device.IMEI
}
return identifier, device.ShopID, nil
}
var card model.IotCard
if err := tx.WithContext(ctx).Select("id", "iccid", "shop_id").Where("id = ?", assetID).First(&card).Error; err != nil {
return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡快照失败")
}
return card.ICCID, card.ShopID, nil
}
// PurchaseRecorder 组合沉默期设置与出库时间写入,供续费等其他发放套餐路径注入。
type PurchaseRecorder struct {
Setter *Setter
Outbound *OutboundRecorder
}
// ApplyOnUsageCreatedInTx 设置沉默期。
func (r PurchaseRecorder) ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error {
return r.Setter.ApplyOnUsageCreatedInTx(ctx, tx, usage, pkg, paidAt, trigger)
}
// RecordForOrderInTx 写出库时间。
func (r PurchaseRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error {
return r.Outbound.RecordForOrderInTx(ctx, tx, order)
}

View File

@@ -0,0 +1,412 @@
package packagesilence
import (
"context"
"slices"
"strings"
"time"
"gorm.io/gorm"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// PolicyService 管理沉默期延期政策(技术方案 §3.6)。权限点 package_silence:policy 在路由层校验。
type PolicyService struct {
db *gorm.DB
audit *audit.Writer
perms PermissionChecker
now func() time.Time
}
// SetPermissionChecker 注入权限校验(package_silence:policy)。
func (s *PolicyService) SetPermissionChecker(checker PermissionChecker) {
s.perms = checker
}
// NewPolicyService 创建延期政策用例。
func NewPolicyService(db *gorm.DB, writer *audit.Writer) *PolicyService {
return &PolicyService{db: db, audit: writer, now: time.Now}
}
// List 分页查询延期政策。
func (s *PolicyService) List(ctx context.Context, req *dto.SilencePolicyListRequest) ([]*dto.SilencePolicyResponse, int64, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, 0, err
}
page, pageSize := normalizePage(req.Page, req.PageSize)
query := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionPolicy{})
if name := strings.TrimSpace(req.Name); name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
}
if req.ScopeType != "" {
query = query.Where("scope_type = ?", req.ScopeType)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.EffectiveAt != "" {
at, err := time.Parse(time.RFC3339, req.EffectiveAt)
if err != nil {
return nil, 0, errors.New(errors.CodeInvalidParam, "生效时间格式应为 RFC3339")
}
query = query.Where("effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", at, at)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计延期政策失败")
}
var policies []model.PackageSilenceExtensionPolicy
if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&policies).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
items := make([]*dto.SilencePolicyResponse, 0, len(policies))
for index := range policies {
items = append(items, policyResponse(&policies[index]))
}
return items, total, nil
}
// Get 查询政策详情。
func (s *PolicyService) Get(ctx context.Context, id uint) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
policy, err := postgres.NewPackageSilencePolicyStore(s.db).GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "延期政策不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
return policyResponse(policy), nil
}
// Create 新增政策:校验字段、同级重叠,写审计。
func (s *PolicyService) Create(ctx context.Context, req *dto.SilencePolicyRequest) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
operatorID := middleware.GetUserIDFromContext(ctx)
policy := &model.PackageSilenceExtensionPolicy{Creator: operatorID, Updater: operatorID, Status: constants.SilencePolicyStatusEnabled}
if err := applyPolicyRequest(policy, req); err != nil {
return nil, err
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := postgres.NewPackageSilencePolicyStore(tx)
if err := store.LockAll(ctx); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期政策失败")
}
if err := s.validateInTx(ctx, tx, policy); err != nil {
return err
}
now := s.now()
policy.CreatedAt, policy.UpdatedAt = now, now
if err := store.Create(ctx, policy); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "新增延期政策失败")
}
return s.writeAudit(ctx, tx, constants.AuditActionSilencePolicyCreated, "新增沉默期延期政策", policy, nil, policyAuditData(policy))
})
if err != nil {
return nil, err
}
return policyResponse(policy), nil
}
// Update 编辑政策:只影响之后发起的延期,已创建订单与已提交审批按快照执行。
func (s *PolicyService) Update(ctx context.Context, id uint, req *dto.SilencePolicyRequest) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
var policy *model.PackageSilenceExtensionPolicy
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := postgres.NewPackageSilencePolicyStore(tx)
if err := store.LockAll(ctx); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期政策失败")
}
current, err := store.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "延期政策不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
before := policyAuditData(current)
if err := applyPolicyRequest(current, req); err != nil {
return err
}
if err := s.validateInTx(ctx, tx, current); err != nil {
return err
}
current.Updater = middleware.GetUserIDFromContext(ctx)
current.UpdatedAt = s.now()
if _, err := store.Update(ctx, current.ID, map[string]any{
"name": current.Name, "scope_type": current.ScopeType, "scope_ids": current.ScopeIDs,
"unit_price": current.UnitPrice, "free_extension_limit": current.FreeExtensionLimit,
"paid_max_months": current.PaidMaxMonths, "single_max_months": current.SingleMaxMonths,
"approval_exempt_types": current.ApprovalExemptTypes, "effective_from": current.EffectiveFrom,
"effective_to": current.EffectiveTo, "status": current.Status, "remark": current.Remark,
"updater": current.Updater, "updated_at": current.UpdatedAt,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期政策失败")
}
policy = current
return s.writeAudit(ctx, tx, constants.AuditActionSilencePolicyUpdated, "修改沉默期延期政策", current, before, policyAuditData(current))
})
if err != nil {
return nil, err
}
return policyResponse(policy), nil
}
// SetStatus 启停政策;启用时同样做重叠校验。
func (s *PolicyService) SetStatus(ctx context.Context, id uint, status int) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
if status != constants.SilencePolicyStatusEnabled && status != constants.SilencePolicyStatusDisabled {
return nil, errors.New(errors.CodeInvalidParam, "政策状态非法")
}
var policy *model.PackageSilenceExtensionPolicy
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := postgres.NewPackageSilencePolicyStore(tx)
if err := store.LockAll(ctx); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期政策失败")
}
current, err := store.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "延期政策不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
before := current.Status
if before == status {
policy = current
return nil
}
current.Status = status
if status == constants.SilencePolicyStatusEnabled {
if err := s.validateOverlapInTx(ctx, tx, current); err != nil {
return err
}
}
current.Updater = middleware.GetUserIDFromContext(ctx)
if _, err := store.Update(ctx, current.ID, map[string]any{"status": status, "updater": current.Updater, "updated_at": s.now()}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期政策状态失败")
}
policy = current
return s.writeAudit(ctx, tx, constants.AuditActionSilencePolicyStatusChanged, "启停沉默期延期政策", current,
map[string]any{"status": before}, map[string]any{"status": status})
})
if err != nil {
return nil, err
}
return policyResponse(policy), nil
}
// MatchForPackage 查询某套餐当前匹配的政策。
func (s *PolicyService) MatchForPackage(ctx context.Context, packageID uint) (*dto.SilencePolicyMatchResponse, error) {
match, _, err := matchPolicyForPackage(ctx, s.db, packageID, s.now())
if err != nil {
return nil, err
}
resp := &dto.SilencePolicyMatchResponse{PackageID: packageID, Matched: match.Policy != nil}
if match.Policy != nil {
resp.Policy = policyResponse(match.Policy)
}
return resp, nil
}
func applyPolicyRequest(policy *model.PackageSilenceExtensionPolicy, req *dto.SilencePolicyRequest) error {
name := strings.TrimSpace(req.Name)
if name == "" || len([]rune(name)) > constants.SilencePolicyNameMaxLen {
return errors.New(errors.CodeInvalidParam, "政策名称不能为空且不超过 100 字")
}
scopeIDs := dedupeUint(req.ScopeIDs)
switch req.ScopeType {
case constants.SilencePolicyScopeAll:
scopeIDs = nil
case constants.SilencePolicyScopeSeries, constants.SilencePolicyScopePackage:
if len(scopeIDs) == 0 {
return errors.New(errors.CodeInvalidParam, "按系列或指定套餐适用时必须选择适用范围")
}
default:
return errors.New(errors.CodeInvalidParam, "适用范围非法")
}
for _, exempt := range req.ApprovalExemptTypes {
if !slices.Contains(constants.SilenceApprovalExemptTypes, exempt) {
return errors.New(errors.CodeInvalidParam, "免审批场景非法")
}
}
if req.EffectiveTo != nil && !req.EffectiveTo.After(req.EffectiveFrom) {
return errors.New(errors.CodeInvalidParam, "失效时间必须晚于生效时间")
}
if req.UnitPrice < 0 || req.FreeExtensionLimit < 0 || req.PaidMaxMonths < 0 {
return errors.New(errors.CodeInvalidParam, "单价、免费次数与付费累计月数不能为负数")
}
policy.Name = name
policy.ScopeType = req.ScopeType
policy.ScopeIDs = model.UintJSONBArray(scopeIDs)
if policy.ScopeIDs == nil {
policy.ScopeIDs = model.UintJSONBArray{}
}
policy.UnitPrice = req.UnitPrice
policy.FreeExtensionLimit = req.FreeExtensionLimit
policy.PaidMaxMonths = req.PaidMaxMonths
policy.SingleMaxMonths = req.SingleMaxMonths
policy.ApprovalExemptTypes = model.StringJSONBArray(dedupeString(req.ApprovalExemptTypes))
if policy.ApprovalExemptTypes == nil {
policy.ApprovalExemptTypes = model.StringJSONBArray{}
}
policy.EffectiveFrom = req.EffectiveFrom
policy.EffectiveTo = req.EffectiveTo
if req.Status != nil {
policy.Status = *req.Status
}
policy.Remark = strings.TrimSpace(req.Remark)
return nil
}
func (s *PolicyService) validateInTx(ctx context.Context, tx *gorm.DB, policy *model.PackageSilenceExtensionPolicy) error {
var sameName int64
if err := tx.WithContext(ctx).Model(&model.PackageSilenceExtensionPolicy{}).
Where("name = ? AND id <> ?", policy.Name, policy.ID).Count(&sameName).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验政策名称失败")
}
if sameName > 0 {
return errors.New(errors.CodeConflict, "政策名称已存在")
}
if err := s.validateScopeIDs(ctx, tx, policy); err != nil {
return err
}
return s.validateOverlapInTx(ctx, tx, policy)
}
func (s *PolicyService) validateScopeIDs(ctx context.Context, tx *gorm.DB, policy *model.PackageSilenceExtensionPolicy) error {
if len(policy.ScopeIDs) == 0 {
return nil
}
var count int64
var target any = &model.Package{}
if policy.ScopeType == constants.SilencePolicyScopeSeries {
target = &model.PackageSeries{}
}
if err := tx.WithContext(ctx).Model(target).Where("id IN ?", []uint(policy.ScopeIDs)).Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验适用范围失败")
}
if int(count) != len(policy.ScopeIDs) {
return errors.New(errors.CodeInvalidParam, "适用范围中存在不存在的系列或套餐")
}
return nil
}
// validateOverlapInTx 校验同一优先级的启用政策适用范围与生效时间段不重叠(silence_policy_overlap)。
func (s *PolicyService) validateOverlapInTx(ctx context.Context, tx *gorm.DB, policy *model.PackageSilenceExtensionPolicy) error {
if policy.Status != constants.SilencePolicyStatusEnabled {
return nil
}
existing, err := postgres.NewPackageSilencePolicyStore(tx).ListEnabled(ctx)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询启用中的延期政策失败")
}
candidate := policyCandidate(policy)
for index := range existing {
other := &existing[index]
if other.ID == policy.ID {
continue
}
if packagedomain.SilencePolicyOverlaps(candidate, policyCandidate(other)) {
return errors.NewWithData(errors.CodeSilencePolicyOverlap, map[string]any{"conflict_policy_id": other.ID, "conflict_policy_name": other.Name})
}
}
return nil
}
func (s *PolicyService) writeAudit(ctx context.Context, tx *gorm.DB, action, summary string, policy *model.PackageSilenceExtensionPolicy, before, after map[string]any) error {
if s.audit == nil {
return nil
}
return s.audit.AppendSilenceResource(ctx, tx, action, summary, audit.SilencePolicyResource(policy, before, after), nil)
}
func policyAuditData(policy *model.PackageSilenceExtensionPolicy) map[string]any {
return map[string]any{
"name": policy.Name, "scope_type": policy.ScopeType, "scope_ids": []uint(policy.ScopeIDs),
"unit_price": policy.UnitPrice, "free_extension_limit": policy.FreeExtensionLimit,
"paid_max_months": policy.PaidMaxMonths, "single_max_months": policy.SingleMaxMonths,
"approval_exempt_types": []string(policy.ApprovalExemptTypes), "effective_from": policy.EffectiveFrom,
"effective_to": policy.EffectiveTo, "status": policy.Status,
}
}
func policyResponse(policy *model.PackageSilenceExtensionPolicy) *dto.SilencePolicyResponse {
scopeIDs := []uint(policy.ScopeIDs)
if scopeIDs == nil {
scopeIDs = []uint{}
}
exempt := []string(policy.ApprovalExemptTypes)
if exempt == nil {
exempt = []string{}
}
return &dto.SilencePolicyResponse{
ID: policy.ID, Name: policy.Name, ScopeType: policy.ScopeType, ScopeTypeName: policyScopeName(policy.ScopeType),
ScopeIDs: scopeIDs, UnitPrice: policy.UnitPrice, FreeExtensionLimit: policy.FreeExtensionLimit,
PaidMaxMonths: policy.PaidMaxMonths, SingleMaxMonths: policy.SingleMaxMonths, ApprovalExemptTypes: exempt,
EffectiveFrom: policy.EffectiveFrom, EffectiveTo: policy.EffectiveTo, Status: policy.Status, Remark: policy.Remark,
Creator: policy.Creator, Updater: policy.Updater, CreatedAt: policy.CreatedAt, UpdatedAt: policy.UpdatedAt,
}
}
func policyScopeName(scope string) string {
switch scope {
case constants.SilencePolicyScopeAll:
return "全部套餐"
case constants.SilencePolicyScopeSeries:
return "套餐系列"
case constants.SilencePolicyScopePackage:
return "指定套餐"
default:
return ""
}
}
func normalizePage(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
return page, pageSize
}
func dedupeUint(values []uint) []uint {
result := make([]uint, 0, len(values))
for _, value := range values {
if value > 0 && !slices.Contains(result, value) {
result = append(result, value)
}
}
return result
}
func dedupeString(values []string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if value != "" && !slices.Contains(result, value) {
result = append(result, value)
}
}
return result
}

View File

@@ -0,0 +1,79 @@
package packagesilence
import (
"context"
"time"
"gorm.io/gorm"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// PolicyMatch 是某套餐当前匹配到的延期政策及其快照;Policy 为 nil 表示未匹配到政策。
type PolicyMatch struct {
Policy *model.PackageSilenceExtensionPolicy
Snapshot *packagedomain.SilencePolicySnapshot
}
// FreeExtensionLimit 返回免费延期次数上限;未匹配到政策时按 1 次处理(技术方案 §3.6)。
func (m PolicyMatch) FreeExtensionLimit() int {
if m.Snapshot == nil {
return 1
}
return m.Snapshot.FreeExtensionLimit
}
// ExemptTypes 返回免审批场景;未匹配到政策时全部需要审批。
func (m PolicyMatch) ExemptTypes() []string {
if m.Snapshot == nil {
return nil
}
return m.Snapshot.ApprovalExemptTypes
}
// matchPolicy 按"指定套餐 > 套餐系列 > 全部套餐"匹配当前生效政策(启用且在生效期内)。
func matchPolicy(ctx context.Context, db *gorm.DB, packageID, seriesID uint, now time.Time) (PolicyMatch, error) {
policies, err := postgres.NewPackageSilencePolicyStore(db).ListEnabled(ctx)
if err != nil {
return PolicyMatch{}, errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
candidates := make([]packagedomain.SilencePolicyCandidate, len(policies))
for index := range policies {
candidates[index] = policyCandidate(&policies[index])
}
best := packagedomain.MatchSilencePolicy(candidates, packageID, seriesID, now)
if best < 0 {
return PolicyMatch{}, nil
}
policy := policies[best]
snapshot := policySnapshot(&policy)
return PolicyMatch{Policy: &policy, Snapshot: &snapshot}, nil
}
// matchPolicyForPackage 读取套餐当前系列后匹配政策。
func matchPolicyForPackage(ctx context.Context, db *gorm.DB, packageID uint, now time.Time) (PolicyMatch, *model.Package, error) {
var pkg model.Package
if err := db.WithContext(ctx).Unscoped().Where("id = ?", packageID).First(&pkg).Error; err != nil {
return PolicyMatch{}, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐失败")
}
match, err := matchPolicy(ctx, db, pkg.ID, pkg.SeriesID, now)
return match, &pkg, err
}
func policyCandidate(policy *model.PackageSilenceExtensionPolicy) packagedomain.SilencePolicyCandidate {
return packagedomain.SilencePolicyCandidate{
ID: policy.ID, ScopeType: policy.ScopeType, ScopeIDs: []uint(policy.ScopeIDs),
Enabled: policy.Status == 1, EffectiveFrom: policy.EffectiveFrom, EffectiveTo: policy.EffectiveTo,
}
}
func policySnapshot(policy *model.PackageSilenceExtensionPolicy) packagedomain.SilencePolicySnapshot {
return packagedomain.SilencePolicySnapshot{
PolicyID: policy.ID, Name: policy.Name, UnitPrice: policy.UnitPrice,
FreeExtensionLimit: policy.FreeExtensionLimit, PaidMaxMonths: policy.PaidMaxMonths,
SingleMaxMonths: policy.SingleMaxMonths, ApprovalExemptTypes: append([]string(nil), policy.ApprovalExemptTypes...),
}
}

View File

@@ -0,0 +1,154 @@
package packagesilence
import (
"context"
"time"
"github.com/bytedance/sonic"
"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/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// recordInput 是一条沉默期流水与对应审计。
type recordInput struct {
Usage *model.PackageUsage // 变更后的套餐记录
EventType string
Trigger string
BeforeStatus *string
BeforeDeadline *time.Time
ChangeDays int
DefaultDays *int
IsAdjusted bool
Reason string
ApprovalID *uint
BatchID *uint
ExtensionID *uint
ExchangeID *uint
ExchangeNo string
RealnameAt *time.Time
Extra map[string]any
AuditAction string
AuditSummary string
}
// writeRecord 在调用方事务内写不可变流水与审计。审计写入失败不回滚业务(统一审计 Writer 自行留痕)。
func writeRecord(ctx context.Context, tx *gorm.DB, writer *audit.Writer, input recordInput) error {
usage := input.Usage
if usage == nil || usage.ID == 0 {
return errors.New(errors.CodeInvalidParam, "沉默期流水缺少套餐记录")
}
assetType, assetID := usageAsset(usage)
operatorID, operatorType := operatorFromContext(ctx)
extra := []byte("{}")
if len(input.Extra) > 0 {
encoded, err := sonic.Marshal(input.Extra)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "序列化沉默期流水扩展信息失败")
}
extra = encoded
}
record := &model.PackageSilenceRecord{
PackageUsageID: usage.ID, EventType: input.EventType, TriggerSource: input.Trigger,
AssetType: assetType, AssetID: assetID,
BeforeStatus: input.BeforeStatus, AfterStatus: usage.SilenceStatus,
BeforeDeadlineAt: input.BeforeDeadline, AfterDeadlineAt: usage.SilenceDeadlineAt,
PeriodDays: usage.SilencePeriodDays, DefaultDaysSnapshot: input.DefaultDays,
IsAdjusted: input.IsAdjusted, ChangeDays: input.ChangeDays,
OperatorID: operatorID, OperatorType: operatorType, Reason: truncate(input.Reason, 500),
ApprovalInstanceID: input.ApprovalID, BatchID: input.BatchID, ExtensionID: input.ExtensionID,
ExchangeOrderID: input.ExchangeID, ExchangeNo: input.ExchangeNo, RealnameAt: input.RealnameAt,
Extra: extra, CreatedAt: time.Now(),
}
if err := postgres.NewPackageSilenceRecordStore(tx).Create(ctx, record); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入沉默期流水失败")
}
if input.AuditAction == "" || writer == nil {
return nil
}
metadata := map[string]any{
"event_type": input.EventType, "trigger": input.Trigger, "record_id": record.ID,
"change_days": input.ChangeDays, "reason": truncate(input.Reason, 200),
}
for key, value := range input.Extra {
metadata[key] = value
}
return writer.AppendSilenceUsage(ctx, tx, audit.SilenceAuditInput{
ActionCode: input.AuditAction, Summary: input.AuditSummary, Usage: usage,
BeforeData: map[string]any{"silence_status": input.BeforeStatus, "silence_deadline_at": input.BeforeDeadline},
AfterData: silenceStateData(usage), Metadata: metadata,
})
}
func silenceStateData(usage *model.PackageUsage) map[string]any {
return map[string]any{
"silence_status": usage.SilenceStatus, "silence_deadline_at": usage.SilenceDeadlineAt,
"silence_period_days": usage.SilencePeriodDays, "silence_extended_days": usage.SilenceExtendedDays,
"silence_paid_extended_days": usage.SilencePaidExtendedDays, "silence_extension_count": usage.SilenceExtensionCount,
"silence_free_extension_used": usage.SilenceFreeExtensionUsed, "silence_expired_at": usage.SilenceExpiredAt,
"silence_adjust_pending": usage.SilenceAdjustPending,
}
}
// usageAsset 返回套餐记录当前载体。
func usageAsset(usage *model.PackageUsage) (string, uint) {
if usage.DeviceID > 0 {
return constants.SilenceAssetTypeDevice, usage.DeviceID
}
return constants.SilenceAssetTypeIotCard, usage.IotCardID
}
func operatorFromContext(ctx context.Context) (uint, string) {
value := auditcontext.From(ctx)
if value.ActorID == "" {
return 0, constants.SilenceOperatorSystem
}
var id uint
for _, r := range value.ActorID {
if r < '0' || r > '9' {
return 0, value.ActorKind
}
id = id*10 + uint(r-'0')
}
switch value.ActorKind {
case constants.AuditActorAccount:
return id, constants.SilenceOperatorAccount
case constants.AuditActorPersonalCustomer:
return id, constants.SilenceOperatorCustomer
default:
return id, value.ActorKind
}
}
func truncate(value string, max int) string {
runes := []rune(value)
if len(runes) <= max {
return value
}
return string(runes[:max])
}
func stringPtr(value string) *string {
return &value
}
func copyStringPtr(value *string) *string {
if value == nil {
return nil
}
copied := *value
return &copied
}
func copyTimePtr(value *time.Time) *time.Time {
if value == nil {
return nil
}
copied := *value
return &copied
}

View File

@@ -0,0 +1,111 @@
package packagesilence
import (
"context"
"time"
"go.uber.org/zap"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"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"
)
// recoveryMinimumAge 是待支付订单进入查单恢复的最短等待时间,避免与客户正在支付的回调竞争。
const recoveryMinimumAge = time.Minute
// RecoverResult 是一轮延期订单恢复的结果。
type RecoverResult struct {
Confirmed int
Closed int
Refunded int
}
// Recover 收敛延期订单(技术方案 §7.1、§8.4):
// - 待支付订单:按冻结商户查单,已支付走支付确认,渠道明确关闭或订单不存在且已过支付截止的关闭;
// - 执行异常且已支付的在线订单:按冻结商户原路退款(渠道请求号固定,重复提交幂等)。
//
// 不受总开关影响:应急关闭后进行中的支付也要处理完。
func (s *ExtensionService) Recover(ctx context.Context) (RecoverResult, error) {
result := RecoverResult{}
now := s.now()
orders, err := postgres.NewPackageSilenceOrderStore(s.db).ListPendingPayment(ctx, now.Add(-recoveryMinimumAge), constants.SilenceOrderRecoverBatchSize)
if err != nil {
return result, errors.Wrap(errors.CodeDatabaseError, err, "查询待支付延期订单失败")
}
for index := range orders {
order := &orders[index]
state, err := s.recoverPending(ctx, order, now)
if err != nil {
s.logger.Warn("延期订单查单恢复失败,下一轮重试", zap.String("order_no", order.OrderNo), zap.Error(err))
continue
}
switch state {
case agentrechargeApp.OnlinePaymentStatePaid:
result.Confirmed++
case agentrechargeApp.OnlinePaymentStateClosed:
result.Closed++
}
}
var exceptions []model.PackageSilenceExtensionOrder
if err := s.db.WithContext(ctx).Where("status = ? AND paid_payment_id IS NOT NULL AND payment_method <> ?",
constants.SilenceOrderStatusException, model.PaymentByOffline).
Order("id ASC").Limit(constants.SilenceOrderRecoverBatchSize).Find(&exceptions).Error; err != nil {
return result, errors.Wrap(errors.CodeDatabaseError, err, "查询待退款的异常延期订单失败")
}
for index := range exceptions {
if err := s.refundOrder(ctx, exceptions[index].ID, "延期执行异常原路退款"); err != nil {
s.logger.Warn("延期异常订单原路退款失败,下一轮重试", zap.String("order_no", exceptions[index].OrderNo), zap.Error(err))
continue
}
result.Refunded++
}
return result, nil
}
func (s *ExtensionService) recoverPending(ctx context.Context, order *model.PackageSilenceExtensionOrder, now time.Time) (string, error) {
var payments []model.Payment
if err := s.db.WithContext(ctx).Where("order_type = ? AND order_id = ? AND status = ?", constants.PaymentOrderTypeSilenceExtension, order.ID, model.PaymentRecordStatusPending).
Order("id DESC").Find(&payments).Error; err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
expired := order.ExpireAt != nil && now.After(*order.ExpireAt)
if len(payments) == 0 {
if expired {
return agentrechargeApp.OnlinePaymentStateClosed, s.closeOrder(ctx, order.ID, constants.SilenceExtensionOrderCloseReasonTimeout)
}
return agentrechargeApp.OnlinePaymentStatePending, nil
}
allClosed := true
for index := range payments {
payment := &payments[index]
state, query, err := s.queryPayment(ctx, payment)
if err != nil {
return "", err
}
switch state {
case agentrechargeApp.OnlinePaymentStatePaid:
paidAt := now
if query.PaidAt != nil {
paidAt = *query.PaidAt
}
return state, s.ConfirmPayment(ctx, PaymentConfirmCommand{
PaymentNo: payment.PaymentNo, ThirdPartyTradeNo: query.ThirdPartyTradeNo, Amount: query.Amount, PaidAt: paidAt,
})
case agentrechargeApp.OnlinePaymentStateClosed:
case agentrechargeApp.OnlinePaymentStateNotFound:
// 渠道没有这笔订单:过支付截止后已不可能再支付,按关闭处理;未过期时可能尚未同步,继续等待。
if !expired {
allClosed = false
}
default:
allClosed = false
}
}
if allClosed {
return agentrechargeApp.OnlinePaymentStateClosed, s.closeOrder(ctx, order.ID, constants.SilenceExtensionOrderCloseReasonTimeout)
}
return agentrechargeApp.OnlinePaymentStatePending, nil
}

View File

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

View File

@@ -0,0 +1,60 @@
package packagesilence
import (
"context"
"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"
)
// ScanResult 是一轮沉默期扫描的结果。
type ScanResult struct {
Scanned int
Expired int
Ended int
}
// Scan 执行沉默期扫描 package:silence:scan(技术方案 §5.4):
// 查询 status = 0、待实名、已过截止、无调整审批的套餐记录,逐条执行统一判定(期内已实名则结束并激活,否则过期)。
// 单批 200、最多 20 批;只在总开关打开时运行;条件更新保证与实时判定并发时只有一方生效。
func (r *Resolver) Scan(ctx context.Context) (ScanResult, error) {
result := ScanResult{}
if !r.sw.Enabled(ctx) {
return result, nil
}
lastID := uint(0)
for batch := 0; batch < constants.SilenceScanMaxBatches; batch++ {
var ids []uint
if err := r.db.WithContext(ctx).Model(&model.PackageUsage{}).
Where("status = ? AND silence_status = ? AND silence_deadline_at < ? AND silence_adjust_pending = ? AND id > ?",
constants.PackageUsageStatusPending, constants.SilenceStatusWaiting, r.now(), false, lastID).
Order("id ASC").Limit(constants.SilenceScanBatchSize).Pluck("id", &ids).Error; err != nil {
return result, errors.Wrap(errors.CodeDatabaseError, err, "查询到期沉默期套餐失败")
}
if len(ids) == 0 {
break
}
for _, id := range ids {
lastID = id
resolved, err := r.ResolveAndActivate(ctx, id, ResolveOptions{Trigger: constants.SilenceTriggerScan, AllowExpire: true})
if err != nil {
r.logger.Warn("沉默期扫描判定失败,下一轮重试", zap.Uint("usage_id", id), zap.Error(err))
continue
}
result.Scanned++
if resolved.Changed && resolved.Status == constants.SilenceStatusExpired {
result.Expired++
}
if resolved.Changed && resolved.Status == constants.SilenceStatusEnded {
result.Ended++
}
}
if len(ids) < constants.SilenceScanBatchSize {
break
}
}
return result, nil
}

View File

@@ -0,0 +1,257 @@
package packagesilence
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// PermissionChecker 校验后台账号权限点(由权限服务实现)。
type PermissionChecker interface {
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
}
// Setter 在订单支付成功、创建套餐记录的同一事务内设置沉默期(技术方案 §4),并处理后台购买时调整。
type Setter struct {
db *gorm.DB
audit *audit.Writer
approval approvalapp.Port
perms PermissionChecker
now func() time.Time
}
// NewSetter 创建沉默期设置用例。
func NewSetter(db *gorm.DB, writer *audit.Writer) *Setter {
return &Setter{db: db, audit: writer, now: time.Now}
}
// SetApprovalPort 注入审批创建接缝(购买时调整超默认值需审批时使用)。
func (s *Setter) SetApprovalPort(port approvalapp.Port) {
s.approval = port
}
// SetPermissionChecker 注入权限校验。
func (s *Setter) SetPermissionChecker(checker PermissionChecker) {
s.perms = checker
}
// PurchaseOverride 是后台购买时由有权限运营设置的本次沉默期。
// 无人参与的路径(自动购包、钱包自动续费等)不会携带,只用默认值。
type PurchaseOverride struct {
Days int
Reason string
OperatorID uint
submitter approvalSubmitter
// needApproval 表示本次订单中至少一笔套餐的设置值超过默认值且未免审批,preparation 已在事务前完成。
needApproval bool
preparation approvalapp.Preparation
exempt map[uint]bool // 套餐 ID → 政策是否勾选 purchase_adjust 免审批
defaults map[uint]int // 套餐 ID → 默认沉默期
}
type overrideContextKey struct{}
// WithPurchaseOverride 把购买时调整值放入上下文,由支付成功发放套餐时读取。
func WithPurchaseOverride(ctx context.Context, override *PurchaseOverride) context.Context {
if override == nil {
return ctx
}
return context.WithValue(ctx, overrideContextKey{}, override)
}
func overrideFromContext(ctx context.Context) *PurchaseOverride {
value, _ := ctx.Value(overrideContextKey{}).(*PurchaseOverride)
return value
}
// PrepareOverride 在下单事务前校验购买时调整:权限、天数、原因,并在需要审批时提前完成审批渠道检查。
// days 为空表示不调整。
func (s *Setter) PrepareOverride(ctx context.Context, operatorID uint, days *int, reason string, packages []*model.Package) (*PurchaseOverride, error) {
if days == nil {
return nil, nil
}
if s.perms == nil {
return nil, errors.New(errors.CodeSilenceOverrideForbidden)
}
allowed, err := s.perms.CheckPermission(ctx, operatorID, constants.PermissionPackageSilenceOverride, constants.PlatformWeb)
if err != nil {
return nil, err
}
if !allowed {
return nil, errors.New(errors.CodeSilenceOverrideForbidden)
}
if *days < constants.SilencePackageDaysMin || *days > constants.SilencePurchaseAdjustDaysMax {
return nil, errors.New(errors.CodeSilencePeriodOutOfRange, "本次沉默期至少 1 天")
}
reason = strings.TrimSpace(reason)
if reason == "" {
return nil, errors.New(errors.CodeInvalidParam, "调整沉默期必须填写原因")
}
override := &PurchaseOverride{
Days: *days, Reason: truncate(reason, 500), OperatorID: operatorID,
exempt: map[uint]bool{}, defaults: map[uint]int{},
}
now := s.now()
for _, pkg := range packages {
if pkg == nil || pkg.SilencePeriodDays == nil || pkg.PackageType == constants.PackageTypeAddon {
continue
}
override.defaults[pkg.ID] = *pkg.SilencePeriodDays
if *days <= *pkg.SilencePeriodDays {
continue
}
match, err := matchPolicy(ctx, s.db, pkg.ID, pkg.SeriesID, now)
if err != nil {
return nil, err
}
if packagedomain.IsSilenceApprovalExempt(match.ExemptTypes(), constants.SilenceExtensionTypePurchaseAdjust) {
override.exempt[pkg.ID] = true
continue
}
override.needApproval = true
}
if override.needApproval {
submitter, err := loadSubmitter(ctx, s.db, operatorID)
if err != nil {
return nil, err
}
preparation, err := prepareApproval(ctx, s.approval, operatorID, "silence-adjust-"+uuid.NewString())
if err != nil {
return nil, err
}
override.submitter = submitter
override.preparation = preparation
}
return override, nil
}
// Eligible 判断新建套餐记录是否进入沉默期(技术方案 §1)。
func Eligible(usage *model.PackageUsage, pkg *model.Package) bool {
return usage != nil && pkg != nil && usage.MasterUsageID == nil && usage.PendingRealnameActivation &&
usage.Status == constants.PackageUsageStatusPending &&
usage.ExpiryBaseSnapshot == constants.PackageExpiryBaseFromActivation && pkg.SilencePeriodDays != nil
}
// ApplyOnUsageCreatedInTx 为刚创建的套餐记录设置沉默期并写流水(总开关关闭也写,只记录)。
// paidAt 为订单支付成功时间;trigger 为订单支付 / 自动购包等来源。
func (s *Setter) ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error {
if s == nil || !Eligible(usage, pkg) {
return nil
}
defaultDays := *pkg.SilencePeriodDays
days := defaultDays
adjusted := false
needApproval := false
reason := ""
override := overrideFromContext(ctx)
if override != nil {
reason = override.Reason
switch {
case override.Days <= defaultDays || override.exempt[pkg.ID]:
days = override.Days
adjusted = override.Days != defaultDays
case override.needApproval:
needApproval = true
}
}
deadline := packagedomain.SilenceDeadline(paidAt, days)
start := paidAt
values := map[string]any{
"silence_status": constants.SilenceStatusWaiting, "silence_start_at": start,
"silence_period_days": days, "silence_deadline_at": deadline, "silence_adjust_pending": needApproval,
}
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id = ? AND silence_status IS NULL", usage.ID).
Updates(values).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "设置套餐沉默期失败")
}
usage.SilenceStatus = stringPtr(constants.SilenceStatusWaiting)
usage.SilenceStartAt = &start
usage.SilencePeriodDays = &days
usage.SilenceDeadlineAt = &deadline
usage.SilenceAdjustPending = needApproval
if err := writeRecord(ctx, tx, s.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventSet, Trigger: trigger,
DefaultDays: &defaultDays, IsAdjusted: adjusted, ChangeDays: days - defaultDays, Reason: reason,
Extra: map[string]any{"requested_days": overrideDays(override), "adjust_pending": needApproval},
AuditAction: constants.AuditActionUsageSilenceSet, AuditSummary: "设置套餐沉默期",
}); err != nil {
return err
}
if needApproval {
return s.submitAdjustInTx(ctx, tx, usage, pkg, override, defaultDays)
}
return nil
}
func overrideDays(override *PurchaseOverride) any {
if override == nil {
return nil
}
return override.Days
}
// submitAdjustInTx 为超默认值的调整创建批次(path = adjust)与企微审批,审批期间套餐不过期。
func (s *Setter) submitAdjustInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, override *PurchaseOverride, defaultDays int) error {
now := s.now()
requested := override.Days
batch := &model.PackageSilenceExtensionBatch{
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathAdjust,
ExtensionType: constants.SilenceExtensionTypePurchaseAdjust, ApprovalRequired: true,
Reason: override.Reason, AdjustPackageUsageID: &usage.ID, AdjustDefaultDays: &defaultDays,
AdjustRequestedDays: &requested, RequestedBy: override.OperatorID, RequestedByType: constants.SilenceOperatorAccount,
RequestedAt: now, Status: constants.SilenceBatchStatusPendingApproval, RequestedCount: 1,
}
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建沉默期调整申请失败")
}
identifier, err := assetIdentifier(ctx, tx, usage)
if err != nil {
return err
}
instanceID, err := createApprovalInTx(ctx, tx, s.approval, override.preparation, batch, override.submitter, ApplyTypePurchaseAdjust, map[string]any{
ApprovalFieldAssetIdentifier: identifier, ApprovalFieldPackageName: pkg.PackageName,
ApprovalFieldOrderNo: usage.OrderNo, ApprovalFieldDefaultDays: defaultDays,
ApprovalFieldRequestedDays: requested, ApprovalFieldReason: override.Reason,
ApprovalFieldOriginalDeadline: formatDate(usage.SilenceDeadlineAt),
})
if err != nil {
return err
}
return writeRecord(ctx, tx, s.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventAdjust, Trigger: constants.SilenceTriggerOrderPaid,
BeforeStatus: usage.SilenceStatus, BeforeDeadline: usage.SilenceDeadlineAt, DefaultDays: &defaultDays,
ChangeDays: requested - defaultDays, Reason: override.Reason, ApprovalID: &instanceID, BatchID: &batch.ID,
Extra: map[string]any{"requested_days": requested, "stage": "requested"},
AuditAction: constants.AuditActionUsageSilenceAdjustRequested, AuditSummary: "申请购买时调整沉默期",
})
}
// assetIdentifier 返回套餐记录当前载体的标识(卡 ICCID / 设备虚拟号或 IMEI)。
func assetIdentifier(ctx context.Context, db *gorm.DB, usage *model.PackageUsage) (string, error) {
if usage.DeviceID > 0 {
var device model.Device
if err := db.WithContext(ctx).Unscoped().Select("id", "virtual_no", "imei").Where("id = ?", usage.DeviceID).First(&device).Error; err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询设备标识失败")
}
if device.VirtualNo != "" {
return device.VirtualNo, nil
}
return device.IMEI, nil
}
var card model.IotCard
if err := db.WithContext(ctx).Unscoped().Select("id", "iccid").Where("id = ?", usage.IotCardID).First(&card).Error; err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡标识失败")
}
return card.ICCID, nil
}

View File

@@ -0,0 +1,116 @@
// Package packagesilence 实现实名即生效套餐沉默期的设置、判定、扫描、总开关与延期用例(add-package-silence-period)。
package packagesilence
import (
"context"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// switchCacheTTL 是总开关在进程内的缓存时间;应急关闭最多延迟这么久在各进程生效。
const switchCacheTTL = 5 * time.Second
// RegisterConfigDefinitions 注册沉默期相关系统配置。
// 总开关注册为只读:通用系统配置接口不能修改,只能走带打开前检查的专用接口。
func RegisterConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger) {
if registry == nil {
return
}
minDays, maxDays := int64(constants.SilencePackageDaysMin), int64(constants.SilencePackageDaysMax)
minPrice, maxPrice := int64(0), int64(1000000)
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigSilenceEnabled, Module: constants.SystemConfigModulePackage, ValueType: constants.SystemConfigTypeBool,
DefaultValue: "false", Readonly: true,
Description: "沉默期总开关:关闭时只记录沉默期,不拦截激活、不扫描、不展示、延期申请拒绝;只能通过沉默期总开关接口切换"},
{Key: constants.SystemConfigSilenceDefaultDays, Module: constants.SystemConfigModulePackage, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.SilenceDefaultPeriodDays), Description: "新建实名即生效套餐时预填的默认沉默期(天)",
Min: &minDays, Max: &maxDays},
{Key: constants.SystemConfigSilenceExtensionUnitPrice, Module: constants.SystemConfigModulePackage, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.SilenceDefaultUnitPrice), Description: "新建延期政策时预填的单价(分/月),实际计价以政策为准",
Min: &minPrice, Max: &maxPrice},
}
for _, definition := range definitions {
if _, exists := registry.Get(definition.Key); exists {
continue
}
if err := registry.Register(definition); err != nil && logger != nil {
logger.Error("注册沉默期系统配置失败,将使用代码默认值", zap.String("config_key", definition.Key), zap.Error(err))
}
}
}
// ConfigReader 读取系统配置的最小接缝。
type ConfigReader interface {
Get(ctx context.Context, key string) (string, error)
}
// Switch 读取沉默期总开关 package.silence.enabled。
// 读取失败时按关闭处理(行为与现状一致),不因配置故障误拦截激活或退款。
type Switch struct {
reader ConfigReader
logger *zap.Logger
mu sync.Mutex
cached bool
expiresAt time.Time
}
// NewSwitch 创建总开关读取器。
func NewSwitch(reader ConfigReader, logger *zap.Logger) *Switch {
if logger == nil {
logger = zap.NewNop()
}
return &Switch{reader: reader, logger: logger}
}
// NewDefaultSwitch 用独立注册表与读取器创建总开关(API 与 Worker 共用同一份数据库与缓存数据)。
func NewDefaultSwitch(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Switch {
registry := systemconfig.NewRegistry()
RegisterConfigDefinitions(registry, logger)
var cache systemconfig.Cache
if redisClient != nil {
cache = systemconfig.NewRedisCache(redisClient)
}
reader := systemconfig.NewReader(db, registry, cache, systemconfig.NewLogAlertSink(logger))
return NewSwitch(reader, logger)
}
// Enabled 返回总开关是否打开。
func (s *Switch) Enabled(ctx context.Context) bool {
if s == nil || s.reader == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if now.Before(s.expiresAt) {
return s.cached
}
value, err := s.reader.Get(ctx, constants.SystemConfigSilenceEnabled)
if err != nil {
s.logger.Warn("读取沉默期总开关失败,按关闭处理", zap.Error(err))
s.cached = false
} else {
s.cached = value == "true"
}
s.expiresAt = now.Add(switchCacheTTL)
return s.cached
}
// Invalidate 清除进程内缓存(本进程切换开关后立即生效)。
func (s *Switch) Invalidate() {
if s == nil {
return
}
s.mu.Lock()
s.expiresAt = time.Time{}
s.mu.Unlock()
}

View File

@@ -0,0 +1,160 @@
package packagesilence
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// SwitchService 切换沉默期总开关(技术方案 §3.7):仅超级管理员;打开前做 4 项检查;每次切换写审计。
type SwitchService struct {
db *gorm.DB
redis *redis.Client
sw *Switch
audit *audit.Writer
now func() time.Time
}
// NewSwitchService 创建总开关切换用例。
func NewSwitchService(db *gorm.DB, redisClient *redis.Client, sw *Switch, writer *audit.Writer) *SwitchService {
return &SwitchService{db: db, redis: redisClient, sw: sw, audit: writer, now: time.Now}
}
// Status 返回总开关当前值与打开前检查结果(页面展示用)。
func (s *SwitchService) Status(ctx context.Context) (*dto.SilenceSwitchResponse, error) {
items, err := s.Precheck(ctx)
if err != nil {
return nil, err
}
return &dto.SilenceSwitchResponse{Enabled: s.sw.Enabled(ctx), Precheck: items, PrecheckPassed: precheckPassed(items)}, nil
}
// Precheck 执行打开前检查,返回每一项的结果(未满足项带数量)。
func (s *SwitchService) Precheck(ctx context.Context) ([]dto.SilenceSwitchPrecheckItem, error) {
db := s.db.WithContext(ctx)
var unconfigured int64
if err := db.Model(&model.Package{}).Where(postgres.SilenceUnconfiguredPackageCondition("tb_package")).Count(&unconfigured).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计未配置默认沉默期的套餐失败")
}
var unbackfilled int64
if err := db.Model(&model.PackageUsage{}).
Where("status = ? AND pending_realname_activation = ? AND master_usage_id IS NULL AND expiry_base_snapshot = ? AND silence_status IS NULL",
constants.PackageUsageStatusPending, true, constants.PackageExpiryBaseFromActivation).
Count(&unbackfilled).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计未回填沉默期的套餐记录失败")
}
now := s.now()
var defaultPolicies int64
if err := db.Model(&model.PackageSilenceExtensionPolicy{}).
Where("scope_type = ? AND status = ? AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)",
constants.SilencePolicyScopeAll, constants.SilencePolicyStatusEnabled, now, now).
Count(&defaultPolicies).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询全部套餐延期政策失败")
}
var scenes int64
if err := db.Model(&model.WeComApprovalScene{}).
Where("business_type = ? AND status = ? AND template_id <> ''", constants.ApprovalBusinessTypePackageSilence, constants.StatusEnabled).
Count(&scenes).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询沉默期审批场景失败")
}
return []dto.SilenceSwitchPrecheckItem{
{Code: constants.SilenceSwitchPrecheckUnconfiguredPackages, Name: "未配置默认沉默期的套餐", Passed: unconfigured == 0, Count: unconfigured},
{Code: constants.SilenceSwitchPrecheckUnbackfilledUsages, Name: "未回填沉默期的待实名套餐记录", Passed: unbackfilled == 0, Count: unbackfilled},
{Code: constants.SilenceSwitchPrecheckDefaultPolicyMissing, Name: "启用中的全部套餐延期政策", Passed: defaultPolicies > 0, Count: defaultPolicies},
{Code: constants.SilenceSwitchPrecheckApprovalSceneMissing, Name: "沉默期企微审批场景已配置并启用", Passed: scenes > 0, Count: scenes},
}, nil
}
func precheckPassed(items []dto.SilenceSwitchPrecheckItem) bool {
for _, item := range items {
if !item.Passed {
return false
}
}
return true
}
// Toggle 打开或关闭总开关。打开前检查未通过时拒绝,返回 silence_switch_precheck_failed 与未满足项。
// 关闭(应急)不做检查:规则暂停、数据保留,已发生的支付回调与审批回调照常处理。
func (s *SwitchService) Toggle(ctx context.Context, enable bool) (*dto.SilenceSwitchResponse, error) {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "只有超级管理员可以切换沉默期总开关")
}
operatorID := middleware.GetUserIDFromContext(ctx)
items, err := s.Precheck(ctx)
if err != nil {
return nil, err
}
if enable && !precheckPassed(items) {
failed := make([]dto.SilenceSwitchPrecheckItem, 0, len(items))
for _, item := range items {
if !item.Passed {
failed = append(failed, item)
}
}
return nil, errors.NewWithData(errors.CodeSilenceSwitchPrecheckFailed, map[string]any{"failed_items": failed},
"沉默期总开关打开前检查未通过")
}
value := "false"
if enable {
value = "true"
}
now := s.now()
var before string
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", constants.SystemConfigSilenceEnabled).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定沉默期总开关失败")
}
var existing model.SystemConfig
findErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("config_key = ?", constants.SystemConfigSilenceEnabled).First(&existing).Error
switch {
case findErr == gorm.ErrRecordNotFound:
before = "false"
existing = model.SystemConfig{
ConfigKey: constants.SystemConfigSilenceEnabled, ConfigValue: value, ValueType: constants.SystemConfigTypeBool,
Module: constants.SystemConfigModulePackage, Description: "沉默期总开关", IsReadonly: true,
Creator: operatorID, Updater: operatorID, CreatedAt: now, UpdatedAt: now,
}
if err := tx.Create(&existing).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入沉默期总开关失败")
}
case findErr != nil:
return errors.Wrap(errors.CodeDatabaseError, findErr, "查询沉默期总开关失败")
default:
before = existing.ConfigValue
if err := tx.Model(&model.SystemConfig{}).Where("id = ?", existing.ID).
Updates(map[string]any{"config_value": value, "updater": operatorID, "updated_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新沉默期总开关失败")
}
}
if s.audit == nil {
return nil
}
id := constants.SystemConfigSilenceEnabled
return s.audit.AppendSilenceResource(ctx, tx, constants.AuditActionPackageSilenceSwitchChanged, "切换沉默期总开关",
audit.ResourceInput{
Type: constants.AuditResourceSystemConfig, ID: &id, Key: id, DisplayName: "沉默期总开关",
Role: constants.AuditResourceRoleConfig, IdentitySnapshot: map[string]any{"config_key": id, "module": constants.SystemConfigModulePackage},
BeforeData: map[string]any{"value": before}, AfterData: map[string]any{"value": value},
}, map[string]any{"precheck": items, "operator_id": operatorID})
})
if err != nil {
return nil, err
}
if s.redis != nil {
_ = s.redis.Del(context.WithoutCancel(ctx), constants.RedisSystemConfigKey(constants.SystemConfigSilenceEnabled)).Err()
}
s.sw.Invalidate()
return &dto.SilenceSwitchResponse{Enabled: enable, Precheck: items, PrecheckPassed: precheckPassed(items)}, nil
}

View File

@@ -0,0 +1,39 @@
package packagesilence
import (
"time"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// BuildView 构造套餐记录的沉默期展示信息;不适用沉默期时返回 nil。
// 待实名但已过截止、还没被扫描到的记录直接显示"沉默期已过期"(技术方案 §5.4)。
// 调用方只在总开关打开时调用。
func BuildView(usage *model.PackageUsage, now time.Time) *dto.PackageSilenceView {
if usage == nil || usage.SilenceStatus == nil || *usage.SilenceStatus == "" {
return nil
}
status := *usage.SilenceStatus
effectiveExpired := usage.Status == constants.PackageUsageStatusPending &&
packagedomain.IsSilenceEffectivelyExpired(status, usage.SilenceDeadlineAt, usage.SilenceAdjustPending, now)
if effectiveExpired {
status = constants.SilenceStatusExpired
}
return &dto.PackageSilenceView{
SilenceStatus: status,
SilenceStatusName: constants.SilenceStatusName(status, usage.SilenceExtendedDays > 0),
SilenceStartAt: usage.SilenceStartAt,
SilencePeriodDays: usage.SilencePeriodDays,
SilenceDeadlineAt: usage.SilenceDeadlineAt,
SilenceExtendedDays: usage.SilenceExtendedDays,
SilencePaidExtendedDays: usage.SilencePaidExtendedDays,
SilenceExtensionCount: usage.SilenceExtensionCount,
SilenceFreeExtensionUsed: usage.SilenceFreeExtensionUsed,
SilenceExpiredAt: usage.SilenceExpiredAt,
SilenceAdjustPending: usage.SilenceAdjustPending,
Extendable: effectiveExpired,
}
}

View File

@@ -367,6 +367,7 @@ func approvalBusinessTypes() []string {
constants.ApprovalBusinessTypeAgentDistribution,
constants.ApprovalBusinessTypeWithdrawalQualification,
constants.ApprovalBusinessTypeCommissionWithdrawal,
constants.ApprovalBusinessTypePackageSilence,
}
}
@@ -516,6 +517,32 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypePackageSilence:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldSilenceApplyType, Name: "申请类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "购买时调整沉默期 / 特殊延期 / 线下付费延期,建议映射到单选控件并配置选项映射"},
{Code: constants.ApprovalFieldSilenceBatchNo, Name: "申请单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "沉默期申请批次号"},
{Code: constants.ApprovalFieldSilenceAssetIdentifier, Name: "资产", ValueType: constants.ApprovalFieldValueTypeString, Description: "ICCID、虚拟号或 IMEI(批量时为首条明细)"},
{Code: constants.ApprovalFieldSilencePackageName, Name: "套餐", ValueType: constants.ApprovalFieldValueTypeString, Description: "套餐名称(批量时为首条明细)"},
{Code: constants.ApprovalFieldSilenceOrderNo, Name: "订单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "套餐订单号或延期订单号"},
{Code: constants.ApprovalFieldSilenceDefaultDays, Name: "默认沉默期天数", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "购买时调整:套餐默认沉默期"},
{Code: constants.ApprovalFieldSilenceRequestedDays, Name: "申请沉默期天数", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "购买时调整:本次申请天数"},
{Code: constants.ApprovalFieldSilenceOriginalDeadline, Name: "原截止日期", ValueType: constants.ApprovalFieldValueTypeString, Description: "YYYY-MM-DD"},
{Code: constants.ApprovalFieldSilenceInputDays, Name: "延期天数", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "申请输入的延期天数"},
{Code: constants.ApprovalFieldSilenceBilledDays, Name: "计费天数", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "按 30 天向上取整后的计费 / 延长天数"},
{Code: constants.ApprovalFieldSilenceNewDeadline, Name: "新截止日期", ValueType: constants.ApprovalFieldValueTypeString, Description: "YYYY-MM-DD"},
{Code: constants.ApprovalFieldSilenceExtensionType, Name: "延期类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "特殊延期类型名称"},
{Code: constants.ApprovalFieldSilenceItemCount, Name: "明细条数", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "批量特殊延期的明细数"},
{Code: constants.ApprovalFieldSilenceAmount, Name: "费用", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数"},
{Code: constants.ApprovalFieldSilenceAmountCent, Name: "费用(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的整数"},
{Code: constants.ApprovalFieldSilenceReason, Name: "原因", ValueType: constants.ApprovalFieldValueTypeString, Description: "申请原因"},
{Code: constants.ApprovalFieldSilenceBasis, Name: "依据", ValueType: constants.ApprovalFieldValueTypeString, Description: "免费或优惠依据"},
{Code: constants.ApprovalFieldSilenceEvidenceKeys, Name: "申请依据附件", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "客户申请记录或截图对象存储 Key 列表"},
{Code: constants.ApprovalFieldSilencePaymentMethod, Name: "收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "线下收款方式名称"},
{Code: constants.ApprovalFieldSilenceTradeNo, Name: "交易单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "线下交易单号"},
{Code: constants.ApprovalFieldSilenceVoucherKeys, Name: "收款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "收款凭证对象存储 Key 列表"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
default:
return nil, false
}
@@ -558,6 +585,8 @@ func approvalBusinessTypeName(businessType string) string {
return "提现资料资格审批"
case constants.ApprovalBusinessTypeCommissionWithdrawal:
return "佣金提现终审"
case constants.ApprovalBusinessTypePackageSilence:
return "沉默期审批(购买时调整 / 特殊延期 / 线下付费延期)"
default:
return "未知审批业务类型"
}