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

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

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

View File

@@ -43,6 +43,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
handlers.StopResumeRule = admin.NewStopResumeRuleHandler(nil, nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.ClientPackageSilence = apphandler.NewClientPackageSilenceHandler(nil, nil, nil)
handlers.PackageSilence = admin.NewPackageSilenceHandler(nil, nil, nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)

View File

@@ -52,6 +52,8 @@ func generateAdminDocs(outputPath string) error {
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
handlers.StopResumeRule = admin.NewStopResumeRuleHandler(nil, nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.ClientPackageSilence = apphandler.NewClientPackageSilenceHandler(nil, nil, nil)
handlers.PackageSilence = admin.NewPackageSilenceHandler(nil, nil, nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)

View File

@@ -0,0 +1,115 @@
// silence-backfill 是沉默期与出库时间历史回填工具(add-package-silence-period,技术方案 §6、§17.4)。
//
// 用法(数据库连接只读取显式传入的 DB_* 环境变量):
//
// DB_HOST=... DB_PORT=... DB_USER=... DB_PASSWORD=... DB_NAME=... DB_SSLMODE=disable \
// go run ./cmd/silence-backfill -target silence # 只输出影响清单(默认 dry-run)
// go run ./cmd/silence-backfill -target silence -execute # 业务确认清单后执行
// go run ./cmd/silence-backfill -target outbound # 出库时间:只输出可回填数与无订单数
// go run ./cmd/silence-backfill -target outbound -execute
//
// 两种回填都只处理为空的记录、分批执行、可重复执行;可在沉默期总开关关闭时运行。
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func main() {
target := flag.String("target", "", "回填对象:silence(套餐沉默期)/ outbound(资产出库时间)")
execute := flag.Bool("execute", false, "执行写入;不传时只输出影响清单")
flag.Parse()
if *target != "silence" && *target != "outbound" {
fail("必须指定 -target silence 或 -target outbound")
}
db, err := openDB()
if err != nil {
fail(err.Error())
}
ctx := auditcontext.With(context.Background(), auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: "silence-backfill", ActorName: "沉默期历史回填工具",
Source: constants.AuditSourceWorker,
})
backfill := packagesilenceapp.NewBackfill(db, auditInfra.NewWriter(auditInfra.NewRegistry(), nil))
started := time.Now()
mode := "dry-run(只出清单)"
if *execute {
mode = "execute(执行写入)"
}
fmt.Printf("目标库:%s,对象:%s,模式:%s\n", os.Getenv("DB_NAME"), *target, mode)
var report any
switch *target {
case "silence":
silence, err := backfill.SilenceBackfill(ctx, *execute)
if err != nil {
fail(err.Error())
}
printSilence(silence)
report = silence
case "outbound":
outbound, err := backfill.OutboundBackfill(ctx, *execute)
if err != nil {
fail(err.Error())
}
report = outbound
}
encoded, _ := json.MarshalIndent(report, "", " ")
fmt.Println(string(encoded))
fmt.Printf("耗时:%s\n", time.Since(started).Round(time.Millisecond))
}
func printSilence(report *packagesilenceapp.SilenceBackfillReport) {
fmt.Printf("待回填记录 %d,可回填 %d,未配置默认沉默期 %d(不回填)\n", report.Candidates, report.Backfillable, report.UnconfiguredCount)
fmt.Printf("期内已实名(记为结束)%d,其中实名时间为空 %d;仍在期内 %d;打开总开关后将过期 %d,其中无法付费延期 %d\n",
report.EndedInPeriod, report.RealnamedWithoutTime, report.Waiting, report.WillExpire, report.WillExpireNoPaidExtend)
fmt.Println("按代理分布:")
for _, item := range packagesilenceapp.SortedCounts(report.ByShop) {
fmt.Printf(" %s:%d\n", item.Name, item.Count)
}
fmt.Println("按套餐分布:")
for _, item := range packagesilenceapp.SortedCounts(report.ByPackage) {
fmt.Printf(" %s:%d\n", item.Name, item.Count)
}
if len(report.UnconfiguredPackages) > 0 {
fmt.Println("未配置默认沉默期的套餐:")
for _, item := range packagesilenceapp.SortedCounts(report.UnconfiguredPackages) {
fmt.Printf(" %s:%d\n", item.Name, item.Count)
}
}
}
func openDB() (*gorm.DB, error) {
required := []string{"DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME"}
for _, key := range required {
if strings.TrimSpace(os.Getenv(key)) == "" {
return nil, fmt.Errorf("缺少环境变量 %s(只使用显式传入的数据库连接)", key)
}
}
sslMode := os.Getenv("DB_SSLMODE")
if sslMode == "" {
sslMode = "disable"
}
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"), sslMode)
return gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)})
}
func fail(message string) {
fmt.Fprintln(os.Stderr, "回填失败:"+message)
os.Exit(1)
}

View File

@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
"os"
"os/signal"
"strconv"
@@ -563,6 +564,8 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
constants.ApprovalBusinessTypeCommissionWithdrawal: distributionwithdrawalApp.NewWithdrawalApprovalHandler(
runtime.db, auditWriter,
),
// 沉默期审批(购买时调整、线下付费延期、特殊延期):总开关关闭时照常处理
constants.ApprovalBusinessTypePackageSilence: packagesilenceApp.NewDecisionHandler(runtime.workerResult.Services.SilenceExtensions),
},
owner,
appLogger,
@@ -1083,6 +1086,28 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
)); err != nil {
return fmt.Errorf("注册每日套餐临期提醒扫描定时任务失败: %w", err)
}
// 沉默期延期订单恢复:每 5 分钟查单、超时关闭、异常订单原路退款;不受总开关影响。
if _, err := asynqScheduler.Register(constants.SilenceOrderRecoverCron, asynq.NewTask(
constants.TaskTypePackageSilenceOrderRecover,
nil,
asynq.MaxRetry(3),
asynq.Timeout(4*time.Minute),
asynq.Unique(4*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageSilenceOrderRecover)),
)); err != nil {
return fmt.Errorf("注册沉默期延期订单恢复定时任务失败: %w", err)
}
// 沉默期扫描:每 10 分钟;总开关关闭时任务直接返回(技术方案 §5.4)。
if _, err := asynqScheduler.Register(constants.SilenceScanCron, asynq.NewTask(
constants.TaskTypePackageSilenceScan,
nil,
asynq.MaxRetry(constants.SilenceScanMaxRetry),
asynq.Timeout(constants.SilenceScanUniqueTTL),
asynq.Unique(constants.SilenceScanUniqueTTL),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageSilenceScan)),
)); err != nil {
return fmt.Errorf("注册沉默期扫描定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 * * *", asynq.NewTask(
constants.TaskTypePackageTrafficAlertScan,
nil,