From 909f4d0ec2dc34dfe69a3f2eb110783c6d6e85c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Tue, 27 Oct 2026 16:32:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E5=90=8D=E5=8D=B3=E7=94=9F?= =?UTF-8?q?=E6=95=88=E5=A5=97=E9=A4=90=E6=B2=89=E9=BB=98=E6=9C=9F=E3=80=81?= =?UTF-8?q?=E5=BB=B6=E6=9C=9F=E5=8A=9E=E7=90=86=E4=B8=8E=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E5=87=BA=E5=BA=93=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录 - 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描 - 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理 - 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付 - 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明 Co-Authored-By: Claude Opus 5.5 --- cmd/api/docs.go | 2 + cmd/gendocs/main.go | 2 + cmd/silence-backfill/main.go | 115 +++ cmd/worker/main.go | 25 + ...7-实名即生效套餐沉默期-前端接口变更说明.md | 116 +++ .../application/assetautorenewal/renew.go | 8 + .../application/assetautorenewal/service.go | 12 + .../application/packagesilence/approval.go | 155 ++++ .../application/packagesilence/backfill.go | 290 ++++++++ .../application/packagesilence/decision.go | 470 ++++++++++++ .../application/packagesilence/exchange.go | 78 ++ .../packagesilence/extension_execute.go | 231 ++++++ .../packagesilence/extension_service.go | 344 +++++++++ .../packagesilence/extension_submit.go | 548 ++++++++++++++ .../packagesilence/online_order.go | 679 ++++++++++++++++++ .../packagesilence/order_helpers.go | 145 ++++ .../application/packagesilence/outbound.go | 223 ++++++ internal/application/packagesilence/policy.go | 412 +++++++++++ .../packagesilence/policy_match.go | 79 ++ internal/application/packagesilence/record.go | 154 ++++ .../application/packagesilence/recover.go | 111 +++ .../application/packagesilence/resolve.go | 328 +++++++++ internal/application/packagesilence/scan.go | 60 ++ .../application/packagesilence/setting.go | 257 +++++++ .../application/packagesilence/settings.go | 116 +++ .../packagesilence/switch_service.go | 160 +++++ internal/application/packagesilence/view.go | 39 + internal/application/wecom/scene.go | 29 + internal/bootstrap/handlers.go | 21 +- internal/bootstrap/package_silence.go | 56 ++ internal/bootstrap/services.go | 18 + internal/bootstrap/types.go | 2 + internal/bootstrap/worker_services.go | 20 + internal/domain/package/silence.go | 374 ++++++++++ internal/exporter/device_scene.go | 44 +- internal/exporter/iot_card_scene.go | 31 +- internal/exporter/package_scene.go | 18 + internal/exporter/package_silence_scene.go | 218 ++++++ internal/exporter/registry.go | 2 + internal/handler/admin/package_silence.go | 359 +++++++++ internal/handler/app/client_asset.go | 17 +- .../handler/app/client_package_silence.go | 166 +++++ internal/handler/callback/payment.go | 27 +- internal/infrastructure/audit/approval.go | 13 + .../infrastructure/audit/package_silence.go | 81 +++ .../audit/package_silence_registry.go | 82 +++ internal/infrastructure/audit/registry.go | 4 +- internal/model/device.go | 1 + internal/model/dto/asset_dto.go | 53 +- internal/model/dto/client_asset_dto.go | 1 + internal/model/dto/device_dto.go | 15 +- internal/model/dto/iot_card_dto.go | 3 + internal/model/dto/order_dto.go | 2 + internal/model/dto/package_dto.go | 39 +- internal/model/dto/package_silence_dto.go | 462 ++++++++++++ internal/model/iot_card.go | 1 + internal/model/package.go | 16 + internal/model/package_silence.go | 243 +++++++ .../polling/package_activation_handler.go | 8 + internal/query/audit/finance.go | 7 +- internal/query/packagesilence/query.go | 382 ++++++++++ internal/routes/admin.go | 3 + internal/routes/package_silence.go | 138 ++++ internal/routes/personal.go | 3 + internal/service/asset/service.go | 45 +- internal/service/device/service.go | 23 + internal/service/exchange/service.go | 23 + internal/service/iot_card/service.go | 23 + internal/service/order/service.go | 41 ++ .../service/package/activation_service.go | 84 ++- .../service/package/addon_main_package.go | 2 + internal/service/package/audit.go | 21 + internal/service/package/service.go | 37 +- internal/service/package/terms.go | 49 ++ internal/service/refund/service.go | 4 + internal/service/refund/silence.go | 42 ++ .../shop_package_batch_allocation/service.go | 6 + internal/service/shop_series_grant/service.go | 6 + internal/store/postgres/device_store.go | 6 + internal/store/postgres/iot_card_store.go | 6 + .../store/postgres/package_silence_store.go | 320 +++++++++ internal/store/postgres/package_store.go | 9 + internal/task/auto_purchase.go | 30 +- internal/task/package_silence_scan.go | 75 ++ ..._add_package_usage_silence_fields.down.sql | 13 + ...59_add_package_usage_silence_fields.up.sql | 31 + ..._package_usage_silence_scan_index.down.sql | 2 + ...dd_package_usage_silence_scan_index.up.sql | 5 + ...add_package_silence_config_fields.down.sql | 5 + ...1_add_package_silence_config_fields.up.sql | 13 + ...262_create_package_silence_tables.down.sql | 6 + ...00262_create_package_silence_tables.up.sql | 317 ++++++++ ...package_silence_policy_and_config.down.sql | 5 + ...d_package_silence_policy_and_config.up.sql | 15 + ...tend_wecom_approval_scene_silence.down.sql | 16 + ...extend_wecom_approval_scene_silence.up.sql | 17 + .../000265_add_asset_outbound_time.down.sql | 6 + .../000265_add_asset_outbound_time.up.sql | 42 ++ .../add-package-silence-period/.openspec.yaml | 2 + .../add-package-silence-period/design.md | 106 +++ .../add-package-silence-period/proposal.md | 40 ++ .../specs/asset-outbound-time/spec.md | 49 ++ .../specs/order-refund-exchange/spec.md | 19 + .../specs/package-queue-activation/spec.md | 15 + .../specs/package-silence-extension/spec.md | 157 ++++ .../specs/package-silence-period/spec.md | 178 +++++ .../add-package-silence-period/tasks.md | 121 ++++ pkg/constants/constants.go | 3 + pkg/constants/package_silence.go | 320 +++++++++ pkg/errors/codes.go | 52 ++ pkg/queue/handler.go | 22 + pkg/queue/types.go | 8 + 112 files changed, 10473 insertions(+), 82 deletions(-) create mode 100644 cmd/silence-backfill/main.go create mode 100644 docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md create mode 100644 internal/application/packagesilence/approval.go create mode 100644 internal/application/packagesilence/backfill.go create mode 100644 internal/application/packagesilence/decision.go create mode 100644 internal/application/packagesilence/exchange.go create mode 100644 internal/application/packagesilence/extension_execute.go create mode 100644 internal/application/packagesilence/extension_service.go create mode 100644 internal/application/packagesilence/extension_submit.go create mode 100644 internal/application/packagesilence/online_order.go create mode 100644 internal/application/packagesilence/order_helpers.go create mode 100644 internal/application/packagesilence/outbound.go create mode 100644 internal/application/packagesilence/policy.go create mode 100644 internal/application/packagesilence/policy_match.go create mode 100644 internal/application/packagesilence/record.go create mode 100644 internal/application/packagesilence/recover.go create mode 100644 internal/application/packagesilence/resolve.go create mode 100644 internal/application/packagesilence/scan.go create mode 100644 internal/application/packagesilence/setting.go create mode 100644 internal/application/packagesilence/settings.go create mode 100644 internal/application/packagesilence/switch_service.go create mode 100644 internal/application/packagesilence/view.go create mode 100644 internal/bootstrap/package_silence.go create mode 100644 internal/domain/package/silence.go create mode 100644 internal/exporter/package_silence_scene.go create mode 100644 internal/handler/admin/package_silence.go create mode 100644 internal/handler/app/client_package_silence.go create mode 100644 internal/infrastructure/audit/package_silence.go create mode 100644 internal/infrastructure/audit/package_silence_registry.go create mode 100644 internal/model/dto/package_silence_dto.go create mode 100644 internal/model/package_silence.go create mode 100644 internal/query/packagesilence/query.go create mode 100644 internal/routes/package_silence.go create mode 100644 internal/service/refund/silence.go create mode 100644 internal/store/postgres/package_silence_store.go create mode 100644 internal/task/package_silence_scan.go create mode 100644 migrations/000259_add_package_usage_silence_fields.down.sql create mode 100644 migrations/000259_add_package_usage_silence_fields.up.sql create mode 100644 migrations/000260_add_package_usage_silence_scan_index.down.sql create mode 100644 migrations/000260_add_package_usage_silence_scan_index.up.sql create mode 100644 migrations/000261_add_package_silence_config_fields.down.sql create mode 100644 migrations/000261_add_package_silence_config_fields.up.sql create mode 100644 migrations/000262_create_package_silence_tables.down.sql create mode 100644 migrations/000262_create_package_silence_tables.up.sql create mode 100644 migrations/000263_seed_package_silence_policy_and_config.down.sql create mode 100644 migrations/000263_seed_package_silence_policy_and_config.up.sql create mode 100644 migrations/000264_extend_wecom_approval_scene_silence.down.sql create mode 100644 migrations/000264_extend_wecom_approval_scene_silence.up.sql create mode 100644 migrations/000265_add_asset_outbound_time.down.sql create mode 100644 migrations/000265_add_asset_outbound_time.up.sql create mode 100644 openspec/changes/add-package-silence-period/.openspec.yaml create mode 100644 openspec/changes/add-package-silence-period/design.md create mode 100644 openspec/changes/add-package-silence-period/proposal.md create mode 100644 openspec/changes/add-package-silence-period/specs/asset-outbound-time/spec.md create mode 100644 openspec/changes/add-package-silence-period/specs/order-refund-exchange/spec.md create mode 100644 openspec/changes/add-package-silence-period/specs/package-queue-activation/spec.md create mode 100644 openspec/changes/add-package-silence-period/specs/package-silence-extension/spec.md create mode 100644 openspec/changes/add-package-silence-period/specs/package-silence-period/spec.md create mode 100644 openspec/changes/add-package-silence-period/tasks.md create mode 100644 pkg/constants/package_silence.go diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 5029b2b2..83b7fbcb 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -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) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index abad3806..7697a66f 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -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) diff --git a/cmd/silence-backfill/main.go b/cmd/silence-backfill/main.go new file mode 100644 index 00000000..33e2f65a --- /dev/null +++ b/cmd/silence-backfill/main.go @@ -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) +} diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 82ea8fb7..fe49a411 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -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, diff --git a/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md b/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md new file mode 100644 index 00000000..baa6685f --- /dev/null +++ b/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md @@ -0,0 +1,116 @@ +# 实名即生效套餐沉默期 — 前端接口变更说明 + +> 对应 OpenSpec Change `add-package-silence-period`;规则以 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md` 为准。接口字段以 `docs/admin-openapi.yaml` 为准,本文只说明页面要做什么。 + +## 1. 总开关对页面的影响 + +沉默期总开关 `package.silence.enabled` **默认关闭**。 + +| 页面 / 功能 | 关闭时 | 打开时 | +|---|---|---| +| 资产套餐列表、H5 套餐历史里的 `silence` 对象 | **不返回**(字段为空),页面不显示沉默期 | 返回,显示状态、截止日 | +| "延期"入口 | 不显示 | `silence.extendable = true` 时显示 | +| 延期资格、试算、下单、线下登记、特殊延期 | 返回错误码 **1274** | 可用 | +| 套餐默认沉默期、延期政策、企微审批场景配置 | 可用 | 可用 | +| 购买时调整沉默期 | 可用 | 可用 | +| 卡 / 设备出库时间 | 不受开关影响 | 不受开关影响 | + +后台"系统配置"通用接口不能修改总开关(只读),请用专用接口: + +- `GET /api/admin/package-silence/switch`:当前值 + 打开前 4 项检查(`precheck[]`:code、name、passed、count)。 +- `PUT /api/admin/package-silence/switch` `{ "enabled": true|false }`:仅超级管理员。打开前检查不通过返回 **1275**,`data.failed_items` 为未满足项,直接展示即可;关闭不检查。 + +## 2. 套餐配置 + +- 新增 / 编辑套餐增加 `silence_period_days`(1~90)、`silence_extension_enabled`(默认 true)。 + - **实名即生效的正式套餐必填**默认沉默期,新建时前端预填 30;不填或越界返回 **1260**。 + - 购买即生效套餐、加油包不显示、不提交这两个字段。 +- 套餐响应增加 `silence_period_days`、`silence_extension_enabled`、`silence_config_required`(是否必须配置)。 +- 实名即生效套餐改为购买即生效、店铺分配(单个、批量、系列授权)里覆盖为与套餐不同的生效条件,返回 **1262**。购买即生效改为实名即生效时,同一请求必须带 `silence_period_days`。 +- "未配置沉默期的实名即生效套餐"清单:套餐列表加 `silence_unconfigured=true`;导出用套餐导出场景,筛选同样传 `silence_unconfigured`。套餐导出新增"默认沉默期(天)""是否支持付费延期"两列。 + +## 3. 后台购买时调整沉默期 + +`POST /api/admin/orders`(后台给资产购买套餐)新增可选字段: + +- `silence_period_days`(≥ 1,不设上限)、`silence_reason`(设置时必填)。 +- 需要 `package_silence:override` 权限,无权限传值返回 **1261**。 +- 不超过默认值:直接生效;超过默认值:按延期政策直接生效,或**先按默认值购买并提交企微审批**(审批中套餐不会过期)。发起人未绑定企微成员时购买被拒。 +- 只对"资产未实名时购买的实名即生效主套餐"生效,其他情况忽略。 + +## 4. 资产套餐展示 + +后台资产套餐历史 / 当前套餐、H5 套餐历史(`/api/c/v1/asset/package-history`)每笔套餐增加 `silence` 对象(总开关打开且适用沉默期时返回): + +| 字段 | 说明 | +|---|---| +| `silence_status` / `silence_status_name` | `waiting` 待实名(延期后显示"延期待实名")/ `expired` 沉默期已过期 / `ended` 沉默期已结束;待实名但已过截止的直接按 `expired` 返回 | +| `silence_deadline_at`、`silence_start_at`、`silence_period_days` | 截止时间(截止日 23:59:59)、起算时间、天数 | +| `silence_extended_days`、`silence_paid_extended_days`、`silence_extension_count`、`silence_free_extension_used` | 延期累计 | +| `silence_expired_at`、`silence_adjust_pending` | 过期时间、调整审批中 | +| `extendable` | 是否显示"延期"入口 | + +套餐沉默期流水:后台 `GET /api/admin/package-silence/records?package_usage_id=`,H5 `GET /api/c/v1/package-silence/records?package_usage_id=`。 + +## 5. 延期(后台) + +均在 `/api/admin/package-silence` 下: + +| 接口 | 权限 | 说明 | +|---|---|---| +| `GET /eligibility?package_usage_id=` | — | 资格:`can_extend`、`special_extension_available`、`reason`、`min_days`/`max_days`、`min_months`/`max_months`、`unit_price`、`options[]`(每档计费天数、金额、新截止日)。**金额全部由后端算,前端只传天数** | +| `GET /quote?package_usage_id=&days=` | — | 输入天数试算(按 30 天向上取整:1→30、31→60) | +| `POST /orders/qrcode` | `package_silence:extend` | 运营生成支付二维码:入参 `package_usage_id`、`days`、`payment_method`(wechat/alipay)、`request_id`、`evidence_refs`;返回 `qr_content` 渲染为二维码。已有客户 H5 待支付订单时后端先查单,未付自动关闭旧单 | +| `POST /orders/offline` | `package_silence:offline` | 登记线下付款:`offline_payment_method_id`(线下收款方式字典)、`offline_trade_no`、`paid_amount`(必须等于计算金额,否则 **1273**)、`paid_at`、`payment_voucher_keys`(1~5)、`evidence_refs`、`request_id`。交易单号重复 **1272**。默认进企微审批 | +| `GET /orders`、`GET /orders/:id` | — | 延期订单列表 / 详情,含 `payment_method`、`payment_merchant_name`、`third_party_trade_no`;导出场景 `package_silence_order` | +| `POST /special` | `package_silence:extend`(批量另需 `package_silence:extend_batch`) | 特殊延期:`extension_type`(free/discount/complaint_compensation/system_compensation/special_customer)、`items[]`(package_usage_id + days)、`reason`、`basis`、`evidence_refs`、`amount`(免费为 0)、优惠价有收款时填收款方式 / 交易单号 / 凭证。批量明细逐行校验,不通过返回 `data.rows[]`(行号与原因)。免费次数用完 **1267** | +| `GET /batches`、`GET /batches/:id` | — | 批次与明细(`has_exception=true` 只看含异常的) | +| `POST /extensions/:id/handle` | `package_silence:exception` | 异常明细 `action=retry|close` + `reason`;在线订单关闭时原路退款 | +| `GET /usages?view=expiring|expired|all` | — | 即将过期(默认 7 天内)/ 已过期套餐;导出场景 `package_silence_usage` | + +延期政策(`package_silence:policy`):`GET/POST /policies`、`GET/PUT /policies/:id`、`PUT /policies/:id/status`、`GET /policies/match?package_id=`。同级重叠返回 **1269**。 + +## 6. 延期(H5) + +均在 `/api/c/v1/package-silence` 下,资产须属于当前客户: + +- `GET /eligibility`、`GET /quote`:同后台。 +- `POST /orders`:自助下单(`package_usage_id`、`days`、`payment_method`、`request_id`),返回 `qr_content`(支付链接或二维码内容);支付成功自动延期。 +- `GET /orders`、`GET /orders/:id`:我的延期订单。 +- `GET /records`:沉默期流水。 + +## 7. 退款 + +沉默期已过期的套餐申请退款返回 **1268**(总开关打开时)。 + +## 8. 卡 / 设备出库时间 + +- 卡列表、设备列表返回 `outbound_at`,支持 `outbound_start`、`outbound_end`(RFC3339)筛选;卡详情、设备详情、资产解析返回 `outbound_at`。 +- 卡、设备导出末尾新增"出库时间"列。 +- 出库记录:`GET /api/admin/assets/:asset_type/:id/outbound-records`(asset_type 为 card / device),按世代倒序,含来源(首次购买套餐 / 换货新资产 / 历史回填)、订单号或换货单号、结束时间与原因。 +- 绑定在设备上的卡不记录出库时间;只读,不提供修改。 + +## 9. 错误码 + +| 码 | 名称 | 说明 | +|---|---|---| +| 1260 | silence_period_out_of_range | 沉默期天数不在允许范围 | +| 1261 | silence_override_forbidden | 无权限调整沉默期 | +| 1262 | package_expiry_base_change_forbidden | 不允许变更该套餐的生效条件 | +| 1263 | silence_extension_not_expired | 套餐未过期 / 无沉默期 / 已激活 / 已退款(`data.reason` 细分) | +| 1264 | silence_extension_disabled | 该套餐不支持付费延期(可申请特殊延期) | +| 1265 | silence_extension_exceeds_limit | 超过可延期期限(`data.min_months`、`data.max_months`) | +| 1266 | silence_extension_months_invalid | 延期天数不在允许范围(`data.min_days`、`data.max_days`) | +| 1267 | silence_free_extension_used_up | 免费延期次数已用完 | +| 1268 | silence_expired_not_refundable | 沉默期已过期的套餐不能退款 | +| 1269 | silence_policy_overlap | 延期政策重叠 | +| 1270 | silence_extension_no_policy | 未匹配到延期政策 | +| 1271 | silence_extension_order_pending | 已有未结束的延期订单或申请 | +| 1272 | silence_offline_trade_no_duplicate | 线下交易单号已登记 | +| 1273 | silence_offline_amount_mismatch | 实收金额与计算金额不一致(`data.expected_amount`) | +| 1274 | silence_feature_disabled | 沉默期功能未开启 | +| 1275 | silence_switch_precheck_failed | 总开关打开前检查未通过(`data.failed_items`) | + +## 10. 企微审批模板 + +超级管理员在"企微审批场景"配置业务类型 **沉默期审批(package_silence_approval)**,模板需有"申请类型"单选控件,选项映射:`购买时调整沉默期`、`特殊延期`、`线下付费延期`。可映射字段见场景配置页的业务字段列表(申请单号、资产、套餐、原截止日期、延期天数、计费天数、费用、原因、附件、收款方式、交易单号、凭证等)。 diff --git a/internal/application/assetautorenewal/renew.go b/internal/application/assetautorenewal/renew.go index d76792ae..092d2d9c 100644 --- a/internal/application/assetautorenewal/renew.go +++ b/internal/application/assetautorenewal/renew.go @@ -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 } diff --git a/internal/application/assetautorenewal/service.go b/internal/application/assetautorenewal/service.go index 66b0e633..61ddeef9 100644 --- a/internal/application/assetautorenewal/service.go +++ b/internal/application/assetautorenewal/service.go @@ -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 创建资产钱包自动续费用例。 diff --git a/internal/application/packagesilence/approval.go b/internal/application/packagesilence/approval.go new file mode 100644 index 00000000..7cb91d60 --- /dev/null +++ b/internal/application/packagesilence/approval.go @@ -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") +} diff --git a/internal/application/packagesilence/backfill.go b/internal/application/packagesilence/backfill.go new file mode 100644 index 00000000..86b36a9e --- /dev/null +++ b/internal/application/packagesilence/backfill.go @@ -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 +} diff --git a/internal/application/packagesilence/decision.go b/internal/application/packagesilence/decision.go new file mode 100644 index 00000000..38fa6445 --- /dev/null +++ b/internal/application/packagesilence/decision.go @@ -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 +} diff --git a/internal/application/packagesilence/exchange.go b/internal/application/packagesilence/exchange.go new file mode 100644 index 00000000..0c16f43b --- /dev/null +++ b/internal/application/packagesilence/exchange.go @@ -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 + } +} diff --git a/internal/application/packagesilence/extension_execute.go b/internal/application/packagesilence/extension_execute.go new file mode 100644 index 00000000..ffdbbde0 --- /dev/null +++ b/internal/application/packagesilence/extension_execute.go @@ -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 +} diff --git a/internal/application/packagesilence/extension_service.go b/internal/application/packagesilence/extension_service.go new file mode 100644 index 00000000..803a63c2 --- /dev/null +++ b/internal/application/packagesilence/extension_service.go @@ -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 +} diff --git a/internal/application/packagesilence/extension_submit.go b/internal/application/packagesilence/extension_submit.go new file mode 100644 index 00000000..120dc049 --- /dev/null +++ b/internal/application/packagesilence/extension_submit.go @@ -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 +} diff --git a/internal/application/packagesilence/online_order.go b/internal/application/packagesilence/online_order.go new file mode 100644 index 00000000..12850309 --- /dev/null +++ b/internal/application/packagesilence/online_order.go @@ -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 +} diff --git a/internal/application/packagesilence/order_helpers.go b/internal/application/packagesilence/order_helpers.go new file mode 100644 index 00000000..0d9cbfdf --- /dev/null +++ b/internal/application/packagesilence/order_helpers.go @@ -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 "" + } +} diff --git a/internal/application/packagesilence/outbound.go b/internal/application/packagesilence/outbound.go new file mode 100644 index 00000000..8c624b3b --- /dev/null +++ b/internal/application/packagesilence/outbound.go @@ -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) +} diff --git a/internal/application/packagesilence/policy.go b/internal/application/packagesilence/policy.go new file mode 100644 index 00000000..7a526318 --- /dev/null +++ b/internal/application/packagesilence/policy.go @@ -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 +} diff --git a/internal/application/packagesilence/policy_match.go b/internal/application/packagesilence/policy_match.go new file mode 100644 index 00000000..40e2de46 --- /dev/null +++ b/internal/application/packagesilence/policy_match.go @@ -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...), + } +} diff --git a/internal/application/packagesilence/record.go b/internal/application/packagesilence/record.go new file mode 100644 index 00000000..71b92798 --- /dev/null +++ b/internal/application/packagesilence/record.go @@ -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 +} diff --git a/internal/application/packagesilence/recover.go b/internal/application/packagesilence/recover.go new file mode 100644 index 00000000..34aed187 --- /dev/null +++ b/internal/application/packagesilence/recover.go @@ -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 +} diff --git a/internal/application/packagesilence/resolve.go b/internal/application/packagesilence/resolve.go new file mode 100644 index 00000000..3f8f70c1 --- /dev/null +++ b/internal/application/packagesilence/resolve.go @@ -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 +} diff --git a/internal/application/packagesilence/scan.go b/internal/application/packagesilence/scan.go new file mode 100644 index 00000000..9364889b --- /dev/null +++ b/internal/application/packagesilence/scan.go @@ -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 +} diff --git a/internal/application/packagesilence/setting.go b/internal/application/packagesilence/setting.go new file mode 100644 index 00000000..d3cd86cd --- /dev/null +++ b/internal/application/packagesilence/setting.go @@ -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 +} diff --git a/internal/application/packagesilence/settings.go b/internal/application/packagesilence/settings.go new file mode 100644 index 00000000..d382df82 --- /dev/null +++ b/internal/application/packagesilence/settings.go @@ -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() +} diff --git a/internal/application/packagesilence/switch_service.go b/internal/application/packagesilence/switch_service.go new file mode 100644 index 00000000..a2736a62 --- /dev/null +++ b/internal/application/packagesilence/switch_service.go @@ -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 +} diff --git a/internal/application/packagesilence/view.go b/internal/application/packagesilence/view.go new file mode 100644 index 00000000..b5f9ffd2 --- /dev/null +++ b/internal/application/packagesilence/view.go @@ -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, + } +} diff --git a/internal/application/wecom/scene.go b/internal/application/wecom/scene.go index a832741e..3db2af6d 100644 --- a/internal/application/wecom/scene.go +++ b/internal/application/wecom/scene.go @@ -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 "未知审批业务类型" } diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index d898a96e..9d9eb1e0 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -8,6 +8,7 @@ import ( merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" packageExpiryApp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry" + packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" roleApp "github.com/break/junhong_cmp_fiber/internal/application/role" shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop" systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" @@ -42,6 +43,7 @@ import ( notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification" operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport" packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" + packagesilenceQuery "github.com/break/junhong_cmp_fiber/internal/query/packagesilence" packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert" priorityPollingQuery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling" shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop" @@ -126,6 +128,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { registerPaymentMethodConfigDefinitions(systemConfigRegistry, deps.Logger) gatewaylimit.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger) carrierqueue.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger) + packagesilenceApp.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger) systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger) var systemConfigCache systemConfigInfra.Cache if deps.Redis != nil { @@ -313,11 +316,16 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant), AdminOrder: admin.NewOrderHandler(svc.Order, validate), AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate), - PaymentCallback: callback.NewPaymentHandler( - svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, - deps.WechatPayment, svc.WechatConfig, paymentStore, - svc.AgentRechargePaymentConfirm, integrationlog.NewRepository(deps.DB), deps.Logger, - ), + PaymentCallback: func() *callback.PaymentHandler { + handler := callback.NewPaymentHandler( + svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, + deps.WechatPayment, svc.WechatConfig, paymentStore, + svc.AgentRechargePaymentConfirm, integrationlog.NewRepository(deps.DB), deps.Logger, + ) + // 沉默期延期支付回调:按 order_type = silence_extension 分发 + handler.SetSilencePaymentConfirmer(svc.PackageSilence.Extensions) + return handler + }(), CTCCRealnameCallback: callback.NewCTCCRealnameHandler( carriercallback.NewCTCCRealnameTranslator(), carriercallback.NewCTCCCardResolver(deps.DB), integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger, @@ -338,6 +346,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig), GatewayRateLimit: admin.NewGatewayRateLimitHandler(pollingSvcPkg.NewGatewayRateLimitService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil), deps.GatewayLimit), deps.Redis, validate), PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency), + PackageSilence: admin.NewPackageSilenceHandler(svc.PackageSilence.Switches, svc.PackageSilence.Policies, + svc.PackageSilence.Extensions, packagesilenceQuery.NewQuery(deps.DB), validate), + ClientPackageSilence: app.NewClientPackageSilenceHandler(svc.PackageSilence.Extensions, packagesilenceQuery.NewQuery(deps.DB), validate), PollingPause: admin.NewPollingPauseHandler(assetPollingSvc, pollingSvcPkg.NewPollingStatusImportService(deps.DB, deps.QueueClient, deps.Logger), validate), CarrierScheduler: admin.NewCarrierSchedulerHandler(pollingSvcPkg.NewCarrierSchedulerService(deps.DB, deps.Redis, diff --git a/internal/bootstrap/package_silence.go b/internal/bootstrap/package_silence.go new file mode 100644 index 00000000..c189aa6c --- /dev/null +++ b/internal/bootstrap/package_silence.go @@ -0,0 +1,56 @@ +package bootstrap + +import ( + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" + + approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" + packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" + refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" + auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + packageSvc "github.com/break/junhong_cmp_fiber/internal/service/package" +) + +// packageSilenceBundle 是沉默期相关用例的装配结果(API 与 Worker 共用)。 +type packageSilenceBundle struct { + Switch *packagesilenceApp.Switch + Resolver *packagesilenceApp.Resolver + Setter *packagesilenceApp.Setter + Outbound *packagesilenceApp.OutboundRecorder + // 以下仅 API 与 Worker 中延期相关入口装配 + Extensions *packagesilenceApp.ExtensionService + Policies *packagesilenceApp.PolicyService + Switches *packagesilenceApp.SwitchService +} + +// newPackageSilenceBundle 装配沉默期总开关、判定、设置与出库时间用例,并把激活闸门注入激活服务。 +func newPackageSilenceBundle(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger, auditWriter *auditInfra.Writer, activation *packageSvc.ActivationService) *packageSilenceBundle { + sw := packagesilenceApp.NewDefaultSwitch(db, redisClient, logger) + resolver := packagesilenceApp.NewResolver(db, sw, auditWriter, logger) + if activation != nil { + resolver.SetActivator(activation) + activation.SetSilenceGate(resolver, sw) + } + return &packageSilenceBundle{ + Switch: sw, Resolver: resolver, + Setter: packagesilenceApp.NewSetter(db, auditWriter), + Outbound: packagesilenceApp.NewOutboundRecorder(auditWriter), + Extensions: packagesilenceApp.NewExtensionService(db, resolver, auditWriter, logger), + Policies: packagesilenceApp.NewPolicyService(db, auditWriter), + Switches: packagesilenceApp.NewSwitchService(db, redisClient, sw, auditWriter), + } +} + +// wireExtensions 注入延期用例的审批、权限、资产归属、商户池与支付渠道。 +func (b *packageSilenceBundle) wireExtensions(approval approvalApp.Port, perms packagesilenceApp.PermissionChecker, ownership packagesilenceApp.OwnershipChecker, + runtime *merchantpayment.RuntimeLoader, ports packagesilenceApp.PaymentPorts, refunder refundchannelApp.Refunder) { + b.Setter.SetApprovalPort(approval) + b.Setter.SetPermissionChecker(perms) + b.Extensions.SetApprovalPort(approval) + b.Extensions.SetPermissionChecker(perms) + b.Extensions.SetOwnershipChecker(ownership) + b.Extensions.SetPayment(runtime, ports, refunder) + b.Policies.SetPermissionChecker(perms) +} diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index cb496f4b..195d0c4a 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -2,6 +2,7 @@ package bootstrap import ( "context" + packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" "github.com/redis/go-redis/v9" "gorm.io/gorm" "time" @@ -97,6 +98,7 @@ type services struct { AssetAudit *assetAuditSvc.Service Role *roleSvc.Service Permission *permissionSvc.Service + PackageSilence *packageSilenceBundle PersonalCustomer *personalCustomerSvc.Service ClientAuth *clientAuthSvc.Service Shop *shopSvc.Service @@ -267,6 +269,7 @@ func initServices(s *stores, deps *Dependencies) *services { // 激活前流量结算:资产从无生效主套餐变为有生效主套餐前,对已切换日快照计量的卡查一次流量入账。 preActivationSettler := cardObservationInfra.NewPreActivationSettler(deps.DB, deps.GatewayClient, cardObservationService, postgres.NewCarrierStore(deps.DB), deps.Logger) packageActivation.SetPreActivationSettler(preActivationSettler) + packageSilence := newPackageSilenceBundle(deps.DB, deps.Redis, deps.Logger, auditWriter, packageActivation) stopResumeService := iotCardSvc.NewStopResumeService( deps.Redis, @@ -338,6 +341,8 @@ func initServices(s *stores, deps *Dependencies) *services { orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB)) orderService.SetObservationSeriesEventWriter(observationSeriesEvents) orderService.SetPriorityEventWriter(priorityEvents) + orderService.SetSilenceSetter(packageSilence.Setter) + orderService.SetOutboundRecorder(packageSilence.Outbound) walletOutbox := outbox.NewRepository() walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter) orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil)) @@ -392,6 +397,7 @@ func initServices(s *stores, deps *Dependencies) *services { refundService.SetNotificationOutbox(walletOutbox) refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis)) refundService.SetLifecycleAudit(auditWriter) + refundService.SetSilenceSwitch(packageSilence.Switch) // 渠道原路退款的登记与执行共用同一用例:API 侧只登记待执行事实与可靠事件, // 真正的渠道调用由 Worker 消费该事件执行。 refundService.SetChannelRefundService( @@ -405,10 +411,12 @@ func initServices(s *stores, deps *Dependencies) *services { exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger) exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository()))) exchangeService.SetAccessAudit(auditWriter) + exchangeService.SetSilenceHook(packagesilenceApp.NewExchangeHook(packageSilence.Setter, packageSilence.Outbound)) exchangeService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation) assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder) assetService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation) assetService.SetAccessAudit(auditWriter) + assetService.SetSilenceSwitch(packageSilence.Switch) agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device) wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB) wecomSceneRepository := wecomInfra.NewSceneRepository(deps.DB) @@ -453,6 +461,15 @@ func initServices(s *stores, deps *Dependencies) *services { roleService.SetAccessAudit(deps.DB, deps.Redis, auditWriter) permissionService := permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis) permissionService.SetAccessAudit(deps.DB, auditWriter) + packageSilence.wireExtensions(approvalCreationService, permissionService, customerBinding, + merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis), + packagesilenceApp.PaymentPorts{ + Wechat: paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger), + Alipay: paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger), + Fuiou: paymentInfra.NewFuiouScanAdapter(paymentIntegration, deps.Logger), + }, + paymentInfra.NewRefundAdapter(wechat.NewRedisCache(deps.Redis), deps.Logger), + ) shopService := shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role) shopService.SetAccessAudit(deps.DB, deps.Redis, auditWriter) commissionWithdrawal := commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest) @@ -513,6 +530,7 @@ func initServices(s *stores, deps *Dependencies) *services { AssetAudit: assetAudit, Role: roleService, Permission: permissionService, + PackageSilence: packageSilence, PersonalCustomer: personalCustomerSvc.NewService(deps.DB, s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger, auditWriter), ClientAuth: clientAuthSvc.New( deps.DB, diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index c3386587..8fe67123 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -67,6 +67,8 @@ type Handlers struct { GatewayRateLimit *admin.GatewayRateLimitHandler CarrierScheduler *admin.CarrierSchedulerHandler PollingPause *admin.PollingPauseHandler + PackageSilence *admin.PackageSilenceHandler + ClientPackageSilence *app.ClientPackageSilenceHandler PollingConcurrency *admin.PollingConcurrencyHandler PollingMonitoring *admin.PollingMonitoringHandler PollingAlert *admin.PollingAlertHandler diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index 465c5122..8cb677ea 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -12,6 +12,8 @@ import ( cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold" employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" + "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" + packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal" auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" @@ -19,6 +21,7 @@ import ( carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment" prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling" walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal" @@ -31,6 +34,7 @@ import ( pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" purchaseValidationSvc "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation" "github.com/break/junhong_cmp_fiber/pkg/queue" + "github.com/break/junhong_cmp_fiber/pkg/wechat" ) type workerServices struct { @@ -92,6 +96,14 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q deps.Logger, ) activationService.SetLifecycleAudit(auditWriter) + packageSilence := newPackageSilenceBundle(deps.DB, deps.Redis, deps.Logger, auditWriter, activationService) + // Worker 内延期用例只承担审批通过后的执行、查单恢复与异常原路退款,不创建新申请。 + silencePaymentLog := integrationlog.NewRepository(deps.DB) + packageSilence.Extensions.SetPayment(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis), packagesilenceApp.PaymentPorts{ + Wechat: paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), silencePaymentLog, deps.Logger), + Alipay: paymentInfra.NewAlipayWapAdapter(silencePaymentLog, deps.Logger), + Fuiou: paymentInfra.NewFuiouScanAdapter(silencePaymentLog, deps.Logger), + }, paymentInfra.NewRefundAdapter(wechat.NewRedisCache(deps.Redis), deps.Logger)) usageService.SetLifecycleAudit(auditWriter) resetService.SetLifecycleAudit(auditWriter) @@ -186,6 +198,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil)) orderService.SetObservationSeriesEventWriter(observationSeriesEvents) orderService.SetPriorityEventWriter(priorityEvents) + orderService.SetSilenceSetter(packageSilence.Setter) + orderService.SetOutboundRecorder(packageSilence.Outbound) // 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机 stopResumeService := iotCardSvc.NewStopResumeService( @@ -218,6 +232,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q ObservationEvents: observationSeriesEvents, PriorityEvents: priorityEvents, }) + assetAutoRenewalService.SetSilenceRecorder(packagesilenceApp.PurchaseRecorder{Setter: packageSilence.Setter, Outbound: packageSilence.Outbound}) activationService.SetObservationSeriesEventWriter(observationSeriesEvents) activationService.SetPriorityEventWriter(priorityEvents) usageService.SetStopResumeCallback(stopResumeService) @@ -253,5 +268,10 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q DeviceBatchAllocator: deviceBatchAllocator, AssetPolling: assetPollingService, IotCardPolling: iotCardAuditService, + SilenceSwitch: packageSilence.Switch, + SilenceResolver: packageSilence.Resolver, + SilenceSetter: packageSilence.Setter, + SilenceOutbound: packageSilence.Outbound, + SilenceExtensions: packageSilence.Extensions, } } diff --git a/internal/domain/package/silence.go b/internal/domain/package/silence.go new file mode 100644 index 00000000..76c75901 --- /dev/null +++ b/internal/domain/package/silence.go @@ -0,0 +1,374 @@ +package packagedomain + +import ( + "slices" + "time" +) + +// 沉默期领域规则(add-package-silence-period,技术方案 §4.2、§5.2、§8.2、§3.6)。 +// 本文件只放纯函数:不读库、不读配置。所有截止时间与延期计费只能由这里计算,禁止各处自写 AddDate 或 SQL interval。 + +const ( + silenceDaysPerMonth = 30 + silenceStatusWait = "waiting" +) + +var silenceLocation = loadSilenceLocation() + +func loadSilenceLocation() *time.Location { + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return time.FixedZone("CST", 8*3600) + } + return location +} + +// SilenceLocation 返回沉默期计算使用的时区 Asia/Shanghai。 +func SilenceLocation() *time.Location { + return silenceLocation +} + +// SilenceDate 返回时间点在 Asia/Shanghai 下的日期(当日 00:00:00)。 +func SilenceDate(t time.Time) time.Time { + local := t.In(silenceLocation) + year, month, day := local.Date() + return time.Date(year, month, day, 0, 0, 0, 0, silenceLocation) +} + +// SilenceDeadline 返回 (日期 + days 天) 当日 23:59:59(Asia/Shanghai)。 +// 例:2026-09-24 10:00 购买、30 天 → 2026-10-24 23:59:59。 +func SilenceDeadline(date time.Time, days int) time.Time { + local := date.In(silenceLocation) + year, month, day := local.Date() + return time.Date(year, month, day+days, 23, 59, 59, 0, silenceLocation) +} + +// SilenceDaysBetween 返回两个时间点按 Asia/Shanghai 日期相差的天数(to − from)。 +func SilenceDaysBetween(from, to time.Time) int { + start := SilenceDate(from) + end := SilenceDate(to) + // 用 UTC 日期差避免夏令时影响(上海无夏令时,这里保持通用)。 + startUTC := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC) + endUTC := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC) + return int(endUTC.Sub(startUTC).Hours() / 24) +} + +// SilenceDecision 是一次沉默期判定的结果。 +type SilenceDecision string + +const ( + // SilenceDecisionNoop 状态不变(不适用沉默期、已结束 / 已过期、仍在期内等待实名、调整审批中)。 + SilenceDecisionNoop SilenceDecision = "noop" + // SilenceDecisionWait 仍在期内等待实名(与 noop 一样不写库,供展示区分)。 + SilenceDecisionWait SilenceDecision = "wait" + // SilenceDecisionEnd 期内已实名,沉默期结束。 + SilenceDecisionEnd SilenceDecision = "end" + // SilenceDecisionExpire 已到期且期内未实名,沉默期过期。 + SilenceDecisionExpire SilenceDecision = "expire" +) + +// SilenceFacts 是判定所需的事实。 +type SilenceFacts struct { + Status string // 当前 silence_status(空表示不适用) + DeadlineAt *time.Time // 当前截止时间 + Realnamed bool // 资产当前是否已实名 + RealnameAt *time.Time // 实名时间(设备为绑定卡 max(实名, 绑定) 的最小值) + AdjustPending bool // 购买时调整审批是否进行中 + Now time.Time +} + +// SilenceJudgement 是判定结果与补写信息。 +type SilenceJudgement struct { + Decision SilenceDecision + // BackfillRealnameAt 为 true 表示资产已实名但实名时间为空,按规则视为期内实名并需补写实名时间。 + BackfillRealnameAt bool +} + +// JudgeSilence 执行统一判定(技术方案 §5.2): +// +// waiting 且 期内已实名 → end +// waiting 且 已到期 且 期内未实名 且 无调整审批 → expire +// 其他 → 不变 +// +// 已实名但实名时间为空的资产视为期内已实名(生产存在 first_realname_at 为空的已实名卡)。 +func JudgeSilence(facts SilenceFacts) SilenceJudgement { + if facts.Status != silenceStatusWait || facts.DeadlineAt == nil { + return SilenceJudgement{Decision: SilenceDecisionNoop} + } + deadline := *facts.DeadlineAt + if facts.Realnamed { + if facts.RealnameAt == nil { + return SilenceJudgement{Decision: SilenceDecisionEnd, BackfillRealnameAt: true} + } + if !facts.RealnameAt.After(deadline) { + return SilenceJudgement{Decision: SilenceDecisionEnd} + } + } + if facts.Now.After(deadline) && !facts.AdjustPending { + return SilenceJudgement{Decision: SilenceDecisionExpire} + } + return SilenceJudgement{Decision: SilenceDecisionWait} +} + +// IsSilenceEffectivelyExpired 判断展示层是否应显示"沉默期已过期":已过期,或待实名但已过截止且无调整审批。 +func IsSilenceEffectivelyExpired(status string, deadlineAt *time.Time, adjustPending bool, now time.Time) bool { + if status == "expired" { + return true + } + return status == silenceStatusWait && deadlineAt != nil && now.After(*deadlineAt) && !adjustPending +} + +// SilenceMonthsForDays 按每月 30 天向上取整:返回计费月数与计费 / 延长天数。 +// 例:1→(1,30);30→(1,30);31→(2,60);60→(2,60)。 +func SilenceMonthsForDays(inputDays int) (months int, billedDays int) { + if inputDays <= 0 { + return 0, 0 + } + months = (inputDays + silenceDaysPerMonth - 1) / silenceDaysPerMonth + return months, months * silenceDaysPerMonth +} + +// SilencePolicySnapshot 是延期计价与上限使用的政策快照。 +type SilencePolicySnapshot struct { + PolicyID uint + Name string + UnitPrice int64 + FreeExtensionLimit int + PaidMaxMonths int + SingleMaxMonths *int + ApprovalExemptTypes []string +} + +// SilenceQuoteKind 是延期报价类型。 +type SilenceQuoteKind string + +const ( + // SilenceQuotePaid 付费延期(在线与线下):受政策累计与单次上限约束。 + SilenceQuotePaid SilenceQuoteKind = "paid" + // SilenceQuoteSpecial 特殊延期(含免费):只要求不少于最少月数。 + SilenceQuoteSpecial SilenceQuoteKind = "special" +) + +// SilenceQuoteInput 是延期报价输入。 +type SilenceQuoteInput struct { + DeadlineAt time.Time + Now time.Time + InputDays int // 0 表示只算资格,不校验输入 + Policy *SilencePolicySnapshot + PaidExtendedDays int + Kind SilenceQuoteKind +} + +// SilenceQuoteOption 是一个月数档位的展示信息。 +type SilenceQuoteOption struct { + Months int + BilledDays int + Amount int64 + NewDeadline time.Time +} + +// SilenceQuote 是延期报价结果。 +type SilenceQuote struct { + OverdueDays int + MinMonths int + MaxMonths int // 付费延期上限;特殊延期无上限时为 0 + MinDays int + MaxDays int + UnitPrice int64 + // 以下字段仅在 InputDays > 0 时有值 + Months int + BilledDays int + Amount int64 + NewDeadline time.Time + // CanExtend 表示该类型(付费 / 特殊)能否办理;Reason 为不能办理的原因码 + CanExtend bool + Reason string + // InputValid 表示输入天数换算后的月数是否在允许范围内(InputDays > 0 时有意义) + InputValid bool +} + +// 报价原因码(与 constants.SilenceReason* 取值一致)。 +const ( + silenceReasonNoPolicy = "no_policy" + silenceReasonExceedsLimit = "exceeds_limit" +) + +// QuoteSilenceExtension 计算延期报价(技术方案 §8.2): +// +// overdue_days = 今天日期 − 截止日期(不小于 0) +// min_months = floor(overdue_days / 30) + 1 +// remain = paid_max_months − paid_extended_days / 30 +// max_months = min(remain, single_max_months) +// months = ceil(input_days / 30);延长天数 = months × 30 +// new_deadline = (截止日期 + 延长天数) 当日 23:59:59 +// amount = months × unit_price(特殊延期由调用方按类型决定金额) +func QuoteSilenceExtension(input SilenceQuoteInput) SilenceQuote { + overdue := SilenceDaysBetween(input.DeadlineAt, input.Now) + if overdue < 0 { + overdue = 0 + } + quote := SilenceQuote{ + OverdueDays: overdue, + MinMonths: overdue/silenceDaysPerMonth + 1, + MinDays: overdue + 1, + } + if input.Policy != nil { + quote.UnitPrice = input.Policy.UnitPrice + } + + switch input.Kind { + case SilenceQuoteSpecial: + quote.CanExtend = true + default: + if input.Policy == nil { + quote.Reason = silenceReasonNoPolicy + } else { + remain := input.Policy.PaidMaxMonths - input.PaidExtendedDays/silenceDaysPerMonth + if remain < 0 { + remain = 0 + } + maxMonths := remain + if input.Policy.SingleMaxMonths != nil && *input.Policy.SingleMaxMonths < maxMonths { + maxMonths = *input.Policy.SingleMaxMonths + } + quote.MaxMonths = maxMonths + quote.MaxDays = maxMonths * silenceDaysPerMonth + quote.CanExtend = quote.MinMonths <= quote.MaxMonths + if !quote.CanExtend { + quote.Reason = silenceReasonExceedsLimit + } + } + } + + if input.InputDays > 0 { + months, billed := SilenceMonthsForDays(input.InputDays) + quote.Months = months + quote.BilledDays = billed + quote.NewDeadline = SilenceDeadline(input.DeadlineAt, billed) + quote.Amount = int64(months) * quote.UnitPrice + quote.InputValid = quote.CanExtend && months >= quote.MinMonths && + (input.Kind == SilenceQuoteSpecial || months <= quote.MaxMonths) && + quote.NewDeadline.After(SilenceDeadline(input.Now, 0)) + } + return quote +} + +// SilenceQuoteOptions 返回付费延期 min~max 每档的计费天数、金额与新截止日;档位数最多 limit 个。 +func SilenceQuoteOptions(quote SilenceQuote, deadlineAt time.Time, limit int) []SilenceQuoteOption { + if !quote.CanExtend || quote.MaxMonths < quote.MinMonths { + return nil + } + options := make([]SilenceQuoteOption, 0, quote.MaxMonths-quote.MinMonths+1) + for months := quote.MinMonths; months <= quote.MaxMonths && len(options) < limit; months++ { + billed := months * silenceDaysPerMonth + options = append(options, SilenceQuoteOption{ + Months: months, BilledDays: billed, Amount: int64(months) * quote.UnitPrice, + NewDeadline: SilenceDeadline(deadlineAt, billed), + }) + } + return options +} + +// SilencePolicyCandidate 是政策匹配与重叠校验的输入。 +type SilencePolicyCandidate struct { + ID uint + ScopeType string // all / series / package + ScopeIDs []uint + Enabled bool + EffectiveFrom time.Time + EffectiveTo *time.Time +} + +func silenceScopePriority(scopeType string) int { + switch scopeType { + case "package": + return 3 + case "series": + return 2 + case "all": + return 1 + default: + return 0 + } +} + +func (c SilencePolicyCandidate) effectiveAt(now time.Time) bool { + if !c.Enabled || now.Before(c.EffectiveFrom) { + return false + } + return c.EffectiveTo == nil || c.EffectiveTo.After(now) +} + +func (c SilencePolicyCandidate) covers(packageID, seriesID uint) bool { + switch c.ScopeType { + case "all": + return true + case "series": + return seriesID != 0 && slices.Contains(c.ScopeIDs, seriesID) + case "package": + return packageID != 0 && slices.Contains(c.ScopeIDs, packageID) + default: + return false + } +} + +// MatchSilencePolicy 按"指定套餐 > 套餐系列 > 全部套餐"选出当前生效的政策,返回下标;未匹配返回 -1。 +// 同一优先级理论上不重叠(保存时校验),若历史数据重叠则取 ID 较大的一条。 +func MatchSilencePolicy(candidates []SilencePolicyCandidate, packageID, seriesID uint, now time.Time) int { + best := -1 + for index, candidate := range candidates { + if !candidate.effectiveAt(now) || !candidate.covers(packageID, seriesID) { + continue + } + if best < 0 { + best = index + continue + } + current := candidates[best] + if silenceScopePriority(candidate.ScopeType) > silenceScopePriority(current.ScopeType) || + (candidate.ScopeType == current.ScopeType && candidate.ID > current.ID) { + best = index + } + } + return best +} + +// SilencePolicyOverlaps 判断两条政策是否在同一优先级上适用范围与生效时间段重叠(只比较启用的政策)。 +func SilencePolicyOverlaps(a, b SilencePolicyCandidate) bool { + if !a.Enabled || !b.Enabled || a.ScopeType != b.ScopeType { + return false + } + if a.ScopeType != "all" && !silenceIDsIntersect(a.ScopeIDs, b.ScopeIDs) { + return false + } + return silenceRangesOverlap(a.EffectiveFrom, a.EffectiveTo, b.EffectiveFrom, b.EffectiveTo) +} + +func silenceIDsIntersect(a, b []uint) bool { + for _, id := range a { + if slices.Contains(b, id) { + return true + } + } + return false +} + +// silenceRangesOverlap 判断 [fromA, toA) 与 [fromB, toB) 是否相交,to 为空表示长期。 +func silenceRangesOverlap(fromA time.Time, toA *time.Time, fromB time.Time, toB *time.Time) bool { + aBeforeB := toA != nil && !toA.After(fromB) + bBeforeA := toB != nil && !toB.After(fromA) + return !aBeforeB && !bBeforeA +} + +// IsSilenceApprovalExempt 判断场景是否在政策快照的免审批场景内;无政策快照时全部需要审批。 +func IsSilenceApprovalExempt(exemptTypes []string, scene string) bool { + return scene != "" && slices.Contains(exemptTypes, scene) +} + +// SilenceSpecialApprovalScene 返回特殊延期判断免审批时使用的场景:批量看 batch,否则看延期类型。 +func SilenceSpecialApprovalScene(extensionType string, isBatch bool) string { + if isBatch { + return "batch" + } + return extensionType +} diff --git a/internal/exporter/device_scene.go b/internal/exporter/device_scene.go index 7ff7d183..8afc9c68 100644 --- a/internal/exporter/device_scene.go +++ b/internal/exporter/device_scene.go @@ -15,7 +15,9 @@ const ( deviceExportBaseHeaderCount = 6 deviceExportCardGroupSize = 5 // deviceExportTailHeaderCount 是当前尾部固定列数(含「关联手机号」「设备合计(GB)」列)。 - deviceExportTailHeaderCount = 8 + deviceExportTailHeaderCount = 9 + // deviceExportPollingTailHeaderCount 是追加「出库时间」前(以「是否轮询」结尾)的尾部列数 + deviceExportPollingTailHeaderCount = 8 // deviceExportGBTailHeaderCount 是新增「是否轮询」之前、带「设备合计(GB)」的尾部列数,供历史任务表头反解。 deviceExportGBTailHeaderCount = 7 // deviceExportPhoneTailHeaderCount 是新增「设备合计(GB)」之前、带「关联手机号」的尾部列数,供历史任务表头反解。 @@ -29,6 +31,8 @@ const ( deviceExportTotalGBHeader = "设备合计(GB)" // deviceExportPollingHeader 是「是否轮询」列的表头,位于导出最后一列。 deviceExportPollingHeader = "是否轮询" + // deviceExportOutboundHeader 出库时间(当前世代首次购买套餐的支付时间) + deviceExportOutboundHeader = "出库时间" // deviceExportMBPerGB MB 换算 GB 的进制。 deviceExportMBPerGB = 1024 ) @@ -127,6 +131,12 @@ func (s *DeviceDataSource) baseQuery(ctx context.Context) *gorm.DB { // 导出任务运行在 Worker 中,权限只能使用任务创建时保存的店铺范围快照。 func (s *DeviceDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { query = applyExportShopScope(query, params, "d.shop_id") + if start, ok := filterTime(params.Filters, "outbound_start"); ok { + query = query.Where("d.outbound_at >= ?", start) + } + if end, ok := filterTime(params.Filters, "outbound_end"); ok { + query = query.Where("d.outbound_at <= ?", end) + } if virtualNo, ok := filterString(params.Filters, "virtual_no"); ok { query = query.Where("d.virtual_no LIKE ?", "%"+virtualNo+"%") @@ -273,7 +283,8 @@ func (s *DeviceDataSource) fetchDevices(ctx context.Context, params ExportParams COALESCE(sh.shop_name, '') AS shop_name, COALESCE(ps.series_name, '') AS series_name, COALESCE(aw.balance, 0) AS wallet_balance, - d.enable_polling + d.enable_polling, + d.outbound_at `). Order("d.id ASC"). Limit(limit). @@ -352,15 +363,16 @@ func (s *DeviceDataSource) fetchActivePackages(ctx context.Context, deviceIDs [] } type deviceExportRow struct { - ID uint `gorm:"column:id"` - VirtualNo string `gorm:"column:virtual_no"` - IMEI string `gorm:"column:imei"` - DeviceName string `gorm:"column:device_name"` - DeviceModel string `gorm:"column:device_model"` - ShopName string `gorm:"column:shop_name"` - SeriesName string `gorm:"column:series_name"` - WalletBalance int64 `gorm:"column:wallet_balance"` - EnablePolling bool `gorm:"column:enable_polling"` + ID uint `gorm:"column:id"` + VirtualNo string `gorm:"column:virtual_no"` + IMEI string `gorm:"column:imei"` + DeviceName string `gorm:"column:device_name"` + DeviceModel string `gorm:"column:device_model"` + ShopName string `gorm:"column:shop_name"` + SeriesName string `gorm:"column:series_name"` + WalletBalance int64 `gorm:"column:wallet_balance"` + EnablePolling bool `gorm:"column:enable_polling"` + OutboundAt *time.Time `gorm:"column:outbound_at"` } type deviceExportCardRow struct { @@ -404,6 +416,7 @@ func buildDeviceExportHeaders(cardGroups int) []string { deviceExportAssociatedPhoneHeader, deviceExportTotalGBHeader, deviceExportPollingHeader, + deviceExportOutboundHeader, ) return headers } @@ -457,9 +470,12 @@ func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg if tailCount >= deviceExportGBTailHeaderCount { row = append(row, formatMB(totalUsage/deviceExportMBPerGB)) } - if tailCount >= deviceExportTailHeaderCount { + if tailCount >= deviceExportPollingTailHeaderCount { row = append(row, yesNo(item.EnablePolling)) } + if tailCount >= deviceExportTailHeaderCount { + row = append(row, formatOptionalTime(item.OutboundAt)) + } return row } @@ -475,8 +491,10 @@ func deviceExportTailCount(headers []string) int { return deviceExportTailHeaderCount } switch headers[len(headers)-1] { - case deviceExportPollingHeader: + case deviceExportOutboundHeader: return deviceExportTailHeaderCount + case deviceExportPollingHeader: + return deviceExportPollingTailHeaderCount case deviceExportTotalGBHeader: return deviceExportGBTailHeaderCount case deviceExportAssociatedPhoneHeader: diff --git a/internal/exporter/iot_card_scene.go b/internal/exporter/iot_card_scene.go index 17f768c8..27dbc766 100644 --- a/internal/exporter/iot_card_scene.go +++ b/internal/exporter/iot_card_scene.go @@ -40,7 +40,7 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int // Headers 返回 IoT 卡导出表头。 func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { // 「关联手机号」「套餐到期时间」「使用流量(GB)」「剩余流量(GB)」依次固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。 - return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader, iotCardExportUsageGBHeader, iotCardExportRemainingGBHeader, iotCardExportPollingHeader}, nil + return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader, iotCardExportUsageGBHeader, iotCardExportRemainingGBHeader, iotCardExportPollingHeader, iotCardExportOutboundHeader}, nil } // Fetch 按 offset/limit 查询 IoT 卡导出数据。 @@ -62,6 +62,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs c.real_name_status, c.enable_polling, c.first_realname_at, + c.outbound_at, c.network_status, COALESCE(pkg.package_name, '') AS package_name, COALESCE(pkg.data_usage_mb, 0) AS data_usage_mb, @@ -82,7 +83,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs if err != nil { return nil, err } - includeExpiry, includeGB, includePolling := iotCardExportTailColumns(params.ResolvedHeaders) + includeExpiry, includeGB, includePolling, includeOutbound := iotCardExportTailColumns(params.ResolvedHeaders) var finalExpiries map[uint]string if includeExpiry { finalExpiries, err = s.loadFinalExpiries(ctx, cardIDs) @@ -118,6 +119,9 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs if includePolling { row = append(row, yesNo(item.EnablePolling)) } + if includeOutbound { + row = append(row, formatOptionalTime(item.OutboundAt)) + } rows = append(rows, row) } return rows, nil @@ -141,19 +145,21 @@ func (s *IotCardDataSource) loadFinalExpiries(ctx context.Context, cardIDs []uin // iotCardExportTailColumns 按冻结表头末列识别尾部版本:末列「是否轮询」输出到期、GB 与是否轮询列, // 末列「剩余流量(GB)」输出到期列与 GB 列,末列「套餐到期时间」只输出到期列,更早的表头都不输出;无冻结表头按当前版本输出。 -func iotCardExportTailColumns(headers []string) (includeExpiry, includeGB, includePolling bool) { +func iotCardExportTailColumns(headers []string) (includeExpiry, includeGB, includePolling, includeOutbound bool) { if len(headers) == 0 { - return true, true, true + return true, true, true, true } switch headers[len(headers)-1] { + case iotCardExportOutboundHeader: + return true, true, true, true case iotCardExportPollingHeader: - return true, true, true + return true, true, true, false case iotCardExportRemainingGBHeader: - return true, true, false + return true, true, false, false case iotCardExportFinalExpiryHeader: - return true, false, false + return true, false, false, false default: - return false, false, false + return false, false, false, false } } @@ -198,6 +204,12 @@ func (s *IotCardDataSource) baseQuery(ctx context.Context) *gorm.DB { // 导出任务运行在 Worker 中,权限只能使用任务创建时保存的店铺范围快照。 func (s *IotCardDataSource) applyFilters(ctx context.Context, query *gorm.DB, params ExportParams) *gorm.DB { query = applyExportShopScope(query, params, "c.shop_id") + if start, ok := filterTime(params.Filters, "outbound_start"); ok { + query = query.Where("c.outbound_at >= ?", start) + } + if end, ok := filterTime(params.Filters, "outbound_end"); ok { + query = query.Where("c.outbound_at <= ?", end) + } if isStandalone, ok := filterOptionalBool(params.Filters, "is_standalone"); ok { query = query.Where("c.is_standalone = ?", isStandalone) @@ -302,6 +314,8 @@ const ( iotCardExportRemainingGBHeader = "剩余流量(GB)" // iotCardExportPollingHeader 是「是否轮询」列的表头,位于 GB 流量列之后、导出最后一列。 iotCardExportPollingHeader = "是否轮询" + // iotCardExportOutboundHeader 出库时间(当前世代首次购买套餐的支付时间) + iotCardExportOutboundHeader = "出库时间" ) type iotCardExportRow struct { @@ -314,6 +328,7 @@ type iotCardExportRow struct { DeviceName string `gorm:"column:device_name"` RealNameStatus int `gorm:"column:real_name_status"` FirstRealnameAt *time.Time `gorm:"column:first_realname_at"` + OutboundAt *time.Time `gorm:"column:outbound_at"` NetworkStatus int `gorm:"column:network_status"` PackageName string `gorm:"column:package_name"` DataUsageMB int64 `gorm:"column:data_usage_mb"` diff --git a/internal/exporter/package_scene.go b/internal/exporter/package_scene.go index 23e1c8cf..1bb461cb 100644 --- a/internal/exporter/package_scene.go +++ b/internal/exporter/package_scene.go @@ -7,6 +7,7 @@ import ( "gorm.io/gorm" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" ) @@ -42,6 +43,7 @@ func (s *PackageDataSource) Headers(context.Context, ExportParams) ([]string, er "套餐时长说明", "套餐周期类型", "套餐天数", "真流量额度(MB)", "虚流量额度(MB)", "是否启用虚流量", "虚流量比例", "流量重置周期", "到期时间基准", "成本价(元)", "建议售价(元)", "价格配置状态", "状态", "上架状态", "是否赠送套餐", + "默认沉默期(天)", "是否支持付费延期", "创建人ID", "更新人ID", "创建时间", "更新时间", "删除时间", }, nil } @@ -111,6 +113,9 @@ func (s *PackageDataSource) applyFilters(query *gorm.DB, params ExportParams) *g if packageType, ok := filterString(params.Filters, "package_type"); ok { query = query.Where("p.package_type = ?", packageType) } + if filterBool(params.Filters, "silence_unconfigured") { + query = query.Where(postgres.SilenceUnconfiguredPackageCondition("p")) + } return query } @@ -141,6 +146,8 @@ func (s *PackageDataSource) selectColumns(params ExportParams) string { p.status, ` + shelfStatusColumn + ` AS shelf_status, p.is_gift, + p.silence_period_days, + p.silence_extension_enabled, p.creator, p.updater, p.created_at, @@ -167,6 +174,8 @@ type packageExportRow struct { Status int `gorm:"column:status"` ShelfStatus int `gorm:"column:shelf_status"` IsGift bool `gorm:"column:is_gift"` + SilencePeriodDays *int `gorm:"column:silence_period_days"` + SilenceExtension bool `gorm:"column:silence_extension_enabled"` Creator uint `gorm:"column:creator"` Updater uint `gorm:"column:updater"` CreatedAt time.Time `gorm:"column:created_at"` @@ -195,6 +204,8 @@ func buildPackageExportRow(item packageExportRow) []string { constants.GetStatusName(item.Status), constants.GetShelfStatusName(item.ShelfStatus), formatYesNo(item.IsGift), + formatSilencePeriodDays(item.SilencePeriodDays), + formatYesNo(item.SilenceExtension), strconv.FormatUint(uint64(item.Creator), 10), strconv.FormatUint(uint64(item.Updater), 10), item.CreatedAt.Format(exportTimeLayout), @@ -226,3 +237,10 @@ func formatYesNo(value bool) string { } return "否" } + +func formatSilencePeriodDays(days *int) string { + if days == nil { + return "" + } + return strconv.Itoa(*days) +} diff --git a/internal/exporter/package_silence_scene.go b/internal/exporter/package_silence_scene.go new file mode 100644 index 00000000..931e9785 --- /dev/null +++ b/internal/exporter/package_silence_scene.go @@ -0,0 +1,218 @@ +package exporter + +import ( + "context" + "strconv" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + packagesilencequery "github.com/break/junhong_cmp_fiber/internal/query/packagesilence" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// 沉默期延期订单与即将过期 / 已过期套餐导出(add-package-silence-period)。 +// 导出在 Worker 中执行,数据范围按任务创建时冻结的账号类型与店铺范围重建上下文。 + +var silenceOrderHeaders = []string{ + "订单号", "批次号", "资产类型", "资产标识", "套餐名称", "来源", "状态", "输入天数", "计费月数", "延长天数", + "单价(元/月)", "金额(元)", "政策", "下单时截止", "预计新截止", "支付方式", "收款商户", "交易流水号", + "线下收款方式", "线下交易单号", "实付金额(元)", "支付时间", "延期生效时间", "退款单号", "退款金额(元)", "退款时间", "异常说明", "创建时间", +} + +var silenceUsageHeaders = []string{ + "套餐记录ID", "订单号", "资产类型", "资产标识", "店铺", "套餐名称", "购买时间", "沉默期状态", "沉默期天数", + "截止时间", "过期时间", "累计延期天数", "付费延期天数", "延期次数", "免费延期次数", "调整审批中", +} + +// silenceExportContext 用任务冻结的账号类型与店铺范围构造查询上下文。 +func silenceExportContext(ctx context.Context, params ExportParams) context.Context { + ctx = context.WithValue(ctx, constants.ContextKeyUserType, params.UserType) + if params.CreatorShopID != nil { + ctx = context.WithValue(ctx, constants.ContextKeyShopID, *params.CreatorShopID) + } + if params.UserType == constants.UserTypeAgent { + ctx = context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, append([]uint{}, params.ScopeShopIDs...)) + } + return ctx +} + +// SilenceOrderDataSource 沉默期延期订单导出数据源(场景 package_silence_order)。 +type SilenceOrderDataSource struct { + query *packagesilencequery.Query +} + +// NewSilenceOrderDataSource 创建延期订单导出数据源。 +func NewSilenceOrderDataSource(db *gorm.DB) *SilenceOrderDataSource { + return &SilenceOrderDataSource{query: packagesilencequery.NewQuery(db)} +} + +// Scene 返回导出场景编码。 +func (s *SilenceOrderDataSource) Scene() string { return constants.ExportTaskSceneSilenceOrder } + +func (s *SilenceOrderDataSource) filter(params ExportParams) packagesilencequery.OrderFilter { + req := dto.SilenceOrderListRequest{} + if id, ok := filterUint(params.Filters, "package_usage_id"); ok && id > 0 { + req.PackageUsageID = &id + } + if id, ok := filterUint(params.Filters, "package_id"); ok && id > 0 { + req.PackageID = &id + } + req.AssetIdentifier, _ = filterString(params.Filters, "asset_identifier") + req.OrderNo, _ = filterString(params.Filters, "order_no") + req.Status, _ = filterString(params.Filters, "status") + req.Source, _ = filterString(params.Filters, "source") + req.PaymentMethod, _ = filterString(params.Filters, "payment_method") + req.MerchantName, _ = filterString(params.Filters, "merchant_name") + req.ThirdPartyTradeNo, _ = filterString(params.Filters, "third_party_trade_no") + req.PaidStart, _ = filterString(params.Filters, "paid_start") + req.PaidEnd, _ = filterString(params.Filters, "paid_end") + return packagesilencequery.OrderFilter{Request: req} +} + +// Count 统计导出行数。 +func (s *SilenceOrderDataSource) Count(ctx context.Context, params ExportParams) (int, error) { + query, err := s.query.OrderQuery(silenceExportContext(ctx, params), s.filter(params)) + if err != nil { + return 0, err + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, err + } + return int(total), nil +} + +// Headers 返回导出表头。 +func (s *SilenceOrderDataSource) Headers(context.Context, ExportParams) ([]string, error) { + return silenceOrderHeaders, nil +} + +// Fetch 按 offset/limit 返回导出行。 +func (s *SilenceOrderDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + if limit <= 0 { + return [][]string{}, nil + } + ctx = silenceExportContext(ctx, params) + query, err := s.query.OrderQuery(ctx, s.filter(params)) + if err != nil { + return nil, err + } + var orders []model.PackageSilenceExtensionOrder + if err := query.Select("o.*").Order("o.id ASC").Offset(offset).Limit(limit).Find(&orders).Error; err != nil { + return nil, err + } + items, err := s.query.OrderItems(ctx, orders) + if err != nil { + return nil, err + } + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + item.OrderNo, item.BatchNo, item.AssetType, item.AssetIdentifier, item.PackageName, item.SourceName, item.StatusName, + strconv.Itoa(item.InputDays), strconv.Itoa(item.Months), strconv.Itoa(item.ExtensionDays), + formatMoneyYuan(item.UnitPrice), formatMoneyYuan(item.Amount), item.PolicyName, + item.BeforeDeadlineAt.Format(exportTimeLayout), item.ExpectedDeadlineAt.Format(exportTimeLayout), + item.PaymentMethod, item.PaymentMerchantName, item.ThirdPartyTradeNo, item.OfflinePaymentMethodName, item.OfflineTradeNo, + formatOptionalMoney(item.PaidAmount), formatOptionalTime(item.PaidAt), formatOptionalTime(item.CompletedAt), + item.RefundNo, formatOptionalMoney(item.RefundAmount), formatOptionalTime(item.RefundedAt), item.ExceptionSummary, + item.CreatedAt.Format(exportTimeLayout), + }) + } + return rows, nil +} + +// SilenceUsageDataSource 即将过期 / 已过期套餐导出数据源(场景 package_silence_usage)。 +type SilenceUsageDataSource struct { + query *packagesilencequery.Query +} + +// NewSilenceUsageDataSource 创建沉默期套餐导出数据源。 +func NewSilenceUsageDataSource(db *gorm.DB) *SilenceUsageDataSource { + return &SilenceUsageDataSource{query: packagesilencequery.NewQuery(db)} +} + +// Scene 返回导出场景编码。 +func (s *SilenceUsageDataSource) Scene() string { return constants.ExportTaskSceneSilenceUsage } + +func (s *SilenceUsageDataSource) request(params ExportParams) dto.SilenceUsageListRequest { + req := dto.SilenceUsageListRequest{} + req.View, _ = filterString(params.Filters, "view") + if days, ok := filterInt(params.Filters, "within_days"); ok { + req.WithinDays = days + } + req.AssetIdentifier, _ = filterString(params.Filters, "asset_identifier") + if id, ok := filterUint(params.Filters, "package_id"); ok && id > 0 { + req.PackageID = &id + } + if id, ok := filterUint(params.Filters, "shop_id"); ok && id > 0 { + req.ShopID = &id + } + req.DeadlineStart, _ = filterString(params.Filters, "deadline_start") + req.DeadlineEnd, _ = filterString(params.Filters, "deadline_end") + return req +} + +// Count 统计导出行数。 +func (s *SilenceUsageDataSource) Count(ctx context.Context, params ExportParams) (int, error) { + query, err := s.query.UsageQuery(silenceExportContext(ctx, params), s.request(params)) + if err != nil { + return 0, err + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, err + } + return int(total), nil +} + +// Headers 返回导出表头。 +func (s *SilenceUsageDataSource) Headers(context.Context, ExportParams) ([]string, error) { + return silenceUsageHeaders, nil +} + +// Fetch 按 offset/limit 返回导出行。 +func (s *SilenceUsageDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + if limit <= 0 { + return [][]string{}, nil + } + ctx = silenceExportContext(ctx, params) + query, err := s.query.UsageQuery(ctx, s.request(params)) + if err != nil { + return nil, err + } + items, err := s.query.FetchUsages(ctx, query, offset, limit) + if err != nil { + return nil, err + } + rows := make([][]string, 0, len(items)) + for _, item := range items { + view := item.Silence + if view == nil { + view = &dto.PackageSilenceView{} + } + rows = append(rows, []string{ + strconv.FormatUint(uint64(item.PackageUsageID), 10), item.OrderNo, item.AssetType, item.AssetIdentifier, item.ShopName, + item.PackageName, item.CreatedAt.Format(exportTimeLayout), view.SilenceStatusName, formatOptionalInt(view.SilencePeriodDays), + formatOptionalTime(view.SilenceDeadlineAt), formatOptionalTime(view.SilenceExpiredAt), strconv.Itoa(view.SilenceExtendedDays), + strconv.Itoa(view.SilencePaidExtendedDays), strconv.Itoa(view.SilenceExtensionCount), strconv.Itoa(view.SilenceFreeExtensionUsed), + formatYesNo(view.SilenceAdjustPending), + }) + } + return rows, nil +} + +func formatOptionalMoney(value *int64) string { + if value == nil { + return "" + } + return formatMoneyYuan(*value) +} + +func formatOptionalInt(value *int) string { + if value == nil { + return "" + } + return strconv.Itoa(*value) +} diff --git a/internal/exporter/registry.go b/internal/exporter/registry.go index f6d95634..defcc7f6 100644 --- a/internal/exporter/registry.go +++ b/internal/exporter/registry.go @@ -49,6 +49,8 @@ func NewDefaultRegistry(db *gorm.DB) *Registry { NewOperationsRenewalDataSource(db), NewStopResumeRuleDataSource(db), NewPollingPauseDataSource(db), + NewSilenceOrderDataSource(db), + NewSilenceUsageDataSource(db), NewStopRefusedAlertDataSource(db, stopResumeAlertThreshold(db)), ) } diff --git a/internal/handler/admin/package_silence.go b/internal/handler/admin/package_silence.go new file mode 100644 index 00000000..3249280d --- /dev/null +++ b/internal/handler/admin/package_silence.go @@ -0,0 +1,359 @@ +package admin + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + packagesilencequery "github.com/break/junhong_cmp_fiber/internal/query/packagesilence" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// PackageSilenceHandler 后台沉默期:总开关、延期政策、延期资格、延期订单、线下登记、特殊延期、批次、异常、流水与出库记录。 +// Handler 只做绑定与参数校验,权限点、数据范围、金额与状态由用例判断。 +type PackageSilenceHandler struct { + switches *packagesilenceapp.SwitchService + policies *packagesilenceapp.PolicyService + extensions *packagesilenceapp.ExtensionService + query *packagesilencequery.Query + validator *validator.Validate +} + +// NewPackageSilenceHandler 创建后台沉默期 Handler。 +func NewPackageSilenceHandler( + switches *packagesilenceapp.SwitchService, + policies *packagesilenceapp.PolicyService, + extensions *packagesilenceapp.ExtensionService, + query *packagesilencequery.Query, + validator *validator.Validate, +) *PackageSilenceHandler { + return &PackageSilenceHandler{switches: switches, policies: policies, extensions: extensions, query: query, validator: validator} +} + +func (h *PackageSilenceHandler) bindQuery(c *fiber.Ctx, prefix string, req any) error { + if err := c.QueryParser(req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + return h.validate(prefix, req) +} + +func (h *PackageSilenceHandler) bindBody(c *fiber.Ctx, prefix string, req any) error { + if err := c.BodyParser(req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + return h.validate(prefix, req) +} + +func (h *PackageSilenceHandler) validate(prefix string, req any) error { + if h.validator == nil { + return nil + } + if err := h.validator.Struct(req); err != nil { + return errors.New(errors.CodeInvalidParam, validationMessage(prefix, req, err)) + } + return nil +} + +func silencePathID(c *fiber.Ctx) (uint, error) { + id, err := c.ParamsInt("id") + if err != nil || id <= 0 { + return 0, errors.New(errors.CodeInvalidParam, "ID 无效") + } + return uint(id), nil +} + +// GetSwitch 查询沉默期总开关与打开前检查。GET /api/admin/package-silence/switch +func (h *PackageSilenceHandler) GetSwitch(c *fiber.Ctx) error { + result, err := h.switches.Status(c.UserContext()) + if err != nil { + return err + } + return response.Success(c, result) +} + +// UpdateSwitch 切换沉默期总开关(仅超级管理员)。PUT /api/admin/package-silence/switch +func (h *PackageSilenceHandler) UpdateSwitch(c *fiber.Ctx) error { + var req dto.SilenceSwitchRequest + if err := h.bindBody(c, "沉默期总开关参数不合法", &req); err != nil { + return err + } + result, err := h.switches.Toggle(c.UserContext(), *req.Enabled) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListPolicies 延期政策列表。GET /api/admin/package-silence/policies +func (h *PackageSilenceHandler) ListPolicies(c *fiber.Ctx) error { + var req dto.SilencePolicyListRequest + if err := h.bindQuery(c, "延期政策查询参数不合法", &req); err != nil { + return err + } + items, total, err := h.policies.List(c.UserContext(), &req) + if err != nil { + return err + } + page, pageSize := req.Page, req.PageSize + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + return response.SuccessWithPagination(c, items, total, page, pageSize) +} + +// GetPolicy 延期政策详情。GET /api/admin/package-silence/policies/:id +func (h *PackageSilenceHandler) GetPolicy(c *fiber.Ctx) error { + id, err := silencePathID(c) + if err != nil { + return err + } + result, err := h.policies.Get(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, result) +} + +// CreatePolicy 新增延期政策。POST /api/admin/package-silence/policies +func (h *PackageSilenceHandler) CreatePolicy(c *fiber.Ctx) error { + var req dto.SilencePolicyRequest + if err := h.bindBody(c, "延期政策参数不合法", &req); err != nil { + return err + } + result, err := h.policies.Create(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// UpdatePolicy 编辑延期政策。PUT /api/admin/package-silence/policies/:id +func (h *PackageSilenceHandler) UpdatePolicy(c *fiber.Ctx) error { + id, err := silencePathID(c) + if err != nil { + return err + } + var req dto.SilencePolicyRequest + if err := h.bindBody(c, "延期政策参数不合法", &req); err != nil { + return err + } + result, err := h.policies.Update(c.UserContext(), id, &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// UpdatePolicyStatus 启停延期政策。PUT /api/admin/package-silence/policies/:id/status +func (h *PackageSilenceHandler) UpdatePolicyStatus(c *fiber.Ctx) error { + id, err := silencePathID(c) + if err != nil { + return err + } + var req dto.SilencePolicyStatusRequest + if err := h.bindBody(c, "延期政策状态参数不合法", &req); err != nil { + return err + } + result, err := h.policies.SetStatus(c.UserContext(), id, *req.Status) + if err != nil { + return err + } + return response.Success(c, result) +} + +// MatchPolicy 查询套餐当前匹配的延期政策。GET /api/admin/package-silence/policies/match +func (h *PackageSilenceHandler) MatchPolicy(c *fiber.Ctx) error { + var req dto.SilencePolicyMatchRequest + if err := h.bindQuery(c, "政策匹配查询参数不合法", &req); err != nil { + return err + } + result, err := h.policies.MatchForPackage(c.UserContext(), req.PackageID) + if err != nil { + return err + } + return response.Success(c, result) +} + +// Eligibility 延期资格。GET /api/admin/package-silence/eligibility +func (h *PackageSilenceHandler) Eligibility(c *fiber.Ctx) error { + var req dto.SilenceEligibilityRequest + if err := h.bindQuery(c, "延期资格查询参数不合法", &req); err != nil { + return err + } + result, err := h.extensions.Eligibility(c.UserContext(), req.PackageUsageID, packagesilenceapp.Scope{}) + if err != nil { + return err + } + return response.Success(c, result) +} + +// Quote 按天数试算付费延期。GET /api/admin/package-silence/quote +func (h *PackageSilenceHandler) Quote(c *fiber.Ctx) error { + var req dto.SilenceQuoteRequest + if err := h.bindQuery(c, "延期试算参数不合法", &req); err != nil { + return err + } + result, err := h.extensions.Quote(c.UserContext(), req.PackageUsageID, req.Days, packagesilenceapp.Scope{}) + if err != nil { + return err + } + return response.Success(c, result) +} + +// CreateQRCodeOrder 运营创建在线延期订单并生成支付二维码。POST /api/admin/package-silence/orders/qrcode +func (h *PackageSilenceHandler) CreateQRCodeOrder(c *fiber.Ctx) error { + var req dto.SilenceOnlineOrderRequest + if err := h.bindBody(c, "延期下单参数不合法", &req); err != nil { + return err + } + result, err := h.extensions.CreateOnlineOrder(c.UserContext(), packagesilenceapp.OnlineOrderCommand{ + Request: req, Scope: packagesilenceapp.Scope{}, PayerClientIP: c.IP(), + }) + if err != nil { + return err + } + return response.Success(c, result) +} + +// RegisterOffline 登记线下付费延期。POST /api/admin/package-silence/orders/offline +func (h *PackageSilenceHandler) RegisterOffline(c *fiber.Ctx) error { + var req dto.SilenceOfflineOrderRequest + if err := h.bindBody(c, "线下付费延期参数不合法", &req); err != nil { + return err + } + result, err := h.extensions.RegisterOffline(c.UserContext(), req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListOrders 延期订单列表。GET /api/admin/package-silence/orders +func (h *PackageSilenceHandler) ListOrders(c *fiber.Ctx) error { + var req dto.SilenceOrderListRequest + if err := h.bindQuery(c, "延期订单查询参数不合法", &req); err != nil { + return err + } + result, err := h.query.ListOrders(c.UserContext(), packagesilencequery.OrderFilter{Request: req}) + if err != nil { + return err + } + return response.Success(c, result) +} + +// GetOrder 延期订单详情。GET /api/admin/package-silence/orders/:id +func (h *PackageSilenceHandler) GetOrder(c *fiber.Ctx) error { + id, err := silencePathID(c) + if err != nil { + return err + } + result, err := h.query.GetOrder(c.UserContext(), id, 0) + if err != nil { + return err + } + return response.Success(c, result) +} + +// SubmitSpecial 提交特殊延期(单个 / 批量)。POST /api/admin/package-silence/special +func (h *PackageSilenceHandler) SubmitSpecial(c *fiber.Ctx) error { + var req dto.SilenceSpecialRequest + if err := h.bindBody(c, "特殊延期参数不合法", &req); err != nil { + return err + } + result, err := h.extensions.SubmitSpecial(c.UserContext(), req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListBatches 延期批次列表。GET /api/admin/package-silence/batches +func (h *PackageSilenceHandler) ListBatches(c *fiber.Ctx) error { + var req dto.SilenceBatchListRequest + if err := h.bindQuery(c, "延期批次查询参数不合法", &req); err != nil { + return err + } + result, err := h.query.ListBatches(c.UserContext(), req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// GetBatch 延期批次详情(含明细)。GET /api/admin/package-silence/batches/:id +func (h *PackageSilenceHandler) GetBatch(c *fiber.Ctx) error { + id, err := silencePathID(c) + if err != nil { + return err + } + result, err := h.extensions.BatchDetail(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, result) +} + +// HandleException 异常明细重试 / 关闭。POST /api/admin/package-silence/extensions/:id/handle +func (h *PackageSilenceHandler) HandleException(c *fiber.Ctx) error { + id, err := silencePathID(c) + if err != nil { + return err + } + var req dto.SilenceExceptionHandleRequest + if err := h.bindBody(c, "异常处理参数不合法", &req); err != nil { + return err + } + result, err := h.extensions.HandleException(c.UserContext(), id, req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListRecords 套餐沉默期流水。GET /api/admin/package-silence/records +func (h *PackageSilenceHandler) ListRecords(c *fiber.Ctx) error { + var req dto.SilenceRecordListRequest + if err := h.bindQuery(c, "沉默期流水查询参数不合法", &req); err != nil { + return err + } + result, err := h.extensions.Records(c.UserContext(), req.PackageUsageID, packagesilenceapp.Scope{}) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListUsages 即将过期 / 已过期套餐。GET /api/admin/package-silence/usages +func (h *PackageSilenceHandler) ListUsages(c *fiber.Ctx) error { + var req dto.SilenceUsageListRequest + if err := h.bindQuery(c, "沉默期套餐查询参数不合法", &req); err != nil { + return err + } + result, err := h.query.ListUsages(c.UserContext(), req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListOutboundRecords 资产各世代出库记录。GET /api/admin/assets/:asset_type/:id/outbound-records +func (h *PackageSilenceHandler) ListOutboundRecords(c *fiber.Ctx) error { + id, err := silencePathID(c) + if err != nil { + return err + } + assetType := c.Params("asset_type") + if assetType != "card" && assetType != "iot_card" && assetType != "device" { + return errors.New(errors.CodeInvalidParam, "资产类型仅支持 card 或 device") + } + result, err := h.query.ListOutboundRecords(c.UserContext(), assetType, id) + if err != nil { + return err + } + return response.Success(c, result) +} diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index f8605318..cc898a13 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -521,7 +521,9 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error { items := make([]*dto.ClientAssetPackageHistoryNode, 0, len(history.Items)) for _, node := range history.Items { - items = append(items, buildClientPackageHistoryNode(node, packageMap)) + item := buildClientPackageHistoryNode(node, packageMap) + h.fillClientSilenceViews(c.UserContext(), item, node) + items = append(items, item) } return response.SuccessWithPagination(c, items, history.Total, req.Page, req.PageSize) @@ -590,6 +592,19 @@ func buildClientPackageHistoryNode(node *assetquery.PackageHistoryNode, packageM return item } +// fillClientSilenceViews 按历史树逐节点填写沉默期展示信息(总开关关闭时不返回)。 +func (h *ClientAssetHandler) fillClientSilenceViews(ctx context.Context, item *dto.ClientAssetPackageHistoryNode, node *assetquery.PackageHistoryNode) { + if item == nil || node == nil || h.assetService == nil { + return + } + item.Silence = h.assetService.SilenceView(ctx, node.Usage) + for index, child := range node.Children { + if index < len(item.Children) { + h.fillClientSilenceViews(ctx, item.Children[index], child) + } + } +} + // RefreshAsset B4 资产刷新 // POST /api/c/v1/asset/refresh func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error { diff --git a/internal/handler/app/client_package_silence.go b/internal/handler/app/client_package_silence.go new file mode 100644 index 00000000..e4c7fb8e --- /dev/null +++ b/internal/handler/app/client_package_silence.go @@ -0,0 +1,166 @@ +package app + +import ( + "context" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" + "github.com/break/junhong_cmp_fiber/internal/middleware" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + packagesilencequery "github.com/break/junhong_cmp_fiber/internal/query/packagesilence" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// ClientPackageSilenceHandler H5 沉默期延期:资格、试算、自助下单、我的延期订单与沉默期流水。 +// 资产归属由用例按当前客户校验;金额与月数全部由后端计算。 +type ClientPackageSilenceHandler struct { + extensions *packagesilenceapp.ExtensionService + query *packagesilencequery.Query + validator *validator.Validate +} + +// NewClientPackageSilenceHandler 创建 H5 沉默期 Handler。 +func NewClientPackageSilenceHandler(extensions *packagesilenceapp.ExtensionService, query *packagesilencequery.Query, validator *validator.Validate) *ClientPackageSilenceHandler { + return &ClientPackageSilenceHandler{extensions: extensions, query: query, validator: validator} +} + +// customerContext 返回当前客户 ID 与跳过后台数据范围的上下文(资产归属另行校验)。 +func (h *ClientPackageSilenceHandler) customerContext(c *fiber.Ctx) (context.Context, uint, error) { + customerID, ok := middleware.GetCustomerID(c) + if !ok || customerID == 0 { + return nil, 0, errors.New(errors.CodeUnauthorized) + } + ctx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{}) + return ctx, customerID, nil +} + +func (h *ClientPackageSilenceHandler) validate(req any) error { + if h.validator == nil { + return nil + } + if err := h.validator.Struct(req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数不合法") + } + return nil +} + +// Eligibility 延期资格。GET /api/c/v1/package-silence/eligibility +func (h *ClientPackageSilenceHandler) Eligibility(c *fiber.Ctx) error { + var req dto.SilenceEligibilityRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validate(&req); err != nil { + return err + } + ctx, customerID, err := h.customerContext(c) + if err != nil { + return err + } + result, err := h.extensions.Eligibility(ctx, req.PackageUsageID, packagesilenceapp.Scope{CustomerID: customerID}) + if err != nil { + return err + } + return response.Success(c, result) +} + +// Quote 按天数试算。GET /api/c/v1/package-silence/quote +func (h *ClientPackageSilenceHandler) Quote(c *fiber.Ctx) error { + var req dto.SilenceQuoteRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validate(&req); err != nil { + return err + } + ctx, customerID, err := h.customerContext(c) + if err != nil { + return err + } + result, err := h.extensions.Quote(ctx, req.PackageUsageID, req.Days, packagesilenceapp.Scope{CustomerID: customerID}) + if err != nil { + return err + } + return response.Success(c, result) +} + +// CreateOrder 客户自助下单付费延期。POST /api/c/v1/package-silence/orders +func (h *ClientPackageSilenceHandler) CreateOrder(c *fiber.Ctx) error { + var req dto.SilenceOnlineOrderRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validate(&req); err != nil { + return err + } + ctx, customerID, err := h.customerContext(c) + if err != nil { + return err + } + req.EvidenceRefs = nil + result, err := h.extensions.CreateOnlineOrder(ctx, packagesilenceapp.OnlineOrderCommand{ + Request: req, Scope: packagesilenceapp.Scope{CustomerID: customerID}, PayerClientIP: c.IP(), + }) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListOrders 我的延期订单。GET /api/c/v1/package-silence/orders +func (h *ClientPackageSilenceHandler) ListOrders(c *fiber.Ctx) error { + var req dto.SilenceOrderListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + ctx, customerID, err := h.customerContext(c) + if err != nil { + return err + } + result, err := h.query.ListOrders(ctx, packagesilencequery.OrderFilter{Request: req, CustomerID: customerID}) + if err != nil { + return err + } + return response.Success(c, result) +} + +// GetOrder 我的延期订单详情。GET /api/c/v1/package-silence/orders/:id +func (h *ClientPackageSilenceHandler) GetOrder(c *fiber.Ctx) error { + id, err := c.ParamsInt("id") + if err != nil || id <= 0 { + return errors.New(errors.CodeInvalidParam) + } + ctx, customerID, err := h.customerContext(c) + if err != nil { + return err + } + result, err := h.query.GetOrder(ctx, uint(id), customerID) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListRecords 套餐沉默期流水。GET /api/c/v1/package-silence/records +func (h *ClientPackageSilenceHandler) ListRecords(c *fiber.Ctx) error { + var req dto.SilenceRecordListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validate(&req); err != nil { + return err + } + ctx, customerID, err := h.customerContext(c) + if err != nil { + return err + } + result, err := h.extensions.Records(ctx, req.PackageUsageID, packagesilenceapp.Scope{CustomerID: customerID}) + if err != nil { + return err + } + return response.Success(c, result) +} diff --git a/internal/handler/callback/payment.go b/internal/handler/callback/payment.go index 220c2b18..5e3303c0 100644 --- a/internal/handler/callback/payment.go +++ b/internal/handler/callback/payment.go @@ -13,6 +13,7 @@ import ( "go.uber.org/zap" agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/model" orderService "github.com/break/junhong_cmp_fiber/internal/service/order" @@ -52,6 +53,17 @@ type PaymentHandler struct { agentPaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService integration *integrationlog.Repository logger *zap.Logger + silence SilencePaymentConfirmer +} + +// SilencePaymentConfirmer 确认沉默期延期订单支付(order_type = silence_extension)。 +type SilencePaymentConfirmer interface { + ConfirmPayment(ctx context.Context, command packagesilenceApp.PaymentConfirmCommand) error +} + +// SetSilencePaymentConfirmer 注入沉默期延期支付确认用例。 +func (h *PaymentHandler) SetSilencePaymentConfirmer(confirmer SilencePaymentConfirmer) { + h.silence = confirmer } func NewPaymentHandler( @@ -197,6 +209,19 @@ func (h *PaymentHandler) dispatchPaymentRecordCallback(ctx context.Context, call } case model.PaymentOrderTypeAgentRecharge: return true, h.confirmAgentRechargePayment(ctx, callback, log) + case constants.PaymentOrderTypeSilenceExtension: + // 沉默期延期:回调按支付单冻结商户已验签,金额由用例按订单冻结金额核对;总开关关闭也照常处理。 + if h.silence == nil { + processErr = errors.New(errors.CodeInternalError, "沉默期延期支付确认未配置") + } else { + processErr = h.silence.ConfirmPayment(auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorExternalSystem, ActorID: callback.Provider, ActorName: "支付渠道回调", + Source: constants.AuditSourceCallback, CorrelationID: callback.PaymentNo, + }), packagesilenceApp.PaymentConfirmCommand{ + PaymentNo: callback.PaymentNo, ThirdPartyTradeNo: callback.TransactionID, + Amount: callback.Amount, PaidAt: callback.PaidAt, + }) + } default: processErr = errors.New(errors.CodeInvalidStatus, "未知支付记录类型") } @@ -538,7 +563,7 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error { MerchantIdentity: notification.AppId, Provider: constants.IntegrationProviderAlipay, RawPayload: c.Body(), ContentType: c.Get("Content-Type"), } - if payment.OrderType == model.PaymentOrderTypeAgentRecharge { + if payment.OrderType == model.PaymentOrderTypeAgentRecharge || payment.OrderType == constants.PaymentOrderTypeSilenceExtension { paidAt, parseErr := time.ParseInLocation("2006-01-02 15:04:05", notification.GmtPayment, time.Local) if parseErr != nil { h.logger.Error("支付宝回调:付款时间格式无效", diff --git a/internal/infrastructure/audit/approval.go b/internal/infrastructure/audit/approval.go index 86b74115..5e1549f5 100644 --- a/internal/infrastructure/audit/approval.go +++ b/internal/infrastructure/audit/approval.go @@ -227,6 +227,19 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str "amount": attempt.Amount, "approval_instance_id": instanceID, }, }}, nil + case constants.ApprovalBusinessTypePackageSilence: + var batch model.PackageSilenceExtensionBatch + if err := tx.WithContext(ctx).First(&batch, businessID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联沉默期批次失败") + } + return []ResourceInput{{ + Type: AuditResourcePackageSilenceBatch, ID: &id, Key: batch.BatchNo, DisplayName: "沉默期申请 " + batch.BatchNo, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, + IdentitySnapshot: map[string]any{ + "id": batch.ID, "batch_no": batch.BatchNo, "path": batch.Path, "extension_type": batch.ExtensionType, + "status": batch.Status, "approval_instance_id": instanceID, + }, + }}, nil default: return nil, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源") } diff --git a/internal/infrastructure/audit/package_silence.go b/internal/infrastructure/audit/package_silence.go new file mode 100644 index 00000000..5792192d --- /dev/null +++ b/internal/infrastructure/audit/package_silence.go @@ -0,0 +1,81 @@ +package audit + +import ( + "context" + "strconv" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// SilenceAuditInput 是沉默期相关审计事实。 +type SilenceAuditInput struct { + ActionCode string + Summary string + Usage *model.PackageUsage + BeforeData map[string]any + AfterData map[string]any + Metadata map[string]any +} + +// AppendSilenceUsage 在调用方事务内追加一条套餐记录级沉默期审计。 +// 请求上下文没有操作者时(Worker、定时任务、回调)按系统任务记录。 +func (w *Writer) AppendSilenceUsage(ctx context.Context, tx *gorm.DB, input SilenceAuditInput) error { + if w == nil || input.Usage == nil || input.Usage.ID == 0 { + return nil + } + resource := PackageUsageResource(input.Usage, constants.AuditResourceRelationPrimary, + constants.AuditResourceRolePackageUsageTarget, input.BeforeData, input.AfterData) + return w.Append(ctx, tx, silenceAppendInput(ctx, input.ActionCode, input.Summary, input.Metadata, []ResourceInput{resource})) +} + +// AppendSilenceResource 追加一条主资源为非套餐记录(套餐、政策、配置、出库记录)的沉默期审计。 +func (w *Writer) AppendSilenceResource(ctx context.Context, tx *gorm.DB, actionCode, summary string, resource ResourceInput, metadata map[string]any) error { + if w == nil { + return nil + } + resource.Relation = constants.AuditResourceRelationPrimary + if resource.Role == "" { + resource.Role = constants.AuditResourceRoleConfig + } + return w.Append(ctx, tx, silenceAppendInput(ctx, actionCode, summary, metadata, []ResourceInput{resource})) +} + +// SilencePolicyResource 构造延期政策审计资源。 +func SilencePolicyResource(policy *model.PackageSilenceExtensionPolicy, beforeData, afterData map[string]any) ResourceInput { + id := strconv.FormatUint(uint64(policy.ID), 10) + return ResourceInput{ + Type: AuditResourcePackageSilencePolicy, ID: &id, Key: id, DisplayName: policy.Name, + IdentitySnapshot: map[string]any{"id": policy.ID, "name": policy.Name, "scope_type": policy.ScopeType, "status": policy.Status}, + BeforeData: beforeData, AfterData: afterData, + } +} + +// AssetOutboundResource 构造出库记录审计资源。 +func AssetOutboundResource(record *model.AssetOutboundRecord, afterData map[string]any) ResourceInput { + id := strconv.FormatUint(uint64(record.ID), 10) + return ResourceInput{ + Type: AuditResourceAssetOutboundRecord, ID: &id, Key: id, DisplayName: record.AssetIdentifierSnapshot, + Role: constants.AuditResourceRoleConfig, + IdentitySnapshot: map[string]any{ + "id": record.ID, "asset_type": record.AssetType, "asset_id": record.AssetID, + "generation": record.Generation, "source": record.Source, + }, + AfterData: afterData, + } +} + +func silenceAppendInput(ctx context.Context, actionCode, summary string, metadata map[string]any, resources []ResourceInput) AppendInput { + input := AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + Result: constants.AuditResultSuccess, Metadata: metadata, Resources: resources, + } + if auditcontext.From(ctx).ActorID == "" { + input.Actor = ActorInput{Kind: constants.AuditActorSystemTask, ID: "package_silence", Name: "沉默期规则"} + input.Source = constants.AuditSourceWorker + } + return input +} diff --git a/internal/infrastructure/audit/package_silence_registry.go b/internal/infrastructure/audit/package_silence_registry.go new file mode 100644 index 00000000..9d8adbb8 --- /dev/null +++ b/internal/infrastructure/audit/package_silence_registry.go @@ -0,0 +1,82 @@ +package audit + +import "github.com/break/junhong_cmp_fiber/pkg/constants" + +// 沉默期审计动作与资源注册(add-package-silence-period,技术方案 §11.2)。 +// 沉默期判定可能发生在后台、C 端、支付回调、Worker 与定时任务,所有套餐记录级动作都放开这些入口。 + +const ( + // AuditResourcePackageSilencePolicy 沉默期延期政策资源。 + AuditResourcePackageSilencePolicy = "package_silence_policy" + // AuditResourcePackageSilenceBatch 沉默期申请批次资源(审批关联业务)。 + AuditResourcePackageSilenceBatch = "package_silence_batch" + // AuditResourceAssetOutboundRecord 资产出库记录资源。 + AuditResourceAssetOutboundRecord = "asset_outbound_record" +) + +func silenceAuditOrigins() []ActionOrigin { + return []ActionOrigin{ + {Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI}, + {Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker}, + {Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler}, + {Actor: constants.AuditActorOpenAPI, Source: constants.AuditSourceOpenAPI}, + {Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}, + {Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback}, + } +} + +func silenceAction(code, name, primaryResource, category, risk string) ActionDefinition { + return ActionDefinition{ + Code: code, Name: name, Category: category, Risk: risk, + PrimaryResource: primaryResource, RequireTransaction: true, + DefaultVisibility: constants.AuditSubjectInternalOnly, + AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult}, + AllowedOrigins: silenceAuditOrigins(), + } +} + +func registerPackageSilenceAudit(registry *Registry) { + usage := constants.AuditResourcePackageUsage + business := constants.AuditCategoryBusiness + config := constants.AuditCategoryConfiguration + normal := constants.AuditRiskNormal + high := constants.AuditRiskHigh + actions := []ActionDefinition{ + silenceAction(constants.AuditActionPackageSilenceDefaultChanged, "修改套餐默认沉默期", constants.AuditResourcePackage, config, normal), + silenceAction(constants.AuditActionPackageSilenceSwitchChanged, "切换沉默期总开关", constants.AuditResourceSystemConfig, config, high), + silenceAction(constants.AuditActionUsageSilenceSet, "设置套餐沉默期", usage, business, normal), + silenceAction(constants.AuditActionUsageSilenceAdjustRequested, "申请购买时调整沉默期", usage, business, normal), + silenceAction(constants.AuditActionUsageSilenceAdjustApproved, "购买时调整沉默期审批通过", usage, business, normal), + silenceAction(constants.AuditActionUsageSilenceAdjustRejected, "购买时调整沉默期审批驳回", usage, business, normal), + silenceAction(constants.AuditActionUsageSilenceExpired, "套餐沉默期过期", usage, business, normal), + silenceAction(constants.AuditActionUsageSilenceEnded, "套餐沉默期结束(期内实名)", usage, business, normal), + silenceAction(constants.AuditActionUsageSilenceExtensionRequested, "申请沉默期延期", usage, business, normal), + silenceAction(constants.AuditActionUsageSilenceExtensionApproved, "沉默期延期审批通过", usage, business, normal), + silenceAction(constants.AuditActionUsageSilenceExtensionRejected, "沉默期延期审批驳回", usage, business, normal), + silenceAction(constants.AuditActionUsageSilenceExtensionApplied, "沉默期延期生效", usage, business, high), + silenceAction(constants.AuditActionUsageSilenceExtensionException, "沉默期延期执行异常", usage, business, high), + silenceAction(constants.AuditActionUsageSilenceExtensionOrderState, "沉默期延期订单状态变化", usage, business, normal), + silenceAction(constants.AuditActionUsageSilenceMigratedOnExchange, "换货迁移套餐沉默期", usage, business, normal), + silenceAction(constants.AuditActionUsageSilenceBackfilled, "回填套餐沉默期", usage, business, normal), + silenceAction(constants.AuditActionSilencePolicyCreated, "新增沉默期延期政策", AuditResourcePackageSilencePolicy, config, normal), + silenceAction(constants.AuditActionSilencePolicyUpdated, "修改沉默期延期政策", AuditResourcePackageSilencePolicy, config, normal), + silenceAction(constants.AuditActionSilencePolicyStatusChanged, "启停沉默期延期政策", AuditResourcePackageSilencePolicy, config, normal), + silenceAction(constants.AuditActionAssetOutboundRecorded, "记录资产出库时间", AuditResourceAssetOutboundRecord, business, normal), + silenceAction(constants.AuditActionAssetOutboundEnded, "结束资产出库记录", AuditResourceAssetOutboundRecord, business, normal), + } + for _, action := range actions { + registry.actionsByCode[action.Code] = action + } + registry.resources[AuditResourcePackageSilencePolicy] = ResourceDefinition{ + Type: AuditResourcePackageSilencePolicy, Name: "沉默期延期政策", + IdentityFields: []string{"id", "name", "scope_type", "status"}, + } + registry.resources[AuditResourcePackageSilenceBatch] = ResourceDefinition{ + Type: AuditResourcePackageSilenceBatch, Name: "沉默期申请批次", + IdentityFields: []string{"id", "batch_no", "path", "extension_type", "status", "approval_instance_id"}, + } + registry.resources[AuditResourceAssetOutboundRecord] = ResourceDefinition{ + Type: AuditResourceAssetOutboundRecord, Name: "资产出库记录", + IdentityFields: []string{"id", "asset_type", "asset_id", "generation", "source"}, + } +} diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index e6e8dc97..86287478 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -481,7 +481,7 @@ func NewRegistry() *Registry { constants.AuditActionAssetAutoRenewalRenewed, "资产钱包自动续费完成", constants.AuditRiskHigh) assetAutoRenewalFailed := assetAutoRenewalAction( constants.AuditActionAssetAutoRenewalFailed, "资产钱包自动续费失败或跳过", constants.AuditRiskNormal) - return &Registry{ + registry := &Registry{ actionsByOperation: map[string]ActionDefinition{ constants.AuditOperationSystemConfigUpdate: systemConfigUpdated, constants.AuditOperationAssetAutoRenewalConfigUpdate: assetAutoRenewalConfigUpdated, @@ -1184,6 +1184,8 @@ func NewRegistry() *Registry { }, }, } + registerPackageSilenceAudit(registry) + return registry } func accountSecurityAction(code, name, risk string) ActionDefinition { diff --git a/internal/model/device.go b/internal/model/device.go index 427a81b1..019bbc0f 100644 --- a/internal/model/device.go +++ b/internal/model/device.go @@ -35,6 +35,7 @@ type Device struct { FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"` AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"` Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"` + OutboundAt *time.Time `gorm:"column:outbound_at;comment:出库时间(当前世代首次购买套餐的支付时间)" json:"outbound_at,omitempty"` // EnablePolling 是否参与轮询,false 时设备下所有卡不加入轮询队列 EnablePolling bool `gorm:"column:enable_polling;not null;default:true" json:"enable_polling"` // 以下字段由 Gateway sync-info 接口同步,有离线存储意义 diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index 90771d96..7323dcda 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -12,6 +12,7 @@ type AssetResolveResponse struct { Status int `json:"status" description:"资产归属状态 (1:在库, 2:已分销;卡仍沿用原卡状态枚举)"` StatusName string `json:"status_name" description:"资产状态名称(中文)"` BatchNo string `json:"batch_no" description:"批次号"` + OutboundAt *time.Time `json:"outbound_at,omitempty" description:"出库时间(当前世代首次购买套餐的支付时间)"` ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"` ShopName string `json:"shop_name,omitempty" description:"所属店铺名称"` SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"` @@ -148,31 +149,32 @@ type AssetRealtimeStatusResponse struct { // AssetPackageResponse 资产套餐信息 type AssetPackageResponse struct { - PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"` - PackageID uint `json:"package_id" description:"套餐ID"` - PackageName string `json:"package_name" description:"套餐名称"` - PackageType string `json:"package_type" description:"套餐类型:formal/addon"` - ExpiryBase string `json:"expiry_base,omitempty" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"` - OrderID uint `json:"order_id" description:"关联订单ID(无订单分配时为0)"` - OrderNo string `json:"order_no,omitempty" description:"订单号快照(无订单分配时为空)"` - RefundID *uint `json:"refund_id,omitempty" description:"退款主键ID快照(未退款时为空)"` - RefundNo string `json:"refund_no,omitempty" description:"退款单号快照(未退款时为空)"` - UsageType string `json:"usage_type" description:"使用类型:single_card/device"` - Status int `json:"status" description:"状态:0待生效 1生效中 2已用完 3已过期 4已失效"` - StatusName string `json:"status_name" description:"状态名称"` - RealTotalMB int64 `json:"real_total_mb" description:"套餐真实总量(MB)"` - RealUsedMB int64 `json:"real_used_mb" description:"套餐真实已用量(MB)"` - VirtualTotalMB int64 `json:"virtual_total_mb" description:"套餐业务停机阈值(MB)"` - VirtualUsedMB float64 `json:"virtual_used_mb" description:"套餐展示已用量(MB)"` - ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"` - EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量(按套餐使用记录快照返回)"` - ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间(待生效套餐为空)"` - ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间(待生效套餐为空)"` - MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID(加油包时有值)"` - Priority int `json:"priority" description:"优先级"` - PaidAmount *int64 `json:"paid_amount,omitempty" description:"购买成本价(分),仅平台账号可见,非平台账号不返回此字段"` - RetailAmount *int64 `json:"retail_amount,omitempty" description:"购买零售价(分),无订单关联时为空"` - CreatedAt time.Time `json:"created_at" description:"创建时间"` + PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"` + PackageID uint `json:"package_id" description:"套餐ID"` + PackageName string `json:"package_name" description:"套餐名称"` + PackageType string `json:"package_type" description:"套餐类型:formal/addon"` + ExpiryBase string `json:"expiry_base,omitempty" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"` + OrderID uint `json:"order_id" description:"关联订单ID(无订单分配时为0)"` + OrderNo string `json:"order_no,omitempty" description:"订单号快照(无订单分配时为空)"` + RefundID *uint `json:"refund_id,omitempty" description:"退款主键ID快照(未退款时为空)"` + RefundNo string `json:"refund_no,omitempty" description:"退款单号快照(未退款时为空)"` + UsageType string `json:"usage_type" description:"使用类型:single_card/device"` + Status int `json:"status" description:"状态:0待生效 1生效中 2已用完 3已过期 4已失效"` + StatusName string `json:"status_name" description:"状态名称"` + RealTotalMB int64 `json:"real_total_mb" description:"套餐真实总量(MB)"` + RealUsedMB int64 `json:"real_used_mb" description:"套餐真实已用量(MB)"` + VirtualTotalMB int64 `json:"virtual_total_mb" description:"套餐业务停机阈值(MB)"` + VirtualUsedMB float64 `json:"virtual_used_mb" description:"套餐展示已用量(MB)"` + ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"` + EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量(按套餐使用记录快照返回)"` + ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间(待生效套餐为空)"` + ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间(待生效套餐为空)"` + MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID(加油包时有值)"` + Priority int `json:"priority" description:"优先级"` + PaidAmount *int64 `json:"paid_amount,omitempty" description:"购买成本价(分),仅平台账号可见,非平台账号不返回此字段"` + RetailAmount *int64 `json:"retail_amount,omitempty" description:"购买零售价(分),无订单关联时为空"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + Silence *PackageSilenceView `json:"silence,omitempty" description:"沉默期信息(总开关打开且适用沉默期时返回)"` } // AssetPackageHistoryNode 后台资产套餐历史层级节点。 @@ -206,6 +208,7 @@ type AssetPackageHistoryNode struct { ExpandByDefault bool `json:"expand_by_default" description:"是否默认展开关联加油包"` RelationshipStatus string `json:"relationship_status,omitempty" description:"关系异常状态:master_missing"` RelationshipStatusName string `json:"relationship_status_name,omitempty" description:"关系异常状态名称"` + Silence *PackageSilenceView `json:"silence,omitempty" description:"沉默期信息(总开关打开且适用沉默期时返回)"` } // AssetPackagesResult 后台资产套餐历史分页结果。 diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go index 7c0bbf42..60079dc7 100644 --- a/internal/model/dto/client_asset_dto.go +++ b/internal/model/dto/client_asset_dto.go @@ -218,6 +218,7 @@ type ClientAssetPackageHistoryNode struct { ExpandByDefault bool `json:"expand_by_default" description:"是否默认展开关联加油包"` RelationshipStatus string `json:"relationship_status,omitempty" description:"关系异常状态:master_missing"` RelationshipStatusName string `json:"relationship_status_name,omitempty" description:"关系异常状态名称"` + Silence *PackageSilenceView `json:"silence,omitempty" description:"沉默期信息(总开关打开且适用沉默期时返回;extendable 为 true 时显示延期入口)"` } // AssetPackageHistoryResponse B3 资产套餐历史层级响应。 diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index c599580f..afdaf937 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -24,6 +24,8 @@ type ListDeviceRequest struct { Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配虚拟号或IMEI(模糊查询,与virtual_no/imei独立)"` AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的设备)"` IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"` + OutboundStart string `json:"outbound_start" query:"outbound_start" description:"出库时间起(RFC3339,含)"` + OutboundEnd string `json:"outbound_end" query:"outbound_end" description:"出库时间止(RFC3339,含)"` } type DeviceResponse struct { @@ -60,12 +62,13 @@ type DeviceResponse struct { LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at" description:"最后 sync-info 同步时间"` // asset_status=3 表示该资产已换货且尚未执行"旧资产转新",不再流通 // generation>1 表示该资产历史上曾执行过换货后转新,目前仍在使用 - AssetStatus int `json:"asset_status" description:"业务状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)"` - AssetStatusName string `json:"asset_status_name" description:"业务状态名称(中文)"` - Generation int `json:"generation" description:"资产世代编号(初始值1,每次换货转新后+1)"` - AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" description:"当前有效授权的企业ID,未授权时为 null"` - AuthorizedEnterpriseName string `json:"authorized_enterprise_name" description:"当前有效授权的企业名称,未授权时为空字符串"` - AssociatedPhones []string `json:"associated_phones" description:"当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)"` + AssetStatus int `json:"asset_status" description:"业务状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)"` + AssetStatusName string `json:"asset_status_name" description:"业务状态名称(中文)"` + Generation int `json:"generation" description:"资产世代编号(初始值1,每次换货转新后+1)"` + AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" description:"当前有效授权的企业ID,未授权时为 null"` + AuthorizedEnterpriseName string `json:"authorized_enterprise_name" description:"当前有效授权的企业名称,未授权时为空字符串"` + AssociatedPhones []string `json:"associated_phones" description:"当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)"` + OutboundAt *time.Time `json:"outbound_at,omitempty" description:"出库时间(当前世代首次购买套餐的支付时间)"` } type ListDeviceResponse struct { diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index 5accde3d..82f9997d 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -28,6 +28,8 @@ type ListStandaloneIotCardRequest struct { RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"实名状态 (0:未实名, 1:已实名)"` AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的卡)"` IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"` + OutboundStart string `json:"outbound_start" query:"outbound_start" description:"出库时间起(RFC3339,含)"` + OutboundEnd string `json:"outbound_end" query:"outbound_end" description:"出库时间止(RFC3339,含)"` } type StandaloneIotCardResponse struct { @@ -58,6 +60,7 @@ type StandaloneIotCardResponse struct { GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"` DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"` CurrentMonthUsageMB float64 `json:"current_month_usage_mb" description:"系统自然月累计流量(MB,不等于运营商周期口径)"` + OutboundAt *time.Time `json:"outbound_at,omitempty" description:"出库时间(当前世代首次购买套餐的支付时间;绑定在设备上的卡不记录)"` LastGatewayReadingMB float64 `json:"last_gateway_reading_mb" description:"运营商当前周期累计流量读数(MB,展示运营商口径时使用)"` CurrentMonthStartDate *time.Time `json:"current_month_start_date" description:"本月开始日期"` LastMonthTotalMB float64 `json:"last_month_total_mb" description:"上月流量总量(MB)"` diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index a14de840..4f4f6fb7 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -16,6 +16,8 @@ type CreateAdminOrderRequest struct { PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"` PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"` PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表(最多5个,通过/storage/upload-url上传图片后获得);线下订单需付款凭证,但实际收款金额大于 0 且由平台账号操作的非赠送线下订单会生成员工代收款账单,该场景凭证由核销申请环节提供,创建订单时可为空"` + SilencePeriodDays *int `json:"silence_period_days" validate:"omitempty,min=1" minimum:"1" description:"本次沉默期(天,可选,需 package_silence:override 权限;不超过套餐默认值直接生效,超过默认值按延期政策直接生效或先按默认值购买并提交企微审批;只对资产未实名时购买的实名即生效主套餐生效)"` + SilenceReason string `json:"silence_reason" validate:"omitempty,max=500" maxLength:"500" description:"调整沉默期原因(设置本次沉默期时必填)"` } type OrderListRequest struct { diff --git a/internal/model/dto/package_dto.go b/internal/model/dto/package_dto.go index 0dee5ca4..75faad90 100644 --- a/internal/model/dto/package_dto.go +++ b/internal/model/dto/package_dto.go @@ -2,21 +2,23 @@ package dto // CreatePackageRequest 创建套餐请求 type CreatePackageRequest struct { - PackageCode string `json:"package_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"套餐编码"` - PackageName string `json:"package_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"套餐名称"` - SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"` - PackageType string `json:"package_type" validate:"required,oneof=formal addon" required:"true" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"` - IsGift bool `json:"is_gift" description:"是否赠送套餐"` - DurationMonths int `json:"duration_months" validate:"required,min=1,max=120" required:"true" minimum:"1" maximum:"120" description:"套餐时长(月数)"` - RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"` - VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"` - EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"` - SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价原始值(分,普通可售套餐留空表示未配置,赠送套餐必须显式为0)"` - CostPrice int64 `json:"cost_price" validate:"required,min=0" required:"true" minimum:"0" description:"成本价(分)"` - CalendarType *string `json:"calendar_type" validate:"omitempty,oneof=natural_month by_day" description:"套餐周期类型 (natural_month:自然月, by_day:按天)"` - DurationDays *int `json:"duration_days" validate:"omitempty,min=1,max=3650" minimum:"1" maximum:"3650" description:"套餐天数(calendar_type=by_day时必填)"` - DataResetCycle *string `json:"data_reset_cycle" validate:"omitempty,oneof=daily monthly yearly none" description:"流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)"` - ExpiryBase string `json:"expiry_base" validate:"omitempty,oneof=from_activation from_purchase" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)" example:"from_activation"` + PackageCode string `json:"package_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"套餐编码"` + PackageName string `json:"package_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"套餐名称"` + SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"` + PackageType string `json:"package_type" validate:"required,oneof=formal addon" required:"true" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"` + IsGift bool `json:"is_gift" description:"是否赠送套餐"` + DurationMonths int `json:"duration_months" validate:"required,min=1,max=120" required:"true" minimum:"1" maximum:"120" description:"套餐时长(月数)"` + RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"` + VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"` + EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"` + SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价原始值(分,普通可售套餐留空表示未配置,赠送套餐必须显式为0)"` + CostPrice int64 `json:"cost_price" validate:"required,min=0" required:"true" minimum:"0" description:"成本价(分)"` + CalendarType *string `json:"calendar_type" validate:"omitempty,oneof=natural_month by_day" description:"套餐周期类型 (natural_month:自然月, by_day:按天)"` + DurationDays *int `json:"duration_days" validate:"omitempty,min=1,max=3650" minimum:"1" maximum:"3650" description:"套餐天数(calendar_type=by_day时必填)"` + DataResetCycle *string `json:"data_reset_cycle" validate:"omitempty,oneof=daily monthly yearly none" description:"流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)"` + ExpiryBase string `json:"expiry_base" validate:"omitempty,oneof=from_activation from_purchase" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)" example:"from_activation"` + SilencePeriodDays *int `json:"silence_period_days" validate:"omitempty,min=1,max=90" minimum:"1" maximum:"90" description:"默认沉默期(天,实名即生效正式套餐必填 1~90,新建预填 30;其他套餐不得填写)"` + SilenceExtensionEnabled *bool `json:"silence_extension_enabled" description:"是否支持付费延期(默认支持;不支持时仍可申请特殊延期)"` } // UpdatePackageRequest 更新套餐请求 @@ -36,6 +38,8 @@ type UpdatePackageRequest struct { DurationDays *int `json:"duration_days" validate:"omitempty,min=1,max=3650" minimum:"1" maximum:"3650" description:"套餐天数(calendar_type=by_day时必填)"` DataResetCycle *string `json:"data_reset_cycle" validate:"omitempty,oneof=daily monthly yearly none" description:"流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)"` ExpiryBase *string `json:"expiry_base" validate:"omitempty,oneof=from_activation from_purchase" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"` + SilencePeriodDays *int `json:"silence_period_days" validate:"omitempty,min=1,max=90" minimum:"1" maximum:"90" description:"默认沉默期(天,实名即生效正式套餐必填 1~90;修改只影响之后的新购买)"` + SilenceExtensionEnabled *bool `json:"silence_extension_enabled" description:"是否支持付费延期"` } // PackageListRequest 套餐列表请求 @@ -47,6 +51,8 @@ type PackageListRequest struct { Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"` ShelfStatus *int `json:"shelf_status" query:"shelf_status" validate:"omitempty,oneof=1 2" description:"上架状态 (1:上架, 2:下架)"` PackageType *string `json:"package_type" query:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"` + // SilenceUnconfigured 为 true 时只返回"未配置默认沉默期的实名即生效正式套餐"(存量补齐清单) + SilenceUnconfigured *bool `json:"silence_unconfigured" query:"silence_unconfigured" description:"只看未配置默认沉默期的实名即生效套餐"` } // UpdatePackageStatusRequest 更新套餐状态请求 @@ -112,6 +118,9 @@ type PackageResponse struct { ExpiryBaseOverrideName string `json:"expiry_base_override_name" description:"分配生效条件覆盖名称(中文)"` EffectiveExpiryBase string `json:"effective_expiry_base" description:"最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"` EffectiveExpiryBaseName string `json:"effective_expiry_base_name" description:"最终生效条件名称(中文)"` + SilencePeriodDays *int `json:"silence_period_days,omitempty" description:"默认沉默期(天),实名即生效正式套餐有值"` + SilenceExtensionEnabled bool `json:"silence_extension_enabled" description:"是否支持付费延期"` + SilenceConfigRequired bool `json:"silence_config_required" description:"是否必须配置默认沉默期(实名即生效正式套餐)"` } // UpdatePackageParams 更新套餐聚合参数 diff --git a/internal/model/dto/package_silence_dto.go b/internal/model/dto/package_silence_dto.go new file mode 100644 index 00000000..d2285d4c --- /dev/null +++ b/internal/model/dto/package_silence_dto.go @@ -0,0 +1,462 @@ +package dto + +import "time" + +// PackageSilenceView 是一笔套餐记录的沉默期展示信息(add-package-silence-period §12.1)。 +// 只在沉默期总开关打开且该套餐适用沉默期时返回;总开关关闭时整个对象不返回。 +type PackageSilenceView struct { + SilenceStatus string `json:"silence_status" description:"沉默期状态:waiting-待实名 expired-沉默期已过期 ended-沉默期已结束;待实名但已过截止的按 expired 返回"` + SilenceStatusName string `json:"silence_status_name" description:"沉默期状态名称(待实名 / 延期待实名 / 沉默期已过期 / 沉默期已结束)"` + SilenceStartAt *time.Time `json:"silence_start_at,omitempty" description:"沉默期起算时间(购买支付成功时间)"` + SilencePeriodDays *int `json:"silence_period_days,omitempty" description:"沉默期天数"` + SilenceDeadlineAt *time.Time `json:"silence_deadline_at,omitempty" description:"当前沉默期截止时间(截止日 23:59:59)"` + SilenceExtendedDays int `json:"silence_extended_days" description:"累计已延期天数"` + SilencePaidExtendedDays int `json:"silence_paid_extended_days" description:"其中付费延期累计天数"` + SilenceExtensionCount int `json:"silence_extension_count" description:"累计延期次数"` + SilenceFreeExtensionUsed int `json:"silence_free_extension_used" description:"已使用免费延期次数"` + SilenceExpiredAt *time.Time `json:"silence_expired_at,omitempty" description:"最近一次过期时间"` + SilenceAdjustPending bool `json:"silence_adjust_pending" description:"购买时调整沉默期的审批是否进行中"` + Extendable bool `json:"extendable" description:"是否显示延期入口(沉默期已过期的待生效套餐)"` +} + +// SilenceSwitchRequest 切换沉默期总开关请求。 +type SilenceSwitchRequest struct { + Enabled *bool `json:"enabled" validate:"required" required:"true" description:"目标状态:true 打开 / false 关闭(应急关闭不做检查)"` +} + +// SilenceSwitchPrecheckItem 是打开前检查的一项结果。 +type SilenceSwitchPrecheckItem struct { + Code string `json:"code" description:"检查项:unconfigured_packages / unbackfilled_usages / default_policy_missing / approval_scene_missing"` + Name string `json:"name" description:"检查项名称"` + Passed bool `json:"passed" description:"是否满足"` + Count int64 `json:"count" description:"数量(前两项为未满足的数量,后两项为已具备的数量)"` +} + +// SilenceSwitchResponse 沉默期总开关状态。 +type SilenceSwitchResponse struct { + Enabled bool `json:"enabled" description:"总开关是否打开"` + PrecheckPassed bool `json:"precheck_passed" description:"打开前检查是否全部通过"` + Precheck []SilenceSwitchPrecheckItem `json:"precheck" nullable:"false" description:"打开前检查各项结果"` +} + +// SilencePolicyRequest 新增 / 编辑延期政策请求。 +type SilencePolicyRequest struct { + Name string `json:"name" validate:"required,min=1,max=100" required:"true" maxLength:"100" description:"政策名称(唯一)"` + ScopeType string `json:"scope_type" validate:"required,oneof=all series package" required:"true" description:"适用范围:all-全部套餐 series-套餐系列 package-指定套餐"` + ScopeIDs []uint `json:"scope_ids" validate:"omitempty,max=500,dive,min=1" description:"系列 ID 或套餐 ID 列表(all 时为空)"` + UnitPrice int64 `json:"unit_price" validate:"min=0,max=1000000" minimum:"0" description:"每月单价(分),1 月 = 30 天"` + FreeExtensionLimit int `json:"free_extension_limit" validate:"min=0,max=100" minimum:"0" description:"每笔套餐免费延期次数"` + PaidMaxMonths int `json:"paid_max_months" validate:"min=0,max=120" minimum:"0" description:"付费延期累计最多月数"` + SingleMaxMonths *int `json:"single_max_months" validate:"omitempty,min=1,max=120" description:"单次付费延期最多月数(为空表示不单独限制)"` + ApprovalExemptTypes []string `json:"approval_exempt_types" validate:"omitempty,dive,oneof=offline_paid free discount complaint_compensation system_compensation special_customer batch purchase_adjust" description:"免审批场景(多选,空表示全部需审批)"` + EffectiveFrom time.Time `json:"effective_from" validate:"required" required:"true" description:"生效时间"` + EffectiveTo *time.Time `json:"effective_to" description:"失效时间(为空表示长期)"` + Status *int `json:"status" validate:"omitempty,oneof=0 1" description:"状态:0-停用 1-启用(新增默认启用)"` + Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"` +} + +// SilencePolicyStatusRequest 启停延期政策请求。 +type SilencePolicyStatusRequest struct { + Status *int `json:"status" validate:"required,oneof=0 1" required:"true" description:"状态:0-停用 1-启用"` +} + +// SilencePolicyListRequest 延期政策列表请求。 +type SilencePolicyListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" description:"每页数量"` + Name string `json:"name" query:"name" validate:"omitempty,max=100" description:"政策名称(模糊)"` + ScopeType string `json:"scope_type" query:"scope_type" validate:"omitempty,oneof=all series package" description:"适用范围"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态"` + EffectiveAt string `json:"effective_at" query:"effective_at" description:"在该时间生效(RFC3339)"` +} + +// SilencePolicyResponse 延期政策。 +type SilencePolicyResponse struct { + ID uint `json:"id" description:"政策 ID"` + Name string `json:"name" description:"政策名称"` + ScopeType string `json:"scope_type" description:"适用范围"` + ScopeTypeName string `json:"scope_type_name" description:"适用范围名称"` + ScopeIDs []uint `json:"scope_ids" nullable:"false" description:"系列或套餐 ID 列表"` + UnitPrice int64 `json:"unit_price" description:"每月单价(分)"` + FreeExtensionLimit int `json:"free_extension_limit" description:"免费延期次数"` + PaidMaxMonths int `json:"paid_max_months" description:"付费延期累计最多月数"` + SingleMaxMonths *int `json:"single_max_months,omitempty" description:"单次付费延期最多月数"` + ApprovalExemptTypes []string `json:"approval_exempt_types" nullable:"false" description:"免审批场景"` + EffectiveFrom time.Time `json:"effective_from" description:"生效时间"` + EffectiveTo *time.Time `json:"effective_to,omitempty" description:"失效时间"` + Status int `json:"status" description:"状态:0-停用 1-启用"` + Remark string `json:"remark" description:"备注"` + Creator uint `json:"creator" description:"创建人"` + Updater uint `json:"updater" description:"更新人"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` +} + +// SilencePolicyMatchResponse 某套餐当前匹配的延期政策。 +type SilencePolicyMatchResponse struct { + PackageID uint `json:"package_id" description:"套餐 ID"` + Matched bool `json:"matched" description:"是否匹配到政策(未匹配时不能付费延期,特殊延期仍可申请)"` + Policy *SilencePolicyResponse `json:"policy,omitempty" description:"匹配到的政策"` +} + +// SilenceEligibilityRequest 延期资格查询请求(后台与 H5 同语义)。 +type SilenceEligibilityRequest struct { + PackageUsageID uint `json:"package_usage_id" query:"package_usage_id" validate:"required,min=1" required:"true" description:"套餐记录 ID"` +} + +// SilenceExtensionOption 一个月数档位。 +type SilenceExtensionOption struct { + Months int `json:"months" description:"计费月数"` + BilledDays int `json:"billed_days" description:"计费 / 延长天数(月数 × 30)"` + Amount int64 `json:"amount" description:"金额(分)"` + NewDeadline string `json:"new_deadline" description:"新截止日期(YYYY-MM-DD,当日 23:59:59 截止)"` +} + +// SilenceEligibilityResponse 延期资格(技术方案 §12.2),金额与月数全部由后端计算。 +type SilenceEligibilityResponse struct { + PackageUsageID uint `json:"package_usage_id" description:"套餐记录 ID"` + SilenceStatus string `json:"silence_status" description:"沉默期状态(待实名但已到期的先判定为过期)"` + CanExtend bool `json:"can_extend" description:"能否办理付费延期"` + SpecialExtensionAvailable bool `json:"special_extension_available" description:"能否申请特殊延期(只要求套餐已过期)"` + Reason string `json:"reason,omitempty" description:"付费延期不能办理的原因:not_expired/no_silence/extension_disabled/no_policy/exceeds_limit/refunded/order_pending/activated/adjust_pending"` + OriginalDeadline string `json:"original_deadline,omitempty" description:"当前截止日期(YYYY-MM-DD)"` + OverdueDays int `json:"overdue_days" description:"已过期天数"` + MinDays int `json:"min_days" description:"可输入的最少天数(付费与特殊延期都适用)"` + MaxDays int `json:"max_days" description:"本次付费延期可输入的最多天数"` + MinMonths int `json:"min_months" description:"最少延期月数"` + MaxMonths int `json:"max_months" description:"本次付费延期最多月数(特殊延期不受此限)"` + ExtendedDays int `json:"extended_days" description:"已累计延期天数"` + PaidExtendedDays int `json:"paid_extended_days" description:"其中付费延期累计天数"` + FreeExtensionAvailable bool `json:"free_extension_available" description:"是否还能申请免费延期"` + FreeExtensionLimit int `json:"free_extension_limit" description:"免费延期次数上限"` + FreeExtensionUsed int `json:"free_extension_used" description:"已使用免费延期次数"` + UnitPrice int64 `json:"unit_price" description:"每月单价(分),取当前匹配的延期政策"` + PolicyID *uint `json:"policy_id,omitempty" description:"当前匹配的延期政策 ID"` + PolicyName string `json:"policy_name,omitempty" description:"当前匹配的延期政策名称"` + ExtensionEnabled bool `json:"extension_enabled" description:"套餐是否支持付费延期"` + PendingOrderNo string `json:"pending_order_no,omitempty" description:"未结束的延期订单号(有则不能新建,需先完成或关闭)"` + Options []SilenceExtensionOption `json:"options" nullable:"false" description:"min~max 每档计费天数、金额与新截止日期"` +} + +// SilenceQuoteRequest 按输入天数试算。 +type SilenceQuoteRequest struct { + PackageUsageID uint `json:"package_usage_id" query:"package_usage_id" validate:"required,min=1" required:"true" description:"套餐记录 ID"` + Days int `json:"days" query:"days" validate:"required,min=1,max=3650" required:"true" description:"输入的延期天数(按每月 30 天向上取整计费)"` +} + +// SilenceQuoteResponse 试算结果。 +type SilenceQuoteResponse struct { + InputDays int `json:"input_days" description:"输入天数"` + Months int `json:"months" description:"计费月数"` + BilledDays int `json:"billed_days" description:"计费 / 延长天数"` + Amount int64 `json:"amount" description:"金额(分)"` + NewDeadline string `json:"new_deadline" description:"新截止日期"` + Valid bool `json:"valid" description:"是否在可办理范围内"` +} + +// SilenceOnlineOrderRequest 在线付费延期下单(H5 自助或后台二维码)。 +type SilenceOnlineOrderRequest struct { + PackageUsageID uint `json:"package_usage_id" validate:"required,min=1" required:"true" description:"套餐记录 ID"` + Days int `json:"days" validate:"required,min=1,max=3650" required:"true" description:"输入的延期天数"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" required:"true" description:"支付方式:wechat / alipay(经商户池路由)"` + RequestID string `json:"request_id" validate:"required,min=1,max=64" required:"true" description:"下单幂等键"` + EvidenceRefs []string `json:"evidence_refs" validate:"omitempty,max=10,dive,max=500" description:"客户申请记录或截图对象存储 Key(后台创建时填写)"` +} + +// SilenceOfflineOrderRequest 登记线下付费延期。 +type SilenceOfflineOrderRequest struct { + PackageUsageID uint `json:"package_usage_id" validate:"required,min=1" required:"true" description:"套餐记录 ID"` + Days int `json:"days" validate:"required,min=1,max=3650" required:"true" description:"输入的延期天数"` + OfflinePaymentMethodID uint `json:"offline_payment_method_id" validate:"required,min=1" required:"true" description:"线下收款方式(字典 ID)"` + OfflineTradeNo string `json:"offline_trade_no" validate:"required,min=1,max=128" required:"true" description:"线下交易单号(同一收款方式下唯一)"` + PaidAmount int64 `json:"paid_amount" validate:"min=0" description:"实收金额(分),必须等于系统计算金额"` + PaidAt time.Time `json:"paid_at" validate:"required" required:"true" description:"付款时间"` + PaymentVoucherKeys []string `json:"payment_voucher_keys" validate:"required,min=1,max=5,dive,max=500" required:"true" description:"收款凭证对象存储 Key(1~5 个)"` + EvidenceRefs []string `json:"evidence_refs" validate:"omitempty,max=10,dive,max=500" description:"客户申请依据"` + RequestID string `json:"request_id" validate:"required,min=1,max=64" required:"true" description:"幂等键"` +} + +// SilenceExtensionOrderResponse 延期订单。 +type SilenceExtensionOrderResponse struct { + ID uint `json:"id" description:"订单 ID"` + OrderNo string `json:"order_no" description:"订单号"` + BatchNo string `json:"batch_no,omitempty" description:"批次号"` + PackageUsageID uint `json:"package_usage_id" description:"套餐记录 ID"` + PackageID uint `json:"package_id" description:"套餐 ID"` + PackageName string `json:"package_name" description:"套餐名称快照"` + AssetType string `json:"asset_type" description:"资产类型 iot_card / device"` + AssetID uint `json:"asset_id" description:"资产 ID"` + AssetIdentifier string `json:"asset_identifier" description:"资产标识快照"` + ShopID uint `json:"shop_id" description:"下单时资产所属店铺"` + CustomerID *uint `json:"customer_id,omitempty" description:"H5 下单的客户"` + Source string `json:"source" description:"来源:client / admin / offline"` + SourceName string `json:"source_name" description:"来源名称"` + InputDays int `json:"input_days" description:"输入天数"` + Months int `json:"months" description:"计费月数"` + ExtensionDays int `json:"extension_days" description:"计费 / 延长天数"` + UnitPrice int64 `json:"unit_price" description:"单价(分/月)"` + Amount int64 `json:"amount" description:"应付金额(分)"` + PolicyName string `json:"policy_name" description:"政策名称快照"` + BeforeDeadlineAt time.Time `json:"before_deadline_at" description:"下单时截止时间"` + ExpectedDeadlineAt time.Time `json:"expected_deadline_at" description:"预计新截止时间"` + Status string `json:"status" description:"状态:pending_payment/pending_approval/rejected/paid/completed/closed/exception/refunded"` + StatusName string `json:"status_name" description:"状态名称"` + PaymentMethod string `json:"payment_method" description:"支付方式:wechat/alipay/offline"` + PaymentMerchantName string `json:"payment_merchant_name,omitempty" description:"收款商户名称(取已支付或已退款支付单快照)"` + ThirdPartyTradeNo string `json:"third_party_trade_no,omitempty" description:"交易流水号"` + PaymentNo string `json:"payment_no,omitempty" description:"支付单号"` + QRContent string `json:"qr_content,omitempty" description:"支付内容(待支付时返回,前端渲染为二维码或跳转链接)"` + OfflinePaymentMethodName string `json:"offline_payment_method_name,omitempty" description:"线下收款方式"` + OfflineTradeNo string `json:"offline_trade_no,omitempty" description:"线下交易单号"` + PaymentVoucherKeys []string `json:"payment_voucher_keys,omitempty" description:"线下收款凭证"` + EvidenceRefs []string `json:"evidence_refs,omitempty" description:"申请依据"` + ApprovalRequired bool `json:"approval_required" description:"是否需要审批"` + ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"审批实例 ID"` + PaidAmount *int64 `json:"paid_amount,omitempty" description:"实付金额(分)"` + PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"` + ExpireAt *time.Time `json:"expire_at,omitempty" description:"支付截止时间"` + CompletedAt *time.Time `json:"completed_at,omitempty" description:"延期生效时间"` + ExceptionCode string `json:"exception_code,omitempty" description:"异常原因码"` + ExceptionSummary string `json:"exception_summary,omitempty" description:"异常说明"` + RefundNo string `json:"refund_no,omitempty" description:"原路退款单号(仅执行异常)"` + RefundAmount *int64 `json:"refund_amount,omitempty" description:"退款金额"` + RefundedAt *time.Time `json:"refunded_at,omitempty" description:"退款时间"` + CreatedBy *uint `json:"created_by,omitempty" description:"后台创建人"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` +} + +// SilenceSpecialItem 特殊延期的一条明细。 +type SilenceSpecialItem struct { + PackageUsageID uint `json:"package_usage_id" validate:"required,min=1" required:"true" description:"套餐记录 ID"` + Days int `json:"days" validate:"required,min=1,max=3650" required:"true" description:"延期天数(按 30 天向上取整)"` +} + +// SilenceSpecialRequest 提交特殊延期(单个或批量)。 +type SilenceSpecialRequest struct { + ExtensionType string `json:"extension_type" validate:"required,oneof=free discount complaint_compensation system_compensation special_customer" required:"true" description:"延期类型:free 免费 / discount 优惠价 / complaint_compensation 客诉补偿 / system_compensation 系统异常补偿 / special_customer 特殊客户政策"` + Items []SilenceSpecialItem `json:"items" validate:"required,min=1,max=1000,dive" required:"true" description:"明细(批量需 package_silence:extend_batch 权限)"` + Reason string `json:"reason" validate:"required,min=1,max=1000" required:"true" description:"原因"` + Basis string `json:"basis" validate:"omitempty,max=1000" description:"免费或优惠依据"` + EvidenceRefs []string `json:"evidence_refs" validate:"omitempty,max=10,dive,max=500" description:"客户申请记录或截图"` + Amount int64 `json:"amount" validate:"min=0" description:"费用(分,免费为 0;优惠价为实收合计)"` + ImportFileKey string `json:"import_file_key" validate:"omitempty,max=500" description:"批量导入的明细文件 Key"` + OfflinePaymentMethodID *uint `json:"offline_payment_method_id" description:"优惠价有收款时:线下收款方式"` + OfflineTradeNo string `json:"offline_trade_no" validate:"omitempty,max=128" description:"优惠价有收款时:交易单号"` + OfflinePaidAt *time.Time `json:"offline_paid_at" description:"优惠价有收款时:付款时间"` + PaymentVoucherKeys []string `json:"payment_voucher_keys" validate:"omitempty,max=5,dive,max=500" description:"优惠价有收款时:收款凭证"` +} + +// SilenceBatchResponse 延期批次。 +type SilenceBatchResponse struct { + ID uint `json:"id" description:"批次 ID"` + BatchNo string `json:"batch_no" description:"批次号"` + Path string `json:"path" description:"路径:online / offline / special / adjust"` + ExtensionType string `json:"extension_type" description:"延期类型"` + ApprovalRequired bool `json:"approval_required" description:"是否需要审批"` + ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"审批实例 ID"` + ApprovalStatus string `json:"approval_status,omitempty" description:"审批状态"` + Status string `json:"status" description:"批次状态"` + Reason string `json:"reason" description:"原因"` + Basis string `json:"basis,omitempty" description:"依据"` + EvidenceRefs []string `json:"evidence_refs" nullable:"false" description:"附件"` + Amount int64 `json:"amount" description:"费用(分)"` + RequestedBy uint `json:"requested_by" description:"发起人"` + RequestedAt time.Time `json:"requested_at" description:"发起时间"` + RequestedCount int `json:"requested_count" description:"明细数"` + SucceededCount int `json:"succeeded_count" description:"成功数"` + FailedCount int `json:"failed_count" description:"失败数"` + CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"` + Items []SilenceExtensionResponse `json:"items,omitempty" description:"明细"` +} + +// SilenceExtensionResponse 延期明细。 +type SilenceExtensionResponse struct { + ID uint `json:"id" description:"明细 ID"` + BatchID uint `json:"batch_id" description:"批次 ID"` + RowNo int `json:"row_no" description:"行号"` + PackageUsageID uint `json:"package_usage_id" description:"套餐记录 ID"` + PackageName string `json:"package_name" description:"套餐名称"` + AssetType string `json:"asset_type" description:"资产类型"` + AssetIdentifier string `json:"asset_identifier" description:"资产标识"` + ExtensionType string `json:"extension_type" description:"延期类型"` + InputDays int `json:"input_days" description:"输入天数"` + Months int `json:"months" description:"计费月数"` + ExtensionDays int `json:"extension_days" description:"延长天数"` + Amount int64 `json:"amount" description:"金额(分)"` + ExtensionOrderID *uint `json:"extension_order_id,omitempty" description:"延期订单 ID"` + BeforeDeadlineAt *time.Time `json:"before_deadline_at,omitempty" description:"延期前截止时间"` + AfterDeadlineAt *time.Time `json:"after_deadline_at,omitempty" description:"延期后截止时间"` + Status string `json:"status" description:"明细状态"` + FailureCode string `json:"failure_code,omitempty" description:"失败 / 异常原因码"` + FailureSummary string `json:"failure_summary,omitempty" description:"失败 / 异常说明"` + ExecutedAt *time.Time `json:"executed_at,omitempty" description:"执行时间"` +} + +// SilenceExceptionHandleRequest 异常明细处理。 +type SilenceExceptionHandleRequest struct { + Action string `json:"action" validate:"required,oneof=retry close" required:"true" description:"处理方式:retry 重试执行 / close 关闭(在线订单关闭时原路退款)"` + Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" description:"处理原因"` +} + +// SilenceRecordResponse 沉默期流水。 +type SilenceRecordResponse struct { + ID uint `json:"id" description:"流水 ID"` + PackageUsageID uint `json:"package_usage_id" description:"套餐记录 ID"` + EventType string `json:"event_type" description:"事件:set/adjust/expired/ended/extend/exchange/backfill/realname_backfill"` + EventName string `json:"event_name" description:"事件名称"` + TriggerSource string `json:"trigger_source" description:"触发来源"` + AssetType string `json:"asset_type" description:"资产类型"` + AssetID uint `json:"asset_id" description:"资产 ID"` + BeforeStatus *string `json:"before_status,omitempty" description:"变更前状态"` + AfterStatus *string `json:"after_status,omitempty" description:"变更后状态"` + BeforeDeadlineAt *time.Time `json:"before_deadline_at,omitempty" description:"变更前截止时间"` + AfterDeadlineAt *time.Time `json:"after_deadline_at,omitempty" description:"变更后截止时间"` + PeriodDays *int `json:"period_days,omitempty" description:"沉默期天数"` + DefaultDays *int `json:"default_days,omitempty" description:"默认值快照"` + IsAdjusted bool `json:"is_adjusted" description:"是否调整"` + ChangeDays int `json:"change_days" description:"变化天数"` + OperatorID uint `json:"operator_id" description:"操作人"` + OperatorType string `json:"operator_type" description:"操作人类型"` + Reason string `json:"reason" description:"原因"` + ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"审批实例"` + BatchID *uint `json:"batch_id,omitempty" description:"批次"` + ExtensionID *uint `json:"extension_id,omitempty" description:"延期明细"` + ExchangeNo string `json:"exchange_no,omitempty" description:"换货单号"` + CreatedAt time.Time `json:"created_at" description:"时间"` +} + +// SilenceOrderListRequest 延期订单列表请求。 +type SilenceOrderListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" description:"每页数量"` + PackageUsageID *uint `json:"package_usage_id" query:"package_usage_id" description:"套餐记录 ID"` + PackageID *uint `json:"package_id" query:"package_id" description:"套餐 ID"` + AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=100" description:"资产标识(ICCID / 虚拟号 / IMEI)"` + OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=40" description:"订单号"` + Status string `json:"status" query:"status" validate:"omitempty,oneof=pending_payment pending_approval rejected paid completed closed exception refunded" description:"状态"` + Source string `json:"source" query:"source" validate:"omitempty,oneof=client admin offline" description:"来源"` + PaymentMethod string `json:"payment_method" query:"payment_method" validate:"omitempty,oneof=wechat alipay offline" description:"支付方式"` + MerchantName string `json:"merchant_name" query:"merchant_name" validate:"omitempty,max=100" description:"收款商户名称(模糊)"` + ThirdPartyTradeNo string `json:"third_party_trade_no" query:"third_party_trade_no" validate:"omitempty,max=100" description:"交易流水号或线下交易单号"` + PaidStart string `json:"paid_start" query:"paid_start" description:"支付时间起(RFC3339)"` + PaidEnd string `json:"paid_end" query:"paid_end" description:"支付时间止(RFC3339)"` +} + +// SilenceOrderPageResult 延期订单分页结果。 +type SilenceOrderPageResult struct { + Items []*SilenceExtensionOrderResponse `json:"items" nullable:"false" description:"订单列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"页码"` + PageSize int `json:"page_size" description:"每页数量"` +} + +// SilenceBatchListRequest 延期批次列表请求。 +type SilenceBatchListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" description:"每页数量"` + BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=40" description:"批次号"` + Path string `json:"path" query:"path" validate:"omitempty,oneof=online offline special adjust" description:"路径"` + ExtensionType string `json:"extension_type" query:"extension_type" validate:"omitempty,max=30" description:"延期类型"` + Status string `json:"status" query:"status" validate:"omitempty,max=30" description:"批次状态"` + HasException *bool `json:"has_exception" query:"has_exception" description:"只看含异常明细的批次"` +} + +// SilenceBatchPageResult 延期批次分页结果。 +type SilenceBatchPageResult struct { + Items []*SilenceBatchResponse `json:"items" nullable:"false" description:"批次列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"页码"` + PageSize int `json:"page_size" description:"每页数量"` +} + +// SilenceUsageListRequest 即将过期 / 已过期套餐列表请求。 +type SilenceUsageListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" description:"每页数量"` + View string `json:"view" query:"view" validate:"omitempty,oneof=expiring expired all" description:"视图:expiring 即将过期(默认 7 天内) / expired 已过期 / all 全部待实名与已过期"` + WithinDays int `json:"within_days" query:"within_days" validate:"omitempty,min=1,max=365" description:"即将过期的天数范围(默认 7)"` + AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=100" description:"资产标识"` + PackageID *uint `json:"package_id" query:"package_id" description:"套餐 ID"` + ShopID *uint `json:"shop_id" query:"shop_id" description:"资产所属店铺"` + DeadlineStart string `json:"deadline_start" query:"deadline_start" description:"截止时间起(RFC3339)"` + DeadlineEnd string `json:"deadline_end" query:"deadline_end" description:"截止时间止(RFC3339)"` +} + +// SilenceUsageItem 即将过期 / 已过期套餐。 +type SilenceUsageItem struct { + PackageUsageID uint `json:"package_usage_id" description:"套餐记录 ID"` + OrderNo string `json:"order_no" description:"订单号"` + PackageID uint `json:"package_id" description:"套餐 ID"` + PackageName string `json:"package_name" description:"套餐名称"` + AssetType string `json:"asset_type" description:"资产类型"` + AssetID uint `json:"asset_id" description:"资产 ID"` + AssetIdentifier string `json:"asset_identifier" description:"资产标识"` + ShopID *uint `json:"shop_id,omitempty" description:"资产所属店铺"` + ShopName string `json:"shop_name" description:"店铺名称"` + CreatedAt time.Time `json:"created_at" description:"购买时间"` + Silence *PackageSilenceView `json:"silence" description:"沉默期信息"` +} + +// SilenceUsagePageResult 即将过期 / 已过期套餐分页。 +type SilenceUsagePageResult struct { + Items []*SilenceUsageItem `json:"items" nullable:"false" description:"列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"页码"` + PageSize int `json:"page_size" description:"每页数量"` +} + +// SilenceIDPath 路径 ID。 +type SilenceIDPath struct { + ID uint `json:"id" params:"id" path:"id" validate:"required,min=1" required:"true" description:"ID"` +} + +// SilenceRecordListRequest 沉默期流水查询请求。 +type SilenceRecordListRequest struct { + PackageUsageID uint `json:"package_usage_id" query:"package_usage_id" validate:"required,min=1" required:"true" description:"套餐记录 ID"` +} + +// SilencePolicyMatchRequest 查询套餐当前匹配政策。 +type SilencePolicyMatchRequest struct { + PackageID uint `json:"package_id" query:"package_id" validate:"required,min=1" required:"true" description:"套餐 ID"` +} + +// AssetOutboundRecordRequest 出库记录查询。 +type AssetOutboundRecordRequest struct { + AssetType string `json:"asset_type" params:"asset_type" path:"asset_type" validate:"required,oneof=card device iot_card" required:"true" description:"资产类型 card/iot_card/device"` + ID uint `json:"id" params:"id" path:"id" validate:"required,min=1" required:"true" description:"资产 ID"` +} + +// AssetOutboundRecordResponse 出库记录。 +type AssetOutboundRecordResponse struct { + ID uint `json:"id" description:"记录 ID"` + AssetType string `json:"asset_type" description:"资产类型"` + AssetID uint `json:"asset_id" description:"资产 ID"` + AssetIdentifier string `json:"asset_identifier" description:"资产标识快照"` + Generation int `json:"generation" description:"世代"` + OutboundAt time.Time `json:"outbound_at" description:"出库时间"` + Source string `json:"source" description:"来源:order / exchange / backfill"` + SourceName string `json:"source_name" description:"来源名称"` + OrderNo string `json:"order_no,omitempty" description:"订单号"` + ExchangeNo string `json:"exchange_no,omitempty" description:"换货单号"` + EndedAt *time.Time `json:"ended_at,omitempty" description:"该世代结束时间"` + EndReason string `json:"end_reason,omitempty" description:"结束原因:exchange_return 换货回库"` + CreatedAt time.Time `json:"created_at" description:"记录时间"` +} + +// SilencePolicyUpdateParams 编辑延期政策(路径 ID + 请求体,仅用于接口文档)。 +type SilencePolicyUpdateParams struct { + ID uint `json:"id" path:"id" required:"true" description:"政策 ID"` + SilencePolicyRequest +} + +// SilencePolicyStatusParams 启停延期政策(路径 ID + 请求体,仅用于接口文档)。 +type SilencePolicyStatusParams struct { + ID uint `json:"id" path:"id" required:"true" description:"政策 ID"` + SilencePolicyStatusRequest +} + +// SilenceExceptionHandleParams 处理异常明细(路径 ID + 请求体,仅用于接口文档)。 +type SilenceExceptionHandleParams struct { + ID uint `json:"id" path:"id" required:"true" description:"延期明细 ID"` + SilenceExceptionHandleRequest +} diff --git a/internal/model/iot_card.go b/internal/model/iot_card.go index e75292fa..494c2bf5 100644 --- a/internal/model/iot_card.go +++ b/internal/model/iot_card.go @@ -51,6 +51,7 @@ type IotCard struct { StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"` AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"` Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"` + OutboundAt *time.Time `gorm:"column:outbound_at;comment:出库时间(当前世代首次购买套餐的支付时间)" json:"outbound_at,omitempty"` IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"` VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可选,非空时全局唯一)" json:"virtual_no,omitempty"` DeviceVirtualNo string `gorm:"column:device_virtual_no;type:varchar(100);not null;default:'';comment:绑定设备的虚拟号快照(未绑定时为空字符串)" json:"device_virtual_no"` diff --git a/internal/model/package.go b/internal/model/package.go index b06aa5c2..1ff1fcdb 100644 --- a/internal/model/package.go +++ b/internal/model/package.go @@ -50,6 +50,10 @@ type Package struct { // ExpiryBase 到期时间基准:from_activation-实名激活时起算,from_purchase-购买时起算 ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算" json:"expiry_base"` VirtualRatio float64 `gorm:"column:virtual_ratio;type:decimal(18,6);default:1.0;comment:展示倍率(real_data_mb/virtual_data_mb),创建套餐时计算存储" json:"virtual_ratio"` + // SilencePeriodDays 实名即生效套餐的默认沉默期(1~90 天),普通套餐为空;为空时新购买不进入沉默期 + SilencePeriodDays *int `gorm:"column:silence_period_days;type:int;comment:默认沉默期天数" json:"silence_period_days,omitempty"` + // SilenceExtensionEnabled 是否支持付费延期(不支持时仍可申请特殊延期) + SilenceExtensionEnabled bool `gorm:"column:silence_extension_enabled;type:boolean;not null;default:true;comment:是否支持付费延期" json:"silence_extension_enabled"` } // TableName 指定表名 @@ -96,6 +100,18 @@ type PackageUsage struct { LastResetAt *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"` NextResetAt *time.Time `gorm:"column:next_reset_at;index:idx_package_usage_next_reset_at;comment:下次流量重置时间(用于定时任务查询)" json:"next_reset_at"` Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"` + + // 沉默期字段(按每笔套餐计算,见 add-package-silence-period) + SilenceStatus *string `gorm:"column:silence_status;type:varchar(16);comment:沉默期状态 waiting/expired/ended" json:"silence_status,omitempty"` + SilenceStartAt *time.Time `gorm:"column:silence_start_at;comment:沉默期起算时间" json:"silence_start_at,omitempty"` + SilencePeriodDays *int `gorm:"column:silence_period_days;type:int;comment:最终沉默期天数" json:"silence_period_days,omitempty"` + SilenceDeadlineAt *time.Time `gorm:"column:silence_deadline_at;comment:当前沉默期截止时间" json:"silence_deadline_at,omitempty"` + SilenceExtendedDays int `gorm:"column:silence_extended_days;type:int;not null;default:0;comment:累计已延期天数" json:"silence_extended_days"` + SilencePaidExtendedDays int `gorm:"column:silence_paid_extended_days;type:int;not null;default:0;comment:付费延期累计天数" json:"silence_paid_extended_days"` + SilenceExtensionCount int `gorm:"column:silence_extension_count;type:int;not null;default:0;comment:累计延期次数" json:"silence_extension_count"` + SilenceFreeExtensionUsed int `gorm:"column:silence_free_extension_used;type:int;not null;default:0;comment:已用免费延期次数" json:"silence_free_extension_used"` + SilenceExpiredAt *time.Time `gorm:"column:silence_expired_at;comment:最近一次沉默期过期时间" json:"silence_expired_at,omitempty"` + SilenceAdjustPending bool `gorm:"column:silence_adjust_pending;type:boolean;not null;default:false;comment:购买时调整审批进行中" json:"silence_adjust_pending"` } // TableName 指定表名 diff --git a/internal/model/package_silence.go b/internal/model/package_silence.go new file mode 100644 index 00000000..e2e21059 --- /dev/null +++ b/internal/model/package_silence.go @@ -0,0 +1,243 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// PackageSilenceExtensionPolicy 沉默期延期政策。 +// 匹配优先级:指定套餐 > 套餐系列 > 全部套餐;同一优先级适用范围与生效时间段不得重叠。 +type PackageSilenceExtensionPolicy struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + Name string `gorm:"column:name;type:varchar(100);not null;comment:政策名称" json:"name"` + ScopeType string `gorm:"column:scope_type;type:varchar(20);not null;comment:适用范围 all/series/package" json:"scope_type"` + ScopeIDs UintJSONBArray `gorm:"column:scope_ids;type:jsonb;not null;default:'[]';comment:系列或套餐ID列表" json:"scope_ids"` + UnitPrice int64 `gorm:"column:unit_price;type:bigint;not null;comment:单价(分/月)" json:"unit_price"` + FreeExtensionLimit int `gorm:"column:free_extension_limit;type:int;not null;default:1;comment:免费延期次数" json:"free_extension_limit"` + PaidMaxMonths int `gorm:"column:paid_max_months;type:int;not null;default:6;comment:付费延期累计最多月数" json:"paid_max_months"` + SingleMaxMonths *int `gorm:"column:single_max_months;type:int;comment:单次付费延期最多月数" json:"single_max_months,omitempty"` + ApprovalExemptTypes StringJSONBArray `gorm:"column:approval_exempt_types;type:jsonb;not null;default:'[]';comment:免审批场景" json:"approval_exempt_types"` + EffectiveFrom time.Time `gorm:"column:effective_from;not null;comment:生效时间" json:"effective_from"` + EffectiveTo *time.Time `gorm:"column:effective_to;comment:失效时间" json:"effective_to,omitempty"` + Status int `gorm:"column:status;type:smallint;not null;default:1;comment:状态 0-停用 1-启用" json:"status"` + Remark string `gorm:"column:remark;type:varchar(500);not null;default:'';comment:备注" json:"remark"` + Creator uint `gorm:"column:creator;not null;default:0" json:"creator"` + Updater uint `gorm:"column:updater;not null;default:0" json:"updater"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"` +} + +// TableName 指定表名 +func (PackageSilenceExtensionPolicy) TableName() string { + return "tb_package_silence_extension_policy" +} + +// PackageSilenceRecord 套餐沉默期变更流水(只新增不修改)。 +type PackageSilenceRecord struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + PackageUsageID uint `gorm:"column:package_usage_id;not null" json:"package_usage_id"` + EventType string `gorm:"column:event_type;type:varchar(30);not null" json:"event_type"` + TriggerSource string `gorm:"column:trigger_source;type:varchar(40);not null;default:''" json:"trigger_source"` + AssetType string `gorm:"column:asset_type;type:varchar(20);not null;default:''" json:"asset_type"` + AssetID uint `gorm:"column:asset_id;not null;default:0" json:"asset_id"` + BeforeStatus *string `gorm:"column:before_status;type:varchar(16)" json:"before_status,omitempty"` + AfterStatus *string `gorm:"column:after_status;type:varchar(16)" json:"after_status,omitempty"` + BeforeDeadlineAt *time.Time `gorm:"column:before_deadline_at" json:"before_deadline_at,omitempty"` + AfterDeadlineAt *time.Time `gorm:"column:after_deadline_at" json:"after_deadline_at,omitempty"` + PeriodDays *int `gorm:"column:period_days" json:"period_days,omitempty"` + DefaultDaysSnapshot *int `gorm:"column:default_days_snapshot" json:"default_days_snapshot,omitempty"` + IsAdjusted bool `gorm:"column:is_adjusted;not null;default:false" json:"is_adjusted"` + ChangeDays int `gorm:"column:change_days;not null;default:0" json:"change_days"` + OperatorID uint `gorm:"column:operator_id;not null;default:0" json:"operator_id"` + OperatorType string `gorm:"column:operator_type;type:varchar(20);not null;default:''" json:"operator_type"` + Reason string `gorm:"column:reason;type:varchar(500);not null;default:''" json:"reason"` + ApprovalInstanceID *uint `gorm:"column:approval_instance_id" json:"approval_instance_id,omitempty"` + BatchID *uint `gorm:"column:batch_id" json:"batch_id,omitempty"` + ExtensionID *uint `gorm:"column:extension_id" json:"extension_id,omitempty"` + ExchangeOrderID *uint `gorm:"column:exchange_order_id" json:"exchange_order_id,omitempty"` + ExchangeNo string `gorm:"column:exchange_no;type:varchar(64);not null;default:''" json:"exchange_no"` + RealnameAt *time.Time `gorm:"column:realname_at" json:"realname_at,omitempty"` + Extra datatypes.JSON `gorm:"column:extra;type:jsonb;not null;default:'{}'" json:"extra"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` +} + +// TableName 指定表名 +func (PackageSilenceRecord) TableName() string { + return "tb_package_silence_record" +} + +// PackageSilenceExtensionBatch 沉默期申请批次:每次延期或购买时调整都建一个批次,企微审批实例关联批次。 +type PackageSilenceExtensionBatch struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + BatchNo string `gorm:"column:batch_no;type:varchar(40);not null" json:"batch_no"` + Path string `gorm:"column:path;type:varchar(20);not null" json:"path"` + ExtensionType string `gorm:"column:extension_type;type:varchar(30);not null" json:"extension_type"` + ApprovalRequired bool `gorm:"column:approval_required;not null;default:false" json:"approval_required"` + Reason string `gorm:"column:reason;type:varchar(1000);not null;default:''" json:"reason"` + EvidenceRefs StringJSONBArray `gorm:"column:evidence_refs;type:jsonb;not null;default:'[]'" json:"evidence_refs"` + Basis string `gorm:"column:basis;type:varchar(1000);not null;default:''" json:"basis"` + ImportFileKey string `gorm:"column:import_file_key;type:varchar(500);not null;default:''" json:"import_file_key"` + PolicyID *uint `gorm:"column:policy_id" json:"policy_id,omitempty"` + PolicyNameSnapshot string `gorm:"column:policy_name_snapshot;type:varchar(100);not null;default:''" json:"policy_name_snapshot"` + UnitPriceSnapshot int64 `gorm:"column:unit_price_snapshot;not null;default:0" json:"unit_price_snapshot"` + ApprovalExemptTypesSnapshot StringJSONBArray `gorm:"column:approval_exempt_types_snapshot;type:jsonb;not null;default:'[]'" json:"approval_exempt_types_snapshot"` + FreeExtensionLimitSnapshot int `gorm:"column:free_extension_limit_snapshot;not null;default:1" json:"free_extension_limit_snapshot"` + PaidMaxMonthsSnapshot int `gorm:"column:paid_max_months_snapshot;not null;default:0" json:"paid_max_months_snapshot"` + SingleMaxMonthsSnapshot *int `gorm:"column:single_max_months_snapshot" json:"single_max_months_snapshot,omitempty"` + AdjustPackageUsageID *uint `gorm:"column:adjust_package_usage_id" json:"adjust_package_usage_id,omitempty"` + AdjustDefaultDays *int `gorm:"column:adjust_default_days" json:"adjust_default_days,omitempty"` + AdjustRequestedDays *int `gorm:"column:adjust_requested_days" json:"adjust_requested_days,omitempty"` + Amount int64 `gorm:"column:amount;not null;default:0" json:"amount"` + OfflinePaymentMethodID *uint `gorm:"column:offline_payment_method_id" json:"offline_payment_method_id,omitempty"` + OfflinePaymentMethodCode *string `gorm:"column:offline_payment_method_code;type:varchar(64)" json:"offline_payment_method_code,omitempty"` + OfflinePaymentMethodName *string `gorm:"column:offline_payment_method_name;type:varchar(100)" json:"offline_payment_method_name,omitempty"` + OfflineTradeNo *string `gorm:"column:offline_trade_no;type:varchar(128)" json:"offline_trade_no,omitempty"` + OfflinePaidAmount *int64 `gorm:"column:offline_paid_amount" json:"offline_paid_amount,omitempty"` + OfflinePaidAt *time.Time `gorm:"column:offline_paid_at" json:"offline_paid_at,omitempty"` + PaymentVoucherKeys StringJSONBArray `gorm:"column:payment_voucher_keys;type:jsonb;not null;default:'[]'" json:"payment_voucher_keys"` + RequestedBy uint `gorm:"column:requested_by;not null;default:0" json:"requested_by"` + RequestedByType string `gorm:"column:requested_by_type;type:varchar(20);not null;default:''" json:"requested_by_type"` + RequestedAt time.Time `gorm:"column:requested_at;not null" json:"requested_at"` + ApprovalInstanceID *uint `gorm:"column:approval_instance_id" json:"approval_instance_id,omitempty"` + ApprovalStatus string `gorm:"column:approval_status;type:varchar(20);not null;default:''" json:"approval_status"` + ApprovalFinishedAt *time.Time `gorm:"column:approval_finished_at" json:"approval_finished_at,omitempty"` + Status string `gorm:"column:status;type:varchar(30);not null" json:"status"` + RequestedCount int `gorm:"column:requested_count;not null;default:0" json:"requested_count"` + SucceededCount int `gorm:"column:succeeded_count;not null;default:0" json:"succeeded_count"` + FailedCount int `gorm:"column:failed_count;not null;default:0" json:"failed_count"` + CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at,omitempty"` + ShopIDTag uint `gorm:"column:shop_id_tag;not null;default:0" json:"shop_id_tag"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +// TableName 指定表名 +func (PackageSilenceExtensionBatch) TableName() string { + return "tb_package_silence_extension_batch" +} + +// PackageSilenceExtension 沉默期延期明细(延期事实),以明细状态条件更新保证幂等。 +type PackageSilenceExtension struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + BatchID uint `gorm:"column:batch_id;not null" json:"batch_id"` + RowNo int `gorm:"column:row_no;not null;default:1" json:"row_no"` + PackageUsageID uint `gorm:"column:package_usage_id;not null" json:"package_usage_id"` + PackageID uint `gorm:"column:package_id;not null;default:0" json:"package_id"` + PackageNameSnapshot string `gorm:"column:package_name_snapshot;type:varchar(255);not null;default:''" json:"package_name_snapshot"` + AssetType string `gorm:"column:asset_type;type:varchar(20);not null;default:''" json:"asset_type"` + AssetID uint `gorm:"column:asset_id;not null;default:0" json:"asset_id"` + AssetIdentifierSnapshot string `gorm:"column:asset_identifier_snapshot;type:varchar(100);not null;default:''" json:"asset_identifier_snapshot"` + ExtensionType string `gorm:"column:extension_type;type:varchar(30);not null" json:"extension_type"` + InputDays int `gorm:"column:input_days;not null" json:"input_days"` + Months int `gorm:"column:months;not null" json:"months"` + ExtensionDays int `gorm:"column:extension_days;not null" json:"extension_days"` + UnitPriceSnapshot int64 `gorm:"column:unit_price_snapshot;not null;default:0" json:"unit_price_snapshot"` + Amount int64 `gorm:"column:amount;not null;default:0" json:"amount"` + ExtensionOrderID *uint `gorm:"column:extension_order_id" json:"extension_order_id,omitempty"` + BeforeDeadlineAt *time.Time `gorm:"column:before_deadline_at" json:"before_deadline_at,omitempty"` + AfterDeadlineAt *time.Time `gorm:"column:after_deadline_at" json:"after_deadline_at,omitempty"` + Status string `gorm:"column:status;type:varchar(20);not null" json:"status"` + FailureCode string `gorm:"column:failure_code;type:varchar(64);not null;default:''" json:"failure_code"` + FailureSummary string `gorm:"column:failure_summary;type:varchar(500);not null;default:''" json:"failure_summary"` + ExecutedAt *time.Time `gorm:"column:executed_at" json:"executed_at,omitempty"` + HandledBy uint `gorm:"column:handled_by;not null;default:0" json:"handled_by"` + HandledAt *time.Time `gorm:"column:handled_at" json:"handled_at,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +// TableName 指定表名 +func (PackageSilenceExtension) TableName() string { + return "tb_package_silence_extension" +} + +// PackageSilenceExtensionOrder 沉默期延期订单(交易记录),在线支付复用 tb_payment(order_type = silence_extension)。 +type PackageSilenceExtensionOrder struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + OrderNo string `gorm:"column:order_no;type:varchar(40);not null" json:"order_no"` + BatchID uint `gorm:"column:batch_id;not null" json:"batch_id"` + PackageUsageID uint `gorm:"column:package_usage_id;not null" json:"package_usage_id"` + PackageID uint `gorm:"column:package_id;not null" json:"package_id"` + PackageNameSnapshot string `gorm:"column:package_name_snapshot;type:varchar(255);not null;default:''" json:"package_name_snapshot"` + AssetType string `gorm:"column:asset_type;type:varchar(20);not null" json:"asset_type"` + AssetID uint `gorm:"column:asset_id;not null" json:"asset_id"` + AssetIdentifierSnapshot string `gorm:"column:asset_identifier_snapshot;type:varchar(100);not null;default:''" json:"asset_identifier_snapshot"` + ShopID uint `gorm:"column:shop_id;not null;default:0" json:"shop_id"` + CustomerID *uint `gorm:"column:customer_id" json:"customer_id,omitempty"` + Source string `gorm:"column:source;type:varchar(20);not null" json:"source"` + CreatedBy *uint `gorm:"column:created_by" json:"created_by,omitempty"` + EvidenceRefs StringJSONBArray `gorm:"column:evidence_refs;type:jsonb;not null;default:'[]'" json:"evidence_refs"` + InputDays int `gorm:"column:input_days;not null" json:"input_days"` + Months int `gorm:"column:months;not null" json:"months"` + ExtensionDays int `gorm:"column:extension_days;not null" json:"extension_days"` + PolicyID *uint `gorm:"column:policy_id" json:"policy_id,omitempty"` + PolicyNameSnapshot string `gorm:"column:policy_name_snapshot;type:varchar(100);not null;default:''" json:"policy_name_snapshot"` + UnitPrice int64 `gorm:"column:unit_price;not null" json:"unit_price"` + ApprovalExemptTypesSnapshot StringJSONBArray `gorm:"column:approval_exempt_types_snapshot;type:jsonb;not null;default:'[]'" json:"approval_exempt_types_snapshot"` + FreeExtensionLimitSnapshot int `gorm:"column:free_extension_limit_snapshot;not null;default:1" json:"free_extension_limit_snapshot"` + PaidMaxMonthsSnapshot int `gorm:"column:paid_max_months_snapshot;not null;default:0" json:"paid_max_months_snapshot"` + SingleMaxMonthsSnapshot *int `gorm:"column:single_max_months_snapshot" json:"single_max_months_snapshot,omitempty"` + Amount int64 `gorm:"column:amount;not null" json:"amount"` + BeforeDeadlineAt time.Time `gorm:"column:before_deadline_at;not null" json:"before_deadline_at"` + ExpectedDeadlineAt time.Time `gorm:"column:expected_deadline_at;not null" json:"expected_deadline_at"` + Status string `gorm:"column:status;type:varchar(20);not null" json:"status"` + PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"` + OfflinePaymentMethodID *uint `gorm:"column:offline_payment_method_id" json:"offline_payment_method_id,omitempty"` + OfflinePaymentMethodCode *string `gorm:"column:offline_payment_method_code;type:varchar(64)" json:"offline_payment_method_code,omitempty"` + OfflinePaymentMethodName *string `gorm:"column:offline_payment_method_name;type:varchar(100)" json:"offline_payment_method_name,omitempty"` + OfflineTradeNo *string `gorm:"column:offline_trade_no;type:varchar(128)" json:"offline_trade_no,omitempty"` + PaymentVoucherKeys StringJSONBArray `gorm:"column:payment_voucher_keys;type:jsonb;not null;default:'[]'" json:"payment_voucher_keys"` + ApprovalRequired bool `gorm:"column:approval_required;not null;default:false" json:"approval_required"` + ApprovalInstanceID *uint `gorm:"column:approval_instance_id" json:"approval_instance_id,omitempty"` + PaidPaymentID *uint `gorm:"column:paid_payment_id" json:"paid_payment_id,omitempty"` + PaidAmount *int64 `gorm:"column:paid_amount" json:"paid_amount,omitempty"` + PaidAt *time.Time `gorm:"column:paid_at" json:"paid_at,omitempty"` + ExpireAt *time.Time `gorm:"column:expire_at" json:"expire_at,omitempty"` + ExtensionID *uint `gorm:"column:extension_id" json:"extension_id,omitempty"` + CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at,omitempty"` + ClosedAt *time.Time `gorm:"column:closed_at" json:"closed_at,omitempty"` + CloseReason string `gorm:"column:close_reason;type:varchar(200);not null;default:''" json:"close_reason"` + ExceptionCode string `gorm:"column:exception_code;type:varchar(64);not null;default:''" json:"exception_code"` + ExceptionSummary string `gorm:"column:exception_summary;type:varchar(500);not null;default:''" json:"exception_summary"` + RefundNo *string `gorm:"column:refund_no;type:varchar(64)" json:"refund_no,omitempty"` + RefundAmount *int64 `gorm:"column:refund_amount" json:"refund_amount,omitempty"` + RefundedAt *time.Time `gorm:"column:refunded_at" json:"refunded_at,omitempty"` + RefundReason *string `gorm:"column:refund_reason;type:varchar(500)" json:"refund_reason,omitempty"` + RequestID *string `gorm:"column:request_id;type:varchar(64)" json:"request_id,omitempty"` + ShopIDTag uint `gorm:"column:shop_id_tag;not null;default:0" json:"shop_id_tag"` + EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag" json:"enterprise_id_tag,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +// TableName 指定表名 +func (PackageSilenceExtensionOrder) TableName() string { + return "tb_package_silence_extension_order" +} + +// AssetOutboundRecord 资产出库记录:每个世代一条,只新增与填写结束信息。 +type AssetOutboundRecord struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + AssetType string `gorm:"column:asset_type;type:varchar(20);not null" json:"asset_type"` + AssetID uint `gorm:"column:asset_id;not null" json:"asset_id"` + AssetIdentifierSnapshot string `gorm:"column:asset_identifier_snapshot;type:varchar(100);not null;default:''" json:"asset_identifier_snapshot"` + Generation int `gorm:"column:generation;not null" json:"generation"` + OutboundAt time.Time `gorm:"column:outbound_at;not null" json:"outbound_at"` + Source string `gorm:"column:source;type:varchar(20);not null" json:"source"` + OrderID *uint `gorm:"column:order_id" json:"order_id,omitempty"` + OrderNo string `gorm:"column:order_no;type:varchar(40);not null;default:''" json:"order_no"` + ExchangeOrderID *uint `gorm:"column:exchange_order_id" json:"exchange_order_id,omitempty"` + ExchangeNo string `gorm:"column:exchange_no;type:varchar(64);not null;default:''" json:"exchange_no"` + ShopID *uint `gorm:"column:shop_id" json:"shop_id,omitempty"` + EndedAt *time.Time `gorm:"column:ended_at" json:"ended_at,omitempty"` + EndReason string `gorm:"column:end_reason;type:varchar(40);not null;default:''" json:"end_reason"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` +} + +// TableName 指定表名 +func (AssetOutboundRecord) TableName() string { + return "tb_asset_outbound_record" +} diff --git a/internal/polling/package_activation_handler.go b/internal/polling/package_activation_handler.go index f74fe4f9..d4b8c58d 100644 --- a/internal/polling/package_activation_handler.go +++ b/internal/polling/package_activation_handler.go @@ -184,6 +184,8 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con AND pending.master_usage_id IS NULL AND pending.deleted_at IS NULL AND (COALESCE(pending.iot_card_id, 0) > 0 OR COALESCE(pending.device_id, 0) > 0) + -- 沉默期已过期的套餐不能激活,不排除会每轮重复提交激活任务(总开关打开时) + AND (? = FALSE OR pending.silence_status IS NULL OR pending.silence_status <> ?) AND NOT EXISTS ( SELECT 1 FROM tb_package_usage AS occupied @@ -216,6 +218,8 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con ORDER BY candidate.priority ASC, candidate.created_at ASC, candidate.id ASC LIMIT ?`, constants.PackageUsageStatusPending, + h.activationService.SilenceRulesEnabled(ctx), + constants.SilenceStatusExpired, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted, constants.RealNameStatusVerified, @@ -426,6 +430,10 @@ func (h *PackageActivationHandler) activateNextPackage(ctx context.Context, tx * Where("master_usage_id IS NULL"). Order("priority ASC, created_at ASC, id ASC"). Limit(1) + // 沉默期已过期的套餐不能挡住同资产后续套餐(总开关打开时) + if h.activationService.SilenceRulesEnabled(ctx) { + query = query.Where("(silence_status IS NULL OR silence_status <> ?)", constants.SilenceStatusExpired) + } if carrierType == constants.AssetTypeIotCard { query = query.Where("iot_card_id = ?", carrierID) diff --git a/internal/query/audit/finance.go b/internal/query/audit/finance.go index 2df50fa7..bcd03b2b 100644 --- a/internal/query/audit/finance.go +++ b/internal/query/audit/finance.go @@ -950,8 +950,8 @@ func (q *Query) loadPaymentFinance(ctx context.Context, filter FinanceFilter, re appendTypedOrderCondition(&conditions, &args, model.PaymentOrderTypeAgentRecharge, refs.agentRecharges) appendTypedOrderCondition(&conditions, &args, model.PaymentOrderTypeRecharge, refs.rechargeOrders) if filter.ShopID != 0 { - conditions = append(conditions, `(order_type = ? AND EXISTS (SELECT 1 FROM tb_order o WHERE o.id = tb_payment.order_id AND o.deleted_at IS NULL AND ((o.buyer_type = ? AND o.buyer_id = ?) OR o.seller_shop_id = ?))) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_agent_recharge_record ar WHERE ar.id = tb_payment.order_id AND ar.deleted_at IS NULL AND ar.shop_id = ?)) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_recharge_order ro WHERE ro.id = tb_payment.order_id AND ro.deleted_at IS NULL AND ro.shop_id_tag = ?))`) - args = append(args, model.PaymentOrderTypePackage, model.BuyerTypeAgent, filter.ShopID, filter.ShopID, model.PaymentOrderTypeAgentRecharge, filter.ShopID, model.PaymentOrderTypeRecharge, filter.ShopID) + conditions = append(conditions, `(order_type = ? AND EXISTS (SELECT 1 FROM tb_order o WHERE o.id = tb_payment.order_id AND o.deleted_at IS NULL AND ((o.buyer_type = ? AND o.buyer_id = ?) OR o.seller_shop_id = ?))) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_agent_recharge_record ar WHERE ar.id = tb_payment.order_id AND ar.deleted_at IS NULL AND ar.shop_id = ?)) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_recharge_order ro WHERE ro.id = tb_payment.order_id AND ro.deleted_at IS NULL AND ro.shop_id_tag = ?)) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_package_silence_extension_order so WHERE so.id = tb_payment.order_id AND so.shop_id_tag = ?))`) + args = append(args, model.PaymentOrderTypePackage, model.BuyerTypeAgent, filter.ShopID, filter.ShopID, model.PaymentOrderTypeAgentRecharge, filter.ShopID, model.PaymentOrderTypeRecharge, filter.ShopID, constants.PaymentOrderTypeSilenceExtension, filter.ShopID) } query = applyFinanceRelationship(query, filter, conditions, args) rows, total, err := loadFinanceRows[model.Payment](query, "updated_at DESC, id DESC", limit, "支付资金事实") @@ -1377,6 +1377,9 @@ func paymentBusinessRefs(row model.Payment) []InvestigationResourceRef { resourceType = constants.AuditResourceAgentRecharge case model.PaymentOrderTypeRecharge: resourceType = constants.AuditResourceRechargeOrder + case constants.PaymentOrderTypeSilenceExtension: + // 沉默期延期订单:延期费单独记账,不分佣、不计入代理业绩 + resourceType = constants.AuditResourceSilenceExtensionOrder } if resourceType == "" { return nil diff --git a/internal/query/packagesilence/query.go b/internal/query/packagesilence/query.go new file mode 100644 index 00000000..e60ec2a3 --- /dev/null +++ b/internal/query/packagesilence/query.go @@ -0,0 +1,382 @@ +// Package packagesilence 提供沉默期延期订单、批次、即将过期 / 已过期套餐与出库记录的只读查询。 +package packagesilence + +import ( + "context" + "strings" + "time" + + "gorm.io/gorm" + + packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" + "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" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// Query 是沉默期只读查询。 +type Query struct { + db *gorm.DB + now func() time.Time +} + +// NewQuery 创建沉默期只读查询。 +func NewQuery(db *gorm.DB) *Query { + return &Query{db: db, now: time.Now} +} + +// OrderFilter 是延期订单筛选(后台列表、导出与 H5 我的订单共用)。 +type OrderFilter struct { + Request dto.SilenceOrderListRequest + CustomerID uint // H5:只看自己的订单 +} + +// OrderQuery 构造延期订单查询(叠加店铺数据范围)。 +func (q *Query) OrderQuery(ctx context.Context, filter OrderFilter) (*gorm.DB, error) { + req := filter.Request + query := q.db.WithContext(ctx).Model(&model.PackageSilenceExtensionOrder{}).Table("tb_package_silence_extension_order AS o") + if filter.CustomerID > 0 { + query = query.Where("o.customer_id = ?", filter.CustomerID) + } else { + query = applyStrictShop(ctx, query, "o.shop_id") + } + if req.PackageUsageID != nil { + query = query.Where("o.package_usage_id = ?", *req.PackageUsageID) + } + if req.PackageID != nil { + query = query.Where("o.package_id = ?", *req.PackageID) + } + if value := strings.TrimSpace(req.AssetIdentifier); value != "" { + query = query.Where("o.asset_identifier_snapshot = ?", value) + } + if value := strings.TrimSpace(req.OrderNo); value != "" { + query = query.Where("o.order_no = ?", value) + } + if req.Status != "" { + query = query.Where("o.status = ?", req.Status) + } + if req.Source != "" { + query = query.Where("o.source = ?", req.Source) + } + if req.PaymentMethod != "" { + query = query.Where("o.payment_method = ?", req.PaymentMethod) + } + if value := strings.TrimSpace(req.MerchantName); value != "" { + query = query.Where("EXISTS (SELECT 1 FROM tb_payment p WHERE p.id = o.paid_payment_id AND p.merchant_name_snapshot LIKE ?)", "%"+value+"%") + } + if value := strings.TrimSpace(req.ThirdPartyTradeNo); value != "" { + query = query.Where("(o.offline_trade_no = ? OR EXISTS (SELECT 1 FROM tb_payment p WHERE p.id = o.paid_payment_id AND p.third_party_trade_no = ?))", value, value) + } + if req.PaidStart != "" { + start, err := time.Parse(time.RFC3339, req.PaidStart) + if err != nil { + return nil, errors.New(errors.CodeInvalidParam, "支付时间起格式应为 RFC3339") + } + query = query.Where("o.paid_at >= ?", start) + } + if req.PaidEnd != "" { + end, err := time.Parse(time.RFC3339, req.PaidEnd) + if err != nil { + return nil, errors.New(errors.CodeInvalidParam, "支付时间止格式应为 RFC3339") + } + query = query.Where("o.paid_at <= ?", end) + } + return query, nil +} + +// ListOrders 分页查询延期订单;支付方式、收款商户、交易流水号取已支付或已退款支付单快照。 +func (q *Query) ListOrders(ctx context.Context, filter OrderFilter) (*dto.SilenceOrderPageResult, error) { + page, pageSize := normalizePage(filter.Request.Page, filter.Request.PageSize) + query, err := q.OrderQuery(ctx, filter) + if err != nil { + return nil, err + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计延期订单失败") + } + var orders []model.PackageSilenceExtensionOrder + if err := query.Select("o.*").Order("o.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&orders).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败") + } + items, err := q.OrderItems(ctx, orders) + if err != nil { + return nil, err + } + return &dto.SilenceOrderPageResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +// GetOrder 查询延期订单详情(叠加数据范围或客户归属)。 +func (q *Query) GetOrder(ctx context.Context, id uint, customerID uint) (*dto.SilenceExtensionOrderResponse, error) { + query, err := q.OrderQuery(ctx, OrderFilter{CustomerID: customerID}) + if err != nil { + return nil, err + } + var order model.PackageSilenceExtensionOrder + if err := query.Select("o.*").Where("o.id = ?", id).First(&order).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "延期订单不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败") + } + items, err := q.OrderItems(ctx, []model.PackageSilenceExtensionOrder{order}) + if err != nil { + return nil, err + } + return items[0], nil +} + +// OrderItems 批量补齐延期订单的支付单快照与批次号(列表、详情与导出共用)。 +func (q *Query) OrderItems(ctx context.Context, orders []model.PackageSilenceExtensionOrder) ([]*dto.SilenceExtensionOrderResponse, error) { + paymentIDs := make([]uint, 0, len(orders)) + batchIDs := make([]uint, 0, len(orders)) + for index := range orders { + if orders[index].PaidPaymentID != nil { + paymentIDs = append(paymentIDs, *orders[index].PaidPaymentID) + } + batchIDs = append(batchIDs, orders[index].BatchID) + } + payments := map[uint]model.Payment{} + if len(paymentIDs) > 0 { + var rows []model.Payment + if err := q.db.WithContext(ctx).Where("id IN ?", paymentIDs).Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败") + } + for _, row := range rows { + payments[row.ID] = row + } + } + batchNos := map[uint]string{} + if len(batchIDs) > 0 { + var rows []model.PackageSilenceExtensionBatch + if err := q.db.WithContext(ctx).Select("id", "batch_no").Where("id IN ?", batchIDs).Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败") + } + for _, row := range rows { + batchNos[row.ID] = row.BatchNo + } + } + items := make([]*dto.SilenceExtensionOrderResponse, 0, len(orders)) + for index := range orders { + order := &orders[index] + item := packagesilenceapp.OrderDTO(order) + item.BatchNo = batchNos[order.BatchID] + if order.PaidPaymentID != nil { + if payment, ok := payments[*order.PaidPaymentID]; ok { + item.PaymentMerchantName = payment.MerchantNameSnapshot + item.ThirdPartyTradeNo = payment.ThirdPartyTradeNo + item.PaymentNo = payment.PaymentNo + } + } + items = append(items, item) + } + return items, nil +} + +// ListBatches 分页查询延期批次(平台与有数据范围的账号按批次店铺标签过滤)。 +func (q *Query) ListBatches(ctx context.Context, req dto.SilenceBatchListRequest) (*dto.SilenceBatchPageResult, error) { + page, pageSize := normalizePage(req.Page, req.PageSize) + query := applyStrictShop(ctx, q.db.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).Table("tb_package_silence_extension_batch AS b"), "b.shop_id_tag") + if value := strings.TrimSpace(req.BatchNo); value != "" { + query = query.Where("b.batch_no = ?", value) + } + if req.Path != "" { + query = query.Where("b.path = ?", req.Path) + } + if req.ExtensionType != "" { + query = query.Where("b.extension_type = ?", req.ExtensionType) + } + if req.Status != "" { + query = query.Where("b.status = ?", req.Status) + } + if req.HasException != nil && *req.HasException { + query = query.Where("EXISTS (SELECT 1 FROM tb_package_silence_extension e WHERE e.batch_id = b.id AND e.status = ?)", constants.SilenceExtensionStatusException) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计延期批次失败") + } + var batches []model.PackageSilenceExtensionBatch + if err := query.Select("b.*").Order("b.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&batches).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败") + } + items := make([]*dto.SilenceBatchResponse, 0, len(batches)) + for index := range batches { + items = append(items, packagesilenceapp.BatchDTO(&batches[index])) + } + return &dto.SilenceBatchPageResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +// usageRow 是即将过期 / 已过期套餐查询行。 +type usageRow struct { + model.PackageUsage + CardICCID string `gorm:"column:card_iccid"` + DeviceNo string `gorm:"column:device_no"` + AssetShopID *uint `gorm:"column:asset_shop_id"` + AssetShopNam string `gorm:"column:asset_shop_name"` +} + +// UsageQuery 构造即将过期 / 已过期套餐查询(叠加资产店铺数据范围)。 +func (q *Query) UsageQuery(ctx context.Context, req dto.SilenceUsageListRequest) (*gorm.DB, error) { + now := q.now() + query := q.db.WithContext(ctx).Table("tb_package_usage AS u"). + 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)"). + Where("u.deleted_at IS NULL AND u.status = ? AND u.master_usage_id IS NULL", constants.PackageUsageStatusPending) + switch req.View { + case "expired": + query = query.Where("(u.silence_status = ? OR (u.silence_status = ? AND u.silence_deadline_at < ? AND u.silence_adjust_pending = FALSE))", + constants.SilenceStatusExpired, constants.SilenceStatusWaiting, now) + case "all": + query = query.Where("u.silence_status IN ?", []string{constants.SilenceStatusWaiting, constants.SilenceStatusExpired}) + default: + within := req.WithinDays + if within <= 0 { + within = constants.SilenceExpiringSoonDaysDefault + } + query = query.Where("u.silence_status = ? AND u.silence_deadline_at >= ? AND u.silence_deadline_at < ?", + constants.SilenceStatusWaiting, now, now.Add(time.Duration(within)*24*time.Hour)) + } + if value := strings.TrimSpace(req.AssetIdentifier); value != "" { + query = query.Where("(c.iccid = ? OR c.virtual_no = ? OR d.virtual_no = ? OR d.imei = ?)", value, value, value, value) + } + if req.PackageID != nil { + query = query.Where("u.package_id = ?", *req.PackageID) + } + if req.ShopID != nil { + query = query.Where("COALESCE(d.shop_id, c.shop_id) = ?", *req.ShopID) + } + if req.DeadlineStart != "" { + start, err := time.Parse(time.RFC3339, req.DeadlineStart) + if err != nil { + return nil, errors.New(errors.CodeInvalidParam, "截止时间起格式应为 RFC3339") + } + query = query.Where("u.silence_deadline_at >= ?", start) + } + if req.DeadlineEnd != "" { + end, err := time.Parse(time.RFC3339, req.DeadlineEnd) + if err != nil { + return nil, errors.New(errors.CodeInvalidParam, "截止时间止格式应为 RFC3339") + } + query = query.Where("u.silence_deadline_at <= ?", end) + } + return applyStrictShop(ctx, query, "COALESCE(d.shop_id, c.shop_id)"), nil +} + +// ListUsages 分页查询即将过期 / 已过期套餐。 +func (q *Query) ListUsages(ctx context.Context, req dto.SilenceUsageListRequest) (*dto.SilenceUsagePageResult, error) { + page, pageSize := normalizePage(req.Page, req.PageSize) + query, err := q.UsageQuery(ctx, req) + if err != nil { + return nil, err + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计沉默期套餐失败") + } + rows, err := q.FetchUsages(ctx, query, (page-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + return &dto.SilenceUsagePageResult{Items: rows, Total: total, Page: page, PageSize: pageSize}, nil +} + +// FetchUsages 按偏移读取即将过期 / 已过期套餐(列表与导出共用)。 +func (q *Query) FetchUsages(ctx context.Context, query *gorm.DB, offset, limit int) ([]*dto.SilenceUsageItem, error) { + var rows []usageRow + if err := query.Select("u.*, c.iccid AS card_iccid, COALESCE(NULLIF(d.virtual_no, ''), d.imei) AS device_no, COALESCE(d.shop_id, c.shop_id) AS asset_shop_id, COALESCE(s.shop_name, '') AS asset_shop_name"). + Order("u.silence_deadline_at ASC, u.id ASC").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询沉默期套餐失败") + } + now := q.now() + items := make([]*dto.SilenceUsageItem, 0, len(rows)) + for index := range rows { + row := &rows[index] + assetType, assetID, identifier := constants.SilenceAssetTypeIotCard, row.IotCardID, row.CardICCID + if row.DeviceID > 0 { + assetType, assetID, identifier = constants.SilenceAssetTypeDevice, row.DeviceID, row.DeviceNo + } + items = append(items, &dto.SilenceUsageItem{ + PackageUsageID: row.ID, OrderNo: row.OrderNo, PackageID: row.PackageID, PackageName: row.PackageName, + AssetType: assetType, AssetID: assetID, AssetIdentifier: identifier, ShopID: row.AssetShopID, + ShopName: row.AssetShopNam, CreatedAt: row.CreatedAt, Silence: packagesilenceapp.BuildView(&row.PackageUsage, now), + }) + } + return items, nil +} + +// ListOutboundRecords 按资产查询各世代出库记录(叠加资产数据范围)。 +func (q *Query) ListOutboundRecords(ctx context.Context, assetType string, assetID uint) ([]dto.AssetOutboundRecordResponse, error) { + if assetType == "card" { + assetType = constants.OutboundAssetTypeIotCard + } + table := "tb_iot_card" + if assetType == constants.OutboundAssetTypeDevice { + table = "tb_device" + } + var count int64 + if err := middleware.ApplyStrictShopFilter(ctx, q.db.WithContext(ctx).Table(table).Where("id = ? AND deleted_at IS NULL", assetID)).Count(&count).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验资产数据范围失败") + } + if count == 0 { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + var records []model.AssetOutboundRecord + if err := q.db.WithContext(ctx).Where("asset_type = ? AND asset_id = ?", assetType, assetID).Order("generation DESC").Find(&records).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询出库记录失败") + } + items := make([]dto.AssetOutboundRecordResponse, 0, len(records)) + for _, record := range records { + items = append(items, dto.AssetOutboundRecordResponse{ + ID: record.ID, AssetType: record.AssetType, AssetID: record.AssetID, AssetIdentifier: record.AssetIdentifierSnapshot, + Generation: record.Generation, OutboundAt: record.OutboundAt, Source: record.Source, SourceName: outboundSourceName(record.Source), + OrderNo: record.OrderNo, ExchangeNo: record.ExchangeNo, EndedAt: record.EndedAt, EndReason: record.EndReason, CreatedAt: record.CreatedAt, + }) + } + return items, nil +} + +func outboundSourceName(source string) string { + switch source { + case constants.OutboundSourceOrder: + return "首次购买套餐" + case constants.OutboundSourceExchange: + return "换货新资产" + case constants.OutboundSourceBackfill: + return "历史回填" + default: + return source + } +} + +// applyStrictShop 按严格店铺数据范围过滤:超管与平台不限;代理只看自己及下级店铺;其他账号无数据。 +func applyStrictShop(ctx context.Context, query *gorm.DB, column string) *gorm.DB { + switch middleware.GetUserTypeFromContext(ctx) { + case constants.UserTypeSuperAdmin, constants.UserTypePlatform: + return query + case constants.UserTypeAgent: + if shopIDs := middleware.GetSubordinateShopIDs(ctx); len(shopIDs) > 0 { + return query.Where(column+" IN ?", shopIDs) + } + if shopID := middleware.GetShopIDFromContext(ctx); shopID > 0 { + return query.Where(column+" = ?", shopID) + } + } + return query.Where("1 = 0") +} + +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 +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 20a895d1..28df5f49 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -136,6 +136,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.PollingConfig != nil { registerPollingConfigRoutes(authGroup, handlers.PollingConfig, doc, basePath) } + if handlers.PackageSilence != nil { + registerPackageSilenceRoutes(authGroup, handlers.PackageSilence, doc, basePath) + } if handlers.PollingPause != nil { registerPollingPauseRoutes(authGroup, handlers.PollingPause, doc, basePath) } diff --git a/internal/routes/package_silence.go b/internal/routes/package_silence.go new file mode 100644 index 00000000..4c6ec6cb --- /dev/null +++ b/internal/routes/package_silence.go @@ -0,0 +1,138 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// silenceSwitchDoc 是总开关对延期接口的影响说明。 +const silenceSwitchDoc = "\n\n沉默期总开关关闭时返回错误码 1274(silence_feature_disabled);已发生的支付回调、查单与审批回调不受开关影响。" + +// registerPackageSilenceRoutes 注册后台沉默期接口(add-package-silence-period)。 +func registerPackageSilenceRoutes(router fiber.Router, handler *admin.PackageSilenceHandler, doc *openapi.Generator, basePath string) { + group := router.Group("/package-silence") + path := basePath + "/package-silence" + tags := []string{"沉默期"} + Register(group, doc, path, "GET", "/switch", handler.GetSwitch, RouteSpec{ + Summary: "查询沉默期总开关", Description: "返回总开关当前值与打开前 4 项检查结果(未配置默认沉默期的套餐、未回填记录、全部套餐延期政策、企微审批场景)。", + Tags: tags, Input: nil, Output: new(dto.SilenceSwitchResponse), Auth: true, + }) + Register(group, doc, path, "PUT", "/switch", handler.UpdateSwitch, RouteSpec{ + Summary: "切换沉默期总开关(仅超级管理员)", + Description: "打开前逐项检查,任一项不满足返回 1275(silence_switch_precheck_failed),data.failed_items 为未满足项与数量;" + + "关闭(应急)不检查:规则暂停、数据保留。每次切换写审计 package.silence_switch_changed。", + Tags: tags, Input: new(dto.SilenceSwitchRequest), Output: new(dto.SilenceSwitchResponse), Auth: true, + }) + policyTags := []string{"沉默期 - 延期政策"} + Register(group, doc, path, "GET", "/policies", handler.ListPolicies, RouteSpec{ + Summary: "延期政策列表", Description: "需 package_silence:policy 权限。", Tags: policyTags, + Input: new(dto.SilencePolicyListRequest), Output: new([]dto.SilencePolicyResponse), Auth: true, + }) + Register(group, doc, path, "GET", "/policies/match", handler.MatchPolicy, RouteSpec{ + Summary: "查询套餐当前匹配的延期政策", Description: "优先级:指定套餐 > 套餐系列 > 全部套餐;只匹配启用且在生效期内的政策。", Tags: policyTags, + Input: new(dto.SilencePolicyMatchRequest), Output: new(dto.SilencePolicyMatchResponse), Auth: true, + }) + Register(group, doc, path, "GET", "/policies/:id", handler.GetPolicy, RouteSpec{ + Summary: "延期政策详情", Tags: policyTags, Input: new(dto.SilenceIDPath), Output: new(dto.SilencePolicyResponse), Auth: true, + }) + Register(group, doc, path, "POST", "/policies", handler.CreatePolicy, RouteSpec{ + Summary: "新增延期政策", Description: "同一优先级的适用范围与生效时间段不能重叠,重叠返回 1269(silence_policy_overlap)。修改只影响之后发起的延期。", + Tags: policyTags, Input: new(dto.SilencePolicyRequest), Output: new(dto.SilencePolicyResponse), Auth: true, + }) + Register(group, doc, path, "PUT", "/policies/:id", handler.UpdatePolicy, RouteSpec{ + Summary: "编辑延期政策", Description: "全量提交字段;已创建的延期订单与已提交的审批按快照执行。", + Tags: policyTags, Input: new(dto.SilencePolicyUpdateParams), Output: new(dto.SilencePolicyResponse), Auth: true, + }) + Register(group, doc, path, "PUT", "/policies/:id/status", handler.UpdatePolicyStatus, RouteSpec{ + Summary: "启停延期政策", Description: "启用时同样做重叠校验。", Tags: policyTags, + Input: new(dto.SilencePolicyStatusParams), Output: new(dto.SilencePolicyResponse), Auth: true, + }) + extTags := []string{"沉默期 - 延期"} + Register(group, doc, path, "GET", "/eligibility", handler.Eligibility, RouteSpec{ + Summary: "延期资格", Description: "待实名但已到期的先判定为过期;返回最少 / 最多天数与月数、单价、各月数档位;金额全部由后端计算。" + silenceSwitchDoc, + Tags: extTags, Input: new(dto.SilenceEligibilityRequest), Output: new(dto.SilenceEligibilityResponse), Auth: true, + }) + Register(group, doc, path, "GET", "/quote", handler.Quote, RouteSpec{ + Summary: "按天数试算付费延期", Description: "按每月 30 天向上取整计费(1 天按 30 天,31 天按 60 天),延长天数等于计费天数。" + silenceSwitchDoc, + Tags: extTags, Input: new(dto.SilenceQuoteRequest), Output: new(dto.SilenceQuoteResponse), Auth: true, + }) + Register(group, doc, path, "POST", "/orders/qrcode", handler.CreateQRCodeOrder, RouteSpec{ + Summary: "运营创建在线延期订单并生成支付二维码", + Description: "需 package_silence:extend 权限;商户按支付方式经商户池路由并冻结;返回 qr_content 由前端渲染为二维码,客户扫码支付后自动延期,不审批。" + + "同一套餐已有待支付订单时先查单:未支付则关闭旧单再新建,已支付则按已支付处理并返回旧单。" + silenceSwitchDoc, + Tags: extTags, Input: new(dto.SilenceOnlineOrderRequest), Output: new(dto.SilenceExtensionOrderResponse), Auth: true, + }) + Register(group, doc, path, "POST", "/orders/offline", handler.RegisterOffline, RouteSpec{ + Summary: "登记线下付费延期", + Description: "需 package_silence:offline 权限;实收金额必须等于系统计算金额(1273),同一收款方式下交易单号唯一(1272);" + + "政策未勾选 offline_paid 免审批时提交企微审批,通过后延期,驳回订单 rejected 且系统不退款。" + silenceSwitchDoc, + Tags: extTags, Input: new(dto.SilenceOfflineOrderRequest), Output: new(dto.SilenceExtensionOrderResponse), Auth: true, + }) + Register(group, doc, path, "GET", "/orders", handler.ListOrders, RouteSpec{ + Summary: "延期订单列表", Description: "叠加店铺数据范围;支付方式、收款商户、交易流水号取已支付或已退款支付单快照。导出使用统一导出任务,场景 package_silence_order,筛选参数同本接口。", + Tags: extTags, Input: new(dto.SilenceOrderListRequest), Output: new(dto.SilenceOrderPageResult), Auth: true, + }) + Register(group, doc, path, "GET", "/orders/:id", handler.GetOrder, RouteSpec{ + Summary: "延期订单详情", Tags: extTags, Input: new(dto.SilenceIDPath), Output: new(dto.SilenceExtensionOrderResponse), Auth: true, + }) + Register(group, doc, path, "POST", "/special", handler.SubmitSpecial, RouteSpec{ + Summary: "提交特殊延期(单个 / 批量)", + Description: "需 package_silence:extend 权限,批量(多条或导入文件)另需 package_silence:extend_batch;免费延期受政策次数约束(1267);" + + "不受付费上限与套餐是否支持延期约束,只要求新截止晚于今天。政策未勾选对应免审批场景(批量看 batch)时提交企微审批,通过后逐条执行。" + silenceSwitchDoc, + Tags: extTags, Input: new(dto.SilenceSpecialRequest), Output: new(dto.SilenceBatchResponse), Auth: true, + }) + Register(group, doc, path, "GET", "/batches", handler.ListBatches, RouteSpec{ + Summary: "延期批次列表", Tags: extTags, Input: new(dto.SilenceBatchListRequest), Output: new(dto.SilenceBatchPageResult), Auth: true, + }) + Register(group, doc, path, "GET", "/batches/:id", handler.GetBatch, RouteSpec{ + Summary: "延期批次详情(含明细)", Tags: extTags, Input: new(dto.SilenceIDPath), Output: new(dto.SilenceBatchResponse), Auth: true, + }) + Register(group, doc, path, "POST", "/extensions/:id/handle", handler.HandleException, RouteSpec{ + Summary: "处理异常延期明细", Description: "需 package_silence:exception 权限;retry 按原快照重新执行,close 关闭(在线订单按冻结商户原路退款)。不得直接改截止时间。", + Tags: extTags, Input: new(dto.SilenceExceptionHandleParams), Output: new(dto.SilenceExtensionResponse), Auth: true, + }) + Register(group, doc, path, "GET", "/records", handler.ListRecords, RouteSpec{ + Summary: "套餐沉默期流水", Description: "设置、调整、过期、结束、延期、换货迁移、回填等不可变流水。", + Tags: tags, Input: new(dto.SilenceRecordListRequest), Output: new([]dto.SilenceRecordResponse), Auth: true, + }) + Register(group, doc, path, "GET", "/usages", handler.ListUsages, RouteSpec{ + Summary: "即将过期 / 已过期套餐", Description: "view=expiring(默认 7 天内到期)/ expired / all;导出使用统一导出任务,场景 package_silence_usage,筛选参数同本接口。", + Tags: tags, Input: new(dto.SilenceUsageListRequest), Output: new(dto.SilenceUsagePageResult), Auth: true, + }) + Register(router, doc, basePath, "GET", "/assets/:asset_type/:id/outbound-records", handler.ListOutboundRecords, RouteSpec{ + Summary: "资产出库记录", Description: "按资产查看各世代出库记录(出库时间、来源、订单号或换货单号、结束时间与原因);只读。", + Tags: []string{"资产 - 出库时间"}, Input: new(dto.AssetOutboundRecordRequest), Output: new([]dto.AssetOutboundRecordResponse), Auth: true, + }) +} + +// registerPersonalPackageSilenceRoutes 注册 H5 沉默期延期接口。 +func registerPersonalPackageSilenceRoutes(router fiber.Router, handler *apphandler.ClientPackageSilenceHandler, doc *openapi.Generator, basePath string) { + group := router.Group("/package-silence") + path := basePath + "/package-silence" + tags := []string{"个人客户 - 沉默期延期"} + Register(group, doc, path, "GET", "/eligibility", handler.Eligibility, RouteSpec{ + Summary: "延期资格", Description: "资产须属于当前客户;套餐沉默期已过期(资产套餐历史 silence.extendable=true)时显示延期入口。" + silenceSwitchDoc, + Tags: tags, Input: new(dto.SilenceEligibilityRequest), Output: new(dto.SilenceEligibilityResponse), Auth: true, + }) + Register(group, doc, path, "GET", "/quote", handler.Quote, RouteSpec{ + Summary: "按天数试算", Description: "按每月 30 天向上取整计费。" + silenceSwitchDoc, + Tags: tags, Input: new(dto.SilenceQuoteRequest), Output: new(dto.SilenceQuoteResponse), Auth: true, + }) + Register(group, doc, path, "POST", "/orders", handler.CreateOrder, RouteSpec{ + Summary: "自助下单付费延期", Description: "返回 qr_content(支付链接 / 二维码内容);支付成功后自动延期,不审批,不从代理钱包扣款。" + silenceSwitchDoc, + Tags: tags, Input: new(dto.SilenceOnlineOrderRequest), Output: new(dto.SilenceExtensionOrderResponse), Auth: true, + }) + Register(group, doc, path, "GET", "/orders", handler.ListOrders, RouteSpec{ + Summary: "我的延期订单", Tags: tags, Input: new(dto.SilenceOrderListRequest), Output: new(dto.SilenceOrderPageResult), Auth: true, + }) + Register(group, doc, path, "GET", "/orders/:id", handler.GetOrder, RouteSpec{ + Summary: "我的延期订单详情", Tags: tags, Input: new(dto.SilenceIDPath), Output: new(dto.SilenceExtensionOrderResponse), Auth: true, + }) + Register(group, doc, path, "GET", "/records", handler.ListRecords, RouteSpec{ + Summary: "套餐沉默期流水", Tags: tags, Input: new(dto.SilenceRecordListRequest), Output: new([]dto.SilenceRecordResponse), Auth: true, + }) +} diff --git a/internal/routes/personal.go b/internal/routes/personal.go index 80b4d5aa..3e4a2880 100644 --- a/internal/routes/personal.go +++ b/internal/routes/personal.go @@ -127,6 +127,9 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, if handlers.ClientPopup != nil { registerPersonalPopupRoutes(authGroup, handlers.ClientPopup, doc, basePath) } + if handlers.ClientPackageSilence != nil { + registerPersonalPackageSilenceRoutes(authGroup, handlers.ClientPackageSilence, doc, basePath) + } // 获取个人资料 Register(authGroup, doc, basePath, "GET", "/profile", handlers.PersonalCustomer.GetProfile, RouteSpec{ diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 9b053f7f..9fac3b79 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -6,6 +6,7 @@ package asset import ( "context" stderrors "errors" + packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume" "strconv" "time" @@ -38,6 +39,7 @@ type PackageExpiryResolver interface { // Service 资产查询与操作服务 type Service struct { + silenceSwitch SilenceSwitch db *gorm.DB deviceStore *postgres.DeviceStore iotCardStore *postgres.IotCardStore @@ -199,6 +201,7 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model. AssetType: "device", AssetID: device.ID, VirtualNo: device.VirtualNo, + OutboundAt: device.OutboundAt, Status: device.Status, StatusName: constants.GetDeviceStatusName(device.Status), BatchNo: device.BatchNo, @@ -343,6 +346,7 @@ func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotC AssetType: "card", AssetID: card.ID, VirtualNo: card.VirtualNo, + OutboundAt: card.OutboundAt, Status: card.Status, StatusName: constants.GetIotCardStatusName(card.Status), BatchNo: card.BatchNo, @@ -862,7 +866,9 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa items := make([]*dto.AssetPackageHistoryNode, 0, len(history.Items)) for _, node := range history.Items { - items = append(items, buildAssetPackageHistoryNode(node, packageMap, callerAccountType)) + item := buildAssetPackageHistoryNode(node, packageMap, callerAccountType) + s.fillSilenceViews(ctx, item, node) + items = append(items, item) } return &dto.AssetPackagesResult{ @@ -1181,6 +1187,7 @@ func (s *Service) buildAssetPackageResponse(ctx context.Context, usage *model.Pa PaidAmount: paidAmount, RetailAmount: usage.RetailAmount, CreatedAt: usage.CreatedAt, + Silence: s.silenceView(ctx, usage), } } @@ -1540,3 +1547,39 @@ func (s *Service) restorePollingAfterExpiryChange(ctx context.Context, assetType } }) } + +// SilenceSwitch 读取沉默期总开关。 +type SilenceSwitch interface { + Enabled(ctx context.Context) bool +} + +// SetSilenceSwitch 注入沉默期总开关;打开时资产套餐列表与详情返回沉默期信息。 +func (s *Service) SetSilenceSwitch(sw SilenceSwitch) { + s.silenceSwitch = sw +} + +// silenceView 返回套餐记录的沉默期展示信息;总开关关闭时不返回。 +func (s *Service) silenceView(ctx context.Context, usage *model.PackageUsage) *dto.PackageSilenceView { + if s.silenceSwitch == nil || !s.silenceSwitch.Enabled(ctx) { + return nil + } + return packagesilenceapp.BuildView(usage, time.Now()) +} + +// fillSilenceViews 按历史树逐节点填写沉默期展示信息。 +func (s *Service) fillSilenceViews(ctx context.Context, item *dto.AssetPackageHistoryNode, node *assetquery.PackageHistoryNode) { + if item == nil || node == nil { + return + } + item.Silence = s.silenceView(ctx, node.Usage) + for index, child := range node.Children { + if index < len(item.Children) { + s.fillSilenceViews(ctx, item.Children[index], child) + } + } +} + +// SilenceView 返回套餐记录的沉默期展示信息(H5 资产套餐历史复用);总开关关闭时返回 nil。 +func (s *Service) SilenceView(ctx context.Context, usage *model.PackageUsage) *dto.PackageSilenceView { + return s.silenceView(ctx, usage) +} diff --git a/internal/service/device/service.go b/internal/service/device/service.go index d60fa2ff..cddaa7fe 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -262,6 +262,9 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li if req.IsAuthorizedToEnterprise != nil { filters["is_authorized_to_enterprise"] = *req.IsAuthorizedToEnterprise } + if err := applyOutboundFilters(filters, req.OutboundStart, req.OutboundEnd); err != nil { + return nil, err + } devices, total, err := s.deviceStore.List(ctx, opts, filters) if err != nil { @@ -892,6 +895,7 @@ func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string activationStatus := activationStatuses[device.ID] resp := &dto.DeviceResponse{ EnablePolling: device.EnablePolling, + OutboundAt: device.OutboundAt, ID: device.ID, VirtualNo: device.VirtualNo, IMEI: device.IMEI, @@ -1769,3 +1773,22 @@ func (s *Service) recordCardCommand(ctx context.Context, card *model.IotCard, ac Attempt: 1, CallErr: callErr, Duration: duration, }) } + +// applyOutboundFilters 解析出库时间范围筛选(RFC3339)。 +func applyOutboundFilters(filters map[string]any, start, end string) error { + if start != "" { + value, err := time.Parse(time.RFC3339, start) + if err != nil { + return errors.New(errors.CodeInvalidParam, "出库时间起格式应为 RFC3339") + } + filters["outbound_start"] = value + } + if end != "" { + value, err := time.Parse(time.RFC3339, end) + if err != nil { + return errors.New(errors.CodeInvalidParam, "出库时间止格式应为 RFC3339") + } + filters["outbound_end"] = value + } + return nil +} diff --git a/internal/service/exchange/service.go b/internal/service/exchange/service.go index 5dc43473..080968ee 100644 --- a/internal/service/exchange/service.go +++ b/internal/service/exchange/service.go @@ -2,6 +2,7 @@ package exchange import ( "context" + packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" "strings" "time" @@ -35,6 +36,12 @@ type Service struct { shippingCreatedNotifier *exchangeapp.ShippingCreatedNotifier auditWriter *audit.Writer logger *zap.Logger + silenceHook *packagesilenceapp.ExchangeHook +} + +// SetSilenceHook 注入沉默期与出库时间换货接缝。 +func (s *Service) SetSilenceHook(hook *packagesilenceapp.ExchangeHook) { + s.silenceHook = hook } func New( @@ -395,6 +402,10 @@ func (s *Service) Renew(ctx context.Context, id uint) error { }).Error; updateErr != nil { return errors.Wrap(errors.CodeDatabaseError, updateErr, "重置旧卡转新状态失败") } + // 出库时间:旧卡回库、世代 +1,清空出库时间并结束旧世代出库记录 + if hookErr := s.silenceHook.OnOldAssetRenewedInTx(ctx, tx, constants.ExchangeAssetTypeIotCard, card.ID, card.Generation, time.Now()); hookErr != nil { + return hookErr + } cardKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &card, VirtualNo: card.VirtualNo}) if cardKey != "" { @@ -468,6 +479,10 @@ func (s *Service) Renew(ctx context.Context, id uint) error { }).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "重置旧设备转新状态失败") } + // 出库时间:旧设备回库、世代 +1,清空出库时间并结束旧世代出库记录 + if err = s.silenceHook.OnOldAssetRenewedInTx(ctx, tx, constants.ExchangeAssetTypeDevice, device.ID, device.Generation, time.Now()); err != nil { + return err + } deviceKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &device, VirtualNo: device.VirtualNo}) if deviceKey != "" { @@ -848,6 +863,10 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order if err != nil { return err } + // 沉默期随套餐记录迁移、不重新起算,写换货流水 + if err = s.silenceHook.OnUsagesMigratedInTx(ctx, tx, order, migration.PackageUsageIDs); err != nil { + return errors.Wrap(errors.CodeExchangeMigrationFailed, err, "写入沉默期换货流水失败") + } } now := time.Now() @@ -878,6 +897,10 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order if result.RowsAffected == 0 { return errors.New(errors.CodeExchangeStatusInvalid) } + // 出库时间:换货后的新资产取换货完成时间(当前世代已有出库时间的不覆盖) + if err = s.silenceHook.OnNewAssetInTx(ctx, tx, order.NewAssetType, *order.NewAssetID, order, now); err != nil { + return err + } orderBefore := map[string]any{"status": fromStatus, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance, "migration_status": beforeMigrationStatus} order.Status = constants.ExchangeStatusCompleted order.CompletedAt = &now diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 0390748b..092dad22 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -275,6 +275,9 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot if req.IsAuthorizedToEnterprise != nil { filters["is_authorized_to_enterprise"] = *req.IsAuthorizedToEnterprise } + if err := applyOutboundFilters(filters, req.OutboundStart, req.OutboundEnd); err != nil { + return nil, err + } // 代理用户注入 subordinate_shop_ids,让 Store 层走并行查询路径 // 避免 PG 对 shop_id IN (...) + ORDER BY 选择全表扫描 @@ -481,6 +484,7 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]str ID: card.ID, ICCID: card.ICCID, VirtualNo: card.VirtualNo, + OutboundAt: card.OutboundAt, CardCategory: card.CardCategory, CarrierID: card.CarrierID, CarrierType: card.CarrierType, @@ -2073,3 +2077,22 @@ func (s *Service) clearRealnameReversalCounter(ctx context.Context, cardID uint) zap.Error(err)) } } + +// applyOutboundFilters 解析出库时间范围筛选(RFC3339)。 +func applyOutboundFilters(filters map[string]any, start, end string) error { + if start != "" { + value, err := time.Parse(time.RFC3339, start) + if err != nil { + return errors.New(errors.CodeInvalidParam, "出库时间起格式应为 RFC3339") + } + filters["outbound_start"] = value + } + if end != "" { + value, err := time.Parse(time.RFC3339, end) + if err != nil { + return errors.New(errors.CodeInvalidParam, "出库时间止格式应为 RFC3339") + } + filters["outbound_end"] = value + } + return nil +} diff --git a/internal/service/order/service.go b/internal/service/order/service.go index afc7e91f..7ee839ee 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -14,6 +14,7 @@ import ( cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" + packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection" @@ -85,6 +86,18 @@ type Service struct { auditWriter *audit.Writer paymentIntegration *integrationlog.Repository billCreation *employeecollectionapp.BillCreationService + silenceSetter *packagesilenceapp.Setter + outboundRecorder *packagesilenceapp.OutboundRecorder +} + +// SetSilenceSetter 注入沉默期设置用例(支付成功创建套餐记录时设置沉默期)。 +func (s *Service) SetSilenceSetter(setter *packagesilenceapp.Setter) { + s.silenceSetter = setter +} + +// SetOutboundRecorder 注入资产出库时间写入用例。 +func (s *Service) SetOutboundRecorder(recorder *packagesilenceapp.OutboundRecorder) { + s.outboundRecorder = recorder } // SetEmployeeCollectionBillCreation 注入员工代收款账单建账用例。 @@ -280,6 +293,18 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde } } + // 购买时调整沉默期:事务前校验权限、天数与原因,超默认值需审批时提前完成审批渠道检查 + if req.SilencePeriodDays != nil { + if s.silenceSetter == nil { + return nil, errors.New(errors.CodeSilenceOverrideForbidden) + } + override, overrideErr := s.silenceSetter.PrepareOverride(ctx, middleware.GetUserIDFromContext(ctx), req.SilencePeriodDays, req.SilenceReason, validationResult.Packages) + if overrideErr != nil { + return nil, overrideErr + } + ctx = packagesilenceapp.WithPurchaseOverride(ctx, override) + } + carrierType, carrierID := resolveAdminCarrierInfoFromVars(orderType, iotCardID, deviceID) existingOrderID, lockToken, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs) lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID) @@ -2334,6 +2359,12 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model createdUsage = true } } + if createdUsage && s.outboundRecorder != nil { + // 出库时间:资产当前世代第 1 次购买套餐的支付时间(只记录展示,不受沉默期总开关影响) + if err := s.outboundRecorder.RecordForOrderInTx(ctx, tx, order); err != nil { + return err + } + } if createdUsage { if s.observationSeriesEvents == nil { return errors.New(errors.CodeInternalError, "购包观测 Outbox Writer 未配置") @@ -2740,6 +2771,16 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m }).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新主套餐状态失败") } + // 沉默期:实名即生效主套餐在资产未实名时购买,从支付成功起计算沉默期(总开关关闭也记录) + if s.silenceSetter != nil { + paidAt := now + if order.PaidAt != nil { + paidAt = *order.PaidAt + } + if err := s.silenceSetter.ApplyOnUsageCreatedInTx(ctx, tx, usage, pkg, paidAt, constants.SilenceTriggerOrderPaid); err != nil { + return err + } + } // 仅在生效状态分支追加优先轮询请求:待生效(含等待实名激活)不属于本 Change 的触发场景。 if status == constants.PackageUsageStatusActive { diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index fc438926..fafbcb9c 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -37,6 +37,41 @@ type ActivationService struct { priorityEvents priorityapp.PriorityEventWriter auditWriter *audit.Writer preActivationSettler PreActivationSettler // 激活前流量结算,可选 + silenceGate SilenceGate // 沉默期激活闸门,可选 + silenceFilter SilenceStatusFilter // 沉默期总开关,可选 +} + +// SilenceGate 是沉默期激活闸门(add-package-silence-period §5.3):激活事务内、实名判断前判定沉默期; +// 返回 false 表示套餐沉默期已过期,任何入口都不能激活。 +type SilenceGate interface { + AllowActivationInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) (bool, error) +} + +// SilenceStatusFilter 返回总开关打开时排队与孤儿扫描需要排除过期套餐的判断。 +type SilenceStatusFilter interface { + Enabled(ctx context.Context) bool +} + +// SetSilenceGate 注入沉默期激活闸门与总开关;未注入时行为与现状一致。 +func (s *ActivationService) SetSilenceGate(gate SilenceGate, filter SilenceStatusFilter) { + s.silenceGate = gate + s.silenceFilter = filter +} + +// excludeSilenceExpired 在总开关打开时为待生效主套餐查询追加"跳过沉默期已过期"条件。 +func (s *ActivationService) excludeSilenceExpired(ctx context.Context, query *gorm.DB) *gorm.DB { + if s.silenceFilter == nil || !s.silenceFilter.Enabled(ctx) { + return query + } + return query.Where("(silence_status IS NULL OR silence_status <> ?)", constants.SilenceStatusExpired) +} + +// allowSilence 执行沉默期闸门;未注入时放行。 +func (s *ActivationService) allowSilence(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) (bool, error) { + if s.silenceGate == nil || usage == nil { + return true, nil + } + return s.silenceGate.AllowActivationInTx(ctx, tx, usage) } // PreActivationSettler 在资产从无生效主套餐变为有生效主套餐之前做一次流量结算(尽力而为,不阻塞激活)。 @@ -172,6 +207,15 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType // 检查是否是主套餐 if usage.MasterUsageID == nil { + // 沉默期闸门:期内实名记为结束(之后照常激活或排队),沉默期已过期的不激活 + allowed, gateErr := s.allowSilence(ctx, tx, usage) + if gateErr != nil { + return gateErr + } + if !allowed { + s.logger.Info("套餐沉默期已过期,实名后不激活", zap.Uint("usage_id", usage.ID)) + continue + } // 主套餐:需要检查是否有已激活或已用完(仍在有效期)的主套餐 var activeMain model.PackageUsage err := tx.Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). @@ -193,6 +237,17 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType } } + if usage.MasterUsageID != nil { + // 挂在沉默期已过期主套餐下的待生效加油包随主套餐一并不可用(D21) + masterExpired, masterErr := s.isMasterSilenceExpired(ctx, tx, *usage.MasterUsageID) + if masterErr != nil { + return masterErr + } + if masterExpired { + continue + } + } + terms, termsErr := ResolveUsageTerms(usage, &pkg, s.logger) if termsErr != nil { return termsErr @@ -636,10 +691,12 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor func (s *ActivationService) getNextPendingMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (*model.PackageUsage, error) { var nextMain model.PackageUsage - err := tx.WithContext(ctx). + query := tx.WithContext(ctx). Where("status = ?", constants.PackageUsageStatusPending). Where("master_usage_id IS NULL"). - Where(carrierType+"_id = ?", carrierID). + Where(carrierType+"_id = ?", carrierID) + // 沉默期已过期的套餐不能挡住同资产后续套餐(总开关打开时) + err := s.excludeSilenceExpired(ctx, query). Order("priority ASC, created_at ASC"). First(&nextMain).Error if err != nil { @@ -669,6 +726,10 @@ func (s *ActivationService) canActivatePendingUsage(ctx context.Context, tx *gor if usage == nil { return false, nil } + allowed, err := s.allowSilence(ctx, tx, usage) + if err != nil || !allowed { + return false, err + } if !usage.PendingRealnameActivation { return true, nil } @@ -996,3 +1057,22 @@ func (s *ActivationService) InvalidateAllPackagesByAsset(ctx context.Context, as return nil } + +// isMasterSilenceExpired 判断主套餐沉默期是否已过期(总开关打开时才生效)。 +func (s *ActivationService) isMasterSilenceExpired(ctx context.Context, tx *gorm.DB, masterUsageID uint) (bool, error) { + if s.silenceFilter == nil || !s.silenceFilter.Enabled(ctx) { + return false, nil + } + var count int64 + if err := tx.WithContext(ctx).Model(&model.PackageUsage{}). + Where("id = ? AND silence_status = ?", masterUsageID, constants.SilenceStatusExpired). + Count(&count).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐沉默期状态失败") + } + return count > 0, nil +} + +// SilenceRulesEnabled 返回沉默期总开关是否打开(孤儿扫描等外部查询用于排除过期套餐)。 +func (s *ActivationService) SilenceRulesEnabled(ctx context.Context) bool { + return s != nil && s.silenceFilter != nil && s.silenceFilter.Enabled(ctx) +} diff --git a/internal/service/package/addon_main_package.go b/internal/service/package/addon_main_package.go index 1e13f9d9..fdc2e160 100644 --- a/internal/service/package/addon_main_package.go +++ b/internal/service/package/addon_main_package.go @@ -23,6 +23,8 @@ func FindAttachableMainPackageForAddon(tx *gorm.DB, carrierType string, carrierI }, now, ). + // 沉默期已过期的待生效主套餐不能再挂加油包(D21:其下加油包随主套餐不可用) + Where("NOT (status = ? AND silence_status IS NOT NULL AND silence_status = ?)", constants.PackageUsageStatusPending, constants.SilenceStatusExpired). Order("priority ASC") switch carrierType { diff --git a/internal/service/package/audit.go b/internal/service/package/audit.go index 022c96ec..8a5ee45d 100644 --- a/internal/service/package/audit.go +++ b/internal/service/package/audit.go @@ -107,5 +107,26 @@ func packageData(pkg *model.Package) map[string]any { "suggested_retail_price": pkg.SuggestedRetailPrice, "price_config_status": pkg.PriceConfigStatus, "is_gift": pkg.IsGift, "status": pkg.Status, "shelf_status": pkg.ShelfStatus, "calendar_type": pkg.CalendarType, "data_reset_cycle": pkg.DataResetCycle, "expiry_base": pkg.ExpiryBase, + "silence_period_days": pkg.SilencePeriodDays, "silence_extension_enabled": pkg.SilenceExtensionEnabled, } } + +// appendSilenceDefaultAudit 在默认沉默期或是否支持延期变化时写审计 package.silence_default_changed。 +// 修改默认值只影响之后的新购买,已有套餐记录的截止日不变。 +func (s *Service) appendSilenceDefaultAudit(ctx context.Context, tx *gorm.DB, before, after *model.Package) error { + if sameIntPointer(before.SilencePeriodDays, after.SilencePeriodDays) && before.SilenceExtensionEnabled == after.SilenceExtensionEnabled { + return nil + } + if s.auditWriter == nil { + return nil + } + beforeData := map[string]any{"silence_period_days": before.SilencePeriodDays, "silence_extension_enabled": before.SilenceExtensionEnabled} + afterData := map[string]any{"silence_period_days": after.SilencePeriodDays, "silence_extension_enabled": after.SilenceExtensionEnabled} + resource := audit.PackageResource(after, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageTarget, beforeData, afterData) + return s.auditWriter.AppendSilenceResource(ctx, tx, constants.AuditActionPackageSilenceDefaultChanged, + "修改套餐默认沉默期 "+after.PackageCode, resource, nil) +} + +func sameIntPointer(left, right *int) bool { + return left == nil && right == nil || left != nil && right != nil && *left == *right +} diff --git a/internal/service/package/service.go b/internal/service/package/service.go index 39d169d7..e9e91e53 100644 --- a/internal/service/package/service.go +++ b/internal/service/package/service.go @@ -149,11 +149,22 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (_ } pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB) pkg.Creator = currentUserID + pkg.SilencePeriodDays = req.SilencePeriodDays + pkg.SilenceExtensionEnabled = req.SilenceExtensionEnabled == nil || *req.SilenceExtensionEnabled + if err := ValidatePackageSilenceConfig(pkg); err != nil { + return nil, err + } if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := postgres.NewPackageStore(tx).Create(ctx, pkg); err != nil { return errors.Wrap(errors.CodeInternalError, err, "创建套餐失败") } + // GORM 对 false 布尔值使用数据库默认值 true,显式关闭时补写一次。 + if !pkg.SilenceExtensionEnabled { + if err := tx.Model(&model.Package{}).Where("id = ?", pkg.ID).Update("silence_extension_enabled", false).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "保存是否支持延期失败") + } + } return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageCreated, "创建套餐商品 "+pkg.PackageCode, pkg, nil, packageData(pkg)) }); err != nil { return nil, err @@ -283,6 +294,21 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq if req.ExpiryBase != nil { pkg.ExpiryBase = *req.ExpiryBase } + if err := ValidatePackageExpiryBaseChange(&before, before.ExpiryBase, pkg.ExpiryBase, req.SilencePeriodDays != nil); err != nil { + return nil, err + } + if req.SilencePeriodDays != nil { + pkg.SilencePeriodDays = req.SilencePeriodDays + } else if !RequiresSilencePeriod(pkg) { + // 套餐不再需要沉默期(如改为加油包)时清空默认值,避免普通套餐残留沉默期配置。 + pkg.SilencePeriodDays = nil + } + if req.SilenceExtensionEnabled != nil { + pkg.SilenceExtensionEnabled = *req.SilenceExtensionEnabled + } + if err := ValidatePackageSilenceConfig(pkg); err != nil { + return nil, err + } var rawSuggestedRetailPrice *int64 switch { @@ -329,7 +355,10 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq if err := postgres.NewPackageStore(tx).Update(ctx, pkg); err != nil { return errors.Wrap(errors.CodeInternalError, err, "更新套餐失败") } - return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageUpdated, "更新套餐商品 "+pkg.PackageCode, pkg, packageData(&before), packageData(pkg)) + if err := s.appendPackageAudit(ctx, tx, constants.AuditActionPackageUpdated, "更新套餐商品 "+pkg.PackageCode, pkg, packageData(&before), packageData(pkg)); err != nil { + return err + } + return s.appendSilenceDefaultAudit(ctx, tx, &before, pkg) }); err != nil { return nil, err } @@ -390,6 +419,9 @@ func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto if req.PackageType != nil { filters["package_type"] = *req.PackageType } + if req.SilenceUnconfigured != nil && *req.SilenceUnconfigured { + filters["silence_unconfigured"] = true + } packages, total, err := s.packageStore.List(ctx, opts, filters) if err != nil { @@ -818,6 +850,9 @@ func initPackageExpiryBaseFields(resp *dto.PackageResponse, pkg *model.Package) resp.DefaultExpiryBaseName = ExpiryBaseName(pkg.ExpiryBase) resp.EffectiveExpiryBase = pkg.ExpiryBase resp.EffectiveExpiryBaseName = ExpiryBaseName(pkg.ExpiryBase) + resp.SilencePeriodDays = pkg.SilencePeriodDays + resp.SilenceExtensionEnabled = pkg.SilenceExtensionEnabled + resp.SilenceConfigRequired = RequiresSilencePeriod(pkg) } func applyAllocationExpiryBase(resp *dto.PackageResponse, pkg *model.Package, allocation *model.ShopPackageAllocation) { diff --git a/internal/service/package/terms.go b/internal/service/package/terms.go index d87fb161..2d0e89df 100644 --- a/internal/service/package/terms.go +++ b/internal/service/package/terms.go @@ -47,3 +47,52 @@ func ExpiryBaseOverrideName(value *string) string { } return ExpiryBaseName(*value) } + +// ValidateAllocationExpiryBaseOverride 校验分配覆盖不得改变套餐生效条件(add-package-silence-period §3.3)。 +// 套餐服务与店铺分配(单个、批量、系列授权)写入路径共用: +// 覆盖把实名即生效套餐改成购买即生效会绕过沉默期;把购买即生效套餐改成实名即生效时该套餐没有默认沉默期,两者都阻断。 +// 覆盖为空(跟随套餐默认)或与套餐一致时放行;加油包不进入沉默期,不受此约束。 +func ValidateAllocationExpiryBaseOverride(pkg *model.Package, override *string) error { + if pkg == nil || override == nil || pkg.PackageType == constants.PackageTypeAddon { + return nil + } + if *override != pkg.ExpiryBase { + return errors.New(errors.CodePackageExpiryBaseChangeForbidden, + "套餐「"+pkg.PackageName+"」不允许通过分配覆盖改变生效条件(实名即生效与购买即生效不能互相覆盖)") + } + return nil +} + +// ValidatePackageExpiryBaseChange 校验套餐自身生效条件变更(§3.3):实名即生效改为购买即生效阻断; +// 购买即生效改为实名即生效时,同一请求必须提供默认沉默期。 +func ValidatePackageExpiryBaseChange(pkg *model.Package, before, after string, silenceDaysSubmitted bool) error { + if pkg == nil || pkg.PackageType == constants.PackageTypeAddon || before == after { + return nil + } + if before == constants.PackageExpiryBaseFromActivation && after == constants.PackageExpiryBaseFromPurchase { + return errors.New(errors.CodePackageExpiryBaseChangeForbidden, "实名即生效套餐不允许改为购买即生效") + } + if before == constants.PackageExpiryBaseFromPurchase && after == constants.PackageExpiryBaseFromActivation && !silenceDaysSubmitted { + return errors.New(errors.CodeSilencePeriodOutOfRange, "改为实名即生效时必须同时设置默认沉默期(1~90 天)") + } + return nil +} + +// RequiresSilencePeriod 判断套餐是否必须配置默认沉默期:实名即生效的正式套餐。 +func RequiresSilencePeriod(pkg *model.Package) bool { + return pkg != nil && pkg.PackageType != constants.PackageTypeAddon && pkg.ExpiryBase == constants.PackageExpiryBaseFromActivation +} + +// ValidatePackageSilenceConfig 校验套餐最终的沉默期配置:实名即生效正式套餐必填 1~90 天;其他套餐不得填写。 +func ValidatePackageSilenceConfig(pkg *model.Package) error { + if RequiresSilencePeriod(pkg) { + if pkg.SilencePeriodDays == nil || *pkg.SilencePeriodDays < constants.SilencePackageDaysMin || *pkg.SilencePeriodDays > constants.SilencePackageDaysMax { + return errors.New(errors.CodeSilencePeriodOutOfRange, "实名即生效套餐必须配置默认沉默期(1~90 天)") + } + return nil + } + if pkg != nil && pkg.SilencePeriodDays != nil { + return errors.New(errors.CodeInvalidParam, "只有实名即生效的正式套餐可以配置沉默期") + } + return nil +} diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 34ab53e9..6998a2ce 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -65,6 +65,7 @@ type Service struct { paymentMerchantRuntime *merchantpayment.RuntimeLoader refundOffset *employeecollectionapp.RefundOffsetService channelRefund *refundchannelapp.Service + silenceSwitch SilenceSwitch } // SetChannelRefundService 注入渠道原路退款用例。 @@ -159,6 +160,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt } return nil, errors.New(errors.CodeInvalidStatus, "仅已支付订单可申请退款") } + if err := s.ensureSilenceRefundable(ctx, order.ID, req.PackageUsageID); err != nil { + return nil, err + } decision, err := s.decideRefundMethods(ctx, order) if err != nil { diff --git a/internal/service/refund/silence.go b/internal/service/refund/silence.go new file mode 100644 index 00000000..c4018014 --- /dev/null +++ b/internal/service/refund/silence.go @@ -0,0 +1,42 @@ +package refund + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// SilenceSwitch 读取沉默期总开关。 +type SilenceSwitch interface { + Enabled(ctx context.Context) bool +} + +// SetSilenceSwitch 注入沉默期总开关;打开时沉默期已过期的套餐不能申请退款。 +func (s *Service) SetSilenceSwitch(sw SilenceSwitch) { + s.silenceSwitch = sw +} + +// ensureSilenceRefundable 拦截沉默期已过期套餐的退款申请(add-package-silence-period §5.5)。 +// 退款套餐定位口径与退款套餐失效一致:优先冻结的套餐记录,否则取订单下主套餐记录。 +func (s *Service) ensureSilenceRefundable(ctx context.Context, orderID uint, packageUsageID *uint) error { + if s.silenceSwitch == nil || !s.silenceSwitch.Enabled(ctx) { + return nil + } + query := s.db.WithContext(ctx).Model(&model.PackageUsage{}). + Where("status = ? AND silence_status = ?", constants.PackageUsageStatusPending, constants.SilenceStatusExpired) + if packageUsageID != nil && *packageUsageID > 0 { + query = query.Where("id = ?", *packageUsageID) + } else { + query = query.Where("order_id = ? AND master_usage_id IS NULL", orderID) + } + var count int64 + if err := query.Count(&count).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐沉默期状态失败") + } + if count > 0 { + return errors.New(errors.CodeSilenceExpiredNotRefundable) + } + return nil +} diff --git a/internal/service/shop_package_batch_allocation/service.go b/internal/service/shop_package_batch_allocation/service.go index 0385df17..1e88cbcb 100644 --- a/internal/service/shop_package_batch_allocation/service.go +++ b/internal/service/shop_package_batch_allocation/service.go @@ -57,6 +57,9 @@ func (s *Service) UpdateExpiryBase(ctx context.Context, id uint, req *dto.Update if err != nil { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } + if err := packagepkg.ValidateAllocationExpiryBaseOverride(pkg, override); err != nil { + return nil, err + } before := allocation.ExpiryBaseOverride beforeAllocation := *allocation defer func() { @@ -154,6 +157,9 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka createdAllocations := make([]*model.ShopPackageAllocation, 0, len(packages)) packageMap := make(map[uint]*model.Package, len(packages)) for _, pkg := range packages { + if err := packagepkg.ValidateAllocationExpiryBaseOverride(pkg, expiryBaseOverride); err != nil { + return nil, err + } packageMap[pkg.ID] = pkg } batchTotal = len(packages) diff --git a/internal/service/shop_series_grant/service.go b/internal/service/shop_series_grant/service.go index 83d232d8..e59722db 100644 --- a/internal/service/shop_series_grant/service.go +++ b/internal/service/shop_series_grant/service.go @@ -105,6 +105,9 @@ func (s *Service) validateInitialGrantPackages(ctx context.Context, tx *gorm.DB, packageMap := make(map[uint]*model.Package, len(packages)) for _, pkg := range packages { + if err := packagepkg.ValidateAllocationExpiryBaseOverride(pkg, req.ExpiryBaseOverride); err != nil { + return nil, err + } packageMap[pkg.ID] = pkg } @@ -947,6 +950,9 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr if pkg.IsGift { return errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理") } + if overrideErr := packagepkg.ValidateAllocationExpiryBaseOverride(pkg, expiryBaseOverride); overrideErr != nil { + return overrideErr + } if allocation.AllocatorShopID > 0 { _, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocation.AllocatorShopID, item.PackageID) if authErr != nil { diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go index 10ac19ee..d27ce289 100644 --- a/internal/store/postgres/device_store.go +++ b/internal/store/postgres/device_store.go @@ -231,6 +231,12 @@ func (s *DeviceStore) applyDeviceFilters(ctx context.Context, query *gorm.DB, fi Select("device_id"). Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID)) } + if start, ok := filters["outbound_start"].(time.Time); ok { + query = query.Where("outbound_at >= ?", start) + } + if end, ok := filters["outbound_end"].(time.Time); ok { + query = query.Where("outbound_at <= ?", end) + } if isAuthorized, ok := filters["is_authorized_to_enterprise"].(bool); ok { subQ := s.db.WithContext(ctx).Table("tb_enterprise_device_authorization"). Select("device_id"). diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index f412ec7f..52f7312f 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -915,6 +915,12 @@ func (s *IotCardStore) applyStandaloneFilters(ctx context.Context, query *gorm.D Select("card_id"). Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID)) } + if start, ok := filters["outbound_start"].(time.Time); ok { + query = query.Where("outbound_at >= ?", start) + } + if end, ok := filters["outbound_end"].(time.Time); ok { + query = query.Where("outbound_at <= ?", end) + } if isAuthorized, ok := filters["is_authorized_to_enterprise"].(bool); ok { subQ := s.db.WithContext(ctx).Table("tb_enterprise_card_authorization"). Select("card_id"). diff --git a/internal/store/postgres/package_silence_store.go b/internal/store/postgres/package_silence_store.go new file mode 100644 index 00000000..ecb8a7d4 --- /dev/null +++ b/internal/store/postgres/package_silence_store.go @@ -0,0 +1,320 @@ +package postgres + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// 沉默期相关 Store(add-package-silence-period)。 +// 所有 Store 只封装持久化;状态迁移一律用"带期望状态的条件更新",返回影响行数由调用方判断是否幂等命中。 +// 需要事务时用 NewXxxStore(tx) 绑定事务连接。 + +// PackageSilencePolicyStore 延期政策 Store。 +type PackageSilencePolicyStore struct{ db *gorm.DB } + +// NewPackageSilencePolicyStore 创建延期政策 Store。 +func NewPackageSilencePolicyStore(db *gorm.DB) *PackageSilencePolicyStore { + return &PackageSilencePolicyStore{db: db} +} + +// Create 新增政策。 +func (s *PackageSilencePolicyStore) Create(ctx context.Context, policy *model.PackageSilenceExtensionPolicy) error { + return s.db.WithContext(ctx).Create(policy).Error +} + +// GetByID 按 ID 查询政策。 +func (s *PackageSilencePolicyStore) GetByID(ctx context.Context, id uint) (*model.PackageSilenceExtensionPolicy, error) { + var policy model.PackageSilenceExtensionPolicy + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&policy).Error; err != nil { + return nil, err + } + return &policy, nil +} + +// ListEnabled 返回全部启用政策(政策数量很少,匹配与重叠校验在领域层完成)。 +func (s *PackageSilencePolicyStore) ListEnabled(ctx context.Context) ([]model.PackageSilenceExtensionPolicy, error) { + var policies []model.PackageSilenceExtensionPolicy + err := s.db.WithContext(ctx).Where("status = ?", constants.SilencePolicyStatusEnabled).Order("id ASC").Find(&policies).Error + return policies, err +} + +// Update 按 ID 更新政策字段。 +func (s *PackageSilencePolicyStore) Update(ctx context.Context, id uint, values map[string]any) (int64, error) { + result := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionPolicy{}).Where("id = ?", id).Updates(values) + return result.RowsAffected, result.Error +} + +// LockAll 以事务级咨询锁串行化政策写入,避免并发保存绕过重叠校验。 +func (s *PackageSilencePolicyStore) LockAll(ctx context.Context) error { + return s.db.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(hashtext('package_silence_policy'))").Error +} + +// PackageSilenceRecordStore 沉默期流水 Store(只新增)。 +type PackageSilenceRecordStore struct{ db *gorm.DB } + +// NewPackageSilenceRecordStore 创建沉默期流水 Store。 +func NewPackageSilenceRecordStore(db *gorm.DB) *PackageSilenceRecordStore { + return &PackageSilenceRecordStore{db: db} +} + +// Create 写入一条流水。 +func (s *PackageSilenceRecordStore) Create(ctx context.Context, record *model.PackageSilenceRecord) error { + return s.db.WithContext(ctx).Create(record).Error +} + +// ListByUsage 按套餐记录查询流水(时间正序)。 +func (s *PackageSilenceRecordStore) ListByUsage(ctx context.Context, usageID uint) ([]model.PackageSilenceRecord, error) { + var records []model.PackageSilenceRecord + err := s.db.WithContext(ctx).Where("package_usage_id = ?", usageID).Order("id ASC").Find(&records).Error + return records, err +} + +// PackageSilenceBatchStore 延期批次 Store。 +type PackageSilenceBatchStore struct{ db *gorm.DB } + +// NewPackageSilenceBatchStore 创建延期批次 Store。 +func NewPackageSilenceBatchStore(db *gorm.DB) *PackageSilenceBatchStore { + return &PackageSilenceBatchStore{db: db} +} + +// Create 新增批次。 +func (s *PackageSilenceBatchStore) Create(ctx context.Context, batch *model.PackageSilenceExtensionBatch) error { + return s.db.WithContext(ctx).Create(batch).Error +} + +// GetByID 按 ID 查询批次。 +func (s *PackageSilenceBatchStore) GetByID(ctx context.Context, id uint) (*model.PackageSilenceExtensionBatch, error) { + var batch model.PackageSilenceExtensionBatch + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&batch).Error; err != nil { + return nil, err + } + return &batch, nil +} + +// LockByID 行锁查询批次。 +func (s *PackageSilenceBatchStore) LockByID(ctx context.Context, id uint) (*model.PackageSilenceExtensionBatch, error) { + var batch model.PackageSilenceExtensionBatch + err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&batch).Error + if err != nil { + return nil, err + } + return &batch, nil +} + +// TransitionStatus 条件更新批次状态,返回影响行数。 +func (s *PackageSilenceBatchStore) TransitionStatus(ctx context.Context, id uint, from []string, to string, values map[string]any) (int64, error) { + updates := copyValues(values) + updates["status"] = to + updates["updated_at"] = time.Now() + result := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}). + Where("id = ? AND status IN ?", id, from).Updates(updates) + return result.RowsAffected, result.Error +} + +// Update 无条件更新批次字段(只用于已持锁的计数与快照写入)。 +func (s *PackageSilenceBatchStore) Update(ctx context.Context, id uint, values map[string]any) error { + updates := copyValues(values) + updates["updated_at"] = time.Now() + return s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).Where("id = ?", id).Updates(updates).Error +} + +// PackageSilenceExtensionStore 延期明细 Store。 +type PackageSilenceExtensionStore struct{ db *gorm.DB } + +// NewPackageSilenceExtensionStore 创建延期明细 Store。 +func NewPackageSilenceExtensionStore(db *gorm.DB) *PackageSilenceExtensionStore { + return &PackageSilenceExtensionStore{db: db} +} + +// Create 新增明细。 +func (s *PackageSilenceExtensionStore) Create(ctx context.Context, extension *model.PackageSilenceExtension) error { + return s.db.WithContext(ctx).Create(extension).Error +} + +// GetByID 按 ID 查询明细。 +func (s *PackageSilenceExtensionStore) GetByID(ctx context.Context, id uint) (*model.PackageSilenceExtension, error) { + var extension model.PackageSilenceExtension + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&extension).Error; err != nil { + return nil, err + } + return &extension, nil +} + +// LockByID 行锁查询明细。 +func (s *PackageSilenceExtensionStore) LockByID(ctx context.Context, id uint) (*model.PackageSilenceExtension, error) { + var extension model.PackageSilenceExtension + err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&extension).Error + if err != nil { + return nil, err + } + return &extension, nil +} + +// ListByBatch 按批次查询明细。 +func (s *PackageSilenceExtensionStore) ListByBatch(ctx context.Context, batchID uint) ([]model.PackageSilenceExtension, error) { + var extensions []model.PackageSilenceExtension + err := s.db.WithContext(ctx).Where("batch_id = ?", batchID).Order("row_no ASC, id ASC").Find(&extensions).Error + return extensions, err +} + +// HasOpenByUsage 判断套餐是否已有未结束明细。 +func (s *PackageSilenceExtensionStore) HasOpenByUsage(ctx context.Context, usageID uint) (bool, error) { + var count int64 + err := s.db.WithContext(ctx).Model(&model.PackageSilenceExtension{}). + Where("package_usage_id = ? AND status IN ?", usageID, OpenSilenceExtensionStatuses()).Count(&count).Error + return count > 0, err +} + +// TransitionStatus 条件更新明细状态,返回影响行数。 +func (s *PackageSilenceExtensionStore) TransitionStatus(ctx context.Context, id uint, from []string, to string, values map[string]any) (int64, error) { + updates := copyValues(values) + updates["status"] = to + updates["updated_at"] = time.Now() + result := s.db.WithContext(ctx).Model(&model.PackageSilenceExtension{}). + Where("id = ? AND status IN ?", id, from).Updates(updates) + return result.RowsAffected, result.Error +} + +// OpenSilenceExtensionStatuses 返回未结束的明细状态(与部分唯一索引一致)。 +func OpenSilenceExtensionStatuses() []string { + return []string{ + constants.SilenceExtensionStatusPendingPayment, constants.SilenceExtensionStatusPendingApproval, + constants.SilenceExtensionStatusApproved, constants.SilenceExtensionStatusExecuting, + constants.SilenceExtensionStatusException, + } +} + +// PackageSilenceOrderStore 延期订单 Store。 +type PackageSilenceOrderStore struct{ db *gorm.DB } + +// NewPackageSilenceOrderStore 创建延期订单 Store。 +func NewPackageSilenceOrderStore(db *gorm.DB) *PackageSilenceOrderStore { + return &PackageSilenceOrderStore{db: db} +} + +// Create 新增订单。 +func (s *PackageSilenceOrderStore) Create(ctx context.Context, order *model.PackageSilenceExtensionOrder) error { + return s.db.WithContext(ctx).Create(order).Error +} + +// GetByID 按 ID 查询订单。 +func (s *PackageSilenceOrderStore) GetByID(ctx context.Context, id uint) (*model.PackageSilenceExtensionOrder, error) { + var order model.PackageSilenceExtensionOrder + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&order).Error; err != nil { + return nil, err + } + return &order, nil +} + +// GetByOrderNo 按订单号查询订单。 +func (s *PackageSilenceOrderStore) GetByOrderNo(ctx context.Context, orderNo string) (*model.PackageSilenceExtensionOrder, error) { + var order model.PackageSilenceExtensionOrder + if err := s.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil { + return nil, err + } + return &order, nil +} + +// LockByID 行锁查询订单。 +func (s *PackageSilenceOrderStore) LockByID(ctx context.Context, id uint) (*model.PackageSilenceExtensionOrder, error) { + var order model.PackageSilenceExtensionOrder + err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&order).Error + if err != nil { + return nil, err + } + return &order, nil +} + +// FindOpenByUsage 查询套餐当前未结束(待支付 / 待审批)的订单,没有时返回 nil。 +func (s *PackageSilenceOrderStore) FindOpenByUsage(ctx context.Context, usageID uint) (*model.PackageSilenceExtensionOrder, error) { + var order model.PackageSilenceExtensionOrder + err := s.db.WithContext(ctx).Where("package_usage_id = ? AND status IN ?", usageID, []string{ + constants.SilenceOrderStatusPendingPayment, constants.SilenceOrderStatusPendingApproval, + }).Order("id DESC").First(&order).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &order, nil +} + +// FindByRequestID 按下单幂等键查询订单,没有时返回 nil。 +func (s *PackageSilenceOrderStore) FindByRequestID(ctx context.Context, requestID string) (*model.PackageSilenceExtensionOrder, error) { + var order model.PackageSilenceExtensionOrder + err := s.db.WithContext(ctx).Where("request_id = ?", requestID).First(&order).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &order, nil +} + +// ListPendingPayment 查询待支付订单(支付恢复任务用),按 ID 正序。 +func (s *PackageSilenceOrderStore) ListPendingPayment(ctx context.Context, createdBefore time.Time, limit int) ([]model.PackageSilenceExtensionOrder, error) { + var orders []model.PackageSilenceExtensionOrder + err := s.db.WithContext(ctx). + Where("status = ? AND created_at < ?", constants.SilenceOrderStatusPendingPayment, createdBefore). + Order("id ASC").Limit(limit).Find(&orders).Error + return orders, err +} + +// TransitionStatus 条件更新订单状态,返回影响行数。 +func (s *PackageSilenceOrderStore) TransitionStatus(ctx context.Context, id uint, from []string, to string, values map[string]any) (int64, error) { + updates := copyValues(values) + updates["status"] = to + updates["updated_at"] = time.Now() + result := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionOrder{}). + Where("id = ? AND status IN ?", id, from).Updates(updates) + return result.RowsAffected, result.Error +} + +// AssetOutboundRecordStore 资产出库记录 Store(只新增与填写结束信息)。 +type AssetOutboundRecordStore struct{ db *gorm.DB } + +// NewAssetOutboundRecordStore 创建出库记录 Store。 +func NewAssetOutboundRecordStore(db *gorm.DB) *AssetOutboundRecordStore { + return &AssetOutboundRecordStore{db: db} +} + +// CreateIfAbsent 插入一条出库记录;同一资产同一世代已存在时不插入,返回是否插入。 +func (s *AssetOutboundRecordStore) CreateIfAbsent(ctx context.Context, record *model.AssetOutboundRecord) (bool, error) { + result := s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "asset_type"}, {Name: "asset_id"}, {Name: "generation"}}, + DoNothing: true, + }).Create(record) + return result.RowsAffected == 1, result.Error +} + +// EndGeneration 为某资产某世代的出库记录填写结束时间与原因,返回影响行数。 +func (s *AssetOutboundRecordStore) EndGeneration(ctx context.Context, assetType string, assetID uint, generation int, endedAt time.Time, reason string) (int64, error) { + result := s.db.WithContext(ctx).Model(&model.AssetOutboundRecord{}). + Where("asset_type = ? AND asset_id = ? AND generation = ? AND ended_at IS NULL", assetType, assetID, generation). + Updates(map[string]any{"ended_at": endedAt, "end_reason": reason}) + return result.RowsAffected, result.Error +} + +// ListByAsset 按资产查询各世代出库记录(世代倒序)。 +func (s *AssetOutboundRecordStore) ListByAsset(ctx context.Context, assetType string, assetID uint) ([]model.AssetOutboundRecord, error) { + var records []model.AssetOutboundRecord + err := s.db.WithContext(ctx).Where("asset_type = ? AND asset_id = ?", assetType, assetID). + Order("generation DESC").Find(&records).Error + return records, err +} + +func copyValues(values map[string]any) map[string]any { + updates := make(map[string]any, len(values)+2) + for key, value := range values { + updates[key] = value + } + return updates +} diff --git a/internal/store/postgres/package_store.go b/internal/store/postgres/package_store.go index dd30ce56..3303593a 100644 --- a/internal/store/postgres/package_store.go +++ b/internal/store/postgres/package_store.go @@ -86,6 +86,9 @@ func (s *PackageStore) List(ctx context.Context, opts *store.QueryOptions, filte if packageType, ok := filters["package_type"].(string); ok && packageType != "" { query = query.Where("tb_package.package_type = ?", packageType) } + if unconfigured, ok := filters["silence_unconfigured"].(bool); ok && unconfigured { + query = query.Where(SilenceUnconfiguredPackageCondition("tb_package")) + } if err := query.Count(&total).Error; err != nil { return nil, 0, err @@ -149,3 +152,9 @@ func (s *PackageStore) CountBySeriesID(ctx context.Context, seriesID uint) (int6 } return count, nil } + +// SilenceUnconfiguredPackageCondition 返回"未配置默认沉默期的实名即生效正式套餐"的条件(总开关打开前检查与补齐清单共用)。 +func SilenceUnconfiguredPackageCondition(alias string) string { + return alias + ".package_type <> '" + constants.PackageTypeAddon + "' AND " + alias + ".expiry_base = '" + + constants.PackageExpiryBaseFromActivation + "' AND " + alias + ".silence_period_days IS NULL" +} diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index a4412335..8a2123a2 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -14,6 +14,7 @@ import ( "gorm.io/gorm/clause" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" @@ -54,6 +55,18 @@ type AutoPurchaseHandler struct { observationSeriesEvents cardObservationApp.SeriesEventWriter priorityEvents priorityapp.PriorityEventWriter auditWriter *audit.Writer + silenceSetter *packagesilenceapp.Setter + outboundRecorder *packagesilenceapp.OutboundRecorder +} + +// SetSilenceSetter 注入沉默期设置用例;自动购包是无人参与路径,只用套餐默认沉默期。 +func (h *AutoPurchaseHandler) SetSilenceSetter(setter *packagesilenceapp.Setter) { + h.silenceSetter = setter +} + +// SetOutboundRecorder 注入资产出库时间写入用例。 +func (h *AutoPurchaseHandler) SetOutboundRecorder(recorder *packagesilenceapp.OutboundRecorder) { + h.outboundRecorder = recorder } // NewAutoPurchaseHandler 创建充值后自动购包处理器 @@ -589,6 +602,7 @@ func (h *AutoPurchaseHandler) activatePackages( return err } + createdUsage := false for _, pkg := range packages { var existingUsage model.PackageUsage err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&existingUsage).Error @@ -598,6 +612,7 @@ func (h *AutoPurchaseHandler) activatePackages( if err != gorm.ErrRecordNotFound { return err } + createdUsage = true if pkg.PackageType == constants.PackageTypeFormal { if err = h.activateMainPackage(ctx, tx, order, pkg, carrierType, carrierID, now); err != nil { @@ -611,7 +626,10 @@ func (h *AutoPurchaseHandler) activatePackages( } } } - + if createdUsage && h.outboundRecorder != nil { + // 出库时间:资产当前世代第 1 次购买套餐的支付时间 + return h.outboundRecorder.RecordForOrderInTx(ctx, tx, order) + } return nil } @@ -722,6 +740,16 @@ func (h *AutoPurchaseHandler) activateMainPackage( }).Error; err != nil { return err } + // 沉默期:无人参与路径只用套餐默认值(上下文不携带调整值) + if h.silenceSetter != nil { + paidAt := now + if order.PaidAt != nil { + paidAt = *order.PaidAt + } + if err = h.silenceSetter.ApplyOnUsageCreatedInTx(ctx, tx, usage, pkg, paidAt, constants.SilenceTriggerAutoPurchase); err != nil { + return err + } + } // 仅在生效状态分支追加优先轮询请求:待生效(含等待实名激活)不属于触发场景。 if status == constants.PackageUsageStatusActive { diff --git a/internal/task/package_silence_scan.go b/internal/task/package_silence_scan.go new file mode 100644 index 00000000..106ef04e --- /dev/null +++ b/internal/task/package_silence_scan.go @@ -0,0 +1,75 @@ +package task + +import ( + "context" + + "github.com/hibiken/asynq" + "go.uber.org/zap" + + packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// PackageSilenceScanHandler 处理沉默期扫描任务 package:silence:scan(每 10 分钟)。 +// 总开关关闭时直接返回;判定使用条件更新,重复执行幂等。 +type PackageSilenceScanHandler struct { + resolver *packagesilenceapp.Resolver + logger *zap.Logger +} + +// NewPackageSilenceScanHandler 创建沉默期扫描任务处理器。 +func NewPackageSilenceScanHandler(resolver *packagesilenceapp.Resolver, logger *zap.Logger) *PackageSilenceScanHandler { + return &PackageSilenceScanHandler{resolver: resolver, logger: logger} +} + +// Handle 执行一轮沉默期扫描。 +func (h *PackageSilenceScanHandler) Handle(ctx context.Context, _ *asynq.Task) error { + if h.resolver == nil { + return nil + } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorScheduledJob, ActorID: constants.TaskTypePackageSilenceScan, + ActorName: "沉默期扫描任务", Source: constants.AuditSourceScheduler, + }) + result, err := h.resolver.Scan(ctx) + if err != nil { + h.logger.Error("沉默期扫描失败", zap.Error(err)) + return err + } + if result.Scanned > 0 { + h.logger.Info("沉默期扫描完成", zap.Int("scanned", result.Scanned), zap.Int("expired", result.Expired), zap.Int("ended", result.Ended)) + } + return nil +} + +// PackageSilenceOrderRecoverHandler 处理延期订单查单恢复任务(每 5 分钟):查单、超时关闭、异常订单原路退款。 +type PackageSilenceOrderRecoverHandler struct { + extensions *packagesilenceapp.ExtensionService + logger *zap.Logger +} + +// NewPackageSilenceOrderRecoverHandler 创建延期订单恢复任务处理器。 +func NewPackageSilenceOrderRecoverHandler(extensions *packagesilenceapp.ExtensionService, logger *zap.Logger) *PackageSilenceOrderRecoverHandler { + return &PackageSilenceOrderRecoverHandler{extensions: extensions, logger: logger} +} + +// Handle 执行一轮延期订单恢复。 +func (h *PackageSilenceOrderRecoverHandler) Handle(ctx context.Context, _ *asynq.Task) error { + if h.extensions == nil { + return nil + } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorScheduledJob, ActorID: constants.TaskTypePackageSilenceOrderRecover, + ActorName: "沉默期延期订单恢复任务", Source: constants.AuditSourceScheduler, + }) + result, err := h.extensions.Recover(ctx) + if err != nil { + h.logger.Error("沉默期延期订单恢复失败", zap.Error(err)) + return err + } + if result.Confirmed+result.Closed+result.Refunded > 0 { + h.logger.Info("沉默期延期订单恢复完成", zap.Int("confirmed", result.Confirmed), zap.Int("closed", result.Closed), zap.Int("refunded", result.Refunded)) + } + return nil +} diff --git a/migrations/000259_add_package_usage_silence_fields.down.sql b/migrations/000259_add_package_usage_silence_fields.down.sql new file mode 100644 index 00000000..2918876c --- /dev/null +++ b/migrations/000259_add_package_usage_silence_fields.down.sql @@ -0,0 +1,13 @@ +-- 回滚套餐记录沉默期字段(会丢失沉默期业务事实,仅限测试环境演练)。 +ALTER TABLE tb_package_usage DROP CONSTRAINT IF EXISTS ck_package_usage_silence_status; +ALTER TABLE tb_package_usage + DROP COLUMN IF EXISTS silence_adjust_pending, + DROP COLUMN IF EXISTS silence_expired_at, + DROP COLUMN IF EXISTS silence_free_extension_used, + DROP COLUMN IF EXISTS silence_extension_count, + DROP COLUMN IF EXISTS silence_paid_extended_days, + DROP COLUMN IF EXISTS silence_extended_days, + DROP COLUMN IF EXISTS silence_deadline_at, + DROP COLUMN IF EXISTS silence_period_days, + DROP COLUMN IF EXISTS silence_start_at, + DROP COLUMN IF EXISTS silence_status; diff --git a/migrations/000259_add_package_usage_silence_fields.up.sql b/migrations/000259_add_package_usage_silence_fields.up.sql new file mode 100644 index 00000000..8cbe92b4 --- /dev/null +++ b/migrations/000259_add_package_usage_silence_fields.up.sql @@ -0,0 +1,31 @@ +-- 实名即生效套餐沉默期:套餐记录字段(add-package-silence-period,技术方案 §3.1)。 +-- 沉默期按每笔套餐计算,字段只建在套餐记录上,资产上不建沉默期字段。 +-- 过期时 status 保持 0,由激活闸门按 silence_status 拦截。 +-- 状态与时间类可空;计数类 NOT NULL DEFAULT 0;布尔 NOT NULL DEFAULT false(PostgreSQL 11+ 常量默认值不重写整表)。 + +ALTER TABLE tb_package_usage + ADD COLUMN silence_status VARCHAR(16), + ADD COLUMN silence_start_at TIMESTAMPTZ, + ADD COLUMN silence_period_days INTEGER, + ADD COLUMN silence_deadline_at TIMESTAMPTZ, + ADD COLUMN silence_extended_days INTEGER NOT NULL DEFAULT 0, + ADD COLUMN silence_paid_extended_days INTEGER NOT NULL DEFAULT 0, + ADD COLUMN silence_extension_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN silence_free_extension_used INTEGER NOT NULL DEFAULT 0, + ADD COLUMN silence_expired_at TIMESTAMPTZ, + ADD COLUMN silence_adjust_pending BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE tb_package_usage + ADD CONSTRAINT ck_package_usage_silence_status + CHECK (silence_status IS NULL OR silence_status IN ('waiting', 'expired', 'ended')); + +COMMENT ON COLUMN tb_package_usage.silence_status IS '沉默期状态:空-不适用 waiting-待实名 expired-沉默期已过期(status 保持 0) ended-截止前已实名'; +COMMENT ON COLUMN tb_package_usage.silence_start_at IS '沉默期起算时间(购买支付成功时间)'; +COMMENT ON COLUMN tb_package_usage.silence_period_days IS '最终沉默期天数(套餐默认值或购买时调整值)'; +COMMENT ON COLUMN tb_package_usage.silence_deadline_at IS '当前沉默期截止时间(截止日 23:59:59 Asia/Shanghai,延期后更新)'; +COMMENT ON COLUMN tb_package_usage.silence_extended_days IS '累计已延期天数(全部延期)'; +COMMENT ON COLUMN tb_package_usage.silence_paid_extended_days IS '其中付费延期(在线与线下)累计天数,与政策累计上限比较'; +COMMENT ON COLUMN tb_package_usage.silence_extension_count IS '累计延期次数'; +COMMENT ON COLUMN tb_package_usage.silence_free_extension_used IS '已使用的免费延期次数'; +COMMENT ON COLUMN tb_package_usage.silence_expired_at IS '最近一次沉默期过期时间'; +COMMENT ON COLUMN tb_package_usage.silence_adjust_pending IS '购买时调整沉默期的审批是否进行中(进行中不过期)'; diff --git a/migrations/000260_add_package_usage_silence_scan_index.down.sql b/migrations/000260_add_package_usage_silence_scan_index.down.sql new file mode 100644 index 00000000..be99bf1a --- /dev/null +++ b/migrations/000260_add_package_usage_silence_scan_index.down.sql @@ -0,0 +1,2 @@ +-- 回滚沉默期扫描索引。 +DROP INDEX IF EXISTS idx_package_usage_silence_scan; diff --git a/migrations/000260_add_package_usage_silence_scan_index.up.sql b/migrations/000260_add_package_usage_silence_scan_index.up.sql new file mode 100644 index 00000000..d92be36e --- /dev/null +++ b/migrations/000260_add_package_usage_silence_scan_index.up.sql @@ -0,0 +1,5 @@ +-- 沉默期扫描部分索引(技术方案 §3.1、§5.4):只覆盖待生效且沉默期待实名的记录,上线时为空,索引很小。 +-- 建索引需扫描一遍表,生产在低峰执行。 +CREATE INDEX IF NOT EXISTS idx_package_usage_silence_scan + ON tb_package_usage (silence_deadline_at, id) + WHERE deleted_at IS NULL AND status = 0 AND silence_status = 'waiting'; diff --git a/migrations/000261_add_package_silence_config_fields.down.sql b/migrations/000261_add_package_silence_config_fields.down.sql new file mode 100644 index 00000000..afb87856 --- /dev/null +++ b/migrations/000261_add_package_silence_config_fields.down.sql @@ -0,0 +1,5 @@ +-- 回滚套餐默认沉默期与是否支持延期。 +ALTER TABLE tb_package DROP CONSTRAINT IF EXISTS ck_package_silence_period_days; +ALTER TABLE tb_package + DROP COLUMN IF EXISTS silence_extension_enabled, + DROP COLUMN IF EXISTS silence_period_days; diff --git a/migrations/000261_add_package_silence_config_fields.up.sql b/migrations/000261_add_package_silence_config_fields.up.sql new file mode 100644 index 00000000..84620376 --- /dev/null +++ b/migrations/000261_add_package_silence_config_fields.up.sql @@ -0,0 +1,13 @@ +-- 套餐默认沉默期与是否支持延期(技术方案 §3.2)。 +-- silence_period_days:实名即生效套餐必填(1~90),普通套餐为空;存量套餐为空,由业务在后台补齐,补齐前新购买不进入沉默期。 +-- silence_extension_enabled:只控制标准付费延期;不支持时仍可申请特殊延期。 +ALTER TABLE tb_package + ADD COLUMN silence_period_days INTEGER, + ADD COLUMN silence_extension_enabled BOOLEAN NOT NULL DEFAULT TRUE; + +ALTER TABLE tb_package + ADD CONSTRAINT ck_package_silence_period_days + CHECK (silence_period_days IS NULL OR silence_period_days BETWEEN 1 AND 90); + +COMMENT ON COLUMN tb_package.silence_period_days IS '默认沉默期天数(实名即生效套餐必填 1~90,普通套餐为空)'; +COMMENT ON COLUMN tb_package.silence_extension_enabled IS '是否支持付费延期(不支持时仍可申请特殊延期)'; diff --git a/migrations/000262_create_package_silence_tables.down.sql b/migrations/000262_create_package_silence_tables.down.sql new file mode 100644 index 00000000..29d8f6ce --- /dev/null +++ b/migrations/000262_create_package_silence_tables.down.sql @@ -0,0 +1,6 @@ +-- 回滚沉默期延期相关 5 张表(会丢失延期订单、延期记录等业务事实,仅限测试环境演练)。 +DROP TABLE IF EXISTS tb_package_silence_extension_order; +DROP TABLE IF EXISTS tb_package_silence_extension; +DROP TABLE IF EXISTS tb_package_silence_extension_batch; +DROP TABLE IF EXISTS tb_package_silence_record; +DROP TABLE IF EXISTS tb_package_silence_extension_policy; diff --git a/migrations/000262_create_package_silence_tables.up.sql b/migrations/000262_create_package_silence_tables.up.sql new file mode 100644 index 00000000..1acb9caa --- /dev/null +++ b/migrations/000262_create_package_silence_tables.up.sql @@ -0,0 +1,317 @@ +-- 沉默期延期政策、沉默期流水、延期订单、延期批次、延期明细(add-package-silence-period,技术方案 §3.4、§3.6、§7)。 +-- +-- 设计选择: +-- 1. 延期政策按"指定套餐 > 套餐系列 > 全部套餐"匹配;同一优先级适用范围与生效时间段不得重叠,由应用层保存时校验。 +-- 2. 沉默期流水只新增不修改:设置、调整、过期、结束、延期、换货迁移、回填、实名时间补写。 +-- 3. 延期订单是交易记录(在线 H5、后台二维码、线下登记),支付单复用 tb_payment(order_type = silence_extension);交易记录不删除。 +-- 4. 每次申请都建一个批次:online 在线付费 / offline 线下付费 / special 特殊延期 / adjust 购买时调整沉默期。 +-- 企微审批实例统一关联批次(审批业务类型只有 package_silence_approval,business_id = 批次 ID)。 +-- 5. 延期明细是延期事实,以明细状态条件更新保证重复回调、重复审批通过只执行一次。 +-- 6. 订单、批次、明细都冻结政策快照;改政策只影响之后发起的延期。 +-- 不使用数据库外键(ENG-MODEL-001)。 + +CREATE TABLE tb_package_silence_extension_policy ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + scope_type VARCHAR(20) NOT NULL, + scope_ids JSONB NOT NULL DEFAULT '[]', + unit_price BIGINT NOT NULL, + free_extension_limit INTEGER NOT NULL DEFAULT 1, + paid_max_months INTEGER NOT NULL DEFAULT 6, + single_max_months INTEGER, + approval_exempt_types JSONB NOT NULL DEFAULT '[]', + effective_from TIMESTAMPTZ NOT NULL, + effective_to TIMESTAMPTZ, + status SMALLINT NOT NULL DEFAULT 1, + remark VARCHAR(500) NOT NULL DEFAULT '', + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT ck_silence_policy_scope_type CHECK (scope_type IN ('all', 'series', 'package')), + CONSTRAINT ck_silence_policy_status CHECK (status IN (0, 1)), + CONSTRAINT ck_silence_policy_unit_price CHECK (unit_price >= 0), + CONSTRAINT ck_silence_policy_free_limit CHECK (free_extension_limit >= 0), + CONSTRAINT ck_silence_policy_paid_max CHECK (paid_max_months >= 0), + CONSTRAINT ck_silence_policy_single_max CHECK (single_max_months IS NULL OR single_max_months >= 1), + CONSTRAINT ck_silence_policy_effective CHECK (effective_to IS NULL OR effective_to > effective_from) +); + +CREATE UNIQUE INDEX uq_silence_policy_name ON tb_package_silence_extension_policy (name) WHERE deleted_at IS NULL; +CREATE INDEX idx_silence_policy_match ON tb_package_silence_extension_policy (scope_type, status) WHERE deleted_at IS NULL; + +COMMENT ON TABLE tb_package_silence_extension_policy IS '沉默期延期政策:适用范围、单价、免费次数、付费上限、免审批场景、生效时间、启停'; +COMMENT ON COLUMN tb_package_silence_extension_policy.scope_type IS '适用范围:all-全部套餐 series-套餐系列 package-指定套餐'; +COMMENT ON COLUMN tb_package_silence_extension_policy.scope_ids IS '系列 ID 或套餐 ID 列表(all 时为空数组)'; +COMMENT ON COLUMN tb_package_silence_extension_policy.unit_price IS '延期单价(分/月,1 月 = 30 天)'; +COMMENT ON COLUMN tb_package_silence_extension_policy.free_extension_limit IS '每笔套餐免费延期次数'; +COMMENT ON COLUMN tb_package_silence_extension_policy.paid_max_months IS '每笔套餐付费延期累计最多月数'; +COMMENT ON COLUMN tb_package_silence_extension_policy.single_max_months IS '单次付费延期最多月数(空表示不单独限制)'; +COMMENT ON COLUMN tb_package_silence_extension_policy.approval_exempt_types IS '免审批场景(多选):offline_paid/free/discount/complaint_compensation/system_compensation/special_customer/batch/purchase_adjust;空数组表示全部需审批'; +COMMENT ON COLUMN tb_package_silence_extension_policy.effective_from IS '生效时间'; +COMMENT ON COLUMN tb_package_silence_extension_policy.effective_to IS '失效时间(空表示长期)'; +COMMENT ON COLUMN tb_package_silence_extension_policy.status IS '状态:0-停用 1-启用'; + +CREATE TABLE tb_package_silence_record ( + id BIGSERIAL PRIMARY KEY, + package_usage_id BIGINT NOT NULL, + event_type VARCHAR(30) NOT NULL, + trigger_source VARCHAR(40) NOT NULL DEFAULT '', + asset_type VARCHAR(20) NOT NULL DEFAULT '', + asset_id BIGINT NOT NULL DEFAULT 0, + before_status VARCHAR(16), + after_status VARCHAR(16), + before_deadline_at TIMESTAMPTZ, + after_deadline_at TIMESTAMPTZ, + period_days INTEGER, + default_days_snapshot INTEGER, + is_adjusted BOOLEAN NOT NULL DEFAULT FALSE, + change_days INTEGER NOT NULL DEFAULT 0, + operator_id BIGINT NOT NULL DEFAULT 0, + operator_type VARCHAR(20) NOT NULL DEFAULT '', + reason VARCHAR(500) NOT NULL DEFAULT '', + approval_instance_id BIGINT, + batch_id BIGINT, + extension_id BIGINT, + exchange_order_id BIGINT, + exchange_no VARCHAR(64) NOT NULL DEFAULT '', + realname_at TIMESTAMPTZ, + extra JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_silence_record_event_type CHECK (event_type IN ( + 'set', 'adjust', 'expired', 'ended', 'extend', 'exchange', 'backfill', 'realname_backfill' + )) +); + +CREATE INDEX idx_silence_record_usage ON tb_package_silence_record (package_usage_id, id); +CREATE INDEX idx_silence_record_created ON tb_package_silence_record (created_at DESC); + +COMMENT ON TABLE tb_package_silence_record IS '套餐沉默期变更流水(只新增不修改)'; +COMMENT ON COLUMN tb_package_silence_record.event_type IS '事件:set-设置 adjust-调整审批结束 expired-过期 ended-期内实名结束 extend-延期 exchange-换货迁移 backfill-历史回填 realname_backfill-补写实名时间'; +COMMENT ON COLUMN tb_package_silence_record.trigger_source IS '触发来源(订单支付、自动购包、实名、扫描、延期、审批、换货、回填等)'; +COMMENT ON COLUMN tb_package_silence_record.default_days_snapshot IS '套餐默认沉默期快照'; +COMMENT ON COLUMN tb_package_silence_record.is_adjusted IS '本次是否使用了购买时调整值'; +COMMENT ON COLUMN tb_package_silence_record.change_days IS '本次变化天数(延期天数或调整后与默认值差额)'; +COMMENT ON COLUMN tb_package_silence_record.operator_type IS '操作人类型:system/account/customer'; +COMMENT ON COLUMN tb_package_silence_record.realname_at IS '判定所用的实名时间'; + +CREATE TABLE tb_package_silence_extension_batch ( + id BIGSERIAL PRIMARY KEY, + batch_no VARCHAR(40) NOT NULL, + path VARCHAR(20) NOT NULL, + extension_type VARCHAR(30) NOT NULL, + approval_required BOOLEAN NOT NULL DEFAULT FALSE, + reason VARCHAR(1000) NOT NULL DEFAULT '', + evidence_refs JSONB NOT NULL DEFAULT '[]', + basis VARCHAR(1000) NOT NULL DEFAULT '', + import_file_key VARCHAR(500) NOT NULL DEFAULT '', + policy_id BIGINT, + policy_name_snapshot VARCHAR(100) NOT NULL DEFAULT '', + unit_price_snapshot BIGINT NOT NULL DEFAULT 0, + approval_exempt_types_snapshot JSONB NOT NULL DEFAULT '[]', + free_extension_limit_snapshot INTEGER NOT NULL DEFAULT 1, + paid_max_months_snapshot INTEGER NOT NULL DEFAULT 0, + single_max_months_snapshot INTEGER, + adjust_package_usage_id BIGINT, + adjust_default_days INTEGER, + adjust_requested_days INTEGER, + amount BIGINT NOT NULL DEFAULT 0, + offline_payment_method_id BIGINT, + offline_payment_method_code VARCHAR(64), + offline_payment_method_name VARCHAR(100), + offline_trade_no VARCHAR(128), + offline_paid_amount BIGINT, + offline_paid_at TIMESTAMPTZ, + payment_voucher_keys JSONB NOT NULL DEFAULT '[]', + requested_by BIGINT NOT NULL DEFAULT 0, + requested_by_type VARCHAR(20) NOT NULL DEFAULT '', + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + approval_instance_id BIGINT, + approval_status VARCHAR(20) NOT NULL DEFAULT '', + approval_finished_at TIMESTAMPTZ, + status VARCHAR(30) NOT NULL, + requested_count INTEGER NOT NULL DEFAULT 0, + succeeded_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + completed_at TIMESTAMPTZ, + shop_id_tag BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_silence_batch_path CHECK (path IN ('online', 'offline', 'special', 'adjust')), + CONSTRAINT ck_silence_batch_type CHECK (extension_type IN ( + 'standard_paid', 'offline_paid', 'free', 'discount', 'complaint_compensation', + 'system_compensation', 'special_customer', 'batch', 'purchase_adjust' + )), + CONSTRAINT ck_silence_batch_status CHECK (status IN ( + 'pending_payment', 'pending_approval', 'executing', 'completed', 'partially_completed', + 'rejected', 'closed', 'failed' + )) +); + +CREATE UNIQUE INDEX uq_silence_batch_no ON tb_package_silence_extension_batch (batch_no); +CREATE UNIQUE INDEX uq_silence_batch_approval ON tb_package_silence_extension_batch (approval_instance_id) WHERE approval_instance_id IS NOT NULL; +CREATE UNIQUE INDEX uq_silence_batch_offline_trade ON tb_package_silence_extension_batch (offline_payment_method_id, offline_trade_no) + WHERE offline_trade_no IS NOT NULL AND path = 'special' AND status NOT IN ('rejected', 'closed'); +CREATE UNIQUE INDEX uq_silence_batch_adjust_open ON tb_package_silence_extension_batch (adjust_package_usage_id) + WHERE path = 'adjust' AND status = 'pending_approval'; +CREATE INDEX idx_silence_batch_list ON tb_package_silence_extension_batch (path, status, created_at DESC); + +COMMENT ON TABLE tb_package_silence_extension_batch IS '沉默期申请批次:每次延期或购买时调整都建一个批次,企微审批实例关联批次'; +COMMENT ON COLUMN tb_package_silence_extension_batch.path IS '路径:online-在线付费 offline-线下付费 special-特殊延期 adjust-购买时调整沉默期'; +COMMENT ON COLUMN tb_package_silence_extension_batch.extension_type IS '类型:standard_paid/offline_paid/free/discount/complaint_compensation/system_compensation/special_customer/batch/purchase_adjust'; +COMMENT ON COLUMN tb_package_silence_extension_batch.approval_required IS '是否需要审批(按政策快照与类型判定,在线付费恒为 false)'; +COMMENT ON COLUMN tb_package_silence_extension_batch.evidence_refs IS '客户申请记录、截图等附件对象存储 Key'; +COMMENT ON COLUMN tb_package_silence_extension_batch.basis IS '免费或优惠依据'; +COMMENT ON COLUMN tb_package_silence_extension_batch.adjust_package_usage_id IS '购买时调整:对应套餐记录'; +COMMENT ON COLUMN tb_package_silence_extension_batch.adjust_default_days IS '购买时调整:套餐默认沉默期'; +COMMENT ON COLUMN tb_package_silence_extension_batch.adjust_requested_days IS '购买时调整:申请天数'; +COMMENT ON COLUMN tb_package_silence_extension_batch.amount IS '批次费用合计(分,免费为 0)'; +COMMENT ON COLUMN tb_package_silence_extension_batch.offline_trade_no IS '优惠价有收款时的线下交易单号'; +COMMENT ON COLUMN tb_package_silence_extension_batch.approval_status IS '审批状态:pending/approved/rejected/canceled'; +COMMENT ON COLUMN tb_package_silence_extension_batch.status IS '批次状态:pending_payment/pending_approval/executing/completed/partially_completed/rejected/closed/failed'; + +CREATE TABLE tb_package_silence_extension ( + id BIGSERIAL PRIMARY KEY, + batch_id BIGINT NOT NULL, + row_no INTEGER NOT NULL DEFAULT 1, + package_usage_id BIGINT NOT NULL, + package_id BIGINT NOT NULL DEFAULT 0, + package_name_snapshot VARCHAR(255) NOT NULL DEFAULT '', + asset_type VARCHAR(20) NOT NULL DEFAULT '', + asset_id BIGINT NOT NULL DEFAULT 0, + asset_identifier_snapshot VARCHAR(100) NOT NULL DEFAULT '', + extension_type VARCHAR(30) NOT NULL, + input_days INTEGER NOT NULL, + months INTEGER NOT NULL, + extension_days INTEGER NOT NULL, + unit_price_snapshot BIGINT NOT NULL DEFAULT 0, + amount BIGINT NOT NULL DEFAULT 0, + extension_order_id BIGINT, + before_deadline_at TIMESTAMPTZ, + after_deadline_at TIMESTAMPTZ, + status VARCHAR(20) NOT NULL, + failure_code VARCHAR(64) NOT NULL DEFAULT '', + failure_summary VARCHAR(500) NOT NULL DEFAULT '', + executed_at TIMESTAMPTZ, + handled_by BIGINT NOT NULL DEFAULT 0, + handled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_silence_extension_status CHECK (status IN ( + 'pending_payment', 'pending_approval', 'approved', 'executing', 'completed', + 'exception', 'failed', 'rejected', 'closed' + )), + CONSTRAINT ck_silence_extension_days CHECK (input_days >= 1 AND months >= 1 AND extension_days = months * 30) +); + +CREATE UNIQUE INDEX uq_silence_extension_batch_usage ON tb_package_silence_extension (batch_id, package_usage_id); +CREATE UNIQUE INDEX uq_silence_extension_order ON tb_package_silence_extension (extension_order_id) WHERE extension_order_id IS NOT NULL; +CREATE UNIQUE INDEX uq_silence_extension_usage_open ON tb_package_silence_extension (package_usage_id) + WHERE status IN ('pending_payment', 'pending_approval', 'approved', 'executing', 'exception'); +CREATE INDEX idx_silence_extension_usage ON tb_package_silence_extension (package_usage_id, id); +CREATE INDEX idx_silence_extension_status ON tb_package_silence_extension (status, updated_at DESC); + +COMMENT ON TABLE tb_package_silence_extension IS '沉默期延期明细(延期事实):每笔套餐一条,同一套餐同时只有一条未结束明细'; +COMMENT ON COLUMN tb_package_silence_extension.row_no IS '批量导入时的明细行号'; +COMMENT ON COLUMN tb_package_silence_extension.input_days IS '输入天数'; +COMMENT ON COLUMN tb_package_silence_extension.months IS '计费月数 = ceil(输入天数 / 30)'; +COMMENT ON COLUMN tb_package_silence_extension.extension_days IS '计费 / 延长天数 = 月数 × 30'; +COMMENT ON COLUMN tb_package_silence_extension.extension_order_id IS '付费延期对应的延期订单(特殊延期为空)'; +COMMENT ON COLUMN tb_package_silence_extension.status IS '明细状态:pending_payment/pending_approval/approved/executing/completed/exception/failed/rejected/closed'; +COMMENT ON COLUMN tb_package_silence_extension.failure_code IS '失败或异常原因码'; + +CREATE TABLE tb_package_silence_extension_order ( + id BIGSERIAL PRIMARY KEY, + order_no VARCHAR(40) NOT NULL, + batch_id BIGINT NOT NULL, + package_usage_id BIGINT NOT NULL, + package_id BIGINT NOT NULL, + package_name_snapshot VARCHAR(255) NOT NULL DEFAULT '', + asset_type VARCHAR(20) NOT NULL, + asset_id BIGINT NOT NULL, + asset_identifier_snapshot VARCHAR(100) NOT NULL DEFAULT '', + shop_id BIGINT NOT NULL DEFAULT 0, + customer_id BIGINT, + source VARCHAR(20) NOT NULL, + created_by BIGINT, + evidence_refs JSONB NOT NULL DEFAULT '[]', + input_days INTEGER NOT NULL, + months INTEGER NOT NULL, + extension_days INTEGER NOT NULL, + policy_id BIGINT, + policy_name_snapshot VARCHAR(100) NOT NULL DEFAULT '', + unit_price BIGINT NOT NULL, + approval_exempt_types_snapshot JSONB NOT NULL DEFAULT '[]', + free_extension_limit_snapshot INTEGER NOT NULL DEFAULT 1, + paid_max_months_snapshot INTEGER NOT NULL DEFAULT 0, + single_max_months_snapshot INTEGER, + amount BIGINT NOT NULL, + before_deadline_at TIMESTAMPTZ NOT NULL, + expected_deadline_at TIMESTAMPTZ NOT NULL, + status VARCHAR(20) NOT NULL, + payment_method VARCHAR(20) NOT NULL, + offline_payment_method_id BIGINT, + offline_payment_method_code VARCHAR(64), + offline_payment_method_name VARCHAR(100), + offline_trade_no VARCHAR(128), + payment_voucher_keys JSONB NOT NULL DEFAULT '[]', + approval_required BOOLEAN NOT NULL DEFAULT FALSE, + approval_instance_id BIGINT, + paid_payment_id BIGINT, + paid_amount BIGINT, + paid_at TIMESTAMPTZ, + expire_at TIMESTAMPTZ, + extension_id BIGINT, + completed_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + close_reason VARCHAR(200) NOT NULL DEFAULT '', + exception_code VARCHAR(64) NOT NULL DEFAULT '', + exception_summary VARCHAR(500) NOT NULL DEFAULT '', + refund_no VARCHAR(64), + refund_amount BIGINT, + refunded_at TIMESTAMPTZ, + refund_reason VARCHAR(500), + request_id VARCHAR(64), + shop_id_tag BIGINT NOT NULL DEFAULT 0, + enterprise_id_tag BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_silence_order_source CHECK (source IN ('client', 'admin', 'offline')), + CONSTRAINT ck_silence_order_status CHECK (status IN ( + 'pending_payment', 'pending_approval', 'rejected', 'paid', 'completed', 'closed', 'exception', 'refunded' + )), + CONSTRAINT ck_silence_order_payment_method CHECK (payment_method IN ('wechat', 'alipay', 'offline')), + CONSTRAINT ck_silence_order_amount CHECK (amount >= 0 AND unit_price >= 0), + CONSTRAINT ck_silence_order_days CHECK (input_days >= 1 AND months >= 1 AND extension_days = months * 30) +); + +CREATE UNIQUE INDEX uq_silence_order_no ON tb_package_silence_extension_order (order_no); +CREATE UNIQUE INDEX uq_silence_order_usage_open ON tb_package_silence_extension_order (package_usage_id) + WHERE status IN ('pending_payment', 'pending_approval'); +CREATE UNIQUE INDEX uq_silence_order_offline_trade ON tb_package_silence_extension_order (offline_payment_method_id, offline_trade_no) + WHERE offline_trade_no IS NOT NULL; +CREATE UNIQUE INDEX uq_silence_order_request ON tb_package_silence_extension_order (request_id) WHERE request_id IS NOT NULL; +CREATE INDEX idx_silence_order_usage ON tb_package_silence_extension_order (package_usage_id, id); +CREATE INDEX idx_silence_order_status ON tb_package_silence_extension_order (status, created_at DESC); +CREATE INDEX idx_silence_order_customer ON tb_package_silence_extension_order (customer_id, created_at DESC) WHERE customer_id IS NOT NULL; +CREATE INDEX idx_silence_order_shop ON tb_package_silence_extension_order (shop_id_tag, created_at DESC); +CREATE INDEX idx_silence_order_asset ON tb_package_silence_extension_order (asset_type, asset_id); +CREATE INDEX idx_silence_order_pending_expire ON tb_package_silence_extension_order (expire_at) WHERE status = 'pending_payment'; + +COMMENT ON TABLE tb_package_silence_extension_order IS '沉默期延期订单(交易记录):在线支付复用 tb_payment(order_type = silence_extension),交易记录不删除'; +COMMENT ON COLUMN tb_package_silence_extension_order.source IS '来源:client-客户 H5 下单 admin-运营后台生成二维码 offline-运营登记线下付款'; +COMMENT ON COLUMN tb_package_silence_extension_order.input_days IS '客户输入天数'; +COMMENT ON COLUMN tb_package_silence_extension_order.months IS '计费月数 = ceil(输入天数 / 30)'; +COMMENT ON COLUMN tb_package_silence_extension_order.extension_days IS '计费 / 延长天数 = 月数 × 30'; +COMMENT ON COLUMN tb_package_silence_extension_order.unit_price IS '单价快照(分/月,取下单时匹配的政策)'; +COMMENT ON COLUMN tb_package_silence_extension_order.amount IS '应付金额快照(分),回调不重新计价'; +COMMENT ON COLUMN tb_package_silence_extension_order.before_deadline_at IS '下单时截止时间'; +COMMENT ON COLUMN tb_package_silence_extension_order.expected_deadline_at IS '预计新截止时间'; +COMMENT ON COLUMN tb_package_silence_extension_order.status IS '状态:pending_payment/pending_approval/rejected/paid/completed/closed/exception/refunded'; +COMMENT ON COLUMN tb_package_silence_extension_order.payment_method IS '支付方式:wechat/alipay(在线)/ offline(线下)'; +COMMENT ON COLUMN tb_package_silence_extension_order.offline_trade_no IS '线下交易单号(与收款方式联合唯一)'; +COMMENT ON COLUMN tb_package_silence_extension_order.paid_payment_id IS '最终支付成功的 tb_payment(商户快照、交易流水号从这张支付单取)'; +COMMENT ON COLUMN tb_package_silence_extension_order.expire_at IS '支付截止时间,超时自动关闭'; +COMMENT ON COLUMN tb_package_silence_extension_order.refund_no IS '仅执行异常原路退款时有值(延期费正常不退)'; +COMMENT ON COLUMN tb_package_silence_extension_order.request_id IS '下单幂等键'; diff --git a/migrations/000263_seed_package_silence_policy_and_config.down.sql b/migrations/000263_seed_package_silence_policy_and_config.down.sql new file mode 100644 index 00000000..b381df55 --- /dev/null +++ b/migrations/000263_seed_package_silence_policy_and_config.down.sql @@ -0,0 +1,5 @@ +-- 回滚沉默期系统配置与默认延期政策。 +DELETE FROM tb_system_config WHERE config_key IN ( + 'package.silence.enabled', 'package.silence.default_days', 'package.silence.extension_unit_price' +); +DELETE FROM tb_package_silence_extension_policy WHERE name = '默认延期政策' AND creator = 0; diff --git a/migrations/000263_seed_package_silence_policy_and_config.up.sql b/migrations/000263_seed_package_silence_policy_and_config.up.sql new file mode 100644 index 00000000..8755d2fb --- /dev/null +++ b/migrations/000263_seed_package_silence_policy_and_config.up.sql @@ -0,0 +1,15 @@ +-- 沉默期默认延期政策与系统配置(技术方案 §3.5、§3.6、§3.7)。 +-- 默认政策:全部套餐、长期有效、500 分/月、免费 1 次、付费累计最多 6 个月、单次不限、无免审批场景(全部需审批)。 +-- package.silence.enabled:沉默期总开关,默认关闭;关闭时只记录沉默期,不执行任何规则。 +INSERT INTO tb_package_silence_extension_policy + (name, scope_type, scope_ids, unit_price, free_extension_limit, paid_max_months, single_max_months, + approval_exempt_types, effective_from, effective_to, status, remark, creator, updater, created_at, updated_at) +SELECT '默认延期政策', 'all', '[]'::jsonb, 500, 1, 6, NULL, '[]'::jsonb, + TIMESTAMPTZ '2026-01-01 00:00:00+08', NULL, 1, '上线预置:全部套餐、长期有效', 0, 0, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_package_silence_extension_policy WHERE name = '默认延期政策' AND deleted_at IS NULL); + +INSERT INTO tb_system_config (config_key, config_value, value_type, module, description, is_readonly, is_sensitive, creator, updater, created_at, updated_at) VALUES + ('package.silence.enabled', 'false', 'bool', 'package', '沉默期总开关:关闭时只记录沉默期,不拦截激活、不扫描、不展示、延期申请拒绝;打开前需通过检查', FALSE, FALSE, 0, 0, NOW(), NOW()), + ('package.silence.default_days', '30', 'int', 'package', '新建实名即生效套餐时预填的默认沉默期(天)', FALSE, FALSE, 0, 0, NOW(), NOW()), + ('package.silence.extension_unit_price', '500', 'int', 'package', '新建延期政策时预填的单价(分/月),实际计价以政策为准', FALSE, FALSE, 0, 0, NOW(), NOW()) +ON CONFLICT (config_key) DO NOTHING; diff --git a/migrations/000264_extend_wecom_approval_scene_silence.down.sql b/migrations/000264_extend_wecom_approval_scene_silence.down.sql new file mode 100644 index 00000000..fa111ab7 --- /dev/null +++ b/migrations/000264_extend_wecom_approval_scene_silence.down.sql @@ -0,0 +1,16 @@ +-- 回滚沉默期审批场景白名单,恢复 000216 的六个业务类型(需先删除该场景配置)。 +DELETE FROM tb_wecom_approval_scene WHERE business_type = 'package_silence_approval'; + +ALTER TABLE tb_wecom_approval_scene + DROP CONSTRAINT IF EXISTS chk_wecom_approval_scene_business; + +ALTER TABLE tb_wecom_approval_scene + ADD CONSTRAINT chk_wecom_approval_scene_business + CHECK (business_type IN ( + 'refund_approval', + 'offline_recharge_approval', + 'employee_collection_approval', + 'agent_distribution_approval', + 'withdrawal_qualification_approval', + 'commission_withdrawal_approval' + )); diff --git a/migrations/000264_extend_wecom_approval_scene_silence.up.sql b/migrations/000264_extend_wecom_approval_scene_silence.up.sql new file mode 100644 index 00000000..546b7709 --- /dev/null +++ b/migrations/000264_extend_wecom_approval_scene_silence.up.sql @@ -0,0 +1,17 @@ +-- 扩展企业微信审批场景业务类型白名单,加入沉默期审批场景 package_silence_approval(技术方案 §11.3)。 +-- 购买时调整沉默期、特殊延期、线下付费延期共用这一个场景,用模板"申请类型"控件区分。 +-- 既有六个场景语义不变。 +ALTER TABLE tb_wecom_approval_scene + DROP CONSTRAINT IF EXISTS chk_wecom_approval_scene_business; + +ALTER TABLE tb_wecom_approval_scene + ADD CONSTRAINT chk_wecom_approval_scene_business + CHECK (business_type IN ( + 'refund_approval', + 'offline_recharge_approval', + 'employee_collection_approval', + 'agent_distribution_approval', + 'withdrawal_qualification_approval', + 'commission_withdrawal_approval', + 'package_silence_approval' + )); diff --git a/migrations/000265_add_asset_outbound_time.down.sql b/migrations/000265_add_asset_outbound_time.down.sql new file mode 100644 index 00000000..979b1925 --- /dev/null +++ b/migrations/000265_add_asset_outbound_time.down.sql @@ -0,0 +1,6 @@ +-- 回滚出库时间与出库记录(派生数据,可由回填重新生成)。 +DROP TABLE IF EXISTS tb_asset_outbound_record; +DROP INDEX IF EXISTS idx_device_outbound_at; +DROP INDEX IF EXISTS idx_iot_card_outbound_at; +ALTER TABLE tb_device DROP COLUMN IF EXISTS outbound_at; +ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS outbound_at; diff --git a/migrations/000265_add_asset_outbound_time.up.sql b/migrations/000265_add_asset_outbound_time.up.sql new file mode 100644 index 00000000..e6211011 --- /dev/null +++ b/migrations/000265_add_asset_outbound_time.up.sql @@ -0,0 +1,42 @@ +-- 卡与设备出库时间、出库记录(技术方案 §17)。 +-- outbound_at = 资产当前世代第 1 次购买套餐的订单支付时间;设备上绑定的卡不记;换货新资产取换货完成时间。 +-- 只做记录与展示,不参与业务判断,不受沉默期总开关影响。 +ALTER TABLE tb_iot_card ADD COLUMN outbound_at TIMESTAMPTZ; +ALTER TABLE tb_device ADD COLUMN outbound_at TIMESTAMPTZ; + +COMMENT ON COLUMN tb_iot_card.outbound_at IS '出库时间(当前世代首次购买套餐的支付时间)'; +COMMENT ON COLUMN tb_device.outbound_at IS '出库时间(当前世代首次购买套餐的支付时间)'; + +CREATE INDEX IF NOT EXISTS idx_iot_card_outbound_at ON tb_iot_card (outbound_at) WHERE outbound_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_device_outbound_at ON tb_device (outbound_at) WHERE outbound_at IS NOT NULL; + +CREATE TABLE tb_asset_outbound_record ( + id BIGSERIAL PRIMARY KEY, + asset_type VARCHAR(20) NOT NULL, + asset_id BIGINT NOT NULL, + asset_identifier_snapshot VARCHAR(100) NOT NULL DEFAULT '', + generation INTEGER NOT NULL, + outbound_at TIMESTAMPTZ NOT NULL, + source VARCHAR(20) NOT NULL, + order_id BIGINT, + order_no VARCHAR(40) NOT NULL DEFAULT '', + exchange_order_id BIGINT, + exchange_no VARCHAR(64) NOT NULL DEFAULT '', + shop_id BIGINT, + ended_at TIMESTAMPTZ, + end_reason VARCHAR(40) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_asset_outbound_asset_type CHECK (asset_type IN ('iot_card', 'device')), + CONSTRAINT ck_asset_outbound_source CHECK (source IN ('order', 'exchange', 'backfill')) +); + +CREATE UNIQUE INDEX uq_asset_outbound_generation ON tb_asset_outbound_record (asset_type, asset_id, generation); +CREATE INDEX idx_asset_outbound_created ON tb_asset_outbound_record (created_at DESC); + +COMMENT ON TABLE tb_asset_outbound_record IS '资产出库记录:每个世代一条,只新增与填写结束信息,不修改出库时间、不删除'; +COMMENT ON COLUMN tb_asset_outbound_record.asset_type IS '资产类型:iot_card / device'; +COMMENT ON COLUMN tb_asset_outbound_record.asset_identifier_snapshot IS '写入时的 ICCID / 虚拟号 / IMEI 快照'; +COMMENT ON COLUMN tb_asset_outbound_record.generation IS '资产世代'; +COMMENT ON COLUMN tb_asset_outbound_record.source IS '来源:order-首次购买套餐 exchange-换货新资产 backfill-历史回填'; +COMMENT ON COLUMN tb_asset_outbound_record.ended_at IS '该世代结束时间(当前世代为空)'; +COMMENT ON COLUMN tb_asset_outbound_record.end_reason IS '结束原因:exchange_return-换货回库'; diff --git a/openspec/changes/add-package-silence-period/.openspec.yaml b/openspec/changes/add-package-silence-period/.openspec.yaml new file mode 100644 index 00000000..97e36420 --- /dev/null +++ b/openspec/changes/add-package-silence-period/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-27 diff --git a/openspec/changes/add-package-silence-period/design.md b/openspec/changes/add-package-silence-period/design.md new file mode 100644 index 00000000..fe6d4b98 --- /dev/null +++ b/openspec/changes/add-package-silence-period/design.md @@ -0,0 +1,106 @@ +## Context + +实名即生效套餐(最终生效条件 `from_activation`)在资产实名前保持 `status = 0`、`pending_realname_activation = true`,现在没有任何期限。业务确认需要沉默期、过期失效与延期收费,规则与 31 项已确定决定见技术方案 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md`(下称"技术方案",章节号以 §n 引用)。本设计只记录实施时必须遵守的结构性决定;字段清单、接口字段、错误码、示例与测试场景以技术方案为准。 + +现状要点: + +- 激活统一经过 `ActivationService.canActivatePendingUsage`(`internal/service/package/activation_service.go`);排队取队首 `getNextPendingMainPackage`;孤儿扫描 `findAndActivateOrphanPackages`(`internal/polling/package_activation_handler.go`,10-27 已加"未实名不选中"的条件)。 +- 订单支付后发放套餐只有两个入口:订单服务 `activatePackage`(6 处调用)与自动购包 `activatePackages`。 +- 在线支付已有商户池路由(`merchantpayment.RuntimeLoader.SelectForNewPaymentWithTx` + `FreezeRoute`)、渠道适配器、支付恢复任务;代理充值的在线创建、线下登记、查单补偿(`internal/application/agentrecharge/`)是延期订单的参照实现。 +- 企微审批按业务类型分发(`internal/application/approval/`),业务类型受 `chk_wecom_approval_scene_business` 白名单约束(最近一次扩展在 `000216`)。 +- 换货迁移套餐记录的条件为 `status IN (0,1,2)`(`internal/service/exchange/migration.go:105`),旧资产世代 +1(`exchange/service.go`)。 +- 迁移编号当前到 `000258`;测试环境是唯一验证面,迁移用 `~/go/bin/migrate` 与 `.env` 的 `DB_*`。 + +## Goals / Non-Goals + +**Goals:** + +- 每笔实名即生效套餐有沉默期;过期后不能激活、不能退款;可按四种方式延期。 +- 总开关默认关闭:上线、配置、回填期间不改变任何现有行为;打开前自动检查。 +- 延期订单与线上订单同一套支付(商户池、回调、查单、原路退款),交易可追溯。 +- 卡、设备出库时间与出库记录。 + +**Non-Goals:** + +- 通知提醒(短信、公众号、弹窗)。 +- 延期费分佣、延期费计入代理业绩。 +- 运营直接修改截止时间的任何接口。 +- 老系统无订单资产的出库时间补数(后续另行回填)。 + +## Decisions + +### D1 沉默期建在套餐记录上,过期不改 `status` + +字段加在 `tb_package_usage`,新增独立 `silence_status`(空 / `waiting` / `expired` / `ended`),过期时 `status` 保持 0,由激活闸门拦截。 + +- 备选 A:过期改 `status = 4`。否决:4 已表示退款、资产失效;延期时要把终态恢复回来,且会被换货迁移条件排除。 +- 备选 B:延期时新建一笔套餐记录(技术方案 v2 的做法)。否决:订单、退款、换货都要处理两条记录的关系,且容易重复计收入。 +- 代价:所有"查 `status = 0` 待生效套餐"的地方都要考虑 `silence_status = expired`。已知入口:激活闸门、排队队首、孤儿扫描、退款申请、展示。实施时 MUST 全局搜索 `PackageUsageStatusPending` 的查询逐一确认。 + +### D2 判定集中在一个领域函数与一个应用用例 + +`internal/domain/package/silence.go` 放纯函数:`SilenceDeadline`、判定(wait / end / expire / noop)、延期报价(overdue、min/max、ceil、金额、新截止)、政策匹配与重叠校验、免审批判断。应用层 `ResolveUsageSilence` 负责加锁、读实名事实、条件更新、写流水与审计,所有入口只调用它。 + +- 理由:时间与金额规则多处使用(激活、扫描、回填、资格、执行),只能有一份实现;纯函数便于在测试环境之外推演边界(日期跨月、ceil)。 + +### D3 总开关只决定"执行",不决定"记录" + +`package.silence.enabled` 关闭时仍写沉默期字段,只是不拦截、不扫描、不展示、延期接口拒绝。 + +- 备选:关闭时完全不写字段。否决:回填后到打开开关之间的新订单会留下缺口,回填永远补不完。 +- 开关读取走现有系统配置缓存;应急关闭后,已发生的支付回调与审批回调 MUST 照常执行(避免收钱不延期),只拒绝新申请。 +- 打开前检查放在开关的写接口里,不满足返回未满足项与数量,前端直接展示。 + +### D4 延期订单单独建表,支付复用线上订单链路 + +`tb_package_silence_extension_order` + `tb_payment(order_type = silence_extension)`。商户池路由、冻结、回调、查单、原路退款全部复用;在线创建参照 `agentrecharge/online_creation.go`,查单参照 `recover_online_payment.go`,线下登记参照 `offline_creation.go`(收款方式字典 `EmployeeCollectionPaymentMethod`)。 + +- 备选:作为一种套餐订单写入 `tb_order`。否决:会进入订单分类、佣金、退款、导出等全部订单逻辑,延期费不分佣、不退款与之冲突。 +- 回调分发:`internal/handler/callback/payment.go` 按 `order_type` 增加分支。`tb_payment.order_type` 无数据库约束,只加代码常量。 + +### D5 批次 + 明细统一四种路径 + +每次延期都建批次(单个即 1 条明细),路径 `online` / `offline` / `special`,`approval_required` 由政策快照与类型决定;执行统一走"延期执行"用例(技术方案 §8.6),以明细状态条件更新保证幂等。这样在线付款、线下审批、特殊审批、批量导入共用一个执行入口与异常重试。 + +### D6 一个审批场景,按关联业务记录分发 + +只新增业务类型 `package_silence_approval`,模板用"申请类型"控件区分三种申请。审批实例关联到调整申请 / 延期批次 / 延期订单,回调按关联记录分发,不解析表单文本。申请人 `creator_userid` 为发起人绑定的企微成员,审批人 `use_template_approver = 1`。 + +### D7 免审批是政策上的多选项 + +`approval_exempt_types` 放在延期政策里,提交时匹配并快照。免审批只跳过审批,其余校验与留痕不变。在线付款始终不审批,不在可选项。 + +### D8 截止与计费全部按天、按 30 天向上取整 + +截止 = 当日 23:59:59(`Asia/Shanghai`);延期按输入天数 `ceil(days / 30)` 计费,延长天数 = 计费天数;新截止从原截止日加。最少月数 `floor(overdue/30) + 1` 保证新截止晚于今天。特殊延期只受最少月数约束。 + +### D9 出库时间写在发放套餐的两个入口 + +在 `activatePackage` 与 `activatePackages` 的事务里写,条件 `outbound_at IS NULL AND generation = 订单世代`,影响行数为 1 时插入出库记录;当前绑定在设备上的卡跳过。换货在世代 +1 的同一事务里清空并结束旧记录,新资产写换货完成时间。 + +### D10 历史回填是独立工具,先出清单 + +回填(沉默期、出库时间)放在 `cmd/` 下的一次性工具,支持"只出清单"与"执行"两种模式,分批、只处理空值、可重复执行。生产执行前 MUST 由业务确认清单。生产只读拉数遵守项目约束(dbhub 只读)。 + +## Risks / Trade-offs + +- [打开总开关后一批历史套餐立即失效,过期满 180 天的无法付费延期] → 回填清单单独列出"将过期""将过期且无法付费延期"数量,业务确认后再打开;特殊延期可兜底;可应急关闭。 +- [遗漏某个查询 `status = 0` 的入口,过期套餐被激活或挡住队列] → D1 要求全局搜索;测试场景覆盖激活、排队、孤儿扫描、退款、换货。 +- [生产存在 `first_realname_at` 为空的已实名卡,被误判过期] → 判定时视为期内已实名并补写时间;回填清单单列数量。 +- [跨天支付导致新截止不晚于当天] → 进入异常并原路退款;下单时 `expire_at` 不跨天可降低发生率。 +- [客户 H5 待支付订单与运营二维码并发] → 新建前查单旧支付单,已支付则不新建;部分唯一索引兜底。 +- [企微模板未配置导致需审批的申请无法提交] → 打开前检查包含模板配置;提交时返回明确错误。 +- [扫描与实名事件并发] → 条件更新 + 影响行数判断,审计只写一次。 +- [down 迁移会删除业务事实] → 只在测试环境演练,生产不用 down 回滚业务,应急用总开关。 + +## Migration Plan + +迁移①~⑨见技术方案 §13.1:`tb_package_usage` 加 10 个字段与部分索引;`tb_package` 加 2 个字段;新建 5 张表;默认政策;系统配置(总开关默认关闭);扩展 `chk_wecom_approval_scene_business` 加入 `package_silence_approval`;`tb_iot_card`、`tb_device` 加 `outbound_at` 与索引;新建 `tb_asset_outbound_record`。加列使用常量默认值(PostgreSQL 11+ 不重写表),建索引放低峰。 + +上线顺序(技术方案 §13.3):测试环境 `up → down → up` 与全量场景验证 → 生产迁移 → 部署(总开关关闭)→ 配置企微模板与权限 → 业务补齐默认沉默期 → 核查新订单字段 → 回填(沉默期、出库时间)→ 超级管理员打开总开关并观察。 + +回滚:业务层面用总开关关闭;代码回滚前先关闭总开关;生产不执行 down。 + +## Open Questions + +无。31 项决定已在技术方案 §15.1 确认。 diff --git a/openspec/changes/add-package-silence-period/proposal.md b/openspec/changes/add-package-silence-period/proposal.md new file mode 100644 index 00000000..8e6ea107 --- /dev/null +++ b/openspec/changes/add-package-silence-period/proposal.md @@ -0,0 +1,40 @@ +## Why + +实名即生效套餐在资产实名前一直处于待生效,现在没有任何期限:客户买了套餐长期不实名,套餐永远挂着,既不能失效也不能收取延期费用。业务已确认《实名即生效套餐沉默期规则需求确认稿_10.16》及 10-27 的补充决定,需要为这类套餐加上沉默期:期限内实名正常生效,过期失效,失效后可付费或经审批延期。同时卡与设备需要记录出库时间(首次购买套餐时间)用于展示。技术方案见 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md`(以下简称"技术方案"),规则以技术方案为准。 + +## What Changes + +- **沉默期按每笔套餐计算**:实名即生效主套餐在资产未实名时购买,从支付成功起计算沉默期,截止日当天 23:59:59 到期;字段建在套餐记录上,资产上不建沉默期字段。 +- **套餐配置**:实名即生效套餐必填默认沉默期(1~90 天,新建预填 30)和"是否支持延期";禁止把实名即生效套餐改成购买即生效(套餐与店铺分配两处);存量套餐由业务在后台补齐。 +- **后台购买时调整沉默期**:默认值以内直接生效;超过默认值默认需企微审批(先按默认值购买,审批中不过期,通过后重算),不设上限。 +- **过期与激活拦截**:截止前实名正常激活(或排队);截止后仍未实名标记为"沉默期已过期",套餐状态保持待生效,由激活入口统一拦截;排队与孤儿套餐恢复跳过过期套餐;过期套餐不能退款。每 10 分钟扫描到期套餐。 +- **沉默期总开关**:默认关闭;关闭时只记录沉默期、不执行任何规则;参数配置与历史回填完成、通过打开前检查后由超级管理员打开;可应急关闭。 +- **延期政策(可配置)**:适用范围(全部 / 系列 / 指定套餐)、每月单价、免费延期次数、付费累计与单次最多月数、免审批场景(多选)、生效时间、启停;预置默认政策(5 元/月、免费 1 次、累计 6 个月)。 +- **延期计费**:从原截止日往后加;客户按天输入,按每月 30 天向上取整计费,延长天数等于计费天数;系统计算最少 / 最多天数,最少超过最多则不能付费延期。 +- **四种延期办理**:H5 自助与后台二维码(在线支付、商户池路由、不审批);线下付款登记(默认审批,可配免审批);特殊延期(免费、优惠、补偿、特殊客户、批量;默认审批,可配免审批,不设上限)。延期订单单独建表,记录支付方式、收款商户、交易流水号或线下收款信息;延期费不分佣、不退(执行异常除外)。 +- **企微审批**:新增一个审批场景,用"申请类型"区分购买时调整、特殊延期、线下付费延期。 +- **换货**:沉默期字段随套餐迁移,不重新起算。 +- **历史回填**:按购买时间起算,不给上线宽限;截止前已实名的记为已结束;先出影响清单,业务确认后执行。 +- **出库时间**:卡、设备新增出库时间(当前世代首次购买套餐的支付时间)与出库记录表;设备上绑定的卡不记;换货新资产取换货完成时间;只做记录展示,不受总开关控制。 +- 不做通知提醒(确认稿第七节不实现)。 + +## Capabilities + +### New Capabilities + +- `package-silence-period`:实名即生效套餐沉默期的设置、购买时调整、实名与过期判定、激活拦截、扫描、总开关与历史回填。 +- `package-silence-extension`:延期政策、延期资格与计费、四种延期办理、延期订单与支付、企微审批与免审批、延期执行与异常处理。 +- `asset-outbound-time`:卡与设备的出库时间与出库记录。 + +### Modified Capabilities + +- `package-queue-activation`:排队接续与孤儿待生效套餐恢复必须跳过沉默期已过期的套餐。 +- `order-refund-exchange`:沉默期已过期的套餐不能申请退款;换货迁移时沉默期随套餐迁移、不重新起算。 + +## Impact + +- 数据库:迁移 9 项(技术方案 §13.1)——`tb_package_usage` 新增 10 个沉默期字段与扫描索引;`tb_package` 新增 2 个字段;新建延期政策、沉默期流水、延期订单、延期批次、延期明细 5 张表;默认政策与系统配置(含总开关,默认关闭);扩展企微审批场景业务类型白名单;`tb_iot_card`、`tb_device` 新增出库时间与出库记录表。总开关关闭前不改变任何现有行为。 +- 代码:套餐服务与分配、订单支付后发放套餐(`internal/service/order/service.go`、`internal/task/auto_purchase.go`)、激活服务(`internal/service/package/activation_service.go`)、排队与孤儿扫描(`internal/polling/package_activation_handler.go`)、退款、换货、支付回调(`internal/handler/callback/payment.go`)、商户池与支付渠道适配、企微审批(`internal/application/approval/`)、新应用层 `internal/application/packagesilence/`、领域规则 `internal/domain/package/`、定时任务、后台与 H5 Handler、导出、财务流水。 +- 接口:新增延期政策、延期资格、延期下单、线下登记、特殊延期、延期订单查询、总开关、出库记录等接口;套餐、购买、资产套餐、卡与设备列表和详情增加字段。H5 新增延期入口。 +- 数据:上线后先补齐套餐默认沉默期,再执行历史回填与出库时间回填;打开总开关后,截止已过且期内未实名的历史套餐会失效(不给宽限)。 +- 运营配置:延期政策、企微审批模板、权限分配、商户池与线下收款方式检查(技术方案 §18.3)。 diff --git a/openspec/changes/add-package-silence-period/specs/asset-outbound-time/spec.md b/openspec/changes/add-package-silence-period/specs/asset-outbound-time/spec.md new file mode 100644 index 00000000..8ed350bd --- /dev/null +++ b/openspec/changes/add-package-silence-period/specs/asset-outbound-time/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: 卡与设备出库时间 + +系统 SHALL 为 IoT 卡与设备记录出库时间 `outbound_at` = 资产当前世代第 1 次购买套餐(主套餐或加油包)的订单支付成功时间。卡取单卡订单,设备取设备订单;**当前绑定在设备上的卡不记录出库时间**(套餐在设备上)。出库时间写入后 MUST NOT 被后续订单覆盖,首笔订单退款也不清空。出库时间只用于展示、筛选与导出,MUST NOT 参与业务判断,不受沉默期总开关影响。 + +写入发生在订单支付后发放套餐的两个入口(订单 `activatePackage`、自动购包 `activatePackages`)的同一事务内:`UPDATE ... SET outbound_at = :paid_at WHERE id = :asset_id AND generation = :order_generation AND outbound_at IS NULL`,影响行数为 1 时插入出库记录。 + +#### Scenario: 首次购买写入 + +- **WHEN** 新卡当前世代第一次支付套餐订单 +- **THEN** 卡 `outbound_at` 为该订单支付时间,并新增一条出库记录(来源 `order`,带订单号) + +#### Scenario: 后续订单不覆盖 + +- **WHEN** 同一世代再次购买套餐,或首笔订单被退款 +- **THEN** `outbound_at` 不变 + +#### Scenario: 设备上绑定的卡 + +- **WHEN** 设备订单支付成功,设备上绑定了两张卡 +- **THEN** 只写设备的出库时间,两张卡不写 + +### Requirement: 出库记录 + +系统 SHALL 用 `tb_asset_outbound_record` 保存每个世代的出库记录:资产类型与 ID、资产标识快照、世代、出库时间、来源(`order` / `exchange` / `backfill`)、订单号或换货单号、店铺、结束时间与原因。同一资产同一世代只有一条(唯一约束);记录只新增与填写结束信息,不修改出库时间、不删除。系统 SHALL 提供按资产查询各世代出库记录的接口。 + +#### Scenario: 换货后历史可查 + +- **WHEN** 资产换货回库后再次销售并购买套餐 +- **THEN** 能查到两条记录:旧世代带结束时间与原因 `exchange_return`,新世代为新的出库时间 + +### Requirement: 换货时的出库时间 + +换货时旧资产世代 +1、回到在库的同一事务内,系统 MUST 清空旧资产 `outbound_at` 并为旧世代记录填写结束时间与原因;换货后的新资产若当前世代出库时间为空,MUST 写入换货完成时间并插入出库记录(来源 `exchange`,带换货单号)。 + +#### Scenario: 换货新资产 + +- **WHEN** 卡 A 换成卡 B 完成 +- **THEN** 卡 A 出库时间清空,卡 B 出库时间为换货完成时间 + +### Requirement: 出库时间历史回填与展示 + +系统 SHALL 提供出库时间回填工具:按资产当前世代最早一笔已支付或已退款的套餐订单回填(排除当前绑定在设备上的卡),分批执行、只更新为空的、可重复执行,并插入来源 `backfill` 的出库记录;回填前输出可回填数与无订单数。老系统迁移、新系统无订单的资产保持为空,后续另行回填。卡列表、设备列表返回出库时间并支持按时间范围筛选;卡详情、设备详情与资产解析返回出库时间;卡、设备导出增加"出库时间"列。字段与记录只读。 + +#### Scenario: 无订单资产 + +- **WHEN** 老系统迁移的卡在新系统没有任何套餐订单 +- **THEN** 回填后出库时间仍为空 diff --git a/openspec/changes/add-package-silence-period/specs/order-refund-exchange/spec.md b/openspec/changes/add-package-silence-period/specs/order-refund-exchange/spec.md new file mode 100644 index 00000000..92d9d3a1 --- /dev/null +++ b/openspec/changes/add-package-silence-period/specs/order-refund-exchange/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: 沉默期已过期的套餐不能退款 + +沉默期总开关打开时,退款申请创建 MUST 拦截 `silence_status = expired` 的套餐,返回 `silence_expired_not_refundable`。沉默期延期费 MUST NOT 退款,仅在延期执行异常时按支付单冻结的商户原路退回。 + +#### Scenario: 申请退款过期套餐 + +- **WHEN** 为一笔沉默期已过期的套餐发起退款申请 +- **THEN** 系统拒绝,返回 `silence_expired_not_refundable` + +### Requirement: 换货时沉默期随套餐迁移 + +换货迁移套餐记录时,沉默期字段 MUST 随记录迁移,不重新起算、不重新给沉默期;现有迁移条件 `status IN (0,1,2)` 已包含 `waiting` 与 `expired` 记录,无需修改。迁移后:`waiting` 记录按新资产实名事实判定;`expired` 记录仍为过期,可在新资产上延期。延期执行按套餐记录当前载体,审批在换货前提交、换货后通过的,延期作用于迁到新资产的同一笔套餐。迁移 MUST 写沉默期流水(换货单号、前后资产、截止时间)。 + +#### Scenario: 过期套餐换货后延期 + +- **WHEN** 卡 A 上一笔沉默期已过期的套餐随换货迁到卡 B +- **THEN** 套餐在卡 B 上仍为过期,截止日不变,可在卡 B 上办理延期 diff --git a/openspec/changes/add-package-silence-period/specs/package-queue-activation/spec.md b/openspec/changes/add-package-silence-period/specs/package-queue-activation/spec.md new file mode 100644 index 00000000..f08945ed --- /dev/null +++ b/openspec/changes/add-package-silence-period/specs/package-queue-activation/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: 排队与孤儿恢复跳过沉默期已过期套餐 + +沉默期总开关打开时,排队取下一个主套餐(`getNextPendingMainPackage`)MUST 跳过 `silence_status = expired` 的套餐记录,避免过期套餐挡住同资产后续套餐;孤儿待生效套餐扫描(`findAndActivateOrphanPackages`)的候选查询 MUST 排除 `silence_status = expired` 的记录,不得为其重复提交激活任务。总开关关闭时行为与现状一致。 + +#### Scenario: 队首是过期套餐 + +- **WHEN** 当前主套餐到期,同资产队首待生效套餐 `silence_status = expired`,其后还有一笔正常待生效套餐 +- **THEN** 系统跳过过期套餐,激活其后的正常套餐 + +#### Scenario: 孤儿扫描不选中过期套餐 + +- **WHEN** 资产没有生效中主套餐,唯一的待生效主套餐已沉默期过期 +- **THEN** 孤儿扫描不选中该记录,不提交激活任务 diff --git a/openspec/changes/add-package-silence-period/specs/package-silence-extension/spec.md b/openspec/changes/add-package-silence-period/specs/package-silence-extension/spec.md new file mode 100644 index 00000000..eded9070 --- /dev/null +++ b/openspec/changes/add-package-silence-period/specs/package-silence-extension/spec.md @@ -0,0 +1,157 @@ +## ADDED Requirements + +### Requirement: 延期政策可配置 + +系统 SHALL 提供延期政策 `tb_package_silence_extension_policy` 的列表、详情、新增、编辑、启停与"查询某套餐当前匹配政策",需 `package_silence:policy` 权限,变更写审计。政策字段:名称、适用范围(`all` 全部 / `series` 系列 / `package` 指定套餐)与范围 ID、每月单价(分)、免费延期次数、付费延期累计最多月数、单次最多月数(可空)、免审批场景(多选)、生效与失效时间、启停。配置按月填写,1 个月 = 30 天。 + +匹配规则:候选为启用且在生效期内的政策,优先级"指定套餐 > 套餐系列 > 全部套餐";同一优先级的适用范围与生效时间段 MUST NOT 重叠,保存时重叠返回 `silence_policy_overlap`。上线预置一条"全部套餐、长期有效、500 分/月、免费 1 次、累计 6 个月、单次不限、无免审批场景"的默认政策。 + +延期订单、批次与明细 MUST 冻结政策快照(政策 ID 与名称、单价、免审批场景、免费次数、付费累计与单次上限);修改或停用政策只影响之后发起的延期。 + +#### Scenario: 按优先级匹配 + +- **WHEN** 存在全部套餐政策(5 元/月)与"年卡"系列政策(3 元/月),为年卡系列套餐查询延期资格 +- **THEN** 单价返回 300 分 + +#### Scenario: 同级重叠 + +- **WHEN** 新建一条与已有系列政策同系列、生效时间重叠的政策 +- **THEN** 保存失败,返回 `silence_policy_overlap` + +#### Scenario: 改价不影响已创建订单 + +- **WHEN** 延期订单创建后政策单价从 5 元改为 8 元 +- **THEN** 该订单仍按 5 元计价 + +### Requirement: 延期资格与计费 + +系统 SHALL 提供按套餐记录查询延期资格的接口(后台与 H5 同语义),返回:能否付费延期 `can_extend` 与原因、能否申请特殊延期、原截止日、已过期天数、最少与最多可输入天数、最少与最多月数、已累计与已付费延期天数、能否申请免费延期、单价、各月数选项(计费天数、金额、新截止日)。计算 MUST 由后端完成,不接受前端传入金额: + +```text +overdue_days = 今天日期 − 套餐截止日期 +min_months = floor(overdue_days / 30) + 1 +remain = 政策.paid_max_months − silence_paid_extended_days / 30 +max_months = min(remain, 政策.single_max_months)(单次上限为空时取 remain) +min_days = overdue_days + 1;max_days = max_months × 30 +months = ceil(输入天数 / 30);计费天数 = 延长天数 = months × 30 +amount = months × 政策单价 +新截止日 = (原截止日 + 延长天数) 当日 23:59:59 +``` + +前置条件:套餐 `silence_status = expired`(`waiting` 但已到期的先判定为过期)。未到期、无沉默期、已激活、已退款的拒绝。套餐 `silence_extension_enabled = false` 或未匹配到政策时不能付费延期,但可申请特殊延期。`min_months > max_months` 时不能付费延期。总开关关闭时返回 `silence_feature_disabled`。 + +#### Scenario: 过期 75 天的资格 + +- **WHEN** 套餐截止日 2026-10-01,2026-12-15 查询,默认政策,未延期过 +- **THEN** 返回 `overdue_days = 75`、`min_days = 76`、`max_days = 180`、`min_months = 3`、`max_months = 6`、单价 500 + +#### Scenario: 按天向上取整计费 + +- **WHEN** 客户输入 1、30、31、60 天 +- **THEN** 分别按 30、30、60、60 天计费并延长相同天数 + +#### Scenario: 超过可延期期限 + +- **WHEN** 截止日 2026-10-01,2027-04-05 查询(已过期 186 天),默认政策 +- **THEN** `can_extend = false`,返回 `min_months = 7`、`max_months = 6`,特殊延期仍可申请 + +### Requirement: 在线付费延期 + +客户在 H5 自助下单,或运营在后台创建订单并生成支付二维码由客户扫码支付,MUST 支付成功后自动延期,不审批。 + +- 创建延期订单(`tb_package_silence_extension_order`)与支付单 `tb_payment`(`order_type = silence_extension`);商户按支付方式经 `merchantpayment` 商户池路由选择并冻结在支付单上;后台二维码复用渠道适配器生成支付内容存入 `qr_content`。 +- 下单时冻结输入天数、月数、单价、金额、下单时截止日与预计新截止日;按当天重算 min/max 校验。 +- 同一笔套餐同时最多一笔未结束订单:新建前若已有待支付订单,先对旧支付单查单,确认未支付则关闭旧订单再新建,已支付则按已支付处理并不再新建。 +- 支付回调按 `order_type` 分发,条件更新 `pending_payment → paid → completed`,重复回调幂等;回调金额不一致进入 `exception`。未支付订单复用支付恢复任务查单,过期关闭。 +- 支付时新截止日不再晚于当天的,明细进入 `exception`,按冻结的商户原路退款。 +- 回调、查单、退款按支付单冻结的商户加载凭证,不按当前商户池推断。 +- 延期费不参与分佣,不计入代理累计充值与业绩。不从代理钱包扣款。 + +#### Scenario: H5 付款后自动延期 + +- **WHEN** 客户在 H5 输入 76 天,微信支付 15 元成功 +- **THEN** 订单 `completed`,套餐截止日延长 90 天,回到 `waiting`,不产生审批与佣金 +- **AND** 订单列表显示支付方式、收款商户名称、交易流水号 + +#### Scenario: 后台二维码与客户旧订单冲突 + +- **WHEN** 客户已有一笔未支付的 H5 延期订单,运营为同一套餐生成二维码 +- **THEN** 系统先查单旧支付单,未支付则关闭旧订单后生成新二维码 + +#### Scenario: 重复支付回调 + +- **WHEN** 同一支付单的成功回调到达两次 +- **THEN** 只延期一次 + +### Requirement: 线下付费延期 + +运营(`package_silence:offline`)登记线下付款时 SHALL 输入延期天数(换算后月数在 min~max 内),填写线下收款方式(复用现有线下收款方式字典)、交易单号、实收金额、付款时间,上传收款凭证与客户申请依据。实收金额 MUST 等于系统计算金额,否则返回 `silence_offline_amount_mismatch`;同一收款方式下交易单号 MUST 唯一,重复返回 `silence_offline_trade_no_duplicate`。 + +政策未勾选免审批场景 `offline_paid` 时,订单 `pending_approval` 并提交企微审批(申请类型"线下付费延期"):通过后执行延期,驳回或撤回订单 `rejected`、不延期、系统不退款。勾选时登记即执行。线下付费计入付费累计天数,受政策上限与套餐"是否支持延期"约束。 + +#### Scenario: 默认需审批 + +- **WHEN** 运营登记一笔 15 元线下付款,政策未设免审批 +- **THEN** 订单待审批,审批通过后延期生效 + +#### Scenario: 交易单号重复 + +- **WHEN** 同一收款方式下再次登记相同交易单号 +- **THEN** 系统拒绝 + +### Requirement: 特殊延期 + +有权限的运营 SHALL 可提交特殊延期:免费、优惠价、客诉补偿、系统异常补偿、特殊客户政策;批量延期需上传明细且仅限 `package_silence:extend_batch`。申请字段包括资产、套餐与订单号、原截止日(系统带出)、延期天数(按天向上取整到 30 的倍数)、原因、客户申请记录或截图、费用(免费为 0)、免费或优惠依据;优惠价有收款的同时登记线下收款信息。 + +特殊延期 MUST NOT 受政策付费上限与套餐"是否支持延期"约束,只要求新截止日晚于今天(月数 ≥ min_months),天数不计入付费累计。免费延期次数 MUST 受政策 `free_extension_limit`(默认 1)约束:提交与执行时都校验,执行成功后计数加 1;驳回与撤回不占次数;同一笔套餐同时只能有一个进行中的免费延期申请。 + +政策未勾选对应免审批场景(批量看 `batch`,否则看延期类型)时提交企微审批(申请类型"特殊延期"),通过后自动执行,驳回或撤回不产生任何变化;勾选时提交即执行。需审批的场景 MUST NOT 先延期后补审批。批量延期逐条执行,部分失败不影响其他行,记录成功数、失败数与失败原因。 + +#### Scenario: 免费延期次数用完 + +- **WHEN** 套餐已成功使用 1 次免费延期,政策免费次数为 1,再次申请免费延期 +- **THEN** 拒绝,返回 `silence_free_extension_used_up` + +#### Scenario: 付费办不了仍可特殊延期 + +- **WHEN** 套餐已过期 186 天、付费延期不可办理 +- **THEN** 仍可提交特殊延期,月数不受上限限制 + +#### Scenario: 批量部分失败 + +- **WHEN** 批量特殊延期 10 条,审批通过时其中 2 条已被退款 +- **THEN** 8 条延期成功,2 条记失败及原因 + +### Requirement: 延期执行 + +所有延期路径 MUST 在同一事务内执行:锁载体与套餐记录并重读;校验仍为 `expired`、新截止日晚于当天;付费延期另校验单次与累计上限(按政策快照);更新 `silence_status = waiting`、`silence_deadline_at = SilenceDeadline(原截止日期, 延长天数)`、累计延期天数、付费延期天数(付费延期)、延期次数、免费次数(免费延期);同事务写明细状态、批次计数、流水与审计;提交后执行沉默期判定,期内已实名则激活或排队。执行以明细状态条件更新保证幂等。执行失败进入 `exception`,有 `package_silence:exception` 权限者可重试或关闭,MUST NOT 直接改库时间。事务内 MUST NOT 调用支付渠道、Gateway 或企微接口。 + +延期只延长实名等待时间,不增加套餐使用时长;延期后再次到期未实名可再次延期。 + +#### Scenario: 延期时资产已实名 + +- **WHEN** 卡在过期后、延期前已实名,延期后新截止日晚于实名时间 +- **THEN** 延期执行后套餐立即激活(或排队) + +#### Scenario: 重复审批通过事件 + +- **WHEN** 同一审批单的通过事件到达两次 +- **THEN** 只执行一次延期 + +### Requirement: 沉默期企微审批场景 + +系统 SHALL 新增一个企微审批业务类型 `package_silence_approval`,用模板中的"申请类型"控件区分"购买时调整沉默期""特殊延期""线下付费延期"。申请人为发起人绑定的企微成员(`creator_userid`),审批人按模板配置(`use_template_approver = 1`);发起人未绑定企微成员时拒绝提交。回调按审批单关联的业务记录(调整申请 / 延期批次 / 延期订单)分发,不依赖表单文本。复用现有场景配置、模板指纹校验、可靠提交、回调与轮询。勾选为免审批的场景不提交到本场景。 + +#### Scenario: 发起人未绑定企微 + +- **WHEN** 未绑定企微成员的运营提交特殊延期 +- **THEN** 系统拒绝提交 + +### Requirement: 延期订单查询 + +系统 SHALL 提供延期订单列表、详情与导出:后台按套餐、资产、订单号、状态、支付方式、收款商户、交易流水号、支付时间筛选,叠加店铺数据范围;在线订单返回 `payment_method`、`payment_merchant_name`、`third_party_trade_no`(取已支付或已退款支付单快照),线下订单返回收款方式与交易单号;H5 客户查看自己的延期订单。延期订单纳入财务交易流水查询与导出。交易记录不删除。 + +#### Scenario: 查询在线延期订单 + +- **WHEN** 后台查询一笔已完成的微信延期订单 +- **THEN** 返回收款商户名称与交易流水号,与线上套餐订单字段一致 diff --git a/openspec/changes/add-package-silence-period/specs/package-silence-period/spec.md b/openspec/changes/add-package-silence-period/specs/package-silence-period/spec.md new file mode 100644 index 00000000..36cb9776 --- /dev/null +++ b/openspec/changes/add-package-silence-period/specs/package-silence-period/spec.md @@ -0,0 +1,178 @@ +## ADDED Requirements + +### Requirement: 实名即生效套餐沉默期按每笔套餐计算 + +系统 SHALL 在订单支付成功、创建套餐记录的同一事务内,为满足条件的套餐记录设置沉默期:主套餐(`master_usage_id IS NULL`)、生效条件快照为 `from_activation`、购买时资产尚未实名(`pending_realname_activation = true`)、所购套餐已配置默认沉默期。沉默期字段 MUST 保存在套餐记录上,资产上 MUST NOT 保存沉默期字段。同一资产的多笔实名即生效套餐 MUST 各自计算。 + +截止时间 MUST 按天计算:`(支付成功日期 + 沉默期天数) 当日 23:59:59`,时区 `Asia/Shanghai`,由唯一领域函数计算。 + +#### Scenario: 资产未实名时购买实名即生效套餐 + +- **WHEN** 2026-09-24 10:00 为未实名的卡购买默认沉默期 30 天的实名即生效主套餐并支付成功 +- **THEN** 套餐记录 `silence_status = waiting`、`silence_start_at` 为支付成功时间、`silence_period_days = 30` +- **AND** `silence_deadline_at = 2026-10-24 23:59:59` +- **AND** 写入一条沉默期流水(set),含默认值快照、是否调整、操作人、原因 + +#### Scenario: 不进入沉默期的套餐 + +- **WHEN** 购买的是加油包、购买即生效套餐,或购买时资产已实名,或套餐未配置默认沉默期 +- **THEN** 套餐记录 `silence_status` 为空,行为与现状一致 + +#### Scenario: 同一资产多笔套餐各自计算 + +- **WHEN** 同一张未实名卡在 09-01 与 09-20 各购买一笔实名即生效套餐 +- **THEN** 两笔套餐的截止日分别按各自的支付日期计算,一笔过期不影响另一笔 + +### Requirement: 套餐默认沉默期与是否支持延期配置 + +实名即生效套餐 MUST 配置默认沉默期 `silence_period_days`(1~90 天,新建时预填 30)与 `silence_extension_enabled`(默认支持);普通套餐 MUST NOT 写入沉默期。修改默认值 MUST 只影响之后的新购买,并写审计。系统 SHALL 提供"未配置沉默期的实名即生效套餐"清单(筛选与导出)。 + +#### Scenario: 实名即生效套餐未填默认沉默期 + +- **WHEN** 新建或编辑 `from_activation` 套餐时未提供 `silence_period_days`,或值不在 1~90 +- **THEN** 系统拒绝保存,返回 `silence_period_out_of_range` + +#### Scenario: 修改默认值不影响已购买记录 + +- **WHEN** 将套餐默认沉默期从 30 改为 45 +- **THEN** 已有套餐记录的截止日不变,之后的新购买按 45 天计算 + +### Requirement: 禁止绕过沉默期的生效条件变更 + +系统 MUST 在套餐服务与店铺分配(单个、批量、系列授权)写入路径的共用校验中阻断:套餐 `from_activation` 改为 `from_purchase`、分配覆盖把 `from_activation` 套餐改为 `from_purchase`、分配覆盖把 `from_purchase` 套餐改为 `from_activation`。套餐 `from_purchase` 改为 `from_activation` 时同一请求 MUST 提供默认沉默期。 + +#### Scenario: 实名即生效改为购买即生效 + +- **WHEN** 管理员把实名即生效套餐改为购买即生效,或在店铺分配上覆盖为购买即生效 +- **THEN** 系统拒绝,返回 `package_expiry_base_change_forbidden` + +### Requirement: 后台购买时调整沉默期 + +后台给资产购买会进入沉默期的套餐时,有 `package_silence:override` 权限的运营 SHALL 可以设置本次沉默期(≥ 1 天,不设上限),必须填写原因;优先级为本次设置值高于套餐默认值。 + +- 设置值不超过默认值:直接生效。 +- 设置值超过默认值:按匹配到的延期政策判断;未勾选免审批场景 `purchase_adjust`(含未匹配到政策)时,系统 MUST 先按默认值创建套餐记录,同时提交企微审批(申请类型"购买时调整沉默期"),并标记 `silence_adjust_pending = true`;勾选时直接按设置值生效。 +- 审批中的套餐 MUST NOT 因到期而过期。审批通过后按 `起算日期 + 审批天数` 重算截止日;驳回或撤回保持默认值。审批结束后立即重新判定沉默期。 +- 自动购包、钱包自动续费等无人参与的路径 MUST 只用默认值。 + +#### Scenario: 无权限传入调整值 + +- **WHEN** 无 `package_silence:override` 权限的账号在购买时传入沉默期 +- **THEN** 系统拒绝,返回 `silence_override_forbidden` + +#### Scenario: 超默认值需审批 + +- **WHEN** 默认 30 天的套餐在后台购买时设置为 120 天,政策未设免审批 +- **THEN** 套餐记录按 30 天设置截止日,`silence_adjust_pending = true`,并提交企微审批 +- **AND** 审批期间超过 30 天截止日也不会过期 +- **AND** 审批通过后截止日改为购买日期 + 120 天;若重算后已过截止且期内未实名,立即过期 + +#### Scenario: 超默认值且政策免审批 + +- **WHEN** 匹配到的延期政策勾选了 `purchase_adjust` +- **THEN** 套餐记录直接按设置值计算截止日,不提交审批 + +### Requirement: 实名与过期判定 + +系统 SHALL 用统一判定处理 `waiting` 的套餐记录:期内已实名则沉默期结束(`ended`),之后按现有逻辑激活;同资产已有生效主套餐时继续排队,排队期间不再受沉默期限制。已到期、期内未实名且无调整审批的,MUST 标记为 `expired`,`status` 保持 0,记录 `silence_expired_at`,写流水与审计。 + +实名时间取实际业务时间:卡取 `first_realname_at`;设备取所有已绑定且已实名卡中 `max(卡实名时间, 绑定时间)` 的最小值。卡当前已实名但 `first_realname_at` 为空时 MUST 视为期内已实名,并补写 `first_realname_at` 为判定时间。 + +判定入口包括:运营商实名观测与人工实名、卡绑定设备、排队接续、指定激活、孤儿套餐扫描、延期生效、调整审批结束、沉默期扫描。 + +#### Scenario: 截止日当天实名 + +- **WHEN** 截止日为 10-24 23:59:59,卡在 10-24 23:30 实名 +- **THEN** 套餐 `silence_status = ended` 并按现有逻辑激活,有效期从激活时间起算 + +#### Scenario: 截止后实名 + +- **WHEN** 卡在 10-25 08:00 才实名 +- **THEN** 套餐标记为 `expired`,不激活 + +#### Scenario: 截止后才绑定已实名卡的设备 + +- **WHEN** 设备套餐截止后,才绑定一张早已实名的卡 +- **THEN** 设备实名时间取绑定时间,晚于截止日,套餐过期 + +#### Scenario: 期内实名但需排队 + +- **WHEN** 资产在截止前实名,但同资产已有生效主套餐 +- **THEN** 这笔套餐 `silence_status = ended`、`status` 仍为 0 排队,之后按排队接续激活,不再因沉默期过期 + +#### Scenario: 实名时间为空的已实名卡 + +- **WHEN** 卡 `real_name_status` 为已实名但 `first_realname_at` 为空 +- **THEN** 判定为期内已实名,不判过期,并补写 `first_realname_at` + +### Requirement: 沉默期已过期的套餐不能激活 + +`silence_status = expired` 的套餐 MUST NOT 被任何激活入口激活。`ActivationService.canActivatePendingUsage` MUST 在现有实名判断前先执行沉默期判定,过期时返回不可激活。对外展示 MUST 显示"沉默期已过期(失效)"并返回截止日与过期时间。任何人 MUST NOT 通过修改时间或状态把 `expired` 恢复为 `waiting`;只能通过延期。 + +#### Scenario: 过期套餐实名后仍不激活 + +- **WHEN** 已过期套餐所属卡完成实名 +- **THEN** 套餐不激活,仍为 `expired` + +#### Scenario: 已到期但尚未被扫描 + +- **WHEN** 套餐仍为 `waiting` 但已过截止日,此时触发任一激活入口 +- **THEN** 入口先判定为过期,不激活 + +### Requirement: 沉默期扫描 + +系统 SHALL 每 10 分钟运行 `package:silence:scan`,查询 `status = 0 AND silence_status = 'waiting' AND silence_deadline_at < now() AND silence_adjust_pending = false` 的记录(单批 200,最多 20 批,Unique 9 分钟,MaxRetry 3),逐条执行统一判定。状态更新 MUST 带期望状态条件并检查影响行数,重复执行幂等。扫描只在总开关打开时运行。 + +#### Scenario: 扫描与实时判定一致 + +- **WHEN** 扫描和实名事件同时处理同一条到期记录 +- **THEN** 只有一方更新成功,结果一致,审计只写一次 + +### Requirement: 沉默期总开关 + +系统 SHALL 提供系统配置 `package.silence.enabled`,默认关闭,仅超级管理员可切换,每次切换写审计 `package.silence_switch_changed`(操作人、前后值、检查结果)。 + +- 关闭时:新购买照常写入沉默期字段(只记录);不拦截激活、不扫描、不拦截退款、不展示沉默期与延期入口;延期资格、下单、线下登记、特殊延期申请返回 `silence_feature_disabled`;套餐与政策配置、审批模板配置、历史回填工具可用;购买时调整照常记录与提交审批。 +- 打开前检查:实名即生效套餐全部已配置默认沉默期;没有"待实名、实名即生效主套餐、沉默期字段为空"的记录;存在启用中的"全部套餐"延期政策;企微审批场景 `package_silence_approval` 已配置启用。任一项不满足 MUST 拒绝打开,返回 `silence_switch_precheck_failed` 及未满足项与数量。 +- 打开后:所有规则同时生效,扫描开始运行。 +- 打开后再关闭:规则暂停、数据保留;已发生的付款回调、查单补偿与审批回调 MUST 照常处理完;关闭期间不接受新的延期申请。 + +#### Scenario: 默认关闭时行为不变 + +- **WHEN** 总开关关闭,未实名卡购买实名即生效套餐后超过截止日才实名 +- **THEN** 套餐记录有截止日,但实名后照常激活,不判过期 + +#### Scenario: 检查未通过拒绝打开 + +- **WHEN** 仍有 3 个实名即生效套餐未配置默认沉默期时尝试打开 +- **THEN** 系统拒绝,返回未满足项"未配置默认沉默期的套餐:3" + +#### Scenario: 应急关闭时处理进行中的付款 + +- **WHEN** 总开关打开后又关闭,此时一笔延期订单的支付回调到达 +- **THEN** 回调照常处理并执行延期 + +### Requirement: 历史数据回填 + +系统 SHALL 提供沉默期回填工具,可在总开关关闭时运行。前提是相关套餐的默认沉默期已补齐。回填范围为 `status = 0 AND pending_realname_activation = true` 的实名即生效主套餐记录;起算为该笔套餐的购买(支付成功)时间;天数取套餐默认沉默期;未配置的套餐不回填并列入清单。回填时 MUST 同时按实名时间判定,截止前已实名的直接记为 `ended`。不给上线宽限:截止早于上线日且期内未实名的,打开总开关后过期。 + +回填 MUST 先生成影响清单(套餐记录数、按代理与套餐分布、期内已实名数、实名时间为空的已实名数、将过期数、将过期且无法付费延期数),经业务确认后执行;每条写流水(backfill);可重复执行。 + +#### Scenario: 回填时已实名 + +- **WHEN** 历史套餐按购买时间算出的截止日之前,卡已实名 +- **THEN** 回填记为 `ended`,不会过期 + +#### Scenario: 回填后截止日已过 + +- **WHEN** 历史套餐购买于 2026-03-01、默认 30 天,上线日为 2026-11-15,卡未实名 +- **THEN** 回填截止日为 2026-03-31 23:59:59,打开总开关后第一轮扫描标记为过期 + +### Requirement: 沉默期变更留痕 + +套餐沉默期的设置、调整、过期、结束、延期、换货迁移、回填 MUST 写入不可变流水 `tb_package_silence_record`(套餐记录、事件类型、变更前后状态与截止时间、天数、默认值快照、是否调整、操作人、原因、审批单号、延期明细、时间),并写对应审计事件。系统 SHALL 提供按套餐查询流水的接口。 + +#### Scenario: 查询一笔套餐的沉默期历史 + +- **WHEN** 查询某笔经历过购买、过期、延期的套餐流水 +- **THEN** 依次返回 set、expired、extend 三条记录及前后截止时间 diff --git a/openspec/changes/add-package-silence-period/tasks.md b/openspec/changes/add-package-silence-period/tasks.md new file mode 100644 index 00000000..d4f1c2fe --- /dev/null +++ b/openspec/changes/add-package-silence-period/tasks.md @@ -0,0 +1,121 @@ +> 规则细节以 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md`(下称"技术方案")为准,任务中的 §n 指技术方案章节。测试环境是唯一验证面(PostgreSQL `junhong_cmp_test`、Redis DB 6、`Iteration` 分支部署、`cmp-test` 日志);迁移用 `~/go/bin/migrate` 与 `.env` 的 `DB_*`;fixture 只创建 / 删除本变更自己的记录;只读诊断用 dbhub。 + +## 1. 数据层(§3、§13.1、§17.2) + +- [x] 1.1 迁移①:`tb_package_usage` 新增 10 个沉默期字段(`silence_status`、`silence_start_at`、`silence_period_days`、`silence_deadline_at`、`silence_extended_days`、`silence_paid_extended_days`、`silence_extension_count`、`silence_free_extension_used`、`silence_expired_at`、`silence_adjust_pending`),计数类 `NOT NULL DEFAULT 0`,布尔 `NOT NULL DEFAULT false`,带列注释;成对 down +- [x] 1.2 迁移②:部分索引 `idx_package_usage_silence_scan (silence_deadline_at, id) WHERE deleted_at IS NULL AND status = 0 AND silence_status = 'waiting'` +- [x] 1.3 迁移③:`tb_package` 新增 `silence_period_days`(可空)、`silence_extension_enabled`(`NOT NULL DEFAULT true`) +- [x] 1.4 迁移④:新建 `tb_package_silence_extension_policy`、`tb_package_silence_record`、`tb_package_silence_extension_order`、`tb_package_silence_extension_batch`、`tb_package_silence_extension`,含 §7.1 / §7.2 的唯一约束与部分唯一索引(同一套餐最多一笔未结束订单、线下收款方式 + 交易单号唯一、`UNIQUE(batch_id, package_usage_id)`、同一套餐最多一条未结束明细) +- [x] 1.5 迁移⑤⑥:插入默认延期政策(全部套餐、长期、500 分/月、免费 1 次、累计 6 个月、单次不限、无免审批场景);插入系统配置 `package.silence.enabled`(false)、`package.silence.default_days`(30)、`package.silence.extension_unit_price`(500) +- [x] 1.6 迁移⑦:扩展 `tb_wecom_approval_scene.chk_wecom_approval_scene_business`,在 `000216` 现有白名单基础上加入 `package_silence_approval`;down 恢复原白名单 +- [x] 1.7 迁移⑧⑨:`tb_iot_card`、`tb_device` 新增 `outbound_at timestamptz NULL` 与索引;新建 `tb_asset_outbound_record`(`UNIQUE(asset_type, asset_id, generation)`) +- [x] 1.8 测试库执行 `up → down → up`,确认约束、索引、默认数据正确 +- [x] 1.9 模型:`PackageUsage`、`Package`、`IotCard`、`Device` 增加字段;新增 6 个表的模型 +- [x] 1.10 Store:延期政策、沉默期流水、延期订单、批次、明细、出库记录的 Store(条件更新方法返回影响行数) +- [x] 1.11 常量与错误码:`silence_status` 取值、延期路径与类型、免审批场景枚举、`order_type = silence_extension`、审批业务类型 `package_silence_approval`、审计动作(§11.2)、权限点(§11.1)、错误码(§12.5) + +## 2. 领域规则(§4.2、§5.2、§8.2、§3.6) + +- [x] 2.1 `internal/domain/package/silence.go`:`SilenceDeadline(date, days)`(当日 23:59:59,Asia/Shanghai) +- [x] 2.2 沉默期判定:输入当前状态、截止时间、实名时间、是否有调整审批、当前时间,输出 wait / end / expire / noop +- [x] 2.3 延期报价:输入截止日、今天、输入天数、政策快照、已付费延期天数、延期类型,输出 overdue、min/max 天数与月数、计费月数与天数、金额、新截止日、能否办理与原因 +- [x] 2.4 政策匹配(指定套餐 > 系列 > 全部,启用且在生效期)与同级重叠校验;免审批判断 +- [x] 2.5 用 §8.2 示例表四种情况、输入 1/30/31/60 天、跨月跨年日期在测试环境调用路径中核对一次结果(不写自动化测试) + - 核对结果(2026-10-27,临时程序调用领域函数,已删除):截止 10-01 时 10-20 / 12-15 / 次年 03-20 / 次年 04-05 分别为 overdue 19/75/170/186、min 1/3/6/7、max 6/6/6/6,选最少新截止 10-31 / 12-30 / 03-30,第四种 `exceeds_limit`;输入 1/30/31/60 天计费 30/30/60/60;09-24 10:00 + 30 → 10-24 23:59:59;跨年 12-20 + 30 → 01-19,跨月 01-31 + 30 → 03-02;判定:截止当天 23:30 实名 → end、截止后实名 → expire、已实名无时间 → end 并补写、调整审批中 → 不过期。 + +## 3. 套餐配置(§3.2、§3.3) + +- [x] 3.1 套餐新增 / 编辑 DTO 与校验:实名即生效套餐必填 `silence_period_days`(1~90)、`silence_extension_enabled`;普通套餐禁止写入;修改默认值写审计 `package.silence_default_changed` +- [x] 3.2 生效条件变更约束:套餐服务与店铺分配(单个、批量、系列授权)共用校验函数,阻断 §3.3 的三种变更,`from_purchase → from_activation` 要求同时提供默认沉默期 +- [x] 3.3 "未配置沉默期的实名即生效套餐"列表接口(筛选、导出) + +## 4. 沉默期设置、判定与激活拦截(§4、§5) + +- [x] 4.1 应用层 `internal/application/packagesilence/resolve.go`:`ResolveUsageSilence`(加锁、读实名事实、条件更新、写流水与审计);实名时间按 §5.1(卡 `first_realname_at`;设备取绑定卡 `max(实名, 绑定)` 的最小值;已实名但时间为空的视为期内并补写) +- [x] 4.2 订单 `activatePackage` 与自动购包 `activatePackages`:创建套餐记录时按 §4.1 设置沉默期并写流水(总开关关闭也写) + - 备注:除订单 `activatePackage` 与自动购包 `activatePackages` 外,钱包自动续费(`internal/application/assetautorenewal/renew.go`)也创建套餐记录,同样接入(无人参与,只用默认值);三处都在创建记录的同一事务内调用 `packagesilence.Setter.ApplyOnUsageCreatedInTx`。 +- [x] 4.3 后台购买:DTO 增加 `silence_period_days`、`silence_reason`;权限 `package_silence:override`;超默认值按政策决定直接生效或提交调整审批(`silence_adjust_pending = true`);无人参与路径只用默认值 + - 备注:审批实例 `(business_type, business_id)` 唯一,三种申请共用 `package_silence_approval`,因此购买时调整也建一个批次(`path = adjust`,迁移 000262 已含该取值与 `adjust_*` 字段),审批统一关联批次 ID。调整值经上下文传入 `activatePackage`(后台下单为钱包 / 线下即时支付,同一请求内发放套餐)。 +- [x] 4.4 `canActivatePendingUsage` 在实名判断前调用 `ResolveUsageSilence`(仅总开关打开时拦截) + - 备注:`ActivateByRealname` 不经过 `canActivatePendingUsage`,同样在主套餐激活前加了闸门;挂在过期主套餐下的待实名加油包随主套餐不激活(D21)。 +- [x] 4.5 `getNextPendingMainPackage` 跳过 `silence_status = expired`;`findAndActivateOrphanPackages` 候选查询排除 `expired`(总开关打开时) +- [x] 4.6 全局排查 `PackageUsageStatusPending` 的 42 处查询,逐一确认过期套餐的处理(激活、排队、展示、统计、停复机评估、换货、退款),在任务备注中列出结论 + - 排查结论(2026-10-27): + - 需改并已改:激活闸门 `canActivatePendingUsage`、实名激活 `ActivateByRealname`、排队队首 `getNextPendingMainPackage`、主套餐到期后提交下一个激活任务 `polling.activateNextPackage`、孤儿扫描 `findAndActivateOrphanPackages`、加油包挂靠 `FindAttachableMainPackageForAddon`(过期主套餐不能再挂加油包)、退款申请创建(4.7)。 + - 不改(过期仍按待生效处理是正确的):换货迁移 `migration.go:105`(随记录迁移)、退款 / 资产失效 `InvalidatePackagesForRefund`、`InvalidateAllPackagesByAsset`(失效照常)、加油包级联失效、实名轮询范围 `polling_realname_scope.go`(过期后仍需采集实名时间,延期后才能判定期内已实名并立即激活)、订单 / 自动购包 / 续费创建时的状态赋值、套餐到期提醒与续费查询 `query/packageexpiry`、`query/assetautorenewal`、运营报表 `domain/operationsreport`(统计口径不变)、`HasPendingPackageUsage`(优先轮询收敛条件)、`agent_open_api` 与各展示处的状态名(展示在 4.8 处理)。 + - 已知影响:资产若只有一笔沉默期已过期的待生效主套餐,钱包自动续费资格仍按"存在待生效主套餐"拦截(与现状一致,不在本变更范围)。 +- [x] 4.7 退款申请创建拦截 `silence_status = expired`(总开关打开时),返回 `silence_expired_not_refundable` +- [x] 4.8 资产套餐列表 / 详情(后台与 H5)增加沉默期字段与状态名;`waiting` 但已过截止的展示为"沉默期已过期";总开关关闭时不返回沉默期展示字段与延期入口标记 +- [x] 4.9 套餐沉默期流水查询接口 + +## 5. 扫描任务与总开关(§3.7、§5.4) + +- [x] 5.1 定时任务 `package:silence:scan`:每 10 分钟、单批 200、最多 20 批、Unique 9 分钟、MaxRetry 3;只在总开关打开时处理;注册到 worker 与调度 +- [x] 5.2 总开关读取(系统配置缓存)与各入口的统一判断函数 +- [x] 5.3 总开关写接口(仅超级管理员):打开前 4 项检查,不满足返回 `silence_switch_precheck_failed` 与未满足项数量;切换写审计 `package.silence_switch_changed` +- [x] 5.4 关闭时延期资格、下单、线下登记、特殊延期申请返回 `silence_feature_disabled`;已发生的支付回调、查单、审批回调不受开关影响 + +## 6. 延期政策(§3.6) + +- [x] 6.1 政策应用层:列表、详情、新增、编辑、启停、查询某套餐当前匹配政策;保存时重叠校验;写审计 +- [x] 6.2 后台 Handler 与路由,权限 `package_silence:policy` + +## 7. 延期办理(§7、§8) + +- [x] 7.1 延期资格接口(后台与 H5):按 §12.2 返回全部字段;前置条件与原因码 +- [x] 7.2 在线下单(H5 自助):参照 `agentrecharge/online_creation.go`,创建延期订单、批次、明细与 `tb_payment`,商户池路由并 `FreezeRoute`,冻结政策快照与金额 +- [x] 7.3 后台二维码:运营创建订单(上传申请依据),调用渠道适配器生成支付内容写入 `qr_content` 返回;已有待支付订单时先查单,未付关闭旧单、已付不新建 +- [x] 7.4 支付回调:`internal/handler/callback/payment.go` 按 `order_type = silence_extension` 分发到延期用例;金额核对;`pending_payment → paid → completed` 条件更新幂等 +- [x] 7.5 支付恢复:未支付订单查单与超时关闭(参照 `recover_online_payment.go`),处理"订单不存在" +- [x] 7.6 线下登记:参照 `offline_creation.go`,收款方式字典、交易单号唯一、实收金额等于计算金额、凭证;按政策快照决定提交审批或直接执行;权限 `package_silence:offline` +- [x] 7.7 特殊延期:单个申请与批量导入(Excel 明细、逐行校验);免费次数校验与并发控制;优惠价登记收款信息;按政策快照决定审批或直接执行;批量权限 `package_silence:extend_batch` +- [x] 7.8 延期执行用例(§8.6):同事务锁载体与套餐、重校验、更新字段、写明细 / 批次 / 流水 / 审计;提交后调用 `ResolveUsageSilence`;以明细状态条件更新保证幂等 +- [x] 7.9 执行异常:明细 `exception`;在线订单按冻结商户原路退款(复用退款渠道适配)并写订单退款字段;后台异常明细重试 / 关闭接口,权限 `package_silence:exception` +- [x] 7.10 确认延期订单不触发佣金计算、不计入代理累计充值与业绩 + +## 8. 企微审批(§11.3) + +- [x] 8.1 接入 `internal/application/approval` 创建与可靠提交:业务类型 `package_silence_approval`,申请类型控件与三种申请的表单映射(§11.3 表);发起人未绑定企微成员拒绝提交 +- [x] 8.2 回调分发:按审批实例关联的调整申请 / 延期批次 / 延期订单处理通过、驳回、撤回;重复事件只处理一次 +- [x] 8.3 调整审批通过后重算截止日并调用 `ResolveUsageSilence`;驳回保持默认值并立即判定 + +## 9. 换货与出库时间(§9、§17) + +- [x] 9.1 换货迁移写沉默期流水(换货单号、前后资产、截止时间);确认 `expired` 记录随迁移且不重算 +- [x] 9.2 出库时间写入函数:订单 `activatePackage` 与自动购包入口调用;条件 `outbound_at IS NULL AND generation = 订单世代`;绑定在设备上的卡跳过;影响行数为 1 时插入出库记录 +- [x] 9.3 换货:旧资产世代 +1 的同一事务清空 `outbound_at` 并结束旧记录;新资产写换货完成时间与 `exchange` 记录 +- [x] 9.4 卡 / 设备列表返回并可按出库时间筛选;详情与资产解析返回;导出增加"出库时间"列;出库记录查询接口 + +## 10. 展示、导出、文档与回填工具(§12、§6、§17.4) + +- [x] 10.1 延期订单列表、详情、导出(字段与线上订单一致);H5 我的延期订单;即将过期 / 已过期套餐列表与导出;延期批次与明细查询 +- [x] 10.2 财务交易流水(`internal/query/audit/finance.go`)纳入延期订单支付类型 +- [x] 10.3 新增 Handler 同步路由、`cmd/api/docs.go`、`cmd/gendocs/main.go`,重新生成 OpenAPI 文档 +- [x] 10.4 沉默期回填工具(`cmd/`):`--dry-run` 输出影响清单(§6 第 7 条各项);执行模式分批、只处理空值、可重复执行、按实名时间判定 `ended`、写流水 +- [x] 10.5 出库时间回填工具:按当前世代最早已支付 / 已退款套餐订单回填,排除绑定在设备上的卡,插入 `backfill` 记录,输出可回填数与无订单数 +- [x] 10.6 前端接口变更说明(新增接口、字段、错误码、总开关对页面的影响) +- [x] 10.7 `gofmt`、`go build ./...`、`go vet`、`openspec validate add-package-silence-period` + +## 11. 测试环境验证(§14、§17.6) + +- [ ] 11.1 部署后确认总开关关闭时行为不变:新购买写入沉默期字段,过期后实名仍激活,延期接口返回 `silence_feature_disabled` +- [ ] 11.2 总开关打开前检查:逐项制造不满足条件,确认拒绝及返回内容;全部满足后打开成功并写审计 +- [ ] 11.3 沉默期设置、购买时调整(直接生效 / 审批通过 / 驳回 / 免审批)、实名与过期判定(§14 第 1~12 条) +- [ ] 11.4 延期资格与计费(§14 第 13~15 条,含示例表四种情况) +- [ ] 11.5 四种延期办理(§14 第 16~21 条):H5 微信或支付宝真实小额支付、后台二维码扫码、线下登记审批、特殊与免费延期、批量部分失败、跨天支付异常退款 +- [ ] 11.6 延期政策与免审批开关(§14 第 22~23 条) +- [ ] 11.7 换货与历史回填(§14 第 24~25 条):回填 dry-run 清单与执行、重复执行结果不变 +- [ ] 11.8 出库时间(§17.6 全部场景) +- [ ] 11.9 应急关闭:打开后关闭,确认规则暂停、数据保留、进行中支付回调照常完成 +- [ ] 11.10 清理本变更创建的测试数据,记录验证结果到本文件 + +## 实施备注(2026-10-27) + +- 迁移编号 000259~000265(测试库 `junhong_cmp_test` 已执行 up → down 7 → up,默认政策、三项系统配置、扫描索引、部分唯一索引、审批白名单核对正确)。 +- 审批关联:三种申请共用 `package_silence_approval`,审批实例 `business_id` 统一为批次 ID;购买时调整也建批次(`path = adjust`),线下付费的批次与订单一一对应。 +- H5 自助与后台二维码都复用代理在线充值的渠道适配器 `CreatePaymentURL`,返回 `qr_content`(支付链接 / 二维码内容),未新增 JSAPI 支付方式。 +- 在线订单执行异常(跨天、金额不一致、订单关闭后到账等)自动按冻结商户原路退款,恢复任务 `package:silence:order_recover` 每 5 分钟重试;线下与特殊延期的异常只能人工重试或关闭。 +- 支付截止时间不跨天(不晚于当天 23:59:59),降低跨天支付进入异常的概率。 +- 总开关在通用系统配置接口中只读,只能通过 `PUT /api/admin/package-silence/switch` 切换;进程内缓存 5 秒。 +- 回填工具:`cmd/silence-backfill`(`-target silence|outbound`,默认只出清单,`-execute` 执行);2026-10-27 在测试库 dry-run:沉默期候选 1 条(套餐未配置默认沉默期、不回填),出库时间可回填卡 9 / 设备 6、无订单卡 7、绑定在设备上的卡 15。 +- 前端说明:`docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md`。 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 72944077..08330dab 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -325,6 +325,9 @@ func QueueForTaskType(taskType string) string { return QueueDataCleanup case TaskTypePackageExpiryReminder: return QueueDataCleanup + case TaskTypePackageSilenceScan, TaskTypePackageSilenceOrderRecover: + // 沉默期扫描与延期订单查单恢复:轻量扫描,复用既有已监听队列,不新增队列与权重。 + return QueueDataCleanup case TaskTypePackageTrafficAlertScan: // 与套餐临期扫描同队列:轻量只读扫描,复用既有已监听队列,不新增队列与权重。 return QueueDataCleanup diff --git a/pkg/constants/package_silence.go b/pkg/constants/package_silence.go new file mode 100644 index 00000000..69936469 --- /dev/null +++ b/pkg/constants/package_silence.go @@ -0,0 +1,320 @@ +package constants + +import "time" + +// 实名即生效套餐沉默期(add-package-silence-period)。 + +// 套餐记录沉默期状态 silence_status。空表示不适用沉默期。 +const ( + SilenceStatusWaiting = "waiting" // 待实名(含延期后的"延期待实名") + SilenceStatusExpired = "expired" // 沉默期已过期(status 保持 0,由激活闸门拦截) + SilenceStatusEnded = "ended" // 截止前已实名,沉默期结束 +) + +// SilenceStatusName 返回沉默期状态展示名。 +func SilenceStatusName(status string, extended bool) string { + switch status { + case SilenceStatusWaiting: + if extended { + return "延期待实名" + } + return "待实名" + case SilenceStatusExpired: + return "沉默期已过期" + case SilenceStatusEnded: + return "沉默期已结束" + default: + return "" + } +} + +// 沉默期时间口径。 +const ( + SilenceTimezone = "Asia/Shanghai" + SilenceDaysPerMonth = 30 + SilencePackageDaysMin = 1 + SilencePackageDaysMax = 90 + SilenceDefaultPeriodDays = 30 + SilenceDefaultUnitPrice = 500 +) + +// 沉默期系统配置 Key。 +const ( + SystemConfigModulePackage = "package" + SystemConfigSilenceEnabled = "package.silence.enabled" + SystemConfigSilenceDefaultDays = "package.silence.default_days" + SystemConfigSilenceExtensionUnitPrice = "package.silence.extension_unit_price" + ApprovalBusinessTypePackageSilence = "package_silence_approval" + PaymentOrderTypeSilenceExtension = "silence_extension" + SilenceExtensionOrderNoPrefix = "SE" + SilenceExtensionBatchNoPrefix = "SB" + SilenceExtensionOrderPayTTL = 30 * time.Minute + SilenceScanBatchSize = 200 + SilenceScanMaxBatches = 20 + SilenceScanUniqueTTL = 9 * time.Minute + SilenceScanMaxRetry = 3 + TaskTypePackageSilenceScan = "package:silence:scan" + TaskTypePackageSilenceOrderRecover = "package:silence:order_recover" + SilenceScanCron = "*/10 * * * *" + SilenceOrderRecoverCron = "*/5 * * * *" + SilenceOrderRecoverBatchSize = 100 + SilenceBatchImportMaxRows = 1000 + SilenceExtensionReasonMaxLen = 1000 + SilenceExtensionVoucherMaxCount = 5 + SilenceExtensionEvidenceMaxCount = 10 + SilencePolicyNameMaxLen = 100 + SilenceExtensionInputDaysMax = 3650 + SilencePurchaseAdjustDaysMax = 3650 + SilenceExtensionOptionsMax = 24 + SilenceBackfillBatchSize = 500 + SilenceOutboundBackfillBatchSize = 5000 + SilenceSwitchPrecheckUnconfiguredPackages = "unconfigured_packages" + SilenceSwitchPrecheckUnbackfilledUsages = "unbackfilled_usages" + SilenceSwitchPrecheckDefaultPolicyMissing = "default_policy_missing" + SilenceSwitchPrecheckApprovalSceneMissing = "approval_scene_missing" + SilenceExpiringSoonDaysDefault = 7 + SilenceExtensionOrderCloseReasonTimeout = "timeout" + SilenceExtensionOrderCloseReasonSuperseded = "superseded" +) + +// 延期政策适用范围。 +const ( + SilencePolicyScopeAll = "all" + SilencePolicyScopeSeries = "series" + SilencePolicyScopePackage = "package" +) + +// 延期政策状态。 +const ( + SilencePolicyStatusDisabled = 0 + SilencePolicyStatusEnabled = 1 +) + +// 延期路径。 +const ( + SilencePathOnline = "online" + SilencePathOffline = "offline" + SilencePathSpecial = "special" + SilencePathAdjust = "adjust" +) + +// 延期类型;除 standard_paid 外同时是政策免审批场景的可选值。 +const ( + SilenceExtensionTypeStandardPaid = "standard_paid" + SilenceExtensionTypeOfflinePaid = "offline_paid" + SilenceExtensionTypeFree = "free" + SilenceExtensionTypeDiscount = "discount" + SilenceExtensionTypeComplaintCompensation = "complaint_compensation" + SilenceExtensionTypeSystemCompensation = "system_compensation" + SilenceExtensionTypeSpecialCustomer = "special_customer" + SilenceExtensionTypeBatch = "batch" + SilenceExtensionTypePurchaseAdjust = "purchase_adjust" +) + +// SilenceApprovalExemptTypes 是政策可勾选的免审批场景(在线付款始终不审批,不在可选项)。 +var SilenceApprovalExemptTypes = []string{ + SilenceExtensionTypeOfflinePaid, SilenceExtensionTypeFree, SilenceExtensionTypeDiscount, + SilenceExtensionTypeComplaintCompensation, SilenceExtensionTypeSystemCompensation, + SilenceExtensionTypeSpecialCustomer, SilenceExtensionTypeBatch, SilenceExtensionTypePurchaseAdjust, +} + +// SilenceSpecialExtensionTypes 是特殊延期可提交的类型。 +var SilenceSpecialExtensionTypes = []string{ + SilenceExtensionTypeFree, SilenceExtensionTypeDiscount, SilenceExtensionTypeComplaintCompensation, + SilenceExtensionTypeSystemCompensation, SilenceExtensionTypeSpecialCustomer, +} + +// 延期批次状态。 +const ( + SilenceBatchStatusPendingPayment = "pending_payment" + SilenceBatchStatusPendingApproval = "pending_approval" + SilenceBatchStatusExecuting = "executing" + SilenceBatchStatusCompleted = "completed" + SilenceBatchStatusPartiallyCompleted = "partially_completed" + SilenceBatchStatusRejected = "rejected" + SilenceBatchStatusClosed = "closed" + SilenceBatchStatusFailed = "failed" +) + +// 审批状态(批次 approval_status)。 +const ( + SilenceApprovalStatusPending = "pending" + SilenceApprovalStatusApproved = "approved" + SilenceApprovalStatusRejected = "rejected" + SilenceApprovalStatusCanceled = "canceled" +) + +// 延期明细状态。 +const ( + SilenceExtensionStatusPendingPayment = "pending_payment" + SilenceExtensionStatusPendingApproval = "pending_approval" + SilenceExtensionStatusApproved = "approved" + SilenceExtensionStatusExecuting = "executing" + SilenceExtensionStatusCompleted = "completed" + SilenceExtensionStatusException = "exception" + SilenceExtensionStatusFailed = "failed" + SilenceExtensionStatusRejected = "rejected" + SilenceExtensionStatusClosed = "closed" +) + +// 延期订单状态。 +const ( + SilenceOrderStatusPendingPayment = "pending_payment" + SilenceOrderStatusPendingApproval = "pending_approval" + SilenceOrderStatusRejected = "rejected" + SilenceOrderStatusPaid = "paid" + SilenceOrderStatusCompleted = "completed" + SilenceOrderStatusClosed = "closed" + SilenceOrderStatusException = "exception" + SilenceOrderStatusRefunded = "refunded" +) + +// 延期订单来源。 +const ( + SilenceOrderSourceClient = "client" + SilenceOrderSourceAdmin = "admin" + SilenceOrderSourceOffline = "offline" +) + +// 沉默期流水事件类型。 +const ( + SilenceRecordEventSet = "set" + SilenceRecordEventAdjust = "adjust" + SilenceRecordEventExpired = "expired" + SilenceRecordEventEnded = "ended" + SilenceRecordEventExtend = "extend" + SilenceRecordEventExchange = "exchange" + SilenceRecordEventBackfill = "backfill" + SilenceRecordEventRealnameBackfill = "realname_backfill" +) + +// 沉默期判定触发来源。 +const ( + SilenceTriggerOrderPaid = "order_paid" + SilenceTriggerAutoPurchase = "auto_purchase" + SilenceTriggerActivation = "activation" + SilenceTriggerScan = "scan" + SilenceTriggerExtension = "extension" + SilenceTriggerAdjustApproval = "adjust_approval" + SilenceTriggerExchange = "exchange" + SilenceTriggerBackfill = "backfill" + SilenceTriggerEligibilityRead = "eligibility" +) + +// 沉默期流水与审计的操作人类型。 +const ( + SilenceOperatorSystem = "system" + SilenceOperatorAccount = "account" + SilenceOperatorCustomer = "customer" +) + +// 延期资格不能付费延期的原因码。 +const ( + SilenceReasonNotExpired = "not_expired" + SilenceReasonNoSilence = "no_silence" + SilenceReasonExtensionDisabled = "extension_disabled" + SilenceReasonNoPolicy = "no_policy" + SilenceReasonExceedsLimit = "exceeds_limit" + SilenceReasonRefunded = "refunded" + SilenceReasonOrderPending = "order_pending" + SilenceReasonActivated = "activated" + SilenceReasonAdjustPending = "adjust_pending" +) + +// 延期执行异常 / 失败原因码。 +const ( + SilenceFailureNotExpired = "not_expired" + SilenceFailureDeadlineNotAfter = "deadline_not_after_today" + SilenceFailureExceedsLimit = "exceeds_limit" + SilenceFailureFreeUsedUp = "free_used_up" + SilenceFailureUsageInvalid = "usage_invalid" + SilenceFailureAmountMismatch = "amount_mismatch" + SilenceFailureRefundFailed = "refund_failed" +) + +// 沉默期权限点(后端统一校验并叠加店铺数据范围)。 +const ( + PermissionPackageSilenceConfig = "package_silence:config" + PermissionPackageSilenceOverride = "package_silence:override" + PermissionPackageSilenceExtend = "package_silence:extend" + PermissionPackageSilenceExtendBatch = "package_silence:extend_batch" + PermissionPackageSilenceOffline = "package_silence:offline" + PermissionPackageSilencePolicy = "package_silence:policy" + PermissionPackageSilenceException = "package_silence:exception" +) + +// 沉默期审计动作(技术方案 §11.2)。 +const ( + AuditActionPackageSilenceDefaultChanged = "package.silence_default_changed" + AuditActionPackageSilenceSwitchChanged = "package.silence_switch_changed" + AuditActionUsageSilenceSet = "package_usage.silence_set" + AuditActionUsageSilenceAdjustRequested = "package_usage.silence_adjust_requested" + AuditActionUsageSilenceAdjustApproved = "package_usage.silence_adjust_approved" + AuditActionUsageSilenceAdjustRejected = "package_usage.silence_adjust_rejected" + AuditActionUsageSilenceExpired = "package_usage.silence_expired" + AuditActionUsageSilenceEnded = "package_usage.silence_ended" + AuditActionUsageSilenceExtensionRequested = "package_usage.silence_extension_requested" + AuditActionUsageSilenceExtensionApproved = "package_usage.silence_extension_approved" + AuditActionUsageSilenceExtensionRejected = "package_usage.silence_extension_rejected" + AuditActionUsageSilenceExtensionApplied = "package_usage.silence_extension_applied" + AuditActionUsageSilenceExtensionException = "package_usage.silence_extension_exception" + AuditActionUsageSilenceExtensionOrderState = "package_usage.silence_extension_order_changed" + AuditActionUsageSilenceMigratedOnExchange = "package_usage.silence_migrated_on_exchange" + AuditActionUsageSilenceBackfilled = "package_usage.silence_backfilled" + AuditActionSilencePolicyCreated = "package_silence_policy.created" + AuditActionSilencePolicyUpdated = "package_silence_policy.updated" + AuditActionSilencePolicyStatusChanged = "package_silence_policy.status_changed" + AuditActionAssetOutboundRecorded = "asset.outbound_recorded" + AuditActionAssetOutboundEnded = "asset.outbound_ended" +) + +// 资产出库记录。 +const ( + OutboundSourceOrder = "order" + OutboundSourceExchange = "exchange" + OutboundSourceBackfill = "backfill" + OutboundEndReasonExchange = "exchange_return" + OutboundAssetTypeIotCard = "iot_card" + OutboundAssetTypeDevice = "device" + SilenceAssetTypeIotCard = "iot_card" + SilenceAssetTypeDevice = "device" + SilenceAuditResourceUsage = "package_usage" + SilenceAuditResourcePackage = "package" + SilenceAuditResourcePolicy = "package_silence_policy" + SilenceAuditResourceConfig = "system_config" + SilenceAuditResourceAsset = "asset" +) + +// 沉默期审批业务字段(企微场景配置把模板控件映射到这些字段)。 +const ( + ApprovalFieldSilenceApplyType = "apply_type" // 申请类型 + ApprovalFieldSilenceBatchNo = "batch_no" // 申请单号 + ApprovalFieldSilenceAssetIdentifier = "asset_identifier" // 资产 + ApprovalFieldSilencePackageName = "package_name" // 套餐 + ApprovalFieldSilenceOrderNo = "order_no" // 订单号 + ApprovalFieldSilenceDefaultDays = "default_days" // 默认沉默期天数 + ApprovalFieldSilenceRequestedDays = "requested_days" // 申请沉默期天数 + ApprovalFieldSilenceOriginalDeadline = "original_deadline" // 原截止日期 + ApprovalFieldSilenceInputDays = "input_days" // 延期天数 + ApprovalFieldSilenceBilledDays = "billed_days" // 计费天数 + ApprovalFieldSilenceNewDeadline = "new_deadline" // 新截止日期 + ApprovalFieldSilenceExtensionType = "extension_type" // 延期类型 + ApprovalFieldSilenceItemCount = "item_count" // 明细条数 + ApprovalFieldSilenceAmount = "amount" // 费用 + ApprovalFieldSilenceAmountCent = "amount_cent" // 费用(分) + ApprovalFieldSilenceReason = "reason" // 原因 + ApprovalFieldSilenceBasis = "basis" // 依据 + ApprovalFieldSilenceEvidenceKeys = "evidence_keys" // 申请依据附件 + ApprovalFieldSilencePaymentMethod = "offline_payment_method" // 收款方式 + ApprovalFieldSilenceTradeNo = "offline_trade_no" // 交易单号 + ApprovalFieldSilenceVoucherKeys = "payment_voucher_keys" // 收款凭证 +) + +// 沉默期导出场景。 +const ( + ExportTaskSceneSilenceOrder = "package_silence_order" // 导出场景:沉默期延期订单 + ExportTaskSceneSilenceUsage = "package_silence_usage" // 导出场景:即将过期 / 已过期套餐 +) + +// AuditResourceSilenceExtensionOrder 是财务流水中延期订单的调查引用资源类型。 +const AuditResourceSilenceExtensionOrder = "package_silence_extension_order" diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index d9c2cb39..297fd358 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -197,6 +197,24 @@ const ( CodeH5PopupConfigurationNotFound = 1250 // 运营弹窗配置不存在 CodeH5PopupRiskNotEligible = 1251 // 当前资产不满足风险换卡条件 + // 实名即生效套餐沉默期相关错误 (1260-1279) + CodeSilencePeriodOutOfRange = 1260 // silence_period_out_of_range:沉默期天数不在允许范围(购买时至少 1 天,套餐默认值 1~90 天) + CodeSilenceOverrideForbidden = 1261 // silence_override_forbidden:无权限调整沉默期 + CodePackageExpiryBaseChangeForbidden = 1262 // package_expiry_base_change_forbidden:不允许变更该套餐的生效条件 + CodeSilenceExtensionNotExpired = 1263 // silence_extension_not_expired:套餐沉默期未过期,不能延期 + CodeSilenceExtensionDisabled = 1264 // silence_extension_disabled:该套餐不支持付费延期,可申请特殊延期 + CodeSilenceExtensionExceedsLimit = 1265 // silence_extension_exceeds_limit:已超过可延期期限 + CodeSilenceExtensionMonthsInvalid = 1266 // silence_extension_months_invalid:延期天数不在允许范围 + CodeSilenceFreeExtensionUsedUp = 1267 // silence_free_extension_used_up:免费延期次数已用完,需付费延期 + CodeSilenceExpiredNotRefundable = 1268 // silence_expired_not_refundable:沉默期已过期的套餐不能退款 + CodeSilencePolicyOverlap = 1269 // silence_policy_overlap:同一优先级的延期政策适用范围与生效时间重叠 + CodeSilenceExtensionNoPolicy = 1270 // silence_extension_no_policy:未匹配到延期政策,不能付费延期 + CodeSilenceExtensionOrderPending = 1271 // silence_extension_order_pending:该套餐已有未结束的延期订单或申请 + CodeSilenceOfflineTradeNoDuplicate = 1272 // silence_offline_trade_no_duplicate:线下交易单号已登记 + CodeSilenceOfflineAmountMismatch = 1273 // silence_offline_amount_mismatch:实收金额与系统计算金额不一致 + CodeSilenceFeatureDisabled = 1274 // silence_feature_disabled:沉默期功能未开启 + CodeSilenceSwitchPrecheckFailed = 1275 // silence_switch_precheck_failed:沉默期总开关打开前检查未通过 + // 服务端错误 (2000-2999) -> 5xx HTTP 状态码 CodeInternalError = 2001 // 内部服务器错误 CodeDatabaseError = 2002 // 数据库错误 @@ -358,6 +376,22 @@ var allErrorCodes = []int{ CodeEmployeeCollectionVoucherInvalid, CodeH5PopupConfigurationNotFound, CodeH5PopupRiskNotEligible, + CodeSilencePeriodOutOfRange, + CodeSilenceOverrideForbidden, + CodePackageExpiryBaseChangeForbidden, + CodeSilenceExtensionNotExpired, + CodeSilenceExtensionDisabled, + CodeSilenceExtensionExceedsLimit, + CodeSilenceExtensionMonthsInvalid, + CodeSilenceFreeExtensionUsedUp, + CodeSilenceExpiredNotRefundable, + CodeSilencePolicyOverlap, + CodeSilenceExtensionNoPolicy, + CodeSilenceExtensionOrderPending, + CodeSilenceOfflineTradeNoDuplicate, + CodeSilenceOfflineAmountMismatch, + CodeSilenceFeatureDisabled, + CodeSilenceSwitchPrecheckFailed, CodeInternalError, CodeDatabaseError, CodeRedisError, @@ -513,6 +547,22 @@ var errorMessages = map[int]string{ CodeEmployeeCollectionVoucherInvalid: "支付凭证数量或内容不符合要求", CodeH5PopupConfigurationNotFound: "运营弹窗配置不存在", CodeH5PopupRiskNotEligible: "当前资产不满足风险换卡条件", + CodeSilencePeriodOutOfRange: "沉默期天数不在允许范围(购买时至少 1 天,套餐默认值 1~90 天)", + CodeSilenceOverrideForbidden: "无权限调整沉默期", + CodePackageExpiryBaseChangeForbidden: "不允许变更该套餐的生效条件", + CodeSilenceExtensionNotExpired: "套餐沉默期未过期,不能延期", + CodeSilenceExtensionDisabled: "该套餐不支持付费延期,可申请特殊延期", + CodeSilenceExtensionExceedsLimit: "已超过可延期期限", + CodeSilenceExtensionMonthsInvalid: "延期天数不在允许范围", + CodeSilenceFreeExtensionUsedUp: "免费延期次数已用完,需付费延期", + CodeSilenceExpiredNotRefundable: "沉默期已过期的套餐不能退款", + CodeSilencePolicyOverlap: "同一优先级的延期政策适用范围与生效时间重叠", + CodeSilenceExtensionNoPolicy: "未匹配到延期政策,不能付费延期", + CodeSilenceExtensionOrderPending: "该套餐已有未结束的延期订单或申请", + CodeSilenceOfflineTradeNoDuplicate: "线下交易单号已登记", + CodeSilenceOfflineAmountMismatch: "实收金额与系统计算金额不一致", + CodeSilenceFeatureDisabled: "沉默期功能未开启", + CodeSilenceSwitchPrecheckFailed: "沉默期总开关打开前检查未通过", CodeInvalidCredentials: "用户名或密码错误", CodeAccountLocked: "账号已锁定", CodePasswordExpired: "密码已过期", @@ -580,6 +630,8 @@ func GetHTTPStatus(code int) int { CodeEmployeeCollectionApplicationStatusInvalid, CodeEmployeeCollectionApplicationPending, CodeEmployeeCollectionBillClosed, + CodeSilenceExtensionOrderPending, + CodeSilenceOfflineTradeNoDuplicate, CodeOpenAPINonceReplay: return 409 // Conflict case CodeTooManyRequests: diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index dfd00c71..5f235220 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -95,6 +95,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux { h.registerNotificationCleanupHandler() h.registerPackageExpiryReminderHandler() h.registerPackageTrafficAlertScanHandler() + h.registerPackageSilenceHandlers() h.registerAutoPurchaseHandler() h.registerDailyTrafficFlushHandler() h.registerOperationsReportSnapshotHandler() @@ -369,6 +370,23 @@ func (h *Handler) registerPackageTrafficAlertScanHandler() { h.logger.Info("注册每日套餐真流量达量预警扫描任务处理器", zap.String("task_type", constants.TaskTypePackageTrafficAlertScan)) } +// registerPackageSilenceHandlers 注册沉默期扫描与延期订单查单恢复任务处理器。 +func (h *Handler) registerPackageSilenceHandlers() { + services := h.workerResult.Services + if services == nil || services.SilenceResolver == nil { + h.logger.Warn("沉默期用例未装配,跳过注册沉默期任务处理器") + return + } + scan := task.NewPackageSilenceScanHandler(services.SilenceResolver, h.logger) + h.mux.HandleFunc(constants.TaskTypePackageSilenceScan, scan.Handle) + h.logger.Info("注册沉默期扫描任务处理器", zap.String("task_type", constants.TaskTypePackageSilenceScan)) + if services.SilenceExtensions != nil { + recoverHandler := task.NewPackageSilenceOrderRecoverHandler(services.SilenceExtensions, h.logger) + h.mux.HandleFunc(constants.TaskTypePackageSilenceOrderRecover, recoverHandler.Handle) + h.logger.Info("注册沉默期延期订单恢复任务处理器", zap.String("task_type", constants.TaskTypePackageSilenceOrderRecover)) + } +} + func (h *Handler) registerAutoPurchaseHandler() { autoPurchaseHandler := task.NewAutoPurchaseHandler( h.db, @@ -385,6 +403,10 @@ func (h *Handler) registerAutoPurchaseHandler() { h.workerResult.Services.PriorityEvents, audit.NewWriter(audit.NewRegistry(), nil), ) + if services := h.workerResult.Services; services != nil { + autoPurchaseHandler.SetSilenceSetter(services.SilenceSetter) + autoPurchaseHandler.SetOutboundRecorder(services.SilenceOutbound) + } h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask) h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge)) } diff --git a/pkg/queue/types.go b/pkg/queue/types.go index 88e1ad59..211aed54 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -2,6 +2,7 @@ package queue import ( "context" + packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" @@ -91,6 +92,13 @@ type WorkerServices struct { AssetPolling *pollingSvc.AssetPollingService // IotCardPolling 是 AssetPolling 使用的卡服务,Worker 启动时注入轮询生命周期回调。 IotCardPolling *iotCardSvc.Service + // 沉默期:总开关、统一判定、设置与出库时间用例(扫描、自动购包、延期执行共用)。 + SilenceSwitch *packagesilenceApp.Switch + SilenceResolver *packagesilenceApp.Resolver + SilenceSetter *packagesilenceApp.Setter + SilenceOutbound *packagesilenceApp.OutboundRecorder + // SilenceExtensions 执行沉默期审批通过后的延期、延期订单查单恢复与异常原路退款。 + SilenceExtensions *packagesilenceApp.ExtensionService } // WorkerBootstrapResult Worker Bootstrap 结果