feat: 实名即生效套餐沉默期、延期办理与资产出库时间
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 创建资产钱包自动续费用例。
|
||||
|
||||
155
internal/application/packagesilence/approval.go
Normal file
155
internal/application/packagesilence/approval.go
Normal 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")
|
||||
}
|
||||
290
internal/application/packagesilence/backfill.go
Normal file
290
internal/application/packagesilence/backfill.go
Normal 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
|
||||
}
|
||||
470
internal/application/packagesilence/decision.go
Normal file
470
internal/application/packagesilence/decision.go
Normal 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
|
||||
}
|
||||
78
internal/application/packagesilence/exchange.go
Normal file
78
internal/application/packagesilence/exchange.go
Normal 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
|
||||
}
|
||||
}
|
||||
231
internal/application/packagesilence/extension_execute.go
Normal file
231
internal/application/packagesilence/extension_execute.go
Normal 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
|
||||
}
|
||||
344
internal/application/packagesilence/extension_service.go
Normal file
344
internal/application/packagesilence/extension_service.go
Normal 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
|
||||
}
|
||||
548
internal/application/packagesilence/extension_submit.go
Normal file
548
internal/application/packagesilence/extension_submit.go
Normal 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("e.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
|
||||
}
|
||||
679
internal/application/packagesilence/online_order.go
Normal file
679
internal/application/packagesilence/online_order.go
Normal 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
|
||||
}
|
||||
145
internal/application/packagesilence/order_helpers.go
Normal file
145
internal/application/packagesilence/order_helpers.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
223
internal/application/packagesilence/outbound.go
Normal file
223
internal/application/packagesilence/outbound.go
Normal 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)
|
||||
}
|
||||
412
internal/application/packagesilence/policy.go
Normal file
412
internal/application/packagesilence/policy.go
Normal 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
|
||||
}
|
||||
79
internal/application/packagesilence/policy_match.go
Normal file
79
internal/application/packagesilence/policy_match.go
Normal 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...),
|
||||
}
|
||||
}
|
||||
154
internal/application/packagesilence/record.go
Normal file
154
internal/application/packagesilence/record.go
Normal 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
|
||||
}
|
||||
111
internal/application/packagesilence/recover.go
Normal file
111
internal/application/packagesilence/recover.go
Normal 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
|
||||
}
|
||||
328
internal/application/packagesilence/resolve.go
Normal file
328
internal/application/packagesilence/resolve.go
Normal 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
|
||||
}
|
||||
60
internal/application/packagesilence/scan.go
Normal file
60
internal/application/packagesilence/scan.go
Normal 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
|
||||
}
|
||||
257
internal/application/packagesilence/setting.go
Normal file
257
internal/application/packagesilence/setting.go
Normal 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
|
||||
}
|
||||
116
internal/application/packagesilence/settings.go
Normal file
116
internal/application/packagesilence/settings.go
Normal 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()
|
||||
}
|
||||
160
internal/application/packagesilence/switch_service.go
Normal file
160
internal/application/packagesilence/switch_service.go
Normal 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
|
||||
}
|
||||
39
internal/application/packagesilence/view.go
Normal file
39
internal/application/packagesilence/view.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 "未知审批业务类型"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user