feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕 - 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败 - 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影 - 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序 - 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列 - 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名 - 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
This commit is contained in:
@@ -45,6 +45,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
@@ -172,6 +173,7 @@ func runWorker(cfg *config.Config) {
|
||||
registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
|
||||
registerCarrierThresholdTasks(taskHandler.GetMux(), runtime, appLogger)
|
||||
registerAssetAutoRenewalTasks(taskHandler.GetMux(), runtime, appLogger)
|
||||
registerPollingPriorityExpiryTask(taskHandler.GetMux(), runtime, appLogger)
|
||||
registerRefundCommissionRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
|
||||
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger)
|
||||
outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
|
||||
@@ -623,6 +625,23 @@ func registerAssetAutoRenewalTasks(mux *asynq.ServeMux, runtime *workerRuntime,
|
||||
appLogger.Info("注册资产钱包自动续费复机结果恢复任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalRecovery))
|
||||
}
|
||||
|
||||
// registerPollingPriorityExpiryTask 注册优先轮询项有效期到期处理任务。
|
||||
// 该任务是超期出队的唯一触发器:查询与读侧都不隐式触发过期处理。
|
||||
func registerPollingPriorityExpiryTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.db == nil {
|
||||
appLogger.Fatal("数据库未配置,无法注册优先轮询项有效期到期处理任务")
|
||||
}
|
||||
expiryService := pollingSvc.NewPriorityExpiryService(
|
||||
runtime.db,
|
||||
postgres.NewPollingPriorityItemStore(runtime.db),
|
||||
appLogger,
|
||||
)
|
||||
expiryService.SetAudit(auditInfra.NewWriter(auditInfra.NewRegistry(), nil))
|
||||
handler := task.NewPollingPriorityExpiryHandler(expiryService, appLogger)
|
||||
mux.HandleFunc(constants.TaskTypePollingPriorityExpiry, handler.Handle)
|
||||
appLogger.Info("注册优先轮询项有效期到期处理任务处理器", zap.String("task_type", constants.TaskTypePollingPriorityExpiry))
|
||||
}
|
||||
|
||||
// registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。
|
||||
func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
|
||||
@@ -897,6 +916,16 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册渠道原路退款结果恢复定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypePollingPriorityExpiry,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Unique(10*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePollingPriorityExpiry)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册优先轮询项有效期到期处理定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypeCarrierThresholdCycle,
|
||||
nil,
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
|
||||
> **AUG26-016 实施口径标注(2026-09-16)**:上文本条中的「普通轮询异常补偿」**本版不实现**。现状该场景没有任何可判定条件(轮询失败按配置间隔无限重入队,无失败上限、无补偿入口),实现等于自造判据;且渠道故障时全量失败卡涌入优先通道、提示通道不受分片背压约束,风险不对称。本版只实现其余四类场景,该场景登记为后续独立 Change,需产品确认后另行实施(依据见 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/design.md` 的「后续候选」)。本条其余文字与其余场景的确认内容不变。
|
||||
|
||||
> **AUG26-016 实施口径标注(2026-09-18)**:`111.md` §23 中的「有效期规则」「异常与重试记录」「人工关闭」「人工重新触发」四项在本次按原文补齐,并受固定有效期与固定尝试上限约束:优先项自入队起超过固定有效期仍未完成即转已过期终态出队(既有未完成项自上线时刻起算,不被批量过期);人工关闭与人工重新触发为独立受权限控制的写入口且必须填写原因;异常与重试事实以既有优先项事实表承载,不新建独立异常记录表。上文标注中「普通轮询异常补偿**本版不实现**」保持不变,仍为后续独立 Change;§23 的 P0/P1/P2 分级与优先级规则配置仍不在本期范围。本条不改变本基线的其余确认内容。
|
||||
|
||||
## 2.16 已确认的导出、时间筛选与代理自充收款方式基线
|
||||
|
||||
临期列表、佣金明细和套餐流量达量预警均复用现有异步导出任务,创建时冻结筛选条件、操作者及可见店铺范围。临期导出一行对应一项资产,取其当前生效主套餐最终到期时间和剩余天数;加油包不单独成行。流量达量预警导出一行对应一条预警记录,套餐、用量、总量、阈值和到期时间使用触发快照,店铺、业务员和用户组按导出执行时当前归属补充。佣金明细的入账后金额冻结每次佣金钱包变动后的实际余额,回溯记录可为负。
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
| 场景 | 断言 | 期望 → 实际(摘录) |
|
||||
| --- | --- | --- |
|
||||
| A Store 生命周期 | 20 PASS | `A1 首次认领命中…→ status=processing claimed_at=2026-09-17 09:24:49+08`;`A2 超租约(120s>90s)允许接管 → taken=true`;`A2 租约内(10s<90s)不允许接管 → taken=false`;`A3 两次 IncrementAttempt 后 attempt_count → 期望=2 实际=2`;`A3 MarkFailed(未累加尝试次数,等同 FailFinal 路径)→ attempt_count=0`;`A3 尝试上限路径 → attempt_count=1`;`A4 分页归一化 Page=0/PageSize=999 → total=29 returned=20` |
|
||||
| A Store 生命周期 | 20 PASS | `A1 首次认领命中…→ status=processing claimed_at=2026-09-17 09:24:49+08`;`A2 超租约(120s>90s)允许接管 → taken=true`;`A2 租约内(10s<90s)不允许接管 → taken=false`;`A3 两次 MarkExecutionStarted 后 attempt_count → 期望=2 实际=2`;`A3 MarkFailed(未累加尝试次数,等同 FailFinal 路径)→ attempt_count=0`;`A3 尝试上限路径 → attempt_count=1`;`A4 分页归一化 Page=0/PageSize=999 → total=29 returned=20` |
|
||||
| B 消费者 | 15 PASS | `B5 设备载体在事务内冻结的绑定卡数 → 3`;`B5 活动项行数(3 卡 × 4 任务类型) → 12`;`B6 重复投递只合并:行数不变、触发次数+1、最近触发时间刷新、触发类型集合去重`;`B7 自动(no_valid_package)+ 人工(manual_trigger)合并为同一行`;`B8 已终态后重复追加同一 event_id:outbox 仍只 1 行`;`B9 同一 event_id 重投不产生重复审计行(EventID 去重) → 1` |
|
||||
| C 触发分类与前置条件 | 13 PASS | `C10 → renewal_activated` / `purchase_activated`;`C11 存在 status=0 → HasPendingPackageUsage=true`;`C12 观测抑制为真 → 不写事件(0 行)`;`C12 无有效套餐 event_id ≤ 48 且主体与载体可区分 → prio:nvp:d:990016999:… len=31` |
|
||||
| D 读侧权限与范围 | 12 PASS | `D13 超管/平台 → total=18/18`;`D13 代理 → total=16`(平台卡与范围外卡各 1 行不可见);`D13 企业 → code=1005`;`D14 详情越权 vs 不存在 → 同一码与文案` |
|
||||
@@ -70,10 +70,12 @@
|
||||
| F 审计来源匹配 | 6 PASS | `F polling_priority.enqueue/claim/manual_denied 注册 → primary=polling_priority_item,origins 含 account/admin_api 与 system_task/worker`;`F 消费者审计 Actor=system_task / Source=worker` |
|
||||
| count(残留计数) | 10 PASS | 卡/设备/绑定/套餐使用/优先项/审计/outbox/两类孤儿审计资源 **均 = 0**,且 `本批全部自建行已清理干净` |
|
||||
|
||||
口径说明(`close-august-iteration-gaps` 引入的重命名,2026-09-18 核对):本表 A3 的 Store 方法在本次重命名中由 `IncrementAttempt` 改为 `MarkExecutionStarted`(`internal/store/postgres/polling_priority_item_store.go`,签名 `(ctx, id, now)`,断言口径不变);人工入队用例文件由 `internal/service/polling/priority_enqueue_service.go` 改名为 `internal/service/polling/priority_manual_service.go`(类型 `PriorityEnqueueService` → `PriorityManualService`,`Enqueue` 签名不变,新增 `Close`/`Retrigger`)。
|
||||
|
||||
### 2.4 H5 加固:事件通道拒绝 `manual_trigger`(2026-09-17 09:24:47)
|
||||
|
||||
`internal/infrastructure/prioritypolling/event.go` 的 `AppendPriorityRequested` 在资源类型校验后显式拒绝
|
||||
`trigger_type='manual_trigger'`(人工入队由 `internal/service/polling/priority_enqueue_service.go` 直写事实表,不经事件通道)。
|
||||
`trigger_type='manual_trigger'`(人工入队由 `internal/service/polling/priority_manual_service.go` 直写事实表,不经事件通道)。
|
||||
|
||||
```
|
||||
[PASS] H5-1 经事件通道投递 manual_trigger 返回参数错误 → code=1001 err=优先轮询事件不接受人工入队触发类型 manual_trigger,人工入队请调用人工入队用例
|
||||
|
||||
@@ -293,7 +293,8 @@
|
||||
"capability": "asset-device",
|
||||
"requirements": [
|
||||
"asset-device::资产业务状态",
|
||||
"identity-access::数据范围拒绝"
|
||||
"identity-access::数据范围拒绝",
|
||||
"asset-device::资产与卡详情的优先轮询状态投影"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -544,6 +545,17 @@
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "http",
|
||||
"entry": "GET /api/admin/carrier-traffic-threshold-hits",
|
||||
"capability": "carrier-channel-traffic-threshold",
|
||||
"requirements": [
|
||||
"carrier-channel-traffic-threshold::通道阈值命中事实留痕与查询",
|
||||
"export-time-filter::受影响端点与旧格式替换",
|
||||
"identity-access::数据范围拒绝"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "http",
|
||||
"entry": "GET /api/admin/commission/withdrawal-requests",
|
||||
@@ -1101,7 +1113,8 @@
|
||||
"order-refund-exchange::订单、退款与换货状态门禁",
|
||||
"order-refund-exchange::代理退款查询按所属店铺隔离",
|
||||
"order-refund-exchange::退款终态事实与失败分类",
|
||||
"order-refund-exchange::退款展示当前退款套餐用量"
|
||||
"order-refund-exchange::退款展示当前退款套餐用量",
|
||||
"order-refund-exchange::退款渠道标识与线下处理流水号"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -1114,7 +1127,18 @@
|
||||
"order-refund-exchange::订单、退款与换货状态门禁",
|
||||
"order-refund-exchange::代理退款查询按所属店铺隔离",
|
||||
"order-refund-exchange::退款终态事实与失败分类",
|
||||
"order-refund-exchange::退款展示当前退款套餐用量"
|
||||
"order-refund-exchange::退款展示当前退款套餐用量",
|
||||
"order-refund-exchange::退款渠道标识与线下处理流水号"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "http",
|
||||
"entry": "GET /api/admin/refunds/order-options",
|
||||
"capability": "order-refund-exchange",
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"order-refund-exchange::退款实收金额与方式矩阵"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -1169,7 +1193,8 @@
|
||||
"capability": "identity-access",
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"business-user-group::店铺负责人和所属组实时推导"
|
||||
"business-user-group::店铺负责人和所属组实时推导",
|
||||
"agent-distribution-withdrawal::店铺下级代理数量投影"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -1207,7 +1232,8 @@
|
||||
"capability": "identity-access",
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"business-user-group::店铺负责人和所属组实时推导"
|
||||
"business-user-group::店铺负责人和所属组实时推导",
|
||||
"agent-distribution-withdrawal::店铺下级代理数量投影"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -2349,7 +2375,10 @@
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"order-refund-exchange::订单、退款与换货状态门禁",
|
||||
"order-refund-exchange::退款实收金额与方式矩阵"
|
||||
"order-refund-exchange::退款实收金额与方式矩阵",
|
||||
"order-refund-exchange::退款原因必填与申请人备注",
|
||||
"order-refund-exchange::退款渠道标识与线下处理流水号",
|
||||
"order-refund-exchange::企业微信退款审批材料字段"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -2383,7 +2412,9 @@
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"order-refund-exchange::订单、退款与换货状态门禁",
|
||||
"order-refund-exchange::企业微信唯一终审与审批尝试重提"
|
||||
"order-refund-exchange::企业微信唯一终审与审批尝试重提",
|
||||
"order-refund-exchange::退款原因必填与申请人备注",
|
||||
"order-refund-exchange::企业微信退款审批材料字段"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -2397,6 +2428,16 @@
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "http",
|
||||
"entry": "POST /api/admin/refunds/{id}/offline-settlement",
|
||||
"capability": "order-refund-exchange",
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"order-refund-exchange::退款渠道标识与线下处理流水号"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "http",
|
||||
"entry": "POST /api/admin/roles",
|
||||
@@ -2608,7 +2649,8 @@
|
||||
"identity-access::令牌生命周期",
|
||||
"personal-customer::个人客户身份",
|
||||
"phone-asset-association::H5 验证建立关联与数量上限",
|
||||
"phone-asset-association::H5 绑定与换绑的关联写入"
|
||||
"phone-asset-association::H5 绑定与换绑的关联写入",
|
||||
"phone-asset-association::短信验证码校验失败次数限制"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -2619,7 +2661,8 @@
|
||||
"requirements": [
|
||||
"identity-access::令牌生命周期",
|
||||
"personal-customer::个人客户身份",
|
||||
"phone-asset-association::H5 绑定与换绑的关联写入"
|
||||
"phone-asset-association::H5 绑定与换绑的关联写入",
|
||||
"phone-asset-association::短信验证码校验失败次数限制"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -3699,7 +3742,8 @@
|
||||
"entry": "POST /api/admin/refunds/{id}/trigger-approval",
|
||||
"capability": "order-refund-exchange",
|
||||
"requirements": [
|
||||
"order-refund-exchange::历史待审批退款可主动接入企业微信审批"
|
||||
"order-refund-exchange::历史待审批退款可主动接入企业微信审批",
|
||||
"order-refund-exchange::企业微信退款审批材料字段"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -3819,7 +3863,8 @@
|
||||
"entry": "GET /api/admin/payment-merchant-pools",
|
||||
"capability": "merchant-payment-routing",
|
||||
"requirements": [
|
||||
"merchant-payment-routing::商户池唯一性与轮询配置"
|
||||
"merchant-payment-routing::商户池唯一性与轮询配置",
|
||||
"merchant-payment-routing::商户池列表投影"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -3837,7 +3882,8 @@
|
||||
"entry": "GET /api/admin/payment-merchant-pools/{id}",
|
||||
"capability": "merchant-payment-routing",
|
||||
"requirements": [
|
||||
"merchant-payment-routing::商户池唯一性与轮询配置"
|
||||
"merchant-payment-routing::商户池唯一性与轮询配置",
|
||||
"merchant-payment-routing::商户池列表投影"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -3911,7 +3957,9 @@
|
||||
"entry": "GET /api/admin/employee-collection-bills",
|
||||
"capability": "employee-collection-bill",
|
||||
"requirements": [
|
||||
"employee-collection-bill::账单余额、状态与关闭"
|
||||
"employee-collection-bill::账单余额、状态与关闭",
|
||||
"employee-collection-bill::员工账单列表查询与筛选",
|
||||
"export-time-filter::受影响端点与旧格式替换"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -3929,7 +3977,8 @@
|
||||
"entry": "GET /api/admin/employee-collection-bills/{id}",
|
||||
"capability": "employee-collection-bill",
|
||||
"requirements": [
|
||||
"employee-collection-bill::账单余额、状态与关闭"
|
||||
"employee-collection-bill::账单余额、状态与关闭",
|
||||
"employee-collection-bill::员工账单详情投影"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -4026,7 +4075,9 @@
|
||||
"entry": "POST /api/c/v1/agent-distribution-registrations",
|
||||
"capability": "agent-distribution-withdrawal",
|
||||
"requirements": [
|
||||
"agent-distribution-withdrawal::分销码与待审批代理注册"
|
||||
"agent-distribution-withdrawal::分销码与待审批代理注册",
|
||||
"agent-distribution-withdrawal::注册审批材料带出业务员",
|
||||
"phone-asset-association::短信验证码校验失败次数限制"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -4424,7 +4475,8 @@
|
||||
"capability": "polling-operations",
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"priority-polling-queue::优先轮询事实与查询的可追溯"
|
||||
"priority-polling-queue::优先轮询事实与查询的可追溯",
|
||||
"export-time-filter::受影响端点与旧格式替换"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -4438,6 +4490,37 @@
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "http",
|
||||
"entry": "POST /api/admin/polling-priority-items/{id}/close",
|
||||
"capability": "polling-operations",
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"priority-polling-queue::优先项尝试次数与出队",
|
||||
"priority-polling-queue::优先轮询事实与查询的可追溯"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "http",
|
||||
"entry": "POST /api/admin/polling-priority-items/{id}/retrigger",
|
||||
"capability": "polling-operations",
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"priority-polling-queue::优先项尝试次数与出队",
|
||||
"priority-polling-queue::优先轮询事实与查询的可追溯"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "async",
|
||||
"entry": "constants.TaskTypePollingPriorityExpiry",
|
||||
"capability": "priority-polling-queue",
|
||||
"requirements": [
|
||||
"priority-polling-queue::优先项尝试次数与出队"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "async",
|
||||
"entry": "constants.OutboxEventTypeAssetAutoRenewalResumeRequested",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,8 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
|
||||
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
|
||||
CardID: card.ID, CarrierID: card.CarrierID,
|
||||
ReadingMB: decision.LastGatewayReadingMB, ObservedAt: observation.Metadata.ObservedAt,
|
||||
// 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。
|
||||
Source: observation.Metadata.Source,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -86,7 +86,12 @@ func (s *Service) processDueLock(ctx context.Context, item *DueLock, stats *Cycl
|
||||
}
|
||||
unlocked := false
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, reason)
|
||||
// 复机结果与解锁时间在同一条件更新内落库:已发起复机记 resumed,仅解锁记 skipped。
|
||||
resumeResult := domain.ResumeResultResumed
|
||||
if !ready {
|
||||
resumeResult = domain.ResumeResultSkipped
|
||||
}
|
||||
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, reason, resumeResult)
|
||||
if unlockErr != nil {
|
||||
return unlockErr
|
||||
}
|
||||
@@ -133,7 +138,7 @@ func (s *Service) processUnresolvableLock(ctx context.Context, item *DueLock, st
|
||||
zap.String("reason", item.Reason))
|
||||
unlocked := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, item.Reason)
|
||||
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, item.Reason, domain.ResumeResultManual)
|
||||
if unlockErr != nil {
|
||||
return unlockErr
|
||||
}
|
||||
|
||||
@@ -61,6 +61,14 @@ func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation doma
|
||||
zap.Uint("carrier_id", carrier.ID), zap.Int("data_reset_day", carrier.DataResetDay))
|
||||
return nil
|
||||
}
|
||||
// 判定时间取本次观测时刻;观测时刻缺失时退化为系统时刻,绝不写出零值判定时间。
|
||||
judgedAt := evaluation.ObservedAt
|
||||
if judgedAt.IsZero() {
|
||||
judgedAt = s.now()
|
||||
}
|
||||
// 命中数值冻结为字面量:读数与阈值共用同一对副本,避免把可变的配置读回留痕。
|
||||
hitTrafficMB := evaluation.ReadingMB
|
||||
hitThresholdValue := threshold.Value
|
||||
lock := &model.CarrierTrafficThresholdLock{
|
||||
CarrierID: evaluation.CarrierID,
|
||||
CardID: evaluation.CardID,
|
||||
@@ -68,6 +76,18 @@ func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation doma
|
||||
Status: domain.LockStatusLocked,
|
||||
StopStatus: domain.TaskStatusPending,
|
||||
ResumeStatus: domain.TaskStatusPending,
|
||||
// 命中事实与停机锁是同一条 INSERT 的列,不会因业务数据导致留痕失败而阻断停机:
|
||||
// 命中列的取值范围由判定前置条件(读数已接受、阈值有效、已判定达量)保证,业务数据无法使
|
||||
// hit_* CHECK 失败。数据库级故障与既有流量观测一致地整体回滚,停机顺延到下一次观测重试,
|
||||
// 不会出现「已提交停机但无留痕」的半事实。
|
||||
// 读数取本次判定使用的网关累计读数(与停机判定同一来源,不是本地用量或套餐真流量);
|
||||
// 阈值与单位取本次判定实际生效的通道配置值,冻结命中时值,之后改阈值不改写历史命中。
|
||||
HitTrafficMB: &hitTrafficMB,
|
||||
HitThresholdValue: &hitThresholdValue,
|
||||
HitThresholdUnit: threshold.Unit,
|
||||
JudgedAt: judgedAt,
|
||||
// 来源按列约束归一:未归类来源记为未记录,避免留痕列 CHECK 反过来阻断停机。
|
||||
TriggerSource: domain.SafeTriggerSource(evaluation.Source),
|
||||
}
|
||||
// 唯一冲突(该周期已处理)在 createLockInTx 内以保存点隔离:不重复建锁、不重复写停机事件。
|
||||
created, err := s.createLockInTx(ctx, tx, lock)
|
||||
@@ -85,6 +105,8 @@ func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation doma
|
||||
s.logger.Info("卡流量达到运营商通道阈值,已写周期锁与停机事件",
|
||||
zap.Uint("carrier_id", evaluation.CarrierID), zap.Uint("card_id", evaluation.CardID),
|
||||
zap.Uint("lock_id", lock.ID), zap.Float64("reading_mb", evaluation.ReadingMB),
|
||||
zap.Float64("threshold_value", hitThresholdValue), zap.String("threshold_unit", threshold.Unit),
|
||||
zap.Time("judged_at", judgedAt), zap.String("trigger_source", lock.TriggerSource),
|
||||
zap.Time("period_start", periodStart))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -328,12 +328,20 @@ func (s *Service) markAnomalyInTx(ctx context.Context, tx *gorm.DB, lockID uint,
|
||||
}
|
||||
|
||||
// unlockInTx 在调用方事务内按 status=locked 条件更新认领解锁,返回 false 表示已被并发推进。
|
||||
// 未满足复机条件时同事务写入可观察原因;锁行与历史任务结果一律保留,绝不删除。
|
||||
func (s *Service) unlockInTx(ctx context.Context, tx *gorm.DB, lockID uint, failureReason string) (bool, error) {
|
||||
//
|
||||
// 同一条件更新同时写入解锁时间与复机结果;未满足复机条件时同事务写入可观察原因。
|
||||
// 更新集合只覆盖锁生命周期与周期恢复留痕,绝不触碰停机命中事实(hit_* / judged_at /
|
||||
// trigger_source):命中事实只由达量判定写入,周期恢复 MUST NOT 覆盖。
|
||||
// 锁行与历史任务结果一律保留,绝不删除。
|
||||
func (s *Service) unlockInTx(ctx context.Context, tx *gorm.DB, lockID uint, failureReason, resumeResult string) (bool, error) {
|
||||
if tx == nil {
|
||||
return false, errors.New(errors.CodeInvalidStatus, "通道阈值解锁必须传入事务句柄")
|
||||
}
|
||||
updates := map[string]any{"status": domain.LockStatusUnlocked}
|
||||
updates := map[string]any{
|
||||
"status": domain.LockStatusUnlocked,
|
||||
"unlocked_at": s.now(),
|
||||
"resume_result": resumeResult,
|
||||
}
|
||||
if failureReason != "" {
|
||||
updates["failure_reason"] = safeFailureReason(failureReason)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (s *RegistrationService) Register(
|
||||
Status: constants.AgentDistributionRegistrationStatusPending,
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
|
||||
registrationApprovalForm(normalized, parent))
|
||||
registrationApprovalForm(normalized, parent, s.parentBusinessOwnerName(ctx, parent)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -252,9 +252,14 @@ func (s *RegistrationService) findDistributionParent(ctx context.Context, distri
|
||||
}
|
||||
|
||||
// verificationFailure 把验证码校验失败转换为对外错误,保留验证码服务给出的可定位提示。
|
||||
// 限流错误码必须原样保留:失败次数达到上限后的锁定必须让调用方看到「稍后再试」,
|
||||
// 其余失败仍按验证码无效统一提示。
|
||||
func verificationFailure(err error) error {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
if appErr.Code == errors.CodeTooManyRequests {
|
||||
return appErr
|
||||
}
|
||||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err, appErr.Message)
|
||||
}
|
||||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
@@ -278,7 +283,16 @@ func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID
|
||||
|
||||
// registrationApprovalForm 生成企业微信审批表单业务快照。
|
||||
// 手机号按脱敏值写入,禁止把完整手机号或密码写入审批表单与审计。
|
||||
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop) map[string]any {
|
||||
//
|
||||
// 业务员取上级店铺当前业务员的名称快照:上级无业务员时以空值提交并显式标记为「无」,
|
||||
// MUST NOT 以提交人、上级店铺主账号或其它账号填充。该字段是只读快照,审批通过时写入新店铺的
|
||||
// 初始业务员仍按既有规则取上级业务员当时值,不因本快照与通过时值不同而改写通过结果。
|
||||
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop, businessOwnerName string) map[string]any {
|
||||
owner := strings.TrimSpace(businessOwnerName)
|
||||
absenceMark := ""
|
||||
if owner == "" {
|
||||
absenceMark = agentDistributionBusinessOwnerAbsenceMark
|
||||
}
|
||||
return map[string]any{
|
||||
constants.ApprovalFieldDistributionCode: distributiondomain.MaskDistributionCode(input.DistributionCode),
|
||||
constants.ApprovalFieldDistributionParentShopID: parent.ID,
|
||||
@@ -290,9 +304,34 @@ func registrationApprovalForm(input distributiondomain.RegistrationInput, parent
|
||||
constants.ApprovalFieldDistributionContactName: input.ContactName,
|
||||
constants.ApprovalFieldDistributionRegion: strings.TrimSpace(
|
||||
input.Province + input.City + input.District + input.Address),
|
||||
constants.ApprovalFieldDistributionBusinessOwner: owner,
|
||||
constants.ApprovalFieldDistributionBusinessOwnerAbsence: absenceMark,
|
||||
}
|
||||
}
|
||||
|
||||
// agentDistributionBusinessOwnerAbsenceMark 是注册审批材料中「上级店铺无业务员」的显式标记。
|
||||
// 与「字段未填」区分开,使审批人看到的是确认无业务员而非漏填。
|
||||
const agentDistributionBusinessOwnerAbsenceMark = "无"
|
||||
|
||||
// parentBusinessOwnerName 读取上级店铺当前业务员的名称快照。
|
||||
// 判定与店铺业务员投影的可用口径一致:账号存在、为平台账号、启用且未删除;
|
||||
// 任一条件不满足时返回空串,由审批材料显式标记为无,MUST NOT 用其它账号填充。
|
||||
func (s *RegistrationService) parentBusinessOwnerName(ctx context.Context, parent *model.Shop) string {
|
||||
if parent == nil || parent.BusinessOwnerAccountID == nil || *parent.BusinessOwnerAccountID == 0 {
|
||||
return ""
|
||||
}
|
||||
var account model.Account
|
||||
if err := s.db.WithContext(ctx).
|
||||
Select("id", "username", "user_type", "status").
|
||||
First(&account, *parent.BusinessOwnerAccountID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
if account.UserType != constants.UserTypePlatform || account.Status != constants.StatusEnabled || account.DeletedAt.Valid {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(account.Username)
|
||||
}
|
||||
|
||||
// attachRegistrationInstance 回写注册记录关联的审批实例,写入一次后不可修改。
|
||||
func attachRegistrationInstance(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -164,7 +164,10 @@ func (s *WithdrawalService) submit(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateWithdrawalInvoice(qualification, input.InvoiceKeys); err != nil {
|
||||
if !qualification.Passed {
|
||||
return qualificationCheckError(qualification)
|
||||
}
|
||||
if err := validateWithdrawalInvoice(qualification.Qualification, input.InvoiceKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDailyWithdrawalLimit(ctx, tx, shopID, policy.DailyWithdrawalLimit, resubmitRequestID == nil); err != nil {
|
||||
@@ -222,6 +225,10 @@ func (s *WithdrawalService) submit(
|
||||
Amount: input.Amount, Fee: fee, FeeRate: policy.FeeRate, ActualAmount: actualAmount,
|
||||
WithdrawalMethod: input.WithdrawalMethod, AccountInfo: accountInfo,
|
||||
InvoiceKeys: invoiceKeys, SubmittedByAccountID: submitter.ID,
|
||||
// 当次资料资格校验结果随尝试冻结:资格被替换或失效后,历史尝试仍返回提交时的版本与结果。
|
||||
QualificationVersionID: qualification.VersionID,
|
||||
QualificationCheckedAt: &qualification.CheckedAt,
|
||||
QualificationPassed: qualificationPassedFlag(qualification.Passed),
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现审批尝试记录失败")
|
||||
@@ -348,30 +355,86 @@ func ensureOwnAgentShopForShopID(ctx context.Context, submitter withdrawalSubmit
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadValidQualification 读取当前有效的提现资料资格;缺失或已失效时拒绝提现申请。
|
||||
// qualificationCheck 是本次提现依据的资料资格校验结果。
|
||||
// 校验结果随当次审批尝试冻结:通过时记录版本标识与校验时间,未通过时给出稳定原因编码,
|
||||
// 供提现详情查询与审计核对;历史尝试不被改写。
|
||||
type qualificationCheck struct {
|
||||
// Qualification 仅在通过时非空。
|
||||
Qualification *model.WithdrawalQualification
|
||||
Passed bool
|
||||
VersionID uint
|
||||
CheckedAt time.Time
|
||||
// FailureReason 是未通过时的稳定原因编码(资格不存在/已失效/已停用/资料未通过审批),通过时为空。
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
// qualificationCheckError 把未通过的稳定原因转成对外的稳定业务错误。
|
||||
// 提示文案同时携带稳定原因编码与中文名称,使拒绝结果与审计记录都能定位到具体原因(本变更要求
|
||||
// 拒绝结果与审计记录包含稳定原因)。
|
||||
func qualificationCheckError(check qualificationCheck) error {
|
||||
name := constants.GetWithdrawalQualificationFailureReasonName(check.FailureReason)
|
||||
detail := check.FailureReason + "/" + name
|
||||
if check.FailureReason == constants.WithdrawalQualificationFailureShopDisabled {
|
||||
return errors.New(errors.CodeInvalidStatus, "提现资料资格校验未通过("+detail+"):店铺已停用,请先恢复店铺后重新提交")
|
||||
}
|
||||
return errors.New(errors.CodeInvalidStatus, "提现资料资格校验未通过("+detail+"):请先完成资料审批后重新提交")
|
||||
}
|
||||
|
||||
// loadValidQualification 读取当前有效的提现资料资格;缺失、失效、未通过审批或店铺停用时返回未通过结果。
|
||||
//
|
||||
// 校验顺序沿用既有实现:先按资料资格判定,再判定店铺启停。稳定原因取值域与迁移
|
||||
// 000237 的列语义一致(资格不存在、已失效、已停用、资料未通过审批)。
|
||||
// 仅在数据库访问失败时返回错误,判定不通过一律以结果表达,使调用方能写审计并回传稳定原因。
|
||||
func loadValidQualification(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
) (*model.WithdrawalQualification, error) {
|
||||
var qualification model.WithdrawalQualification
|
||||
) (qualificationCheck, error) {
|
||||
check := qualificationCheck{CheckedAt: time.Now().UTC()}
|
||||
var latest model.WithdrawalQualification
|
||||
err := tx.WithContext(ctx).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
|
||||
First(&qualification).Error
|
||||
Where("shop_id = ?", shopID).
|
||||
Order("id DESC").
|
||||
First(&latest).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批")
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureMissing
|
||||
return check, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询有效提现资料资格失败")
|
||||
return check, errors.Wrap(errors.CodeDatabaseError, err, "查询提现资料资格失败")
|
||||
}
|
||||
check.VersionID = latest.ID
|
||||
if latest.Status != constants.WithdrawalQualificationStatusApproved {
|
||||
if latest.Status == constants.WithdrawalQualificationStatusInvalidated {
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureInvalidated
|
||||
} else {
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureNotApproved
|
||||
}
|
||||
return check, nil
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).Select("id", "status").First(&shop, shopID).Error; err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批")
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
|
||||
return check, nil
|
||||
}
|
||||
return check, errors.Wrap(errors.CodeDatabaseError, err, "查询提现店铺状态失败")
|
||||
}
|
||||
if shop.Status != constants.ShopStatusEnabled {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "店铺已停用,提现资料资格已失效")
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
|
||||
return check, nil
|
||||
}
|
||||
return &qualification, nil
|
||||
check.Qualification = &latest
|
||||
check.Passed = true
|
||||
return check, nil
|
||||
}
|
||||
|
||||
// qualificationPassedFlag 把校验结果转为尝试表的 0/1 标记,取值与迁移 000237 的 CHECK 一致。
|
||||
func qualificationPassedFlag(passed bool) int {
|
||||
if passed {
|
||||
return constants.WithdrawalQualificationCheckPassed
|
||||
}
|
||||
return constants.WithdrawalQualificationCheckNotPassed
|
||||
}
|
||||
|
||||
// validateWithdrawalInvoice 校验申请级发票仅在企业主体且已登记发票资料时提交。
|
||||
@@ -696,6 +759,9 @@ func withdrawalAuditSnapshot(
|
||||
"latest_approval_instance_id": request.LatestApprovalInstanceID,
|
||||
"anomaly_flag": request.AnomalyFlag,
|
||||
"invoice_count": len(decodeInvoiceKeys(attempt.InvoiceKeys)),
|
||||
// 当次资料资格校验留痕:版本标识与是否通过,未通过时的稳定原因不在成功快照中重复。
|
||||
"qualification_version_id": attempt.QualificationVersionID,
|
||||
"qualification_passed": attempt.QualificationPassed,
|
||||
}
|
||||
if wallet != nil {
|
||||
snapshot["wallet_id"] = wallet.ID
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
@@ -200,6 +201,14 @@ func (s *CandidateService) matchOperationConfig(ctx context.Context, page string
|
||||
Where("(jsonb_array_length(shop_ids) = 0 OR ?::jsonb <@ shop_ids)", shopJSON).
|
||||
Where("(jsonb_array_length(device_types) = 0 OR ?::jsonb <@ device_types)", deviceJSON).
|
||||
Where("(jsonb_array_length(card_types) = 0 OR ?::jsonb <@ card_types)", cardJSON).
|
||||
// 候选排序:先按类型类别(风险换卡提醒已在调用方先行处理,此处为推广高于公告),
|
||||
// 再按显式优先级降序,同优先级取最近更新时间最新,最后按 ID 稳定排序。
|
||||
// 类别顺序 MUST 用显式表达式表达:announcement 的字典序小于 promotion,
|
||||
// 直接按 popup_type 排序会把公告排在推广之前,与 111.md §13.3 相反。
|
||||
Order(clause.OrderBy{Expression: clause.Expr{
|
||||
SQL: "CASE popup_type WHEN ? THEN 0 ELSE 1 END ASC",
|
||||
Vars: []any{constants.H5PopupTypePromotion},
|
||||
}}).
|
||||
Order("priority DESC, updated_at DESC, id DESC").
|
||||
Take(&config).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -44,6 +44,7 @@ func NewConfigurationService(db *gorm.DB, audit *audit.Writer) *ConfigurationSer
|
||||
type configurationInput struct {
|
||||
Title string
|
||||
Content string
|
||||
PopupType string
|
||||
Pages []string
|
||||
ShopIDs []uint
|
||||
DeviceTypes []string
|
||||
@@ -69,7 +70,12 @@ func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5P
|
||||
if request.Enabled != nil && *request.Enabled {
|
||||
enabled = constants.H5PopupStatusEnabled
|
||||
}
|
||||
priority := 0
|
||||
// 弹窗类型必填:它既参与类别排序,也决定缺省优先级的取值来源。
|
||||
popupType := strings.TrimSpace(request.PopupType)
|
||||
if !constants.IsH5PopupType(popupType) {
|
||||
return 0, errors.New(errors.CodeInvalidParam, "运营弹窗类型必须为 promotion(套餐政策推广)或 announcement(通用公告)")
|
||||
}
|
||||
priority := constants.H5PopupTypeDefaultPriority(popupType)
|
||||
if request.Priority != nil {
|
||||
priority = *request.Priority
|
||||
}
|
||||
@@ -78,7 +84,7 @@ func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5P
|
||||
actionType = *request.ActionType
|
||||
}
|
||||
normalized, err := normalizeConfigurationInput(configurationInput{
|
||||
Title: request.Title, Content: request.Content, Pages: request.Pages,
|
||||
Title: request.Title, Content: request.Content, PopupType: popupType, Pages: request.Pages,
|
||||
ShopIDs: request.ShopIDs, DeviceTypes: request.DeviceTypes, CardTypes: request.CardTypes,
|
||||
Priority: priority, Frequency: request.Frequency, ActionType: actionType,
|
||||
Enabled: enabled, StartsAt: request.StartsAt, EndsAt: request.EndsAt,
|
||||
@@ -88,7 +94,7 @@ func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5P
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
record := &model.H5PopupConfiguration{
|
||||
Title: normalized.Title, Content: normalized.Content,
|
||||
Title: normalized.Title, Content: normalized.Content, PopupType: normalized.PopupType,
|
||||
Pages: model.StringJSONBArray(normalized.Pages), ShopIDs: toJSONBStrings(normalized.ShopIDs),
|
||||
DeviceTypes: model.StringJSONBArray(normalized.DeviceTypes), CardTypes: model.StringJSONBArray(normalized.CardTypes),
|
||||
Priority: normalized.Priority, Frequency: normalized.Frequency, ActionType: normalized.ActionType,
|
||||
@@ -136,7 +142,7 @@ func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.
|
||||
beforeData := configurationAuditSnapshot(&before)
|
||||
|
||||
merged := configurationInput{
|
||||
Title: record.Title, Content: record.Content, Pages: storePages(record),
|
||||
Title: record.Title, Content: record.Content, PopupType: record.PopupType, Pages: storePages(record),
|
||||
ShopIDs: storeShopIDs(record), DeviceTypes: storeDeviceTypes(record), CardTypes: storeCardTypes(record),
|
||||
Priority: record.Priority, Frequency: record.Frequency, ActionType: record.ActionType,
|
||||
Enabled: record.Enabled, StartsAt: record.StartsAt, EndsAt: record.EndsAt,
|
||||
@@ -147,6 +153,9 @@ func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.
|
||||
if request.Content != nil {
|
||||
merged.Content = *request.Content
|
||||
}
|
||||
if request.PopupType != nil {
|
||||
merged.PopupType = strings.TrimSpace(*request.PopupType)
|
||||
}
|
||||
if request.Pages != nil {
|
||||
merged.Pages = *request.Pages
|
||||
}
|
||||
@@ -184,6 +193,7 @@ func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.
|
||||
now := time.Now().UTC()
|
||||
record.Title = normalized.Title
|
||||
record.Content = normalized.Content
|
||||
record.PopupType = normalized.PopupType
|
||||
record.Pages = model.StringJSONBArray(normalized.Pages)
|
||||
record.ShopIDs = toJSONBStrings(normalized.ShopIDs)
|
||||
record.DeviceTypes = model.StringJSONBArray(normalized.DeviceTypes)
|
||||
@@ -311,6 +321,11 @@ func normalizeConfigurationInput(input configurationInput) (configurationInput,
|
||||
if popupURLPattern.MatchString(normalized.Content) || popupRoutePattern.MatchString(normalized.Content) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文不接受 URL 或前端路由,只能使用受控动作")
|
||||
}
|
||||
// 类型必填且必须落在受控取值域内:它是候选排序的第一顺位类别键,非法值会让配置落进未定义类别。
|
||||
normalized.PopupType = strings.TrimSpace(input.PopupType)
|
||||
if !constants.IsH5PopupType(normalized.PopupType) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗类型必须为 promotion(套餐政策推广)或 announcement(通用公告)")
|
||||
}
|
||||
normalized.Pages = dedupeStrings(input.Pages)
|
||||
if len(normalized.Pages) == 0 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗至少需要一个命中页面")
|
||||
@@ -471,16 +486,16 @@ func configurationAuditResourceID(id uint) *string {
|
||||
// configurationAuditIdentity 生成配置身份快照,不含正文内容。
|
||||
func configurationAuditIdentity(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"id": record.ID, "title": record.Title, "pages": storePages(record),
|
||||
"id": record.ID, "title": record.Title, "popup_type": record.PopupType, "pages": storePages(record),
|
||||
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
|
||||
"enabled": record.Enabled, "version": record.Version,
|
||||
}
|
||||
}
|
||||
|
||||
// configurationAuditSnapshot 生成配置审计前后值快照,覆盖范围、优先级、频率、受控动作、启停、有效期与版本。
|
||||
// configurationAuditSnapshot 生成配置审计前后值快照,覆盖类型、范围、优先级、频率、受控动作、启停、有效期与版本。
|
||||
func configurationAuditSnapshot(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"id": record.ID, "title": record.Title, "content": record.Content,
|
||||
"id": record.ID, "title": record.Title, "content": record.Content, "popup_type": record.PopupType,
|
||||
"pages": storePages(record), "shop_ids": storeShopIDs(record),
|
||||
"device_types": storeDeviceTypes(record), "card_types": storeCardTypes(record),
|
||||
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
|
||||
|
||||
@@ -193,29 +193,21 @@ func (s *ManagementService) ListPools(ctx context.Context, req dto.PaymentMercha
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计商户池失败")
|
||||
}
|
||||
var pools []model.PaymentMerchantPool
|
||||
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&pools).Error; err != nil {
|
||||
var rows []model.PaymentMerchantPool
|
||||
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
|
||||
}
|
||||
poolIDs := make([]uint, 0, len(pools))
|
||||
for index := range pools {
|
||||
poolIDs = append(poolIDs, pools[index].ID)
|
||||
pools := make([]*model.PaymentMerchantPool, 0, len(rows))
|
||||
for index := range rows {
|
||||
pools = append(pools, &rows[index])
|
||||
}
|
||||
membersByPool := make(map[uint][]uint, len(pools))
|
||||
if len(poolIDs) > 0 {
|
||||
var members []model.PaymentMerchantPoolMember
|
||||
if err := s.db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
|
||||
}
|
||||
for index := range members {
|
||||
member := &members[index]
|
||||
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
|
||||
}
|
||||
projections, err := loadPoolProjections(ctx, s.db, pools, time.Now())
|
||||
if err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
|
||||
}
|
||||
result := make([]*dto.PaymentMerchantPoolResponse, 0, len(pools))
|
||||
for index := range pools {
|
||||
pool := &pools[index]
|
||||
result = append(result, &dto.PaymentMerchantPoolResponse{ID: pool.ID, Name: pool.Name, PaymentMethod: pool.PaymentMethod, Enabled: pool.Status == model.PaymentMerchantStatusEnabled, Strategy: pool.Strategy, ThresholdAmount: pool.ThresholdAmount, ThresholdCount: pool.ThresholdCount, StatisticCycle: pool.StatisticCycle, TimePeriodValue: pool.TimePeriodValue, TimePeriodUnit: pool.TimePeriodUnit, TimePeriodStartedAt: pool.TimePeriodStartedAt, RoutingEpoch: pool.RoutingEpoch, MemberIDs: membersByPool[pool.ID], Remark: pool.Remark})
|
||||
for _, pool := range pools {
|
||||
result = append(result, poolResponse(pool, projections[pool.ID]))
|
||||
}
|
||||
return result, total, nil
|
||||
}
|
||||
@@ -232,7 +224,7 @@ func (s *ManagementService) GetPool(ctx context.Context, id uint) (*dto.PaymentM
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
return s.poolView(ctx, &pool, time.Now())
|
||||
}
|
||||
|
||||
// ListMerchants 分页查询特权商户配置。
|
||||
@@ -429,8 +421,19 @@ func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.Payme
|
||||
if !creating && poolEpochChanged(&pool, &req, previousMemberIDs) {
|
||||
pool.RoutingEpoch++
|
||||
}
|
||||
// 时间轮询池修改周期数值、单位、起始时间或成员顺序并保存成功时,起点由服务端按保存成功时间重算,
|
||||
// 忽略请求携带的旧起点,避免前端时钟或漏传导致时段漂移。
|
||||
restartTimedSlot := !creating && strings.TrimSpace(req.Strategy) == model.PaymentMerchantStrategyTime && timedSlotChanged(&pool, &req, previousMemberIDs)
|
||||
pool.Name, pool.PaymentMethod, pool.Strategy, pool.Remark = strings.TrimSpace(req.Name), strings.TrimSpace(req.PaymentMethod), strings.TrimSpace(req.Strategy), strings.TrimSpace(req.Remark)
|
||||
pool.ThresholdAmount, pool.ThresholdCount, pool.StatisticCycle, pool.TimePeriodValue, pool.TimePeriodUnit, pool.TimePeriodStartedAt = req.ThresholdAmount, req.ThresholdCount, req.StatisticCycle, req.TimePeriodValue, req.TimePeriodUnit, req.TimePeriodStartedAt
|
||||
if creating {
|
||||
// 创建按请求起点;请求缺省起点时取保存时间。
|
||||
restartTimedSlot = pool.Strategy == model.PaymentMerchantStrategyTime && pool.TimePeriodStartedAt == nil
|
||||
}
|
||||
savedAt := time.Now()
|
||||
if restartTimedSlot {
|
||||
pool.TimePeriodStartedAt = &savedAt
|
||||
}
|
||||
if req.Enabled {
|
||||
var others int64
|
||||
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
|
||||
@@ -479,7 +482,7 @@ func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.Payme
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存商户池失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
return s.poolView(ctx, &pool, time.Now())
|
||||
}
|
||||
|
||||
// SetPoolEnabled enables or disables a pool after rechecking the active-pool and member invariants.
|
||||
@@ -537,7 +540,7 @@ func (s *ManagementService) SetPoolEnabled(ctx context.Context, id uint, enabled
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新商户池状态失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
return s.poolView(ctx, &pool, time.Now())
|
||||
}
|
||||
|
||||
func validatePoolMembers(ctx context.Context, tx *gorm.DB, method string, ids []uint) error {
|
||||
@@ -582,6 +585,17 @@ func poolEpochChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerch
|
||||
return pool.StatisticCycle == nil || (*pool.StatisticCycle != "day" && *pool.StatisticCycle != "month") || !sameMemberSet(previousMemberIDs, request.MemberIDs)
|
||||
}
|
||||
|
||||
// timedSlotChanged 判断保存时间轮询池时是否需要按保存成功时间重算时段起点:
|
||||
// 周期数值、单位、起始时间或成员顺序任一变化即视为需要重算。
|
||||
func timedSlotChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerchantPoolRequest, previousMemberIDs []uint) bool {
|
||||
if !sameInt64(pool.TimePeriodValue, request.TimePeriodValue) ||
|
||||
!sameString(pool.TimePeriodUnit, request.TimePeriodUnit) ||
|
||||
!sameTime(pool.TimePeriodStartedAt, request.TimePeriodStartedAt) {
|
||||
return true
|
||||
}
|
||||
return !sameMemberOrder(previousMemberIDs, request.MemberIDs)
|
||||
}
|
||||
|
||||
func sameMemberOrder(left, right []uint) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
@@ -633,16 +647,25 @@ func sameTime(a, b *time.Time) bool {
|
||||
func merchantResponse(m *model.PaymentMerchant) *dto.PaymentMerchantResponse {
|
||||
return &dto.PaymentMerchantResponse{ID: m.ID, Name: m.Name, PaymentMethod: m.PaymentMethod, ProviderType: m.ProviderType, MerchantIdentity: m.MerchantIdentity, Credentials: m.Credentials, CredentialVersion: m.CredentialVersion, Enabled: m.Status == model.PaymentMerchantStatusEnabled, Remark: m.Remark, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}
|
||||
}
|
||||
func poolResponse(ctx context.Context, db *gorm.DB, p *model.PaymentMerchantPool) (*dto.PaymentMerchantPoolResponse, error) {
|
||||
var rows []model.PaymentMerchantPoolMember
|
||||
if err := db.WithContext(ctx).Where("pool_id = ?", p.ID).Order("sort_order ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
// poolResponse 组装商户池响应,成员、成员计数与命中成员由只读投影提供。
|
||||
func poolResponse(p *model.PaymentMerchantPool, projection poolProjection) *dto.PaymentMerchantPoolResponse {
|
||||
response := &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: projection.MemberIDs, EnabledMemberCount: projection.EnabledMembers, MemberCount: len(projection.MemberIDs), UpdatedAt: p.UpdatedAt, Remark: p.Remark}
|
||||
if projection.CurrentMerchant != nil {
|
||||
merchantID := projection.CurrentMerchant.ID
|
||||
response.CurrentMerchantID = &merchantID
|
||||
response.CurrentMerchantName = projection.CurrentMerchant.Name
|
||||
}
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.MerchantID)
|
||||
return response
|
||||
}
|
||||
|
||||
// poolView 读取商户池的只读列表投影并组装响应。
|
||||
func (s *ManagementService) poolView(ctx context.Context, pool *model.PaymentMerchantPool, now time.Time) (*dto.PaymentMerchantPoolResponse, error) {
|
||||
projections, err := loadPoolProjections(ctx, s.db, []*model.PaymentMerchantPool{pool}, now)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
|
||||
}
|
||||
return &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: ids, Remark: p.Remark}, nil
|
||||
return poolResponse(pool, projections[pool.ID]), nil
|
||||
}
|
||||
|
||||
// GetAuthorization 查询特权全局授权配置。
|
||||
|
||||
@@ -249,58 +249,145 @@ func (l *RuntimeLoader) SelectForNewPaymentWithTx(ctx context.Context, tx *gorm.
|
||||
return &RouteSelection{Merchant: chosen, Pool: &pool}, nil
|
||||
}
|
||||
|
||||
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
|
||||
// routingOutcome 是只读选路结果。
|
||||
type routingOutcome struct {
|
||||
// Merchant 是当前命中成员。
|
||||
Merchant *model.PaymentMerchant
|
||||
// OpenNewRound 为真表示每轮累计已全部达标,调用方需以条件更新开启新一轮。
|
||||
OpenNewRound bool
|
||||
}
|
||||
|
||||
// routeStats 是已按统计世代隔离的商户成功累计。
|
||||
type routeStats struct {
|
||||
amounts map[uint]int64
|
||||
counts map[uint]int64
|
||||
}
|
||||
|
||||
// newRouteStats 创建空的成功累计聚合。
|
||||
func newRouteStats() routeStats {
|
||||
return routeStats{amounts: map[uint]int64{}, counts: map[uint]int64{}}
|
||||
}
|
||||
|
||||
// add 累加一条成功事实。
|
||||
func (s routeStats) add(row *model.PaymentMerchantRoutingSuccess) {
|
||||
s.amounts[row.MerchantID] += row.Amount
|
||||
s.counts[row.MerchantID]++
|
||||
}
|
||||
|
||||
// statisticCycle 返回金额/笔数池的统计周期。
|
||||
func statisticCycle(pool *model.PaymentMerchantPool) (string, error) {
|
||||
if pool.StatisticCycle == nil {
|
||||
return "", errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
|
||||
}
|
||||
return *pool.StatisticCycle, nil
|
||||
}
|
||||
|
||||
// thresholdValue 返回金额/笔数池的达标阈值。
|
||||
func thresholdValue(pool *model.PaymentMerchantPool) (int64, error) {
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount {
|
||||
if pool.ThresholdAmount == nil {
|
||||
return 0, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
|
||||
}
|
||||
return *pool.ThresholdAmount, nil
|
||||
}
|
||||
if pool.ThresholdCount == nil {
|
||||
return 0, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
|
||||
}
|
||||
return *pool.ThresholdCount, nil
|
||||
}
|
||||
|
||||
// selectRouteMember 只读计算当前命中成员,与支付创建共用同一套规则:
|
||||
// 金额/笔数取成员顺序中第一个未达阈值的成员;「每轮累计」全部达标时取成员顺序第一项,
|
||||
// 由调用方开启新一轮;自然日与自然月全部达标时视为当期无可用成员;时间方式按当前时段计算。
|
||||
// 池内只有一个启用成员时该成员固定命中,不因达到阈值被跳过、也不因全部达标被拒绝,
|
||||
// 该规则优先于自然周期拒绝语义,短路在全部达标判定之前。
|
||||
// 本函数 MUST NOT 写库:不创建支付单、不推进统计世代、不计入成功累计。
|
||||
func selectRouteMember(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, stats routeStats, now time.Time) (routingOutcome, error) {
|
||||
if len(merchants) == 0 {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
|
||||
return routingOutcome{}, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
|
||||
}
|
||||
if pool.Strategy == model.PaymentMerchantStrategyTime {
|
||||
return chooseTimedMerchant(pool, merchants, now)
|
||||
merchant, err := chooseTimedMerchant(pool, merchants, now)
|
||||
if err != nil {
|
||||
return routingOutcome{}, err
|
||||
}
|
||||
return routingOutcome{Merchant: merchant}, nil
|
||||
}
|
||||
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
|
||||
return routingOutcome{}, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
|
||||
}
|
||||
if pool.StatisticCycle == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
|
||||
cycle, err := statisticCycle(pool)
|
||||
if err != nil {
|
||||
return routingOutcome{}, err
|
||||
}
|
||||
threshold, err := thresholdValue(pool)
|
||||
if err != nil {
|
||||
return routingOutcome{}, err
|
||||
}
|
||||
if len(merchants) == 1 {
|
||||
return routingOutcome{Merchant: merchants[0]}, nil
|
||||
}
|
||||
for _, merchant := range merchants {
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount {
|
||||
if stats.amounts[merchant.ID] < threshold {
|
||||
return routingOutcome{Merchant: merchant}, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if stats.counts[merchant.ID] < threshold {
|
||||
return routingOutcome{Merchant: merchant}, nil
|
||||
}
|
||||
}
|
||||
if cycle != "round" {
|
||||
return routingOutcome{}, errors.New(errors.CodeNoPaymentConfig, "当前周期暂无可用商户")
|
||||
}
|
||||
return routingOutcome{Merchant: merchants[0], OpenNewRound: true}, nil
|
||||
}
|
||||
|
||||
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
|
||||
var stats routeStats
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount || pool.Strategy == model.PaymentMerchantStrategyCount {
|
||||
loaded, err := loadPoolRouteStats(ctx, tx, pool, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats = loaded
|
||||
}
|
||||
outcome, err := selectRouteMember(pool, merchants, stats, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !outcome.OpenNewRound {
|
||||
return outcome.Merchant, nil
|
||||
}
|
||||
// 每轮累计全部达标:当轮结束,以条件更新递增统计世代开启新一轮;
|
||||
// 世代已被并发改变时按并发冲突拒绝本次创建,绝不在旧世代上重复累计。
|
||||
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return outcome.Merchant, nil
|
||||
}
|
||||
|
||||
// loadPoolRouteStats 读取单个商户池当前统计世代的成功累计。
|
||||
// 自然日与自然月周期只统计支付时间不早于当期窗口起点的成功事实。
|
||||
func loadPoolRouteStats(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, now time.Time) (routeStats, error) {
|
||||
cycle, err := statisticCycle(pool)
|
||||
if err != nil {
|
||||
return routeStats{}, err
|
||||
}
|
||||
query := tx.WithContext(ctx).Where("pool_id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch)
|
||||
if start, limited := routingWindowStart(*pool.StatisticCycle, now); limited {
|
||||
if start, limited := routingWindowStart(cycle, now); limited {
|
||||
query = query.Where("paid_at >= ?", start)
|
||||
}
|
||||
var rows []model.PaymentMerchantRoutingSuccess
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
return routeStats{}, err
|
||||
}
|
||||
amounts := make(map[uint]int64, len(merchants))
|
||||
counts := make(map[uint]int64, len(merchants))
|
||||
for _, row := range rows {
|
||||
amounts[row.MerchantID] += row.Amount
|
||||
counts[row.MerchantID]++
|
||||
stats := newRouteStats()
|
||||
for index := range rows {
|
||||
stats.add(&rows[index])
|
||||
}
|
||||
for _, merchant := range merchants {
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount {
|
||||
if pool.ThresholdAmount == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
|
||||
}
|
||||
if amounts[merchant.ID] < *pool.ThresholdAmount {
|
||||
return merchant, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if pool.ThresholdCount == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
|
||||
}
|
||||
if counts[merchant.ID] < *pool.ThresholdCount {
|
||||
return merchant, nil
|
||||
}
|
||||
}
|
||||
if *pool.StatisticCycle != "round" {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig, "当前统计周期内暂无可用商户")
|
||||
}
|
||||
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return merchants[0], nil
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func chooseTimedMerchant(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
|
||||
@@ -353,6 +440,115 @@ func advanceRoutingEpoch(ctx context.Context, tx *gorm.DB, pool *model.PaymentMe
|
||||
return nil
|
||||
}
|
||||
|
||||
// poolProjection 是商户池列表投影的只读计算结果。
|
||||
type poolProjection struct {
|
||||
// MemberIDs 是按顺序排列的成员商户ID。
|
||||
MemberIDs []uint
|
||||
// EnabledMembers 是成员中当前启用且支付方式与池一致的商户数量。
|
||||
EnabledMembers int
|
||||
// CurrentMerchant 是当前命中成员;池停用或无可用成员时为空。
|
||||
CurrentMerchant *model.PaymentMerchant
|
||||
}
|
||||
|
||||
// loadPoolProjections 按页批量计算成员计数与当前命中成员,供列表与详情投影复用。
|
||||
// 只读:不创建支付单、不推进统计世代、不计入成功累计;查询次数为常数,不随池数量线性增长。
|
||||
// 池停用或没有可用成员时当前命中成员为空,不影响其余字段返回。
|
||||
func loadPoolProjections(ctx context.Context, db *gorm.DB, pools []*model.PaymentMerchantPool, now time.Time) (map[uint]poolProjection, error) {
|
||||
projections := make(map[uint]poolProjection, len(pools))
|
||||
if len(pools) == 0 {
|
||||
return projections, nil
|
||||
}
|
||||
poolIDs := make([]uint, 0, len(pools))
|
||||
for _, pool := range pools {
|
||||
poolIDs = append(poolIDs, pool.ID)
|
||||
}
|
||||
var members []model.PaymentMerchantPoolMember
|
||||
if err := db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
membersByPool := make(map[uint][]uint, len(pools))
|
||||
merchantIDs := make([]uint, 0, len(members))
|
||||
for index := range members {
|
||||
member := &members[index]
|
||||
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
|
||||
merchantIDs = append(merchantIDs, member.MerchantID)
|
||||
}
|
||||
merchants := make(map[uint]*model.PaymentMerchant)
|
||||
if len(merchantIDs) > 0 {
|
||||
var rows []model.PaymentMerchant
|
||||
if err := db.WithContext(ctx).Where("id IN ? AND status = ?", merchantIDs, model.PaymentMerchantStatusEnabled).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range rows {
|
||||
merchant := &rows[index]
|
||||
merchants[merchant.ID] = merchant
|
||||
}
|
||||
}
|
||||
// 金额与笔数启用池当前世代的成功累计一次查出:按池拼出「池 + 世代(+ 当期窗口起点)」
|
||||
// 条件,既避免逐池查询,也避免把其他世代与上一周期的成功事实读进内存。
|
||||
terms := make([]string, 0, len(pools))
|
||||
args := make([]any, 0, len(pools)*3)
|
||||
for _, pool := range pools {
|
||||
if pool.Status != model.PaymentMerchantStatusEnabled || len(membersByPool[pool.ID]) == 0 {
|
||||
continue
|
||||
}
|
||||
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
|
||||
continue
|
||||
}
|
||||
cycle, err := statisticCycle(pool)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if start, limited := routingWindowStart(cycle, now); limited {
|
||||
terms = append(terms, "(pool_id = ? AND routing_epoch = ? AND paid_at >= ?)")
|
||||
args = append(args, pool.ID, pool.RoutingEpoch, start)
|
||||
continue
|
||||
}
|
||||
terms = append(terms, "(pool_id = ? AND routing_epoch = ?)")
|
||||
args = append(args, pool.ID, pool.RoutingEpoch)
|
||||
}
|
||||
statsByPool := make(map[uint]routeStats, len(pools))
|
||||
if len(terms) > 0 {
|
||||
var rows []model.PaymentMerchantRoutingSuccess
|
||||
if err := db.WithContext(ctx).Where(strings.Join(terms, " OR "), args...).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range rows {
|
||||
row := &rows[index]
|
||||
stats, ok := statsByPool[row.PoolID]
|
||||
if !ok {
|
||||
stats = newRouteStats()
|
||||
statsByPool[row.PoolID] = stats
|
||||
}
|
||||
stats.add(row)
|
||||
}
|
||||
}
|
||||
for _, pool := range pools {
|
||||
ordered := orderedEnabledMembers(pool, membersByPool[pool.ID], merchants)
|
||||
projection := poolProjection{MemberIDs: membersByPool[pool.ID], EnabledMembers: len(ordered)}
|
||||
if pool.Status == model.PaymentMerchantStatusEnabled {
|
||||
if outcome, err := selectRouteMember(pool, ordered, statsByPool[pool.ID], now); err == nil {
|
||||
projection.CurrentMerchant = outcome.Merchant
|
||||
}
|
||||
}
|
||||
projections[pool.ID] = projection
|
||||
}
|
||||
return projections, nil
|
||||
}
|
||||
|
||||
// orderedEnabledMembers 按成员顺序返回启用且支付方式与池一致的商户。
|
||||
func orderedEnabledMembers(pool *model.PaymentMerchantPool, memberIDs []uint, merchants map[uint]*model.PaymentMerchant) []*model.PaymentMerchant {
|
||||
ordered := make([]*model.PaymentMerchant, 0, len(memberIDs))
|
||||
for _, id := range memberIDs {
|
||||
merchant := merchants[id]
|
||||
if merchant == nil || merchant.Status != model.PaymentMerchantStatusEnabled || merchant.PaymentMethod != pool.PaymentMethod {
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, merchant)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
// FreezeRoute writes only non-sensitive route facts onto the payment.
|
||||
func FreezeRoute(payment *model.Payment, route *RouteSelection) {
|
||||
if payment == nil || route == nil || route.Merchant == nil || route.Pool == nil {
|
||||
|
||||
@@ -19,16 +19,37 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RefundMaterial 承载本次提交由调用方按既有展示口径解析出的审批材料事实。
|
||||
//
|
||||
// 材料由服务层解析后传入,使审批材料与退款展示字段共用同一份解析规则(ENG-ARCH-002:
|
||||
// 读取口径不复制);材料不可解析时以空值或零值传入,MUST NOT 阻断审批提交。
|
||||
type RefundMaterial struct {
|
||||
// AssetType 是退款关联资产类型:card 单卡、device 设备,沿用退款申请的下单快照推导。
|
||||
AssetType string
|
||||
// DeviceType 与 DeviceModel 取退款申请关联设备,无关联设备时为空串。
|
||||
DeviceType string
|
||||
DeviceModel string
|
||||
// PackageUsedMB 与 PackageTotalMB 是当前退款套餐的已用量与总量(MB,真实流量),解析不到时为 0。
|
||||
PackageUsedMB int64
|
||||
PackageTotalMB int64
|
||||
// OriginalChannelTradeNo 是创建申请时冻结的原支付渠道交易流水号,线下订单为空串。
|
||||
OriginalChannelTradeNo string
|
||||
}
|
||||
|
||||
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
|
||||
//
|
||||
// 本次提交的审批尝试记录由本用例在退款申请落库后于同一事务内构造,调用方只提供
|
||||
// 需要按冻结商户派生、应用层无法自行生成的渠道退款请求号。
|
||||
// 需要按冻结商户派生、应用层无法自行生成的渠道退款请求号,以及本次提交的申请人备注与审批材料。
|
||||
type CreateCommand struct {
|
||||
Refund *model.RefundRequest
|
||||
Order *model.Order
|
||||
SubmitterAccountID uint
|
||||
// ChannelRefundRequestNo 是原路退款本次尝试冻结的渠道退款请求号;非原路方式为空。
|
||||
ChannelRefundRequestNo string
|
||||
// ApplicantRemark 是本次提交的申请人备注,冻结进当次审批尝试快照;不写入退款主表审批备注。
|
||||
ApplicantRemark string
|
||||
// Material 是本次提交的审批材料事实,冻结进当次尝试快照与审批业务快照。
|
||||
Material RefundMaterial
|
||||
}
|
||||
|
||||
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
|
||||
@@ -76,7 +97,10 @@ func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWrite
|
||||
|
||||
// TriggerHistorical 为历史待审批退款补发一次企业微信审批。
|
||||
// 历史申请尚未接入尝试模式,因此本次补发同时建立首条尝试记录并把业务标识切换到该记录。
|
||||
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) (*CreateResult, error) {
|
||||
//
|
||||
// 补发不为历史申请追溯退款原因必填(该校验只作用于创建与重提用例),但材料仍按既有展示口径
|
||||
// 解析后传入,使新增控件的审批材料在补发路径同样完整,避免模板缺字段导致提交失败。
|
||||
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint, material RefundMaterial) (*CreateResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || refundID == 0 {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
}
|
||||
@@ -129,7 +153,7 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
|
||||
}
|
||||
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, ¤tOrder, "")
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, ¤tOrder, "", "", material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -138,7 +162,7 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||||
}
|
||||
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account)
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account, "", material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -202,7 +226,7 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account)
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -224,7 +248,7 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
|
||||
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
|
||||
}
|
||||
attempt, err = buildAttempt(ctx, tx, command.Refund, command.Order, command.ChannelRefundRequestNo)
|
||||
attempt, err = buildAttempt(ctx, tx, command.Refund, command.Order, command.ChannelRefundRequestNo, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -271,6 +295,10 @@ type ResubmitCommand struct {
|
||||
Refund *model.RefundRequest
|
||||
// ChannelRefundRequestNo 是原路退款本次重提冻结的渠道退款请求号;非原路方式为空。
|
||||
ChannelRefundRequestNo string
|
||||
// ApplicantRemark 是本次重提的申请人备注,冻结进新的审批尝试快照;历史尝试备注不被改写。
|
||||
ApplicantRemark string
|
||||
// Material 是本次重提的审批材料事实,冻结进新的尝试快照与审批业务快照。
|
||||
Material RefundMaterial
|
||||
}
|
||||
|
||||
// Resubmit 修改并重提未成功退款申请,新增审批尝试记录与新的企业微信审批实例。
|
||||
@@ -318,7 +346,7 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R
|
||||
current.RefundVoucherKey = command.Refund.RefundVoucherKey
|
||||
current.CustomerAccountInfo = command.Refund.CustomerAccountInfo
|
||||
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, &order, command.ChannelRefundRequestNo)
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, &order, command.ChannelRefundRequestNo, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -331,7 +359,7 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account)
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -345,6 +373,8 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R
|
||||
"refund_reason": current.RefundReason,
|
||||
"refund_voucher_key": current.RefundVoucherKey,
|
||||
"customer_account_info": current.CustomerAccountInfo,
|
||||
"source_payment_no": current.SourcePaymentNo,
|
||||
"original_channel_trade_no": current.OriginalChannelTradeNo,
|
||||
"failure_reason": "",
|
||||
"failure_message": "",
|
||||
"channel_refund_status": constants.RefundChannelStatusNone,
|
||||
@@ -428,15 +458,23 @@ func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*m
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、客户收款信息与套餐使用快照。
|
||||
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、申请人备注、客户收款信息与套餐使用快照。
|
||||
// attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。
|
||||
// package_usage_snapshot 必须是非空 JSON 对象,因此快照只能在这里按订单事实生成,不能由调用方预置。
|
||||
func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, channelRefundRequestNo string) (*model.RefundRequestAttempt, error) {
|
||||
func buildAttempt(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
refund *model.RefundRequest,
|
||||
order *model.Order,
|
||||
channelRefundRequestNo string,
|
||||
applicantRemark string,
|
||||
material RefundMaterial,
|
||||
) (*model.RefundRequestAttempt, error) {
|
||||
attemptNo, err := nextAttemptNo(ctx, tx, refund.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot, err := packageUsageSnapshot(ctx, tx, refund, order)
|
||||
snapshot, err := packageUsageSnapshot(ctx, tx, refund, order, material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -447,6 +485,7 @@ func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest,
|
||||
RefundAmount: refund.RequestedRefundAmount,
|
||||
FrozenActualReceivedAmount: refund.FrozenActualReceivedAmount,
|
||||
RefundReason: refund.RefundReason,
|
||||
Remark: strings.TrimSpace(applicantRemark),
|
||||
CustomerAccountInfo: refund.CustomerAccountInfo,
|
||||
CustomerVoucherKeys: refund.RefundVoucherKey,
|
||||
PackageUsageSnapshot: snapshot,
|
||||
@@ -468,12 +507,21 @@ func nextAttemptNo(ctx context.Context, tx *gorm.DB, refundID uint) (int, error)
|
||||
return row.MaxAttemptNo + 1, nil
|
||||
}
|
||||
|
||||
// packageUsageSnapshot 冻结本次申请关联的套餐使用情况,作为企业微信审批判断材料。
|
||||
// packageUsageSnapshot 冻结本次申请关联的套餐使用情况与本次提交的审批材料,作为企业微信审批判断材料。
|
||||
// 本期退款不按套餐已用流量计算金额,因此该快照只作审批与追溯材料,不参与金额校验。
|
||||
func packageUsageSnapshot(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (datatypes.JSON, error) {
|
||||
//
|
||||
// 用量、设备维度与原渠道交易流水号由调用方按既有展示口径解析后传入(material):材料不可解析时
|
||||
// 以空值或零值写入,使快照键始终存在而 MUST NOT 阻断审批提交。
|
||||
func packageUsageSnapshot(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, material RefundMaterial) (datatypes.JSON, error) {
|
||||
snapshot := map[string]any{
|
||||
"order_type": order.OrderType,
|
||||
"asset_identifier": order.AssetIdentifier,
|
||||
"asset_type": material.AssetType,
|
||||
"device_type": material.DeviceType,
|
||||
"device_model": material.DeviceModel,
|
||||
"package_used_mb": material.PackageUsedMB,
|
||||
"package_total_mb": material.PackageTotalMB,
|
||||
"channel_trade_no": material.OriginalChannelTradeNo,
|
||||
}
|
||||
if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 {
|
||||
var usage model.PackageUsage
|
||||
@@ -545,7 +593,10 @@ func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRe
|
||||
return nil
|
||||
}
|
||||
|
||||
func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) {
|
||||
// refundSnapshots 冻结提交人与审批业务快照。
|
||||
// 审批业务快照是企微表单构建的数据源,因此本变更新增的审批材料字段必须在此出现,
|
||||
// 且即使不可解析也要以空值或零值写入:控件映射已配置时缺失键会被表单构建明确拒绝。
|
||||
func refundSnapshots(refund *model.RefundRequest, account *model.Account, applicantRemark string, material RefundMaterial) ([]byte, []byte, error) {
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||||
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
|
||||
})
|
||||
@@ -563,6 +614,13 @@ func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byt
|
||||
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
|
||||
constants.ApprovalFieldRefundReason: refund.RefundReason,
|
||||
constants.ApprovalFieldPackageUsageID: refund.PackageUsageID,
|
||||
constants.ApprovalFieldRefundAssetType: material.AssetType,
|
||||
constants.ApprovalFieldRefundDeviceType: material.DeviceType,
|
||||
constants.ApprovalFieldRefundDeviceModel: material.DeviceModel,
|
||||
constants.ApprovalFieldRefundPackageUsedMB: material.PackageUsedMB,
|
||||
constants.ApprovalFieldRefundPackageTotalMB: material.PackageTotalMB,
|
||||
constants.ApprovalFieldRefundChannelTradeNo: material.OriginalChannelTradeNo,
|
||||
constants.ApprovalFieldRefundApplicantRemark: strings.TrimSpace(applicantRemark),
|
||||
constants.ApprovalFieldSubmitterID: account.ID,
|
||||
constants.ApprovalFieldSubmitterName: account.Username,
|
||||
})
|
||||
|
||||
@@ -288,9 +288,62 @@ func validateSceneMapping(businessType string, mapping []dto.WeComControlMapping
|
||||
}
|
||||
}
|
||||
}
|
||||
// 本变更新增字段必须配置映射:缺失即明确失败,避免审批材料被静默丢弃后审批人看不到关键依据。
|
||||
// 既有未映射的可选控件不受影响,保持既有静默跳过行为。
|
||||
wanted, ok := sceneBusinessFields(businessType)
|
||||
if !ok {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信审批业务类型无效")
|
||||
}
|
||||
for _, field := range wanted {
|
||||
if !field.RequiredMapping {
|
||||
continue
|
||||
}
|
||||
if _, exists := businessFields[field.Code]; !exists {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信场景缺少必须映射的业务字段: "+field.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequiredSceneField 描述本变更新增、必须配置控件映射的业务字段。
|
||||
type RequiredSceneField struct {
|
||||
// Code 是 control_mapping.business_field 应填写的稳定字段编码。
|
||||
Code string
|
||||
// Name 是字段中文名称,用于在缺失时指出具体缺哪个字段/控件。
|
||||
Name string
|
||||
}
|
||||
|
||||
// RequiredSceneFields 返回指定业务类型下必须配置控件映射的业务字段,顺序与场景白名单一致。
|
||||
//
|
||||
// 这是「必须映射字段」的唯一判定来源:场景映射校验(保存时)与表单构建(提交时)都从
|
||||
// 这里取集合,MUST NOT 再维护第二份清单。既有未标注必须映射的可选字段不在返回结果中,
|
||||
// 因此其缺失映射仍保持既有静默跳过行为。
|
||||
func RequiredSceneFields(businessType string) []RequiredSceneField {
|
||||
fields, ok := sceneBusinessFields(businessType)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]RequiredSceneField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if field.RequiredMapping {
|
||||
result = append(result, RequiredSceneField{Code: field.Code, Name: field.Name})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// approvalBusinessTypes 返回全部已登记审批业务场景,供只读字段汇总复用。
|
||||
func approvalBusinessTypes() []string {
|
||||
return []string{
|
||||
constants.ApprovalBusinessTypeRefund,
|
||||
constants.ApprovalBusinessTypeOfflineRecharge,
|
||||
constants.ApprovalBusinessTypeEmployeeCollection,
|
||||
constants.ApprovalBusinessTypeAgentDistribution,
|
||||
constants.ApprovalBusinessTypeWithdrawalQualification,
|
||||
constants.ApprovalBusinessTypeCommissionWithdrawal,
|
||||
}
|
||||
}
|
||||
|
||||
func allowedSceneBusinessField(businessType, businessField string) bool {
|
||||
fields, ok := sceneBusinessFields(businessType)
|
||||
if !ok {
|
||||
@@ -334,6 +387,13 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
|
||||
{Code: constants.ApprovalFieldRefundVoucherKey, Name: "退款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的退款凭证列表"},
|
||||
{Code: constants.ApprovalFieldRefundReason, Name: "退款原因", ValueType: constants.ApprovalFieldValueTypeString, Description: "业务提交人填写的退款原因"},
|
||||
{Code: constants.ApprovalFieldPackageUsageID, Name: "套餐使用记录 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "退款关联套餐使用记录 ID;无关联记录时可能为空"},
|
||||
{Code: constants.ApprovalFieldRefundAssetType, Name: "资产类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款关联资产类型:card 单卡、device 设备;沿用退款申请的下单快照推导", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundDeviceType, Name: "设备类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款申请关联设备的类型;资产不是设备或无关联设备时为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundDeviceModel, Name: "设备型号", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款申请关联设备的型号;资产不是设备或无关联设备时为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundPackageUsedMB, Name: "当前退款套餐已用量(MB)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "当前退款套餐已用量(MB,真实流量);解析不到套餐时为 0", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundPackageTotalMB, Name: "当前退款套餐总量(MB)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "当前退款套餐总量(MB,真实流量);解析不到套餐时为 0", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundChannelTradeNo, Name: "原支付渠道交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "创建申请时冻结的原成功支付记录渠道交易流水号;线下订单为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundApplicantRemark, Name: "申请人备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提交填写的申请人备注快照;未填写时为空字符串。与审批备注不是同一字段", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
@@ -364,6 +424,8 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
|
||||
{Code: constants.ApprovalFieldDistributionPhoneMasked, Name: "注册手机号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的注册手机号,禁止写入完整手机号"},
|
||||
{Code: constants.ApprovalFieldDistributionContactName, Name: "联系人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册填写的联系人姓名"},
|
||||
{Code: constants.ApprovalFieldDistributionRegion, Name: "注册地址摘要", ValueType: constants.ApprovalFieldValueTypeString, Description: "省市区与详细地址拼接的注册地址摘要"},
|
||||
{Code: constants.ApprovalFieldDistributionBusinessOwner, Name: "业务员", ValueType: constants.ApprovalFieldValueTypeString, Description: "上级代理店铺当前启用业务员的名称快照,用于确认将同步给新代理的初始业务员;上级无业务员时为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldDistributionBusinessOwnerAbsence, Name: "无业务员标记", ValueType: constants.ApprovalFieldValueTypeString, Description: "上级店铺当前无业务员时提交「无」,有业务员时为空字符串,MUST NOT 用提交人或上级主账号填充", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
@@ -421,17 +483,12 @@ func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComCon
|
||||
}
|
||||
|
||||
func validApprovalBusinessType(businessType string) bool {
|
||||
switch businessType {
|
||||
case constants.ApprovalBusinessTypeRefund,
|
||||
constants.ApprovalBusinessTypeOfflineRecharge,
|
||||
constants.ApprovalBusinessTypeEmployeeCollection,
|
||||
constants.ApprovalBusinessTypeAgentDistribution,
|
||||
constants.ApprovalBusinessTypeWithdrawalQualification,
|
||||
constants.ApprovalBusinessTypeCommissionWithdrawal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
for _, candidate := range approvalBusinessTypes() {
|
||||
if candidate == businessType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func approvalBusinessTypeName(businessType string) string {
|
||||
|
||||
@@ -135,7 +135,7 @@ type services struct {
|
||||
PollingAlert *pollingSvc.AlertService
|
||||
PollingCleanup *pollingSvc.CleanupService
|
||||
PollingManualTrigger *pollingSvc.ManualTriggerService
|
||||
PriorityPolling *pollingSvc.PriorityEnqueueService
|
||||
PriorityPolling *pollingSvc.PriorityManualService
|
||||
Asset *assetSvc.Service
|
||||
AssetLifecycle *assetSvc.LifecycleService
|
||||
AssetWallet *assetWalletSvc.Service
|
||||
@@ -457,8 +457,9 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
pollingManualTriggerService := pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger)
|
||||
pollingManualTriggerService.SetAudit(deps.DB, auditWriter)
|
||||
|
||||
// 人工优先入队:与人工触发共用同包权限判定,但使用独立的优先提示通道与合并语义。
|
||||
priorityPollingService := pollingSvc.NewPriorityEnqueueService(
|
||||
// 人工优先轮询入口:入队、人工关闭与人工重触发;与人工触发共用同包权限判定,
|
||||
// 但使用独立的优先提示通道与合并语义。
|
||||
priorityPollingService := pollingSvc.NewPriorityManualService(
|
||||
deps.DB, s.IotCard, s.PollingPriorityItem, pollingQueueMgr, deps.Logger,
|
||||
)
|
||||
priorityPollingService.SetAudit(auditWriter)
|
||||
|
||||
@@ -15,4 +15,9 @@ type Evaluation struct {
|
||||
ReadingMB float64
|
||||
// ObservedAt 是本次读数的观测时刻。
|
||||
ObservedAt time.Time
|
||||
// Source 是本次流量观测的来源(constants.CardObservationSource*)。
|
||||
//
|
||||
// 它是判定端获取触发来源的唯一位置:达量命中留痕必须能区分自动判定、人工同步与回调,
|
||||
// 因此调用点把观测来源原样透传,判定端只按列约束归一,不在本处另立来源枚举。
|
||||
Source string
|
||||
}
|
||||
|
||||
51
internal/domain/carrierthreshold/hit.go
Normal file
51
internal/domain/carrierthreshold/hit.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package carrierthreshold
|
||||
|
||||
import "github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
|
||||
// TriggerSourceUnrecorded 表示命中来源未记录。
|
||||
//
|
||||
// 取值与 tb_carrier_traffic_threshold_lock.trigger_source 的 CHECK 允许的空串一致:
|
||||
// 本次上线前的历史行与无法归类的观测来源都记为未记录,读侧按「无」展示。
|
||||
const TriggerSourceUnrecorded = ""
|
||||
|
||||
// triggerSources 是 trigger_source 列 CHECK 允许的全部非空取值,与
|
||||
// pkg/constants.CardObservationSource* 完全一致,两处必须同时演进。
|
||||
var triggerSources = []string{
|
||||
constants.CardObservationSourcePolling,
|
||||
constants.CardObservationSourceManualSync,
|
||||
constants.CardObservationSourceManualOverride,
|
||||
constants.CardObservationSourceCarrierCallback,
|
||||
constants.CardObservationSourceBusinessEvent,
|
||||
}
|
||||
|
||||
// IsTriggerSource 判断取值是否为列约束允许的命中来源。
|
||||
func IsTriggerSource(source string) bool {
|
||||
for _, allowed := range triggerSources {
|
||||
if source == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SafeTriggerSource 把流量观测来源归一到列约束允许的取值集合。
|
||||
//
|
||||
// 命中事实与停机锁是同一条 INSERT,来源列带 CHECK 约束:若把未归类的来源(上游新增观测来源而
|
||||
// 本处枚举尚未同步)原样写库,INSERT 会因约束失败并回滚整个流量观测事务,等价于因留痕失败而
|
||||
// 延迟停机。因此无法归类的来源一律记为「未记录」,停机事实与留痕写入都不会因此失败。
|
||||
func SafeTriggerSource(source string) string {
|
||||
if IsTriggerSource(source) {
|
||||
return source
|
||||
}
|
||||
return TriggerSourceUnrecorded
|
||||
}
|
||||
|
||||
// 周期恢复的复机结果取值,与 tb_carrier_traffic_threshold_lock.resume_result 的 CHECK 一致。
|
||||
const (
|
||||
// ResumeResultResumed 表示新周期条件满足、已发起复机;最终确认结果由 resume_status 承载。
|
||||
ResumeResultResumed = "resumed"
|
||||
// ResumeResultSkipped 表示新周期仅解锁、未发起复机,跳过原因写入既有失败原因列。
|
||||
ResumeResultSkipped = "skipped"
|
||||
// ResumeResultManual 表示周期归属不可判定,已按跨期语义解锁并转人工核对。
|
||||
ResumeResultManual = "manual"
|
||||
)
|
||||
@@ -3,17 +3,20 @@ package exporter
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
// CommissionRecordDataSource 佣金明细导出数据源。
|
||||
// 粒度为佣金记录:原佣金与回溯明细各占一行,金额保持分并在展示层转元,
|
||||
// 负数金额与可为负的余额原样导出,不因符号或余额不足被裁剪。
|
||||
// 归属与设备维度列按导出执行时的当前事实补充,缺值以空值导出,不因个别行缺值丢失整行或整列。
|
||||
type CommissionRecordDataSource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -50,11 +53,16 @@ func (s *CommissionRecordDataSource) Count(ctx context.Context, params ExportPar
|
||||
}
|
||||
|
||||
// Headers 返回佣金明细导出表头。
|
||||
//
|
||||
// 前十五列按 111.md §18.2 与变更 delta 的固定列序输出:店铺、业务员、用户组、资产类型、设备类型、
|
||||
// 设备型号、资产标识、订单号、下单时间、佣金金额、佣金来源、佣金状态、关联佣金明细、入账后金额、创建时间。
|
||||
// 其后的记录来源、记录ID、来源退款单号、是否可提现与佣金入账时间为变更前既有列,予以保留:
|
||||
// 「回溯记录导出」场景要求导出结果仍可辨认行来源、退款单标识与不可提现标识,delta 对导出列的约束为「至少包含」上述十五列。
|
||||
func (s *CommissionRecordDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"记录来源", "记录ID", "代理店铺名称", "关联订单号", "资产标识", "佣金来源",
|
||||
"金额(元)", "是否可提现", "状态", "回溯后佣金余额(元)",
|
||||
"原佣金记录ID", "来源退款单号", "佣金入账时间", "生成时间",
|
||||
"店铺", "业务员", "用户组", "资产类型", "设备类型", "设备型号", "资产标识", "订单号", "下单时间",
|
||||
"佣金金额", "佣金来源", "佣金状态", "关联佣金明细", "入账后金额", "创建时间",
|
||||
"记录来源", "记录ID", "来源退款单号", "是否可提现", "佣金入账时间",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -80,9 +88,11 @@ func (s *CommissionRecordDataSource) Fetch(ctx context.Context, params ExportPar
|
||||
Select(`
|
||||
ledger.source,
|
||||
ledger.id,
|
||||
COALESCE(sh.shop_name, '') AS shop_name,
|
||||
ledger.shop_id,
|
||||
ledger.order_no,
|
||||
COALESCE(NULLIF(ledger.iccid, ''), ledger.virtual_no, '') AS asset_identifier,
|
||||
ledger.order_created_at,
|
||||
ledger.iot_card_id,
|
||||
ledger.device_id,
|
||||
ledger.commission_source,
|
||||
ledger.amount,
|
||||
ledger.withdrawable,
|
||||
@@ -93,31 +103,97 @@ func (s *CommissionRecordDataSource) Fetch(ctx context.Context, params ExportPar
|
||||
ledger.released_at,
|
||||
ledger.created_at
|
||||
`).
|
||||
Joins("LEFT JOIN tb_shop AS sh ON sh.id = ledger.shop_id").
|
||||
// 合并后统一排序并分页,保证两类记录落在同一结果集,任一条不缺失也不重复。
|
||||
Order("ledger.created_at DESC").Order("ledger.id DESC").Order("ledger.source ASC").
|
||||
Limit(limit).Offset(offset)
|
||||
if err := query.Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
// 归属与设备维度按导出执行时当前事实批量补充:只读当前店铺、账号、用户组与资产,不回写任何事实。
|
||||
shopIDs := make([]uint, 0, len(items))
|
||||
cardIDs := make([]uint, 0, len(items))
|
||||
deviceIDs := make([]uint, 0, len(items))
|
||||
originalIDs := make([]uint, 0, len(items))
|
||||
for _, item := range items {
|
||||
shopIDs = append(shopIDs, item.ShopID)
|
||||
if item.IotCardID != nil {
|
||||
cardIDs = append(cardIDs, *item.IotCardID)
|
||||
}
|
||||
if item.DeviceID != nil {
|
||||
deviceIDs = append(deviceIDs, *item.DeviceID)
|
||||
}
|
||||
if item.Source == sourceOriginal {
|
||||
originalIDs = append(originalIDs, item.ID)
|
||||
}
|
||||
}
|
||||
shops, err := s.loadShopOwners(ctx, normalizeUintSlice(shopIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownerIDs := make([]uint, 0, len(shops))
|
||||
for _, shop := range shops {
|
||||
if shop.BusinessOwnerAccountID != nil {
|
||||
ownerIDs = append(ownerIDs, *shop.BusinessOwnerAccountID)
|
||||
}
|
||||
}
|
||||
ownerIDs = normalizeUintSlice(ownerIDs)
|
||||
ownerNames, err := loadAccountNames(ctx, s.db, ownerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupNames, err := businessUserGroupNames(ctx, s.db, ownerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assets, err := s.loadAssetDimensions(ctx, normalizeUintSlice(cardIDs), normalizeUintSlice(deviceIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clawbackIDs, err := s.loadClawbackIDs(ctx, normalizeUintSlice(originalIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
shop := shops[item.ShopID]
|
||||
var ownerName, groupName string
|
||||
// 业务员取店铺当前业务员,用户组按该业务员实时推导;店铺无业务员时两列均留空。
|
||||
if shop.BusinessOwnerAccountID != nil {
|
||||
ownerName = ownerNames[*shop.BusinessOwnerAccountID]
|
||||
groupName = currentOwnerGroupName(groupNames, shop.BusinessOwnerAccountID)
|
||||
}
|
||||
assetType, assetIdentifier, deviceType, deviceModel := assets.describe(item.IotCardID, item.DeviceID)
|
||||
// 关联佣金明细:原佣金列其全部回溯明细编号,回溯明细列被回溯的原佣金编号。
|
||||
relatedCommission := formatOptionalUint(item.OriginalCommissionID)
|
||||
if item.Source == sourceOriginal {
|
||||
relatedCommission = formatClawbackIDList(clawbackIDs[item.ID])
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
shop.ShopName,
|
||||
ownerName,
|
||||
groupName,
|
||||
assetType,
|
||||
deviceType,
|
||||
deviceModel,
|
||||
assetIdentifier,
|
||||
item.OrderNo,
|
||||
formatOptionalTime(item.OrderCreatedAt),
|
||||
formatMoneyYuan(item.Amount),
|
||||
formatCommissionSource(item.CommissionSource),
|
||||
constants.GetCommissionRecordStatusName(item.Status),
|
||||
relatedCommission,
|
||||
formatMoneyYuan(item.BalanceAfter),
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
formatCommissionLedgerSource(item.Source),
|
||||
strconv.FormatUint(uint64(item.ID), 10),
|
||||
item.ShopName,
|
||||
item.OrderNo,
|
||||
item.AssetIdentifier,
|
||||
formatCommissionSource(item.CommissionSource),
|
||||
formatMoneyYuan(item.Amount),
|
||||
formatCommissionWithdrawable(item.Source, item.Withdrawable),
|
||||
constants.GetCommissionRecordStatusName(item.Status),
|
||||
formatMoneyYuan(item.BalanceAfter),
|
||||
formatOptionalUint(item.OriginalCommissionID),
|
||||
item.RefundNo,
|
||||
formatCommissionWithdrawable(item.Source, item.Withdrawable),
|
||||
formatOptionalTime(item.ReleasedAt),
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
@@ -128,10 +204,9 @@ func (s *CommissionRecordDataSource) originalBranch(ctx context.Context, params
|
||||
query := s.db.WithContext(ctx).Table("tb_commission_record AS c").
|
||||
Where("c.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_order o ON c.order_id = o.id AND o.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_iot_card ic ON c.iot_card_id = ic.id AND ic.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_device d ON c.device_id = d.id AND d.deleted_at IS NULL").
|
||||
Select(`'` + sourceOriginal + `' AS source, c.id, c.shop_id, c.order_id, o.order_no, ` +
|
||||
`ic.iccid, d.virtual_no, c.commission_source, c.amount, c.balance_after, c.status, ` +
|
||||
`o.created_at AS order_created_at, c.iot_card_id, c.device_id, ` +
|
||||
`c.commission_source, c.amount, c.balance_after, c.status, ` +
|
||||
`c.released_at, c.created_at, NULL::bigint AS original_commission_id, ''::varchar AS refund_no, ` +
|
||||
`NULL::boolean AS withdrawable`)
|
||||
query = applyExportShopScope(query, params, "c.shop_id")
|
||||
@@ -143,10 +218,9 @@ func (s *CommissionRecordDataSource) clawbackBranch(ctx context.Context, params
|
||||
query := s.db.WithContext(ctx).Table("tb_commission_clawback_record AS g").
|
||||
Joins("LEFT JOIN tb_commission_record oc ON oc.id = g.original_commission_id").
|
||||
Joins("LEFT JOIN tb_order o ON g.order_id = o.id AND o.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_iot_card ic ON oc.iot_card_id = ic.id AND ic.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_device d ON oc.device_id = d.id AND oc.deleted_at IS NULL").
|
||||
Select(`'` + sourceClawback + `' AS source, g.id, g.shop_id, g.order_id, ` +
|
||||
`COALESCE(NULLIF(g.order_no, ''), o.order_no) AS order_no, ic.iccid, d.virtual_no, ` +
|
||||
`COALESCE(NULLIF(g.order_no, ''), o.order_no) AS order_no, o.created_at AS order_created_at, ` +
|
||||
`oc.iot_card_id, oc.device_id, ` +
|
||||
`g.commission_source, g.amount, g.balance_after, g.status, ` +
|
||||
`NULL::timestamp AS released_at, g.created_at, g.original_commission_id, g.refund_no, g.withdrawable`)
|
||||
query = applyExportShopScope(query, params, "g.shop_id")
|
||||
@@ -191,9 +265,11 @@ func applyCommissionExportFilters(query *gorm.DB, params ExportParams, shopColum
|
||||
type commissionRecordExportRow struct {
|
||||
Source string `gorm:"column:source"`
|
||||
ID uint `gorm:"column:id"`
|
||||
ShopName string `gorm:"column:shop_name"`
|
||||
ShopID uint `gorm:"column:shop_id"`
|
||||
OrderNo string `gorm:"column:order_no"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier"`
|
||||
OrderCreatedAt *time.Time `gorm:"column:order_created_at"`
|
||||
IotCardID *uint `gorm:"column:iot_card_id"`
|
||||
DeviceID *uint `gorm:"column:device_id"`
|
||||
CommissionSource string `gorm:"column:commission_source"`
|
||||
Amount int64 `gorm:"column:amount"`
|
||||
Withdrawable *bool `gorm:"column:withdrawable"`
|
||||
@@ -205,6 +281,161 @@ type commissionRecordExportRow struct {
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
// commissionExportShop 是执行时当前店铺归属投影。
|
||||
type commissionExportShop struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ShopName string `gorm:"column:shop_name"`
|
||||
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
|
||||
}
|
||||
|
||||
// commissionAssetDevice 是执行时当前设备类型与型号投影。
|
||||
type commissionAssetDevice struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
VirtualNo string `gorm:"column:virtual_no"`
|
||||
DeviceType string `gorm:"column:device_type"`
|
||||
DeviceModel string `gorm:"column:device_model"`
|
||||
}
|
||||
|
||||
// commissionExportAssets 是执行时当前资产维度投影:卡标识、卡当前绑定设备与设备自身的类型型号。
|
||||
type commissionExportAssets struct {
|
||||
cardICCIDs map[uint]string
|
||||
boundDeviceIDs map[uint]uint
|
||||
devices map[uint]commissionAssetDevice
|
||||
}
|
||||
|
||||
// describe 返回单条佣金记录资产在导出执行时的当前维度。
|
||||
// 关联卡时资产类型为卡、资产标识取卡 ICCID、设备维度取该卡当前绑定的设备;
|
||||
// 关联设备时资产类型为设备、资产标识取设备虚拟号、设备维度取设备自身;
|
||||
// 既无卡也无设备、或对应资产已不可读时一律返回空值,不影响该行其余列。
|
||||
func (a commissionExportAssets) describe(cardID, deviceID *uint) (assetType, identifier, deviceType, deviceModel string) {
|
||||
switch {
|
||||
case cardID != nil:
|
||||
assetType = assetTypeName(constants.AssetTypeIotCard)
|
||||
identifier = a.cardICCIDs[*cardID]
|
||||
if bound, ok := a.boundDeviceIDs[*cardID]; ok {
|
||||
device := a.devices[bound]
|
||||
deviceType, deviceModel = device.DeviceType, device.DeviceModel
|
||||
}
|
||||
case deviceID != nil:
|
||||
assetType = assetTypeName(constants.AssetTypeDevice)
|
||||
device := a.devices[*deviceID]
|
||||
identifier = device.VirtualNo
|
||||
deviceType, deviceModel = device.DeviceType, device.DeviceModel
|
||||
default:
|
||||
return "", "", "", ""
|
||||
}
|
||||
return assetType, identifier, deviceType, deviceModel
|
||||
}
|
||||
|
||||
// loadShopOwners 按店铺 ID 批量读取执行时当前店铺名称与业务员账号。
|
||||
func (s *CommissionRecordDataSource) loadShopOwners(ctx context.Context, shopIDs []uint) (map[uint]commissionExportShop, error) {
|
||||
result := make(map[uint]commissionExportShop, len(shopIDs))
|
||||
if len(shopIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []commissionExportShop
|
||||
if err := s.db.WithContext(ctx).Table("tb_shop").
|
||||
Select("id, shop_name, business_owner_account_id").
|
||||
Where("id IN ? AND deleted_at IS NULL", shopIDs).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询佣金导出店铺当前归属失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.ID] = row
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadAssetDimensions 按卡与设备 ID 批量读取执行时当前资产维度。
|
||||
// 卡资产的设备维度取该卡当前绑定的设备(一卡至多一条生效绑定),未绑定或绑定设备已删除时留空。
|
||||
func (s *CommissionRecordDataSource) loadAssetDimensions(ctx context.Context, cardIDs, deviceIDs []uint) (commissionExportAssets, error) {
|
||||
result := commissionExportAssets{
|
||||
cardICCIDs: make(map[uint]string, len(cardIDs)),
|
||||
boundDeviceIDs: make(map[uint]uint, len(cardIDs)),
|
||||
devices: make(map[uint]commissionAssetDevice, len(deviceIDs)),
|
||||
}
|
||||
if len(cardIDs) > 0 {
|
||||
var cards []struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ICCID string `gorm:"column:iccid"`
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Table("tb_iot_card").
|
||||
Select("id, iccid").
|
||||
Where("id IN ? AND deleted_at IS NULL", cardIDs).
|
||||
Scan(&cards).Error; err != nil {
|
||||
return commissionExportAssets{}, errors.Wrap(errors.CodeDatabaseError, err, "查询佣金导出资产卡标识失败")
|
||||
}
|
||||
for _, card := range cards {
|
||||
result.cardICCIDs[card.ID] = card.ICCID
|
||||
}
|
||||
|
||||
var bindings []struct {
|
||||
IotCardID uint `gorm:"column:iot_card_id"`
|
||||
DeviceID uint `gorm:"column:device_id"`
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Table("tb_device_sim_binding AS b").
|
||||
Select("b.iot_card_id, b.device_id").
|
||||
Joins("JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL").
|
||||
Where("b.iot_card_id IN ? AND b.bind_status = ? AND b.deleted_at IS NULL", cardIDs, constants.BindStatusBound).
|
||||
Scan(&bindings).Error; err != nil {
|
||||
return commissionExportAssets{}, errors.Wrap(errors.CodeDatabaseError, err, "查询佣金导出卡当前绑定设备失败")
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
result.boundDeviceIDs[binding.IotCardID] = binding.DeviceID
|
||||
deviceIDs = append(deviceIDs, binding.DeviceID)
|
||||
}
|
||||
}
|
||||
deviceIDs = normalizeUintSlice(deviceIDs)
|
||||
if len(deviceIDs) > 0 {
|
||||
var devices []commissionAssetDevice
|
||||
if err := s.db.WithContext(ctx).Table("tb_device").
|
||||
Select("id, virtual_no, device_type, device_model").
|
||||
Where("id IN ? AND deleted_at IS NULL", deviceIDs).
|
||||
Scan(&devices).Error; err != nil {
|
||||
return commissionExportAssets{}, errors.Wrap(errors.CodeDatabaseError, err, "查询佣金导出设备类型与型号失败")
|
||||
}
|
||||
for _, device := range devices {
|
||||
result.devices[device.ID] = device
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadClawbackIDs 按原佣金批量读取其全部回溯明细编号,按生成顺序稳定排列。
|
||||
func (s *CommissionRecordDataSource) loadClawbackIDs(ctx context.Context, originalIDs []uint) (map[uint][]uint, error) {
|
||||
result := make(map[uint][]uint, len(originalIDs))
|
||||
if len(originalIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
OriginalCommissionID uint `gorm:"column:original_commission_id"`
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Table("tb_commission_clawback_record").
|
||||
Select("id, original_commission_id").
|
||||
Where("original_commission_id IN ?", originalIDs).
|
||||
Order("id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原佣金关联回溯明细失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.OriginalCommissionID] = append(result[row.OriginalCommissionID], row.ID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// formatClawbackIDList 输出原佣金关联的全部回溯明细编号;无回溯关系时为空。
|
||||
func formatClawbackIDList(ids []uint) string {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
parts = append(parts, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
return strings.Join(parts, "、")
|
||||
}
|
||||
|
||||
// formatCommissionLedgerSource 把记录来源转为导出用中文描述。
|
||||
func formatCommissionLedgerSource(source string) string {
|
||||
if source == sourceClawback {
|
||||
|
||||
@@ -20,9 +20,13 @@ const operationsReportEmptyValue = "-"
|
||||
// 合计行的分组列值。
|
||||
const operationsReportTotalGroup = "合计"
|
||||
|
||||
// 序号列表头;分组行按导出展示顺序从 1 连续递增,合计行留空不写入任何数字或文字。
|
||||
const operationsReportSerialHeader = "序号"
|
||||
|
||||
// OperationsActivationDataSource 设备激活情况报表导出数据源。
|
||||
//
|
||||
// 导出列与页面展示字段一致并包含合计行,不含任何文字总结;
|
||||
// 导出列与页面展示字段一致并包含合计行,首列为「序号」:分组行按展示顺序从 1 连续递增,
|
||||
// 合计行的序号列留空且其余各列的既有表示不变;导出不含任何文字总结;
|
||||
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
|
||||
// 本场景只对超级管理员与平台账号开放:受控入口已做角色门禁,这里再按任务内冻结的账号类型复核一次,
|
||||
// 阻止通过通用导出入口以代理身份创建本场景任务后读到运营报表数据。
|
||||
@@ -81,12 +85,13 @@ func (s *OperationsActivationDataSource) build(ctx context.Context, params Expor
|
||||
return nil, err
|
||||
}
|
||||
headers := []string{
|
||||
response.GroupName, "采购数量", "累计激活数", "激活率", "新增激活数",
|
||||
operationsReportSerialHeader, response.GroupName, "采购数量", "累计激活数", "激活率", "新增激活数",
|
||||
"累计在网数", "活跃用户数", "累计用量(GB)", "单用户卡均(GB)", "含零预测卡均(GB)", "不含零预测卡均(GB)",
|
||||
}
|
||||
rows := make([][]string, 0, len(response.Items)+1)
|
||||
for _, item := range response.Items {
|
||||
for index, item := range response.Items {
|
||||
rows = append(rows, []string{
|
||||
strconv.Itoa(index + 1),
|
||||
item.GroupValue,
|
||||
formatOptionalInt64(item.PurchasedDeviceCount),
|
||||
formatOptionalInt64(item.ActivatedDeviceCount),
|
||||
@@ -103,6 +108,8 @@ func (s *OperationsActivationDataSource) build(ctx context.Context, params Expor
|
||||
if response.Totals != nil {
|
||||
total := response.Totals
|
||||
rows = append(rows, []string{
|
||||
// 合计行序号列留空,其余各列沿用既有表示。
|
||||
"",
|
||||
operationsReportTotalGroup,
|
||||
formatOptionalInt64(total.PurchasedDeviceCount),
|
||||
formatOptionalInt64(total.ActivatedDeviceCount),
|
||||
@@ -121,7 +128,8 @@ func (s *OperationsActivationDataSource) build(ctx context.Context, params Expor
|
||||
|
||||
// OperationsRenewalDataSource 套餐续费情况报表导出数据源。
|
||||
//
|
||||
// 导出列与页面展示字段一致并包含合计行,不含任何文字总结;
|
||||
// 导出列与页面展示字段一致并包含合计行,首列为「序号」:分组行按展示顺序从 1 连续递增,
|
||||
// 合计行的序号列留空且其余各列的既有表示不变;导出不含任何文字总结;
|
||||
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
|
||||
// 本场景同样按任务内冻结的账号类型复核导出资格。
|
||||
type OperationsRenewalDataSource struct {
|
||||
@@ -178,10 +186,11 @@ func (s *OperationsRenewalDataSource) build(ctx context.Context, params ExportPa
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headers := []string{response.GroupName, "到期资产数", "续费资产数", "续费率", "新增未续费数"}
|
||||
headers := []string{operationsReportSerialHeader, response.GroupName, "到期资产数", "续费资产数", "续费率", "新增未续费数"}
|
||||
rows := make([][]string, 0, len(response.Items)+1)
|
||||
for _, item := range response.Items {
|
||||
for index, item := range response.Items {
|
||||
rows = append(rows, []string{
|
||||
strconv.Itoa(index + 1),
|
||||
item.GroupValue,
|
||||
formatOptionalInt64(item.DueAssetCount),
|
||||
formatOptionalInt64(item.RenewedAssetCount),
|
||||
@@ -192,6 +201,8 @@ func (s *OperationsRenewalDataSource) build(ctx context.Context, params ExportPa
|
||||
if response.Totals != nil {
|
||||
total := response.Totals
|
||||
rows = append(rows, []string{
|
||||
// 合计行序号列留空,其余各列沿用既有表示。
|
||||
"",
|
||||
operationsReportTotalGroup,
|
||||
formatOptionalInt64(total.DueAssetCount),
|
||||
formatOptionalInt64(total.RenewedAssetCount),
|
||||
|
||||
@@ -43,6 +43,7 @@ func (s *RefundDataSource) Headers(context.Context, ExportParams) ([]string, err
|
||||
"当前退款套餐已用量(MB)", "当前退款套餐总量(MB)", "原订单金额(元)",
|
||||
"实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款方式",
|
||||
"冻结实收金额(元)", "渠道退款状态", "渠道退款流水号", "渠道退款金额(元)", "失败分类", "异常标记",
|
||||
"来源支付单号", "原支付渠道交易流水号", "线下退款处理流水号",
|
||||
"退款原因", "审批备注",
|
||||
"审批来源", "审批状态", "退款处理状态", "退款申请时间", "退款审批时间", "提交人", "退款凭证",
|
||||
}, nil
|
||||
@@ -70,6 +71,9 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
|
||||
r.channel_refund_status,
|
||||
r.channel_refund_no,
|
||||
r.channel_refund_amount,
|
||||
r.source_payment_no,
|
||||
r.original_channel_trade_no,
|
||||
r.offline_settlement_no,
|
||||
r.failure_reason,
|
||||
r.anomaly_flag,
|
||||
r.refund_reason,
|
||||
@@ -146,6 +150,9 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
|
||||
constants.RefundChannelStatusName(item.ChannelRefundStatus),
|
||||
item.ChannelRefundNo,
|
||||
formatMoneyYuan(item.ChannelRefundAmount),
|
||||
item.SourcePaymentNo,
|
||||
item.OriginalChannelTradeNo,
|
||||
item.OfflineSettlementNo,
|
||||
constants.RefundFailureReasonName(item.FailureReason),
|
||||
formatRefundAnomalyFlag(item.AnomalyFlag),
|
||||
item.RefundReason,
|
||||
@@ -203,6 +210,9 @@ type refundExportRow struct {
|
||||
ChannelRefundStatus int `gorm:"column:channel_refund_status"`
|
||||
ChannelRefundNo string `gorm:"column:channel_refund_no"`
|
||||
ChannelRefundAmount int64 `gorm:"column:channel_refund_amount"`
|
||||
SourcePaymentNo string `gorm:"column:source_payment_no"`
|
||||
OriginalChannelTradeNo string `gorm:"column:original_channel_trade_no"`
|
||||
OfflineSettlementNo string `gorm:"column:offline_settlement_no"`
|
||||
FailureReason string `gorm:"column:failure_reason"`
|
||||
AnomalyFlag int `gorm:"column:anomaly_flag"`
|
||||
RefundReason string `gorm:"column:refund_reason"`
|
||||
|
||||
@@ -39,7 +39,7 @@ type AssetHandler struct {
|
||||
iotCardStopResume *iotCardService.StopResumeService
|
||||
assetPolling *pollingSvc.AssetPollingService
|
||||
assetLifecycleService AssetLifecycleService
|
||||
exchangeTraceQuery AssetExchangeTraceResolver
|
||||
detailProjection AssetDetailProjectionResolver
|
||||
observationSeries cardObservationApp.BestEffortSeriesDispatcher
|
||||
packageExpiryQuery *packageExpiryQuery.Query
|
||||
packageExpiryTrigger func(context.Context) error
|
||||
@@ -89,9 +89,13 @@ func (h *AssetHandler) SetPackageExpiryQueue(client *queue.Client) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssetExchangeTraceResolver 定义资产详情换货链路读取用例。
|
||||
type AssetExchangeTraceResolver interface {
|
||||
// AssetDetailProjectionResolver 定义资产详情的附加只读投影读取用例:
|
||||
// 换货链路与优先轮询状态。两者都是只读查询,不修改任何业务状态。
|
||||
type AssetDetailProjectionResolver interface {
|
||||
// Resolve 查询资产单节点换货链路。
|
||||
Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error)
|
||||
// ResolvePriorityPolling 查询资产的优先轮询状态投影(只读,不改变队列)。
|
||||
ResolvePriorityPolling(ctx context.Context, assetType string, assetID uint) (*dto.AssetPriorityPollingProjection, error)
|
||||
}
|
||||
|
||||
// NewAssetHandler 创建资产管理处理器
|
||||
@@ -102,16 +106,16 @@ func NewAssetHandler(
|
||||
iotCardSvc *iotCardService.Service,
|
||||
iotCardStopResume *iotCardService.StopResumeService,
|
||||
assetPolling *pollingSvc.AssetPollingService,
|
||||
exchangeTraceQuery AssetExchangeTraceResolver,
|
||||
detailProjection AssetDetailProjectionResolver,
|
||||
) *AssetHandler {
|
||||
return &AssetHandler{
|
||||
assetService: assetSvc,
|
||||
assetAuditService: assetAuditService,
|
||||
deviceService: deviceSvc,
|
||||
iotCardService: iotCardSvc,
|
||||
iotCardStopResume: iotCardStopResume,
|
||||
assetPolling: assetPolling,
|
||||
exchangeTraceQuery: exchangeTraceQuery,
|
||||
assetService: assetSvc,
|
||||
assetAuditService: assetAuditService,
|
||||
deviceService: deviceSvc,
|
||||
iotCardService: iotCardSvc,
|
||||
iotCardStopResume: iotCardStopResume,
|
||||
assetPolling: assetPolling,
|
||||
detailProjection: detailProjection,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +148,14 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||
return err
|
||||
}
|
||||
result.ExchangeTrace = &dto.AssetExchangeTrace{}
|
||||
if h.exchangeTraceQuery != nil {
|
||||
result.ExchangeTrace, err = h.exchangeTraceQuery.Resolve(c.UserContext(), result.AssetType, result.AssetID)
|
||||
result.PriorityPolling = &dto.AssetPriorityPollingProjection{}
|
||||
if h.detailProjection != nil {
|
||||
result.ExchangeTrace, err = h.detailProjection.Resolve(c.UserContext(), result.AssetType, result.AssetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 优先轮询投影只读:读取不改变队列状态,也不触发任何上游调用。
|
||||
result.PriorityPolling, err = h.detailProjection.ResolvePriorityPolling(c.UserContext(), result.AssetType, result.AssetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -33,6 +33,25 @@ func (h *CarrierHandler) List(c *fiber.Ctx) error {
|
||||
return response.SuccessWithPagination(c, carriers, total, req.Page, req.PageSize)
|
||||
}
|
||||
|
||||
// ListThresholdHits 查询通道阈值命中记录。
|
||||
// GET /api/admin/carrier-traffic-threshold-hits
|
||||
//
|
||||
// 路由组已挂「仅超级管理员与平台账号」门禁,查询侧仍复核账号类型并下推卡数据范围(ENG-AUTHZ-001);
|
||||
// 时间筛选与分页归一化都在查询侧按统一契约完成,Handler 不做任何跳过业务校验的分支。
|
||||
func (h *CarrierHandler) ListThresholdHits(c *fiber.Ctx) error {
|
||||
var req dto.CarrierThresholdHitListRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
result, err := h.service.ListThresholdHits(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
func (h *CarrierHandler) Create(c *fiber.Ctx) error {
|
||||
var req dto.CreateCarrierRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
|
||||
@@ -110,6 +110,9 @@ func (h *EmployeeCollectionHandler) ListPaymentMethods(c *fiber.Ctx) error {
|
||||
// ListBills 分页查询员工代收款账单。
|
||||
// GET /api/admin/employee-collection-bills
|
||||
func (h *EmployeeCollectionHandler) ListBills(c *fiber.Ctx) error {
|
||||
if err := rejectLegacyBillTimeParams(c); err != nil {
|
||||
return err
|
||||
}
|
||||
var request dto.EmployeeCollectionBillListRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
@@ -124,6 +127,9 @@ func (h *EmployeeCollectionHandler) ListBills(c *fiber.Ctx) error {
|
||||
// StatisticsBills 汇总员工代收款账单金额与待处理数量。
|
||||
// GET /api/admin/employee-collection-bills/statistics
|
||||
func (h *EmployeeCollectionHandler) StatisticsBills(c *fiber.Ctx) error {
|
||||
if err := rejectLegacyBillTimeParams(c); err != nil {
|
||||
return err
|
||||
}
|
||||
var request dto.EmployeeCollectionBillStatisticsRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
@@ -135,6 +141,23 @@ func (h *EmployeeCollectionHandler) StatisticsBills(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// billLegacyTimeParams 是本次被统一时间筛选替换的旧参数名。
|
||||
var billLegacyTimeParams = []string{"created_from", "created_to"}
|
||||
|
||||
// rejectLegacyBillTimeParams 拒绝携带旧时间参数名的账单列表与统计请求。
|
||||
//
|
||||
// 时间筛选已统一为 start_time/end_time(严格 RFC3339 带时区秒级闭区间),
|
||||
// 旧参数名 MUST NOT 被静默忽略或保留别名:静默忽略会让调用方以为筛选生效而拿到全量数据。
|
||||
func rejectLegacyBillTimeParams(c *fiber.Ctx) error {
|
||||
for _, name := range billLegacyTimeParams {
|
||||
if c.Context().QueryArgs().Has(name) {
|
||||
return errors.New(errors.CodeInvalidParam,
|
||||
"账单时间筛选参数已统一为 start_time 与 end_time,不再接受 "+name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBill 查询员工代收款账单详情。
|
||||
// GET /api/admin/employee-collection-bills/:id
|
||||
func (h *EmployeeCollectionHandler) GetBill(c *fiber.Ctx) error {
|
||||
|
||||
@@ -14,26 +14,26 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// PriorityPollingHandler 处理卡轮询优先队列的人工入队与只读查询请求。
|
||||
// PriorityPollingHandler 处理卡轮询优先队列的人工入口(入队、关闭、重触发)与只读查询请求。
|
||||
type PriorityPollingHandler struct {
|
||||
enqueue *pollingSvc.PriorityEnqueueService
|
||||
manual *pollingSvc.PriorityManualService
|
||||
query *prioritypollingquery.Query
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
// NewPriorityPollingHandler 创建卡轮询优先队列处理器。
|
||||
func NewPriorityPollingHandler(
|
||||
enqueue *pollingSvc.PriorityEnqueueService,
|
||||
manual *pollingSvc.PriorityManualService,
|
||||
query *prioritypollingquery.Query,
|
||||
validator *validator.Validate,
|
||||
) *PriorityPollingHandler {
|
||||
return &PriorityPollingHandler{enqueue: enqueue, query: query, validator: validator}
|
||||
return &PriorityPollingHandler{manual: manual, query: query, validator: validator}
|
||||
}
|
||||
|
||||
// Enqueue 人工优先入队。
|
||||
// POST /api/admin/polling-priority-items
|
||||
func (h *PriorityPollingHandler) Enqueue(c *fiber.Ctx) error {
|
||||
if h.enqueue == nil {
|
||||
if h.manual == nil {
|
||||
return errors.New(errors.CodeInternalError, "人工优先入队能力未配置")
|
||||
}
|
||||
var req dto.EnqueuePriorityPollingRequest
|
||||
@@ -43,7 +43,7 @@ func (h *PriorityPollingHandler) Enqueue(c *fiber.Ctx) error {
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, validation.Message("人工优先入队参数不合法", &req, err))
|
||||
}
|
||||
result, err := h.enqueue.Enqueue(c.UserContext(), req.CardID, req.Reason, middleware.GetUserIDFromContext(c.UserContext()))
|
||||
result, err := h.manual.Enqueue(c.UserContext(), req.CardID, req.Reason, middleware.GetUserIDFromContext(c.UserContext()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -74,6 +74,84 @@ func (h *PriorityPollingHandler) Enqueue(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Close 人工关闭未完成的优先轮询项。
|
||||
// 只允许超级管理员与平台账号;原因必填并写入审计,关闭后不再发起上游调用且保留既有执行结果与失败原因。
|
||||
// POST /api/admin/polling-priority-items/:id/close
|
||||
func (h *PriorityPollingHandler) Close(c *fiber.Ctx) error {
|
||||
if h.manual == nil {
|
||||
return errors.New(errors.CodeInternalError, "人工关闭优先轮询项能力未配置")
|
||||
}
|
||||
id, err := pathID(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var req dto.ClosePriorityPollingItemRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, validation.Message("人工关闭优先轮询项参数不合法", &req, err))
|
||||
}
|
||||
result, err := h.manual.Close(c.UserContext(), id, req.Reason, middleware.GetUserIDFromContext(c.UserContext()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, dto.ClosePriorityPollingItemResponse{
|
||||
ItemID: result.ItemID,
|
||||
CardID: result.CardID,
|
||||
TaskType: result.TaskType,
|
||||
TaskTypeName: constants.PollingPriorityTaskTypeName(result.TaskType),
|
||||
Status: result.Status,
|
||||
StatusName: constants.PollingPriorityStatusName(result.Status),
|
||||
DequeuedAt: result.DequeuedAt,
|
||||
Reason: result.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrigger 人工重触发失败出队或已过期的优先轮询项。
|
||||
// 复用人工入队入口为该卡该任务类型建立新的活动项(尝试次数从零开始),原因必填并写入审计;
|
||||
// 权限与人工入队一致(含数据范围内的代理账号),不绕过活动项唯一键代表的并发上限。
|
||||
// POST /api/admin/polling-priority-items/:id/retrigger
|
||||
func (h *PriorityPollingHandler) Retrigger(c *fiber.Ctx) error {
|
||||
if h.manual == nil {
|
||||
return errors.New(errors.CodeInternalError, "人工重触发优先轮询项能力未配置")
|
||||
}
|
||||
id, err := pathID(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var req dto.RetriggerPriorityPollingItemRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, validation.Message("人工重触发优先轮询项参数不合法", &req, err))
|
||||
}
|
||||
result, err := h.manual.Retrigger(c.UserContext(), id, req.Reason, middleware.GetUserIDFromContext(c.UserContext()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
view := dto.RetriggerPriorityPollingItemResponse{
|
||||
SourceItemID: result.SourceItemID,
|
||||
CardID: result.Enqueue.CardID,
|
||||
Reason: result.Reason,
|
||||
}
|
||||
if len(result.Enqueue.Items) > 0 {
|
||||
// 重触发只针对来源项的任务类型,因此结果里至多一项;防御性取首项。
|
||||
item := result.Enqueue.Items[0]
|
||||
view.TaskType = item.TaskType
|
||||
view.TaskTypeName = constants.PollingPriorityTaskTypeName(item.TaskType)
|
||||
view.ItemID = item.ItemID
|
||||
view.Status = item.Status
|
||||
view.StatusName = constants.PollingPriorityStatusName(item.Status)
|
||||
view.AttemptCount = item.AttemptCount
|
||||
view.TriggerCount = item.TriggerCount
|
||||
view.LastTriggeredAt = item.LastTriggeredAt
|
||||
view.Created = item.Created
|
||||
}
|
||||
return response.Success(c, view)
|
||||
}
|
||||
|
||||
// ListPriorityItems 分页查询优先轮询项。
|
||||
// GET /api/admin/polling-priority-items
|
||||
func (h *PriorityPollingHandler) ListPriorityItems(c *fiber.Ctx) error {
|
||||
|
||||
@@ -162,3 +162,44 @@ func (h *RefundHandler) Resubmit(c *fiber.Ctx) error {
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
// OrderOptions 按来源订单查询可选退款方式
|
||||
// GET /api/admin/refunds/order-options
|
||||
func (h *RefundHandler) OrderOptions(c *fiber.Ctx) error {
|
||||
var req dto.RefundOrderOptionsRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if req.OrderID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "订单ID不能为空")
|
||||
}
|
||||
|
||||
result, err := h.service.OrderOptions(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// RegisterOfflineSettlement 登记或更正线下退款处理流水号
|
||||
// POST /api/admin/refunds/:id/offline-settlement
|
||||
func (h *RefundHandler) RegisterOfflineSettlement(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的退款申请ID")
|
||||
}
|
||||
|
||||
var req dto.OfflineSettlementRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
req.ID = uint(id)
|
||||
|
||||
result, err := h.service.RegisterOfflineSettlement(c.UserContext(), uint(id), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
@@ -276,6 +276,10 @@ func NewRegistry() *Registry {
|
||||
pollingPriorityDequeued := pollingPriorityAction(constants.AuditActionPollingPriorityDequeued, "卡轮询优先项失败出队", constants.AuditRiskNormal, true)
|
||||
// 人工优先入队的拒绝只可能来自后台账号入口,不登记 Worker 侧来源。
|
||||
pollingPriorityManualDenied := pollingPriorityAction(constants.AuditActionPollingPriorityManualDenied, "拒绝人工优先入队", constants.AuditRiskNormal, false)
|
||||
// 人工关闭与人工重触发只可能来自后台账号入口;超期处理只可能来自计划任务,两者都必须分别声明来源。
|
||||
pollingPriorityClosed := pollingPriorityAction(constants.AuditActionPollingPriorityClosed, "人工关闭卡轮询优先项", constants.AuditRiskNormal, false)
|
||||
pollingPriorityExpired := pollingPriorityAction(constants.AuditActionPollingPriorityExpired, "卡轮询优先项有效期到期出队", constants.AuditRiskNormal, true)
|
||||
pollingPriorityRetriggered := pollingPriorityAction(constants.AuditActionPollingPriorityRetriggered, "人工重触发卡轮询优先项", constants.AuditRiskNormal, false)
|
||||
wecomCredentialsRead := ActionDefinition{
|
||||
Code: constants.AuditActionWeComCredentialsRead, Name: "读取企业微信应用明文凭据",
|
||||
Category: constants.AuditCategorySecurity, Risk: constants.AuditRiskHigh,
|
||||
@@ -359,6 +363,8 @@ func NewRegistry() *Registry {
|
||||
// 渠道原路退款的调用与恢复都由 Worker 触发,与既有的退款系统动作入口一致。
|
||||
refundChannelCalled := refundSystemAction(constants.AuditActionRefundChannelCalled, "发起渠道原路退款")
|
||||
refundChannelRecovered := refundSystemAction(constants.AuditActionRefundChannelRecovered, "恢复渠道原路退款结果")
|
||||
// 线下退款处理流水号补录与更正由后台授权账号发起,与既有退款后台动作入口一致。
|
||||
refundOfflineSettled := refundAction(constants.AuditActionRefundOfflineSettled, "登记线下退款处理流水号", false)
|
||||
approvalRequested := approvalAction(constants.AuditActionApprovalRequested, "提交通用审批申请", []ActionOrigin{
|
||||
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
|
||||
})
|
||||
@@ -659,6 +665,9 @@ func NewRegistry() *Registry {
|
||||
constants.AuditActionPollingPriorityCompleted: pollingPriorityCompleted,
|
||||
constants.AuditActionPollingPriorityDequeued: pollingPriorityDequeued,
|
||||
constants.AuditActionPollingPriorityManualDenied: pollingPriorityManualDenied,
|
||||
constants.AuditActionPollingPriorityClosed: pollingPriorityClosed,
|
||||
constants.AuditActionPollingPriorityExpired: pollingPriorityExpired,
|
||||
constants.AuditActionPollingPriorityRetriggered: pollingPriorityRetriggered,
|
||||
constants.AuditActionWeComCredentialsRead: wecomCredentialsRead,
|
||||
constants.AuditActionRoleCreated: roleCreated,
|
||||
constants.AuditActionRoleUpdated: roleUpdated,
|
||||
@@ -734,6 +743,7 @@ func NewRegistry() *Registry {
|
||||
constants.AuditActionRefundAnomalyFlagged: refundAnomalyFlagged,
|
||||
constants.AuditActionRefundChannelCalled: refundChannelCalled,
|
||||
constants.AuditActionRefundChannelRecovered: refundChannelRecovered,
|
||||
constants.AuditActionRefundOfflineSettled: refundOfflineSettled,
|
||||
constants.AuditActionApprovalRequested: approvalRequested,
|
||||
constants.AuditActionApprovalSubmissionSynced: approvalSubmissionSynced,
|
||||
constants.AuditActionApprovalSubmissionRecovered: approvalSubmissionRecovered,
|
||||
|
||||
@@ -65,7 +65,7 @@ func (w *PriorityEventWriter) AppendPriorityRequested(ctx context.Context, tx *g
|
||||
if event.ResourceType != constants.AssetTypeIotCard && event.ResourceType != constants.AssetTypeDevice {
|
||||
return errors.New(errors.CodeInvalidParam, "优先轮询事件资源类型不受支持")
|
||||
}
|
||||
// 人工入队不经事件通道:manual_trigger 由 internal/service/polling/priority_enqueue_service.go 直写事实表,
|
||||
// 人工入队不经事件通道:manual_trigger 由 internal/service/polling/priority_manual_service.go 直写事实表,
|
||||
// 事件通道没有操作者,无法满足事实表 CHECK (trigger_type <> 'manual_trigger' OR manual_operator_id > 0)。
|
||||
// 权威约束在事实表 CHECK,这里只是把「DB 23514 → 整个投递事务回滚」提前为可定位的参数错误,
|
||||
// 避免调用方业务事务被数据库层失败牵连回滚。
|
||||
@@ -170,9 +170,14 @@ func (c *PriorityRequestedConsumer) Consume(ctx context.Context, envelope outbox
|
||||
return lookupErr
|
||||
}
|
||||
store := c.store.WithTx(tx)
|
||||
assetSnapshots, assetErr := store.AssetSnapshots(ctx, cardIDs)
|
||||
if assetErr != nil {
|
||||
return assetErr
|
||||
}
|
||||
createdCount, mergedCount = 0, 0
|
||||
for _, cardID := range cardIDs {
|
||||
for _, taskType := range taskTypes {
|
||||
asset := assetSnapshots[cardID]
|
||||
item := &model.PollingPriorityItem{
|
||||
CardID: cardID,
|
||||
TaskType: taskType,
|
||||
@@ -180,6 +185,10 @@ func (c *PriorityRequestedConsumer) Consume(ctx context.Context, envelope outbox
|
||||
SourceOrderID: optionalUintPointer(event.SourceOrderID),
|
||||
SourcePackageUsageID: optionalUintPointer(event.SourcePackageUsageID),
|
||||
ShopIDSnapshot: shopSnapshots[cardID],
|
||||
AssetType: asset.AssetType,
|
||||
AssetID: optionalUintPointer(asset.AssetID),
|
||||
DeviceNoSnapshot: asset.DeviceNo,
|
||||
AgentShopIDSnapshot: asset.AgentShopID,
|
||||
LastTriggeredAt: event.OccurredAt,
|
||||
}
|
||||
created, insertErr := store.InsertOrMerge(ctx, item)
|
||||
|
||||
@@ -34,7 +34,7 @@ func (e *attachmentPreparationError) Unwrap() error {
|
||||
}
|
||||
|
||||
// buildApprovalForm 按后台已校验映射将渠道无关业务快照组装为企微控件值。
|
||||
func buildApprovalForm(ctx context.Context, applicationID, instanceID uint, mappingJSON, templateJSON, snapshotJSON []byte, attachments approvalAttachmentPort) (approvalFormBuildResult, error) {
|
||||
func buildApprovalForm(ctx context.Context, businessType string, applicationID, instanceID uint, mappingJSON, templateJSON, snapshotJSON []byte, attachments approvalAttachmentPort) (approvalFormBuildResult, error) {
|
||||
var mappings []dto.WeComControlMappingItem
|
||||
if err := sonic.Unmarshal(mappingJSON, &mappings); err != nil || len(mappings) == 0 {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批控件映射无效")
|
||||
@@ -51,6 +51,33 @@ func buildApprovalForm(ctx context.Context, applicationID, instanceID uint, mapp
|
||||
for _, control := range template.Controls {
|
||||
requiredControls[control.ID] = control.Required
|
||||
}
|
||||
// 本变更新增字段必须配置控件映射:场景里没有该字段的映射条目时,下面的遍历永远不会看到它,
|
||||
// 该字段会被静默丢弃。因此在组装前先针对本次提交使用的映射逐一确认必须字段有条目,
|
||||
// 缺失即明确失败并指出缺哪个字段/控件;本判定与场景映射校验共用同一份 RequiredSceneFields。
|
||||
// 既有未标注必须映射的可选字段不在其中,缺失映射时保持既有静默跳过行为。
|
||||
mappingFields := make(map[string]struct{}, len(mappings))
|
||||
for _, item := range mappings {
|
||||
mappingFields[strings.TrimSpace(item.BusinessField)] = struct{}{}
|
||||
}
|
||||
for _, field := range wecomapp.RequiredSceneFields(businessType) {
|
||||
if _, exists := mappingFields[field.Code]; !exists {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus,
|
||||
"企业微信审批场景缺少必须配置的控件映射: "+field.Name+"(业务字段 "+field.Code+"),请先配置该控件映射后再提交")
|
||||
}
|
||||
}
|
||||
// 本变更新增字段缺失快照值时同样明确失败:映射已配置却拿不到值,静默跳过会让审批材料
|
||||
// 与本地事实不一致。该校验必须在循环之前完成——循环内的既有文件类控件会经
|
||||
// attachments.Upload 触达外部系统,若把校验留在循环内,失败可能晚于外部调用(ENG-TX-001
|
||||
// 要求事务内不得持有不可回滚的长外部 I/O,同理失败必须发生在任何外呼之前)。
|
||||
// 既有未映射(或映射到既有可选字段)的控件保持既有静默跳过行为。
|
||||
requiredFields := make(map[string]string)
|
||||
for _, field := range wecomapp.RequiredSceneFields(businessType) {
|
||||
requiredFields[field.Code] = field.Name
|
||||
if _, exists := lookupSnapshotValue(snapshot, field.Code); !exists {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus,
|
||||
"审批业务快照缺少必须提交的字段: "+field.Name+"(业务字段 "+field.Code+"),请确认已配置对应控件与业务字段映射")
|
||||
}
|
||||
}
|
||||
contents := make([]map[string]any, 0, len(mappings))
|
||||
attachmentCount := 0
|
||||
for _, mapping := range mappings {
|
||||
@@ -59,6 +86,9 @@ func buildApprovalForm(ctx context.Context, applicationID, instanceID uint, mapp
|
||||
if requiredControls[mapping.ControlID] {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少模板必填字段: "+mapping.BusinessField)
|
||||
}
|
||||
if name, required := requiredFields[strings.TrimSpace(mapping.BusinessField)]; required {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少必须提交的字段: "+name+",请确认已配置对应控件与业务字段映射")
|
||||
}
|
||||
continue
|
||||
}
|
||||
value, count, err := buildControlValue(ctx, applicationID, instanceID, mapping, raw, attachments)
|
||||
|
||||
@@ -48,7 +48,7 @@ func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbo
|
||||
return nil
|
||||
}
|
||||
form, err := buildApprovalForm(
|
||||
ctx, record.Context.ApplicationID, event.InstanceID,
|
||||
ctx, record.Context.BusinessType, record.Context.ApplicationID, event.InstanceID,
|
||||
record.Context.ControlMapping, record.Context.TemplateSnapshot, record.Instance.RequestSnapshot, c.attachments,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -30,6 +30,23 @@ type CarrierTrafficThresholdLock struct {
|
||||
// AnomalyFlag 为 1 表示查询窗口超期或失败无法自动确认,必须退出自动扫描转人工核对。
|
||||
AnomalyFlag int `gorm:"column:anomaly_flag;type:smallint;not null;default:0" json:"anomaly_flag"`
|
||||
FailureReason string `gorm:"column:failure_reason;type:varchar(500);not null;default:''" json:"failure_reason"`
|
||||
// HitTrafficMB 是命中时该卡当前计费周期的累计流量读数(MB)。
|
||||
// 取与停机判定同一来源的运营商网关累计读数换算值,不使用本地用量统计或套餐真流量;
|
||||
// 本次上线前的历史行为空,表示读数不可重建,读侧按「无」展示。
|
||||
HitTrafficMB *float64 `gorm:"column:hit_traffic_mb;type:numeric(18,2)" json:"hit_traffic_mb,omitempty"`
|
||||
// HitThresholdValue 是本次判定实际生效的通道阈值数值,冻结命中时值:
|
||||
// 之后修改通道阈值不会改写历史命中;历史行为空。
|
||||
HitThresholdValue *float64 `gorm:"column:hit_threshold_value;type:numeric(18,2)" json:"hit_threshold_value,omitempty"`
|
||||
// HitThresholdUnit 是本次判定阈值的单位(MB/GB);空串表示历史行未记录。
|
||||
HitThresholdUnit string `gorm:"column:hit_threshold_unit;type:varchar(8);not null;default:''" json:"hit_threshold_unit"`
|
||||
// JudgedAt 是本次达量判定的时刻,既有行以创建时间回填,是命中记录查询与排序的业务时间。
|
||||
JudgedAt time.Time `gorm:"column:judged_at;type:timestamptz;not null" json:"judged_at"`
|
||||
// TriggerSource 是触发本次判定的流量观测来源(constants.CardObservationSource*);空串表示未记录。
|
||||
TriggerSource string `gorm:"column:trigger_source;type:varchar(20);not null;default:''" json:"trigger_source"`
|
||||
// UnlockedAt 是周期恢复时的解锁时间,只在跨期解锁成功时写入;绝不覆盖上面的命中事实。
|
||||
UnlockedAt *time.Time `gorm:"column:unlocked_at;type:timestamptz" json:"unlocked_at,omitempty"`
|
||||
// ResumeResult 是周期恢复的复机结果(resumed/skipped/manual);跳过原因沿用 FailureReason。
|
||||
ResumeResult string `gorm:"column:resume_result;type:varchar(30);not null;default:''" json:"resume_result"`
|
||||
}
|
||||
|
||||
// TableName 指定运营商通道流量阈值周期锁表名。
|
||||
|
||||
@@ -132,8 +132,14 @@ type WithdrawalRequestAttemptItem struct {
|
||||
ApprovalInstanceID uint `json:"approval_instance_id" description:"本次尝试关联的通用审批实例ID,0 表示尚未关联"`
|
||||
ApprovalStatus int `json:"approval_status" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
|
||||
ApprovalStatusName string `json:"approval_status_name" description:"通用审批状态名称(中文)"`
|
||||
ReleasedAt string `json:"released_at,omitempty" description:"本次冻结释放时间,空表示冻结仍未结算"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
// 本次提交依据的提现资料资格校验留痕,冻结在尝试快照上,历史尝试不被新版本改写。
|
||||
QualificationVersionID uint `json:"qualification_version_id" description:"本次依据的有效提现资料资格版本ID,0 表示资格不存在或早于本变更的历史尝试"`
|
||||
QualificationCheckedAt string `json:"qualification_checked_at,omitempty" description:"本次资料资格校验时间,空表示历史尝试无留痕"`
|
||||
QualificationPassed int `json:"qualification_passed" description:"本次资料资格校验是否通过 (0:未通过, 1:通过)"`
|
||||
QualificationFailureReason string `json:"qualification_failure_reason" description:"未通过时的稳定原因编码 (qualification_missing:资格不存在, qualification_invalidated:已失效, shop_disabled:已停用, qualification_not_approved:资料未通过审批),通过时为空"`
|
||||
QualificationFailureName string `json:"qualification_failure_name" description:"未通过原因中文名称,通过时为空"`
|
||||
ReleasedAt string `json:"released_at,omitempty" description:"本次冻结释放时间,空表示冻结仍未结算"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
// ShopWithdrawalRequestDetailResp 提现申请详情响应。
|
||||
|
||||
@@ -71,9 +71,23 @@ type AssetResolveResponse struct {
|
||||
GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"`
|
||||
GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"`
|
||||
// 流量汇总字段(仅在 ?include_usage_summary=true 时返回非 null 值)
|
||||
TotalVirtualUsedMB *float64 `json:"total_virtual_used_mb" description:"当前世代所有套餐虚已用之和(MB),未请求时为 null"`
|
||||
TotalVirtualRemainingMB *float64 `json:"total_virtual_remaining_mb" description:"当前世代所有套餐虚剩余之和(MB),未请求时为 null"`
|
||||
ExchangeTrace *AssetExchangeTrace `json:"exchange_trace" description:"换货链路;对象始终存在,前代或后代不存在时对应字段为 null"`
|
||||
TotalVirtualUsedMB *float64 `json:"total_virtual_used_mb" description:"当前世代所有套餐虚已用之和(MB),未请求时为 null"`
|
||||
TotalVirtualRemainingMB *float64 `json:"total_virtual_remaining_mb" description:"当前世代所有套餐虚剩余之和(MB),未请求时为 null"`
|
||||
ExchangeTrace *AssetExchangeTrace `json:"exchange_trace" description:"换货链路;对象始终存在,前代或后代不存在时对应字段为 null"`
|
||||
PriorityPolling *AssetPriorityPollingProjection `json:"priority_polling" description:"优先轮询状态只读投影;对象始终存在,无任何优先轮询事实时字段以否或空值返回,不使用普通轮询结果填充"`
|
||||
}
|
||||
|
||||
// AssetPriorityPollingProjection 是资产解析响应中的优先轮询状态只读投影。
|
||||
// 口径与该卡(设备取其在用卡)在优先轮询事实表中的未完成活动项一致;
|
||||
// 读取不创建、不合并、不推进、不出队任何优先项,也不触发上游调用。
|
||||
type AssetPriorityPollingProjection struct {
|
||||
InPriorityPolling bool `json:"in_priority_polling" description:"是否处于优先轮询中:存在待执行或执行中的优先轮询项"`
|
||||
LastTriggerType string `json:"last_trigger_type,omitempty" description:"最近触发场景(触发类型):purchase_activated-主套餐购买后立即生效 renewal_activated-续购套餐生效 queue_activated-排队主套餐顺延生效 addon_activated-加油包生效 no_valid_package-资产无有效套餐 manual_trigger-人工入队"`
|
||||
LastTriggerName string `json:"last_trigger_name,omitempty" description:"最近触发场景名称"`
|
||||
LastResult string `json:"last_result,omitempty" description:"最近轮询结果:processing-处理中 success-执行成功 failed-轮询失败 closed-已关闭 expired-已过期"`
|
||||
LastResultName string `json:"last_result_name,omitempty" description:"最近轮询结果名称"`
|
||||
LastPollingAt *time.Time `json:"last_polling_at" description:"最近轮询时间:最近一次优先执行时间,未执行过时取最近触发时间;无优先轮询事实时为 null"`
|
||||
FailureReason string `json:"failure_reason,omitempty" description:"最近一次可安全展示的失败原因;无失败时为 empty"`
|
||||
}
|
||||
|
||||
// AssetExchangeTrace 资产单节点双向换货链路。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type CreateCarrierRequest struct {
|
||||
CarrierCode string `json:"carrier_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"运营商编码"`
|
||||
CarrierName string `json:"carrier_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"运营商名称"`
|
||||
@@ -88,3 +90,55 @@ type CarrierPageResult struct {
|
||||
Page int `json:"page" description:"当前页"`
|
||||
PageSize int `json:"size" description:"每页数量"`
|
||||
}
|
||||
|
||||
// CarrierThresholdHitListRequest 是通道阈值命中记录的查询参数。
|
||||
//
|
||||
// 判定时间使用统一时间筛选参数名 start_time/end_time(带显式时区的 RFC3339 秒级,闭区间含两端,
|
||||
// 开始晚于结束拒绝);计费周期起点用周期起点的瞬时精确匹配,与判定时间共用同一严格解析器。
|
||||
type CarrierThresholdHitListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
CarrierID *uint `json:"carrier_id" query:"carrier_id" validate:"omitempty,min=1" minimum:"1" description:"运营商通道ID"`
|
||||
CardID *uint `json:"card_id" query:"card_id" validate:"omitempty,min=1" minimum:"1" description:"IoT卡ID"`
|
||||
// PeriodStart 为计费周期起点,取值必须与命中事实记录的周期起点时刻一致(如东八区 2026-09-01T00:00:00+08:00)。
|
||||
PeriodStart string `json:"period_start" query:"period_start" description:"计费周期起点,带时区的RFC3339秒级时间,精确匹配该计费周期"`
|
||||
StartTime string `json:"start_time" query:"start_time" description:"判定时间范围开始,带时区的RFC3339秒级时间,闭区间含该时刻"`
|
||||
EndTime string `json:"end_time" query:"end_time" description:"判定时间范围结束,带时区的RFC3339秒级时间,闭区间含该时刻"`
|
||||
}
|
||||
|
||||
// CarrierThresholdHitItem 是一条通道阈值命中事实。
|
||||
//
|
||||
// 累计流量与阈值为命中时冻结的证据:历史行(本次上线前)未记录读数与阈值,按「无」返回空值,
|
||||
// 不阻断查询;判定时间取既有创建时间。响应不包含任何凭证与上游响应原文。
|
||||
type CarrierThresholdHitItem struct {
|
||||
ID uint `json:"id" description:"命中记录ID"`
|
||||
CarrierID uint `json:"carrier_id" description:"运营商通道ID"`
|
||||
CardID uint `json:"card_id" description:"IoT卡ID"`
|
||||
// PeriodStart 是计费周期起点。
|
||||
PeriodStart time.Time `json:"period_start" description:"计费周期起点"`
|
||||
// HitTrafficMB 为空表示无(历史行读数不可重建)。
|
||||
HitTrafficMB *float64 `json:"hit_traffic_mb" description:"命中时该卡当前计费周期的累计流量读数(MB),取与停机判定同一来源的网关累计读数,不使用本地用量或套餐真流量;为空表示无"`
|
||||
// HitThresholdValue 为空表示无(历史行未记录)。
|
||||
HitThresholdValue *float64 `json:"hit_threshold_value" description:"本次判定实际生效的通道阈值数值,冻结命中时值,通道阈值变更不改写历史命中;为空表示无"`
|
||||
// HitThresholdUnit 为空字符串表示无(历史行未记录)。
|
||||
HitThresholdUnit string `json:"hit_threshold_unit" description:"本次判定阈值单位 MB-兆字节 GB-吉字节;空字符串表示无"`
|
||||
// TriggerSource 取值与 constants.CardObservationSource* 一致,空字符串表示无。
|
||||
TriggerSource string `json:"trigger_source" description:"触发本次判定的流量观测来源 polling-周期轮询 manual_sync-手动同步 manual_override-人工纠偏 carrier_callback-运营商回调 business_event-业务成功后的观测;空字符串表示无"`
|
||||
JudgedAt time.Time `json:"judged_at" description:"判定时间,既有锁行取创建时间"`
|
||||
Status string `json:"status" description:"锁状态 locked-持锁中 unlocked-已跨期解锁"`
|
||||
// UnlockedAt 为空表示尚未解锁。
|
||||
UnlockedAt *time.Time `json:"unlocked_at" description:"周期恢复的解锁时间,未解锁为空"`
|
||||
// ResumeResult 为空字符串表示未处理或历史行。
|
||||
ResumeResult string `json:"resume_result" description:"周期恢复的复机结果 resumed-已发起复机 skipped-仅解锁未复机 manual-周期归属不可判定转人工;空字符串表示未处理或历史行"`
|
||||
// FailureReason 只承载可安全展示的跳过原因或转人工原因,不含内部细节与渠道报文。
|
||||
FailureReason string `json:"failure_reason" description:"跳过复机或转人工核对的可安全原因,无则为空"`
|
||||
CreatedAt time.Time `json:"created_at" description:"记录创建时间"`
|
||||
}
|
||||
|
||||
// CarrierThresholdHitPageResult 是通道阈值命中记录的分页结果。
|
||||
type CarrierThresholdHitPageResult struct {
|
||||
List []*CarrierThresholdHitItem `json:"items" description:"通道阈值命中记录列表,按判定时间倒序"`
|
||||
Total int64 `json:"total" description:"总数"`
|
||||
Page int `json:"page" description:"当前页"`
|
||||
PageSize int `json:"size" description:"每页数量"`
|
||||
}
|
||||
|
||||
@@ -55,27 +55,37 @@ type EmployeeCollectionPaymentMethodListResponse struct {
|
||||
// 枚举说明:source_type 对应 constants.EmployeeCollectionSourceTypeOrder/Recharge,
|
||||
// status 对应 constants.EmployeeCollectionBillStatusPending/Partial/Settled/Closed。
|
||||
type EmployeeCollectionBillListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"`
|
||||
SourceType *string `json:"source_type" query:"source_type" validate:"omitempty,oneof=order recharge" enum:"order,recharge" description:"按来源类型过滤 (order:后台线下套餐订单, recharge:代理线下充值)"`
|
||||
SourceNo *string `json:"source_no" query:"source_no" validate:"omitempty,max=64" maxLength:"64" description:"按来源单号精确过滤"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按账单状态过滤 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
|
||||
DebtorAccountID *uint `json:"debtor_account_id" query:"debtor_account_id" description:"按欠款人后台账号ID过滤;非超级管理员固定为当前账号,该参数被忽略"`
|
||||
CustomerID *uint `json:"customer_id" query:"customer_id" description:"按来源客户或店铺ID过滤,匹配来源订单买家或充值归属店铺"`
|
||||
CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始(YYYY-MM-DD,含当日 00:00:00)"`
|
||||
CreatedTo *string `json:"created_to" query:"created_to" description:"创建时间截止(YYYY-MM-DD,含当日 23:59:59)"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"`
|
||||
BillID *uint `json:"bill_id" query:"bill_id" validate:"omitempty,min=1" minimum:"1" description:"按账单编号精确过滤;账单编号即账单记录标识(主键ID)"`
|
||||
SourceType *string `json:"source_type" query:"source_type" validate:"omitempty,oneof=order recharge" enum:"order,recharge" description:"按来源类型过滤 (order:后台线下套餐订单, recharge:代理线下充值)"`
|
||||
SourceNo *string `json:"source_no" query:"source_no" validate:"omitempty,max=64" maxLength:"64" description:"按来源单号精确过滤"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按账单状态过滤 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
|
||||
DebtorAccountID *uint `json:"debtor_account_id" query:"debtor_account_id" description:"按欠款人后台账号ID过滤;非超级管理员固定为当前账号,该参数被忽略"`
|
||||
DebtorKeyword *string `json:"debtor_keyword" query:"debtor_keyword" validate:"omitempty,max=50" maxLength:"50" description:"按欠款人姓名或账号模糊过滤,取去空白后的子串匹配,最多 50 字符;无需先取得账号ID"`
|
||||
CustomerKeyword *string `json:"customer_keyword" query:"customer_keyword" validate:"omitempty,max=50" maxLength:"50" description:"按客户名称模糊过滤,取去空白后的子串匹配,最多 50 字符;无需先取得客户ID"`
|
||||
CustomerID *uint `json:"customer_id" query:"customer_id" description:"按来源客户或店铺ID过滤,匹配来源订单买家或充值归属店铺"`
|
||||
StartTime *string `json:"start_time" query:"start_time" description:"账单产生时间起始,必须是带显式时区的 RFC3339 秒级时间(如 2026-09-01T00:00:00+08:00),闭区间含该时刻"`
|
||||
EndTime *string `json:"end_time" query:"end_time" description:"账单产生时间截止,必须是带显式时区的 RFC3339 秒级时间,闭区间含该时刻"`
|
||||
DecidedStartTime *string `json:"decided_start_time" query:"decided_start_time" description:"核销通过时间起始,取该账单已通过分摊关联的申请表审批终态时间;格式与 start_time 相同,闭区间含该时刻"`
|
||||
DecidedEndTime *string `json:"decided_end_time" query:"decided_end_time" description:"核销通过时间截止,取该账单已通过分摊关联的申请表审批终态时间;格式与 end_time 相同,闭区间含该时刻"`
|
||||
}
|
||||
|
||||
// EmployeeCollectionBillStatisticsRequest 查询员工代收款账单统计请求。
|
||||
// 与账单列表使用同一批筛选字段与可见性范围,但不接受分页参数。
|
||||
type EmployeeCollectionBillStatisticsRequest struct {
|
||||
SourceType *string `json:"source_type" query:"source_type" validate:"omitempty,oneof=order recharge" enum:"order,recharge" description:"按来源类型过滤 (order:后台线下套餐订单, recharge:代理线下充值)"`
|
||||
SourceNo *string `json:"source_no" query:"source_no" validate:"omitempty,max=64" maxLength:"64" description:"按来源单号精确过滤"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按账单状态过滤 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
|
||||
DebtorAccountID *uint `json:"debtor_account_id" query:"debtor_account_id" description:"按欠款人后台账号ID过滤;非超级管理员固定为当前账号,该参数被忽略"`
|
||||
CustomerID *uint `json:"customer_id" query:"customer_id" description:"按来源客户或店铺ID过滤,匹配来源订单买家或充值归属店铺"`
|
||||
CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始(YYYY-MM-DD,含当日 00:00:00)"`
|
||||
CreatedTo *string `json:"created_to" query:"created_to" description:"创建时间截止(YYYY-MM-DD,含当日 23:59:59)"`
|
||||
BillID *uint `json:"bill_id" query:"bill_id" validate:"omitempty,min=1" minimum:"1" description:"按账单编号精确过滤;账单编号即账单记录标识(主键ID)"`
|
||||
SourceType *string `json:"source_type" query:"source_type" validate:"omitempty,oneof=order recharge" enum:"order,recharge" description:"按来源类型过滤 (order:后台线下套餐订单, recharge:代理线下充值)"`
|
||||
SourceNo *string `json:"source_no" query:"source_no" validate:"omitempty,max=64" maxLength:"64" description:"按来源单号精确过滤"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按账单状态过滤 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
|
||||
DebtorAccountID *uint `json:"debtor_account_id" query:"debtor_account_id" description:"按欠款人后台账号ID过滤;非超级管理员固定为当前账号,该参数被忽略"`
|
||||
DebtorKeyword *string `json:"debtor_keyword" query:"debtor_keyword" validate:"omitempty,max=50" maxLength:"50" description:"按欠款人姓名或账号模糊过滤,取去空白后的子串匹配,最多 50 字符;无需先取得账号ID"`
|
||||
CustomerKeyword *string `json:"customer_keyword" query:"customer_keyword" validate:"omitempty,max=50" maxLength:"50" description:"按客户名称模糊过滤,取去空白后的子串匹配,最多 50 字符;无需先取得客户ID"`
|
||||
CustomerID *uint `json:"customer_id" query:"customer_id" description:"按来源客户或店铺ID过滤,匹配来源订单买家或充值归属店铺"`
|
||||
StartTime *string `json:"start_time" query:"start_time" description:"账单产生时间起始,必须是带显式时区的 RFC3339 秒级时间(如 2026-09-01T00:00:00+08:00),闭区间含该时刻"`
|
||||
EndTime *string `json:"end_time" query:"end_time" description:"账单产生时间截止,必须是带显式时区的 RFC3339 秒级时间,闭区间含该时刻"`
|
||||
DecidedStartTime *string `json:"decided_start_time" query:"decided_start_time" description:"核销通过时间起始,取该账单已通过分摊关联的申请表审批终态时间;格式与 start_time 相同,闭区间含该时刻"`
|
||||
DecidedEndTime *string `json:"decided_end_time" query:"decided_end_time" description:"核销通过时间截止,取该账单已通过分摊关联的申请表审批终态时间;格式与 end_time 相同,闭区间含该时刻"`
|
||||
}
|
||||
|
||||
// EmployeeCollectionBillResponse 员工代收款账单行响应。
|
||||
@@ -90,6 +100,7 @@ type EmployeeCollectionBillResponse struct {
|
||||
CustomerSnapshot map[string]any `json:"customer_snapshot" description:"来源客户或店铺只读快照,不含付款凭证内容"`
|
||||
ReceivableAmount int64 `json:"receivable_amount" description:"应收金额(分)"`
|
||||
ReceivedAmount int64 `json:"received_amount" description:"已核销金额(分),仅企业微信最终通过的分摊计入"`
|
||||
UnsettledAmount int64 `json:"unsettled_amount" description:"未核销金额(分),应收金额减已核销金额;已关闭账单为 0"`
|
||||
ReservedAmount int64 `json:"reserved_amount" description:"审批中预占金额(分)"`
|
||||
RemainingAmount int64 `json:"remaining_amount" description:"剩余可核销金额(分),已关闭账单为 0"`
|
||||
Status int `json:"status" description:"账单状态 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
|
||||
@@ -194,10 +205,37 @@ type EmployeeCollectionBillApplicationResponse struct {
|
||||
|
||||
// EmployeeCollectionBillDetailResponse 员工代收款账单详情响应。
|
||||
type EmployeeCollectionBillDetailResponse struct {
|
||||
Bill *EmployeeCollectionBillResponse `json:"bill" description:"账单事实"`
|
||||
Refunds []*EmployeeCollectionBillRefundResponse `json:"refunds" description:"来源订单退款冲销关联"`
|
||||
Allocations []*EmployeeCollectionBillAllocationResponse `json:"allocations" description:"该账单的核销分摊"`
|
||||
Applications []*EmployeeCollectionBillApplicationResponse `json:"applications" description:"涉及该账单的核销申请与审批历史"`
|
||||
Bill *EmployeeCollectionBillResponse `json:"bill" description:"账单事实"`
|
||||
Refunds []*EmployeeCollectionBillRefundResponse `json:"refunds" description:"来源订单退款冲销关联"`
|
||||
Allocations []*EmployeeCollectionBillAllocationResponse `json:"allocations" description:"该账单的核销分摊"`
|
||||
Applications []*EmployeeCollectionBillApplicationResponse `json:"applications" description:"涉及该账单的核销申请与审批历史"`
|
||||
Operations []*EmployeeCollectionBillOperationLogResponse `json:"operations" description:"该账单的操作日志投影,按操作时间升序;仅含安全摘要,不含凭证内容与完整收款原文"`
|
||||
SourceOrder *EmployeeCollectionBillSourceSnapshotResponse `json:"source_order" description:"来源订单或来源充值记录的只读快照;来源不可读或字段缺失时对应字段为空值,不影响详情其余内容"`
|
||||
}
|
||||
|
||||
// EmployeeCollectionBillOperationLogResponse 是账单维度既有审计事实的安全投影。
|
||||
// 只返回动作、操作账号名称快照、操作时间与变更前后摘要(白名单字段),
|
||||
// MUST NOT 返回支付凭证内容、完整收款信息或其它敏感原文。
|
||||
type EmployeeCollectionBillOperationLogResponse struct {
|
||||
ActionCode string `json:"action_code" description:"稳定动作编码"`
|
||||
ActionName string `json:"action_name" description:"动作中文名称"`
|
||||
Summary string `json:"summary" description:"事件中文摘要"`
|
||||
OperatorName string `json:"operator_name" description:"操作账号名称快照"`
|
||||
OccurredAt time.Time `json:"occurred_at" description:"操作发生时间"`
|
||||
Result string `json:"result" description:"操作结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)"`
|
||||
BeforeSummary map[string]any `json:"before_summary" description:"变更前安全摘要,只包含允许展示的账单字段"`
|
||||
AfterSummary map[string]any `json:"after_summary" description:"变更后安全摘要,只包含允许展示的账单字段"`
|
||||
}
|
||||
|
||||
// EmployeeCollectionBillSourceSnapshotResponse 是来源订单或来源充值记录的只读快照投影。
|
||||
// 来源记录已不可读或字段缺失时对应字段为空值,详情其余内容正常返回。
|
||||
type EmployeeCollectionBillSourceSnapshotResponse struct {
|
||||
SourceType string `json:"source_type" description:"来源业务类型 (order:后台线下套餐订单, recharge:代理线下充值)"`
|
||||
SourceTypeName string `json:"source_type_name" description:"来源业务类型中文名称"`
|
||||
SourceNo string `json:"source_no" description:"来源单号"`
|
||||
Amount int64 `json:"amount" description:"来源实收金额或充值金额(分)"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty" description:"来源记录创建时间"`
|
||||
AssetIdentifier string `json:"asset_identifier" description:"来源资产标识(卡为ICCID,设备优先虚拟号其次IMEI);来源无资产维度或不可读时为空"`
|
||||
}
|
||||
|
||||
// CloseEmployeeCollectionBillRequest 关闭员工代收款账单请求
|
||||
|
||||
@@ -8,6 +8,7 @@ import "time"
|
||||
type CreateH5PopupConfigurationRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"弹窗标题,1~100 字符;投放时冻结写入通知标题"`
|
||||
Content string `json:"content" validate:"required,min=1,max=2000" required:"true" minLength:"1" maxLength:"2000" description:"弹窗正文,1~2000 字符;投放时冻结写入通知正文,不接受 HTML、URL 或前端路由"`
|
||||
PopupType string `json:"popup_type" validate:"required,oneof=promotion announcement" required:"true" enum:"promotion,announcement" description:"弹窗类型,必填 (promotion:套餐政策推广, announcement:通用公告);类别优先级为风险换卡提醒 > 套餐政策推广 > 通用公告,类别优先于显式优先级"`
|
||||
Pages []string `json:"pages" validate:"required,min=1,max=4,dive,oneof=home asset_detail package_purchase asset_wallet_recharge" required:"true" description:"命中页面集合,至少一项 (home:首页, asset_detail:资产详情, package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值)"`
|
||||
ShopIDs []uint `json:"shop_ids" validate:"omitempty,max=200,dive,min=1" maxLength:"200" description:"店铺范围;不传或空数组表示全量,同维度多选取任一命中"`
|
||||
DeviceTypes []string `json:"device_types" validate:"omitempty,max=100,dive,min=1,max=50" maxLength:"100" description:"设备类型范围;不传或空数组表示全量;资产该维度无值(独立卡或未绑定设备)时不命中已配置范围"`
|
||||
@@ -26,6 +27,7 @@ type CreateH5PopupConfigurationRequest struct {
|
||||
type UpdateH5PopupConfigurationRequest struct {
|
||||
Title *string `json:"title" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"弹窗标题,1~100 字符;不传保持原值"`
|
||||
Content *string `json:"content" validate:"omitempty,min=1,max=2000" minLength:"1" maxLength:"2000" description:"弹窗正文,1~2000 字符;不传保持原值,不接受 HTML、URL 或前端路由"`
|
||||
PopupType *string `json:"popup_type" validate:"omitempty,oneof=promotion announcement" enum:"promotion,announcement" description:"弹窗类型 (promotion:套餐政策推广, announcement:通用公告);不传保持原值。类别优先级为风险换卡提醒 > 套餐政策推广 > 通用公告,类别优先于显式优先级"`
|
||||
Pages *[]string `json:"pages" validate:"omitempty,min=1,max=4,dive,oneof=home asset_detail package_purchase asset_wallet_recharge" description:"命中页面集合;不传保持原值,传空数组等价于非法(页面必选)"`
|
||||
ShopIDs *[]uint `json:"shop_ids" validate:"omitempty,max=200,dive,min=1" maxLength:"200" description:"店铺范围;不传保持原值,传空数组表示改为全量"`
|
||||
DeviceTypes *[]string `json:"device_types" validate:"omitempty,max=100,dive,min=1,max=50" maxLength:"100" description:"设备类型范围;不传保持原值,传空数组表示改为全量"`
|
||||
@@ -55,6 +57,8 @@ type H5PopupConfigurationResponse struct {
|
||||
ID uint `json:"id" description:"配置ID"`
|
||||
Title string `json:"title" description:"弹窗标题"`
|
||||
Content string `json:"content" description:"弹窗正文"`
|
||||
PopupType string `json:"popup_type" enums:"promotion,announcement" description:"弹窗类型 (promotion:套餐政策推广, announcement:通用公告)"`
|
||||
PopupTypeText string `json:"popup_type_text" description:"弹窗类型名称(中文)"`
|
||||
Pages []string `json:"pages" enums:"home,asset_detail,package_purchase,asset_wallet_recharge" description:"命中页面集合 (home:首页, asset_detail:资产详情, package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值)"`
|
||||
ShopIDs []uint `json:"shop_ids" description:"店铺范围;空数组表示全量"`
|
||||
DeviceTypes []string `json:"device_types" description:"设备类型范围;空数组表示全量"`
|
||||
|
||||
@@ -21,21 +21,21 @@ type CreatePackageRequest struct {
|
||||
|
||||
// UpdatePackageRequest 更新套餐请求
|
||||
type UpdatePackageRequest struct {
|
||||
PackageName *string `json:"package_name" validate:"omitempty,min=1,max=255" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
PackageType *string `json:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
IsGift *bool `json:"is_gift" description:"是否赠送套餐"`
|
||||
DurationMonths *int `json:"duration_months" validate:"omitempty,min=1,max=120" 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)"`
|
||||
ClearSuggestedRetailPrice bool `json:"clear_suggested_retail_price" description:"是否清空建议售价并恢复为未配置状态"`
|
||||
CostPrice *int64 `json:"cost_price" validate:"omitempty,min=0" 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:购买时起算)"`
|
||||
PackageName *string `json:"package_name" validate:"omitempty,min=1,max=255" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
PackageType *string `json:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
IsGift *bool `json:"is_gift" description:"是否赠送套餐"`
|
||||
DurationMonths *int `json:"duration_months" validate:"omitempty,min=1,max=120" 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)"`
|
||||
ClearSuggestedRetailPrice bool `json:"clear_suggested_retail_price" description:"是否清空建议售价并恢复为未配置状态"`
|
||||
CostPrice *int64 `json:"cost_price" validate:"omitempty,min=0" 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:购买时起算)"`
|
||||
}
|
||||
|
||||
// PackageListRequest 套餐列表请求
|
||||
|
||||
@@ -93,6 +93,11 @@ type PaymentMerchantPoolResponse struct {
|
||||
TimePeriodStartedAt *time.Time `json:"time_period_started_at,omitempty" description:"时间轮询起始时间"`
|
||||
RoutingEpoch int64 `json:"routing_epoch" description:"当前路由统计世代"`
|
||||
MemberIDs []uint `json:"member_ids" description:"有序商户ID列表"`
|
||||
EnabledMemberCount int `json:"enabled_member_count" description:"启用成员数量,即成员中当前启用的商户数量"`
|
||||
MemberCount int `json:"member_count" description:"成员总数量"`
|
||||
CurrentMerchantID *uint `json:"current_merchant_id,omitempty" description:"当前命中商户ID;商户池停用或无可用成员时为空"`
|
||||
CurrentMerchantName string `json:"current_merchant_name,omitempty" description:"当前命中商户名称;商户池停用或无可用成员时为空"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"最近配置更新时间"`
|
||||
Remark string `json:"remark" description:"备注"`
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,13 @@ type PhoneAssetAssociationResponse struct {
|
||||
StatusName string `json:"status_name" description:"关联状态中文名称"`
|
||||
Source string `json:"source" description:"建立来源,固定为 h5_sms_verification"`
|
||||
EstablishedAt string `json:"established_at" description:"建立时间(短信验证通过时间)"`
|
||||
ValidAssociationCount int64 `json:"valid_association_count" description:"该手机号当前有效关联数量(只统计有效关系,卡与设备各计一项,与「最多关联十项」上限判定同一计数口径)"`
|
||||
InvalidatedAt string `json:"invalidated_at" description:"失效时间;有效关系为空"`
|
||||
InvalidationMethod string `json:"invalidation_method" description:"失效方式 (backend_single:后台单项, backend_batch:后台批量, csv_import:CSV导入);有效关系为空"`
|
||||
InvalidationMethodName string `json:"invalidation_method_name" description:"失效方式中文名称;有效关系为空"`
|
||||
InvalidationReason string `json:"invalidation_reason" description:"失效原因;有效关系为空"`
|
||||
InvalidatorID uint `json:"invalidator_id" description:"最近解绑人账号ID(后台账号标识);有效关系为空,值为 0"`
|
||||
InvalidatorName string `json:"invalidator_name" description:"最近解绑人后台账号名称快照;有效关系为空。不以创建人或最后更新人填充"`
|
||||
}
|
||||
|
||||
// PhoneAssetAssociationPageResult 手机号—资产关联列表分页响应。
|
||||
|
||||
@@ -24,7 +24,7 @@ type EnqueuePriorityPollingItemView struct {
|
||||
ItemID uint `json:"item_id" description:"优先轮询项ID"`
|
||||
TaskType string `json:"task_type" description:"轮询任务类型"`
|
||||
TaskTypeName string `json:"task_type_name" description:"轮询任务类型名称"`
|
||||
Status string `json:"status" description:"状态:pending-待执行 processing-执行中 completed-已完成 failed-失败出队"`
|
||||
Status string `json:"status" description:"状态:pending-待执行 processing-执行中 completed-已完成 failed-失败出队 closed-已关闭 expired-已过期"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
TriggerCount int `json:"trigger_count" description:"累计触发次数(同一卡同一任务类型的后续触发合并计数)"`
|
||||
LastTriggeredAt time.Time `json:"last_triggered_at" description:"最近触发时间"`
|
||||
@@ -32,42 +32,59 @@ type EnqueuePriorityPollingItemView struct {
|
||||
}
|
||||
|
||||
// PriorityPollingItemListRequest 是优先轮询项列表查询请求。
|
||||
// 时间范围按入队时间(created_at)取闭区间,复用统一时间筛选参数与语义。
|
||||
type PriorityPollingItemListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"`
|
||||
CardID *uint `json:"card_id" query:"card_id" validate:"omitempty" description:"按卡ID筛选"`
|
||||
TaskType *string `json:"task_type" query:"task_type" validate:"omitempty,oneof=polling:realname polling:carddata polling:package polling:card_status" enum:"polling:realname,polling:carddata,polling:package,polling:card_status" description:"按轮询任务类型筛选:polling:realname-实名检查 polling:carddata-流量检查 polling:package-套餐检查 polling:card_status-卡状态检查"`
|
||||
Status *string `json:"status" query:"status" validate:"omitempty,oneof=pending processing completed failed" enum:"pending,processing,completed,failed" description:"按状态筛选:pending-待执行 processing-执行中 completed-已完成 failed-失败出队"`
|
||||
TriggerType *string `json:"trigger_type" query:"trigger_type" validate:"omitempty,oneof=purchase_activated renewal_activated queue_activated addon_activated no_valid_package manual_trigger" enum:"purchase_activated,renewal_activated,queue_activated,addon_activated,no_valid_package,manual_trigger" description:"按触发类型筛选:purchase_activated-主套餐购买后立即生效 renewal_activated-续购套餐生效 queue_activated-排队主套餐顺延生效 addon_activated-加油包生效 no_valid_package-资产无有效套餐 manual_trigger-人工入队"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"`
|
||||
CardID *uint `json:"card_id" query:"card_id" validate:"omitempty" description:"按卡ID筛选"`
|
||||
TaskType *string `json:"task_type" query:"task_type" validate:"omitempty,oneof=polling:realname polling:carddata polling:package polling:card_status" enum:"polling:realname,polling:carddata,polling:package,polling:card_status" description:"按轮询任务类型筛选:polling:realname-实名检查 polling:carddata-流量检查 polling:package-套餐检查 polling:card_status-卡状态检查"`
|
||||
Status *string `json:"status" query:"status" validate:"omitempty,oneof=pending processing completed failed closed expired" enum:"pending,processing,completed,failed,closed,expired" description:"按状态筛选:pending-待执行 processing-执行中 completed-已完成 failed-失败出队 closed-已关闭 expired-已过期"`
|
||||
TriggerType *string `json:"trigger_type" query:"trigger_type" validate:"omitempty,oneof=purchase_activated renewal_activated queue_activated addon_activated no_valid_package manual_trigger" enum:"purchase_activated,renewal_activated,queue_activated,addon_activated,no_valid_package,manual_trigger" description:"按触发类型筛选:purchase_activated-主套餐购买后立即生效 renewal_activated-续购套餐生效 queue_activated-排队主套餐顺延生效 addon_activated-加油包生效 no_valid_package-资产无有效套餐 manual_trigger-人工入队"`
|
||||
StartTime string `json:"start_time" query:"start_time" description:"入队时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"`
|
||||
EndTime string `json:"end_time" query:"end_time" description:"入队时间结束(格式与 start_time 一致,闭区间含该时刻);开始晚于结束被拒绝"`
|
||||
AbnormalOnly *bool `json:"abnormal_only" query:"abnormal_only" description:"「异常与重试」视图:仅返回失败终态、已过期、尝试次数达到上限或存在失败原因的优先项;与其他筛选取与关系"`
|
||||
}
|
||||
|
||||
// PriorityPollingItemResponse 是优先轮询项的读侧投影。
|
||||
// 不含优先级分级:本能力不提供 P0/P1/P2 分级字段。
|
||||
type PriorityPollingItemResponse struct {
|
||||
ID uint `json:"id" description:"优先轮询项ID"`
|
||||
CardID uint `json:"card_id" description:"卡ID"`
|
||||
ICCID string `json:"iccid,omitempty" description:"卡ICCID(卡已不存在时为空)"`
|
||||
TaskType string `json:"task_type" description:"轮询任务类型"`
|
||||
TaskTypeName string `json:"task_type_name" description:"轮询任务类型名称"`
|
||||
Status string `json:"status" description:"状态:pending-待执行 processing-执行中 completed-已完成 failed-失败出队"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
TriggerType string `json:"trigger_type" description:"首次触发类型:purchase_activated renewal_activated queue_activated addon_activated no_valid_package manual_trigger"`
|
||||
TriggerTypeName string `json:"trigger_type_name" description:"首次触发类型名称"`
|
||||
TriggerTypes string `json:"trigger_types" description:"触发来源集合(去重后的全部触发类型,英文逗号分隔)"`
|
||||
TriggerCount int `json:"trigger_count" description:"累计触发次数"`
|
||||
LastTriggeredAt time.Time `json:"last_triggered_at" description:"最近触发时间"`
|
||||
SourceOrderID *uint `json:"source_order_id,omitempty" description:"来源订单ID"`
|
||||
SourcePackageUse *uint `json:"source_package_usage_id,omitempty" description:"来源套餐使用记录ID"`
|
||||
AttemptCount int `json:"attempt_count" description:"真实发起执行的次数(上限3次)"`
|
||||
ClaimedAt *time.Time `json:"claimed_at,omitempty" description:"最近认领时间"`
|
||||
ManualReason string `json:"manual_reason,omitempty" description:"人工入队原因"`
|
||||
ManualOperator uint `json:"manual_operator_id,omitempty" description:"人工入队操作者账号ID"`
|
||||
ManualOperatorNm string `json:"manual_operator_name,omitempty" description:"人工入队操作者名称"`
|
||||
ShopIDSnapshot *uint `json:"shop_id_snapshot,omitempty" description:"触发时冻结的卡所属店铺ID,平台卡为空"`
|
||||
Result string `json:"result" description:"执行结果:success-成功 failed-失败,未出结果时为空"`
|
||||
ResultName string `json:"result_name" description:"执行结果名称"`
|
||||
FailureReason string `json:"failure_reason,omitempty" description:"可安全展示的失败原因"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"最近更新时间"`
|
||||
ID uint `json:"id" description:"优先轮询项ID"`
|
||||
CardID uint `json:"card_id" description:"卡ID"`
|
||||
ICCID string `json:"iccid,omitempty" description:"卡ICCID(卡已不存在时为空)"`
|
||||
AssetType string `json:"asset_type" description:"资产类型:iot_card-物联网卡 device-设备(入队时按设备—卡绑定冻结,独立卡为卡自身)"`
|
||||
AssetID *uint `json:"asset_id" description:"资产ID(与 asset_type 共同构成资产身份);历史行未回填时为 null"`
|
||||
DeviceNo string `json:"device_no,omitempty" description:"设备号快照(资产绑定设备时的设备虚拟号,独立卡为空)"`
|
||||
TaskType string `json:"task_type" description:"轮询任务类型"`
|
||||
TaskTypeName string `json:"task_type_name" description:"轮询任务类型名称"`
|
||||
Status string `json:"status" description:"状态:pending-待执行 processing-执行中 completed-已完成 failed-失败出队 closed-已关闭 expired-已过期"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
TriggerType string `json:"trigger_type" description:"首次触发类型:purchase_activated renewal_activated queue_activated addon_activated no_valid_package manual_trigger"`
|
||||
TriggerTypeName string `json:"trigger_type_name" description:"首次触发类型名称"`
|
||||
TriggerTypes string `json:"trigger_types" description:"触发来源集合(去重后的全部触发类型,英文逗号分隔)"`
|
||||
TriggerCount int `json:"trigger_count" description:"累计触发次数"`
|
||||
LastTriggeredAt time.Time `json:"last_triggered_at" description:"最近触发时间"`
|
||||
SourceOrderID *uint `json:"source_order_id,omitempty" description:"来源订单ID"`
|
||||
SourcePackageUse *uint `json:"source_package_usage_id,omitempty" description:"来源套餐使用记录ID"`
|
||||
AttemptCount int `json:"attempt_count" description:"真实发起执行的次数"`
|
||||
AttemptLimit int `json:"attempt_limit" description:"最大尝试次数(固定常量,只读;达到后记录失败终态并出队)"`
|
||||
ClaimedAt *time.Time `json:"claimed_at,omitempty" description:"最近认领时间"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty" description:"最近一次执行开始时间(未发起执行时为 null)"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty" description:"最近一次执行结束时间(未发起执行时为 null)"`
|
||||
DurationMS int64 `json:"duration_ms" description:"最近一次执行耗时(毫秒);执行开始或结束时间缺失时为 0"`
|
||||
NextRunAt *time.Time `json:"next_run_at" description:"下一次计划执行时间;未排期为 null(读侧不据此判定已过期)"`
|
||||
PriorityEffectiveFrom *time.Time `json:"priority_effective_from" description:"有效期起算时间(入队时间,只读);历史终态行为 null"`
|
||||
DequeuedAt *time.Time `json:"dequeued_at" description:"出队时间(任一终态写入);仍在队列中时为 null"`
|
||||
ManualReason string `json:"manual_reason,omitempty" description:"人工入队原因"`
|
||||
ManualOperator uint `json:"manual_operator_id,omitempty" description:"人工入队操作者账号ID"`
|
||||
ManualOperatorNm string `json:"manual_operator_name,omitempty" description:"人工入队操作者名称"`
|
||||
ShopIDSnapshot *uint `json:"shop_id_snapshot,omitempty" description:"触发时冻结的卡所属店铺ID,平台卡为空"`
|
||||
AgentShopID *uint `json:"agent_shop_id_snapshot,omitempty" description:"代理归属:卡所属店铺的直接上级代理店铺ID,一级代理与平台卡为空"`
|
||||
Result string `json:"result" description:"执行结果:success-成功 failed-失败,未出结果时为空"`
|
||||
ResultName string `json:"result_name" description:"执行结果名称"`
|
||||
FailureReason string `json:"failure_reason,omitempty" description:"可安全展示的最近一次失败原因"`
|
||||
IntegrationLogID string `json:"integration_log_id,omitempty" description:"最近一次上游交互的集成交互日志标识(不含凭证与响应原文)"`
|
||||
CreatedAt time.Time `json:"created_at" description:"入队时间(创建时间)"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"最近更新时间"`
|
||||
}
|
||||
|
||||
// PriorityPollingItemListResponse 是优先轮询项列表响应。
|
||||
@@ -78,3 +95,42 @@ type PriorityPollingItemListResponse struct {
|
||||
// 与 pkg/response.PaginationData 一致:响应侧字段名为 size,请求侧仍用 page_size。
|
||||
PageSize int `json:"size" description:"每页条数"`
|
||||
}
|
||||
|
||||
// ClosePriorityPollingItemRequest 是人工关闭优先轮询项请求。
|
||||
type ClosePriorityPollingItemRequest struct {
|
||||
Reason string `json:"reason" validate:"required,min=1,max=500" description:"人工关闭原因(必填,最长500字符,写入审计)"`
|
||||
}
|
||||
|
||||
// ClosePriorityPollingItemResponse 是人工关闭优先轮询项结果。
|
||||
type ClosePriorityPollingItemResponse struct {
|
||||
ItemID uint `json:"item_id" description:"优先轮询项ID"`
|
||||
CardID uint `json:"card_id" description:"卡ID"`
|
||||
TaskType string `json:"task_type" description:"轮询任务类型"`
|
||||
TaskTypeName string `json:"task_type_name" description:"轮询任务类型名称"`
|
||||
Status string `json:"status" description:"关闭后的状态:closed-已关闭"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
DequeuedAt time.Time `json:"dequeued_at" description:"出队时间"`
|
||||
Reason string `json:"reason" description:"本次人工关闭原因"`
|
||||
}
|
||||
|
||||
// RetriggerPriorityPollingItemRequest 是失败或已过期优先项的人工重触发请求。
|
||||
type RetriggerPriorityPollingItemRequest struct {
|
||||
Reason string `json:"reason" validate:"required,min=1,max=500" description:"人工重触发原因(必填,最长500字符,写入审计)"`
|
||||
}
|
||||
|
||||
// RetriggerPriorityPollingItemResponse 是人工重触发结果。
|
||||
// attempt_count 恒为 0:重触发为该卡该任务类型建立新的活动项,尝试次数从零开始。
|
||||
type RetriggerPriorityPollingItemResponse struct {
|
||||
SourceItemID uint `json:"source_item_id" description:"被重触发的原终态优先轮询项ID"`
|
||||
CardID uint `json:"card_id" description:"卡ID"`
|
||||
TaskType string `json:"task_type" description:"轮询任务类型"`
|
||||
TaskTypeName string `json:"task_type_name" description:"轮询任务类型名称"`
|
||||
ItemID uint `json:"item_id" description:"新建或合并到的活动优先轮询项ID"`
|
||||
Status string `json:"status" description:"活动项状态:pending-待执行 processing-执行中"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
AttemptCount int `json:"attempt_count" description:"当前尝试次数(新活动项恒为 0)"`
|
||||
TriggerCount int `json:"trigger_count" description:"累计触发次数"`
|
||||
LastTriggeredAt time.Time `json:"last_triggered_at" description:"最近触发时间"`
|
||||
Reason string `json:"reason" description:"本次人工重触发原因"`
|
||||
Created bool `json:"created" description:"是否新建活动项:false 表示已合并到既有活动项"`
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ type CreateRefundRequest struct {
|
||||
CustomerAccountInfo string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"`
|
||||
RefundVoucherKey []string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(最多5个,仅客户收款信息退款方式必填,通过/storage/upload-url上传图片后获得)"`
|
||||
RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
|
||||
PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID(可选)"`
|
||||
RefundReason string `json:"refund_reason" validate:"required,max=1000" required:"true" maxLength:"1000" description:"退款原因(必填,去除首尾空白后不得为空)"`
|
||||
// Remark 是申请人备注,随本次提交冻结进当次审批尝试快照;与审批备注(详情响应中的 remark)语义不同。
|
||||
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"申请人备注(可选,随本次提交冻结进审批尝试快照,不改变审批备注)"`
|
||||
PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID(可选)"`
|
||||
}
|
||||
|
||||
// RefundIDRequest 退款申请ID路径参数
|
||||
@@ -32,7 +34,9 @@ type ResubmitRefundRequest struct {
|
||||
CustomerAccountInfo *string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"`
|
||||
RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"`
|
||||
RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(重新提交时可替换;客户收款信息退款方式必填,最多5个)"`
|
||||
RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
|
||||
RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因(不填沿用原有原因;重提时去除首尾空白后不得为空)"`
|
||||
// Remark 是本次重提的申请人备注,随本次尝试冻结;不填沿用该退款单最近一次尝试的备注。
|
||||
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"申请人备注(可选,随本次重提冻结进新的审批尝试快照,历史尝试备注不被改写)"`
|
||||
}
|
||||
|
||||
// ApproveRefundRequest 审批通过退款申请请求
|
||||
@@ -71,51 +75,57 @@ type RefundResponse struct {
|
||||
PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"`
|
||||
// 当前退款套餐用量:按冻结套餐使用记录 → 订单主套餐 → 订单任一套餐的优先级解析,
|
||||
// 只用于展示、查询与导出,不参与退款金额校验、套餐失效或佣金回溯。
|
||||
RefundPackageUsedMB int64 `json:"refund_package_used_mb" description:"当前退款套餐已用量(MB,真实流量;解析不到套餐时为0)"`
|
||||
RefundPackageTotalMB int64 `json:"refund_package_total_mb" description:"当前退款套餐总量(MB,真实流量;解析不到套餐时为0)"`
|
||||
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
|
||||
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
|
||||
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"`
|
||||
ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"`
|
||||
RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表(最多5个)"`
|
||||
RefundReason string `json:"refund_reason" description:"退款原因"`
|
||||
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
Method string `json:"method" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包),空表示未接入方式的存量申请"`
|
||||
MethodName string `json:"method_name" description:"退款方式中文名称"`
|
||||
FrozenActualReceivedAmount int64 `json:"frozen_actual_received_amount" description:"系统派生并冻结的权威实收金额(分),作为可退金额上限"`
|
||||
CustomerAccountInfo string `json:"customer_account_info" description:"客户收款信息自由文本快照,仅客户收款信息退款方式有值"`
|
||||
ChannelRefundStatus int `json:"channel_refund_status" description:"渠道原路退款状态 (0:未发起, 1:处理中, 2:已成功, 3:已失败),0 表示未发起渠道退款或不适用该退款方式"`
|
||||
ChannelRefundStatusName string `json:"channel_refund_status_name" description:"渠道原路退款状态中文名称"`
|
||||
ChannelRefundNo string `json:"channel_refund_no" description:"渠道退款流水号,渠道明确成功或失败后回填"`
|
||||
ChannelRefundRequestNo string `json:"channel_refund_request_no" description:"渠道退款请求号快照,用于幂等与对账"`
|
||||
ChannelRefundAmount int64 `json:"channel_refund_amount" description:"提交渠道的退款金额快照(分)"`
|
||||
ChannelRefundedAt string `json:"channel_refunded_at,omitempty" description:"渠道明确退款成功时间"`
|
||||
FailureReason string `json:"failure_reason" description:"结构化失败分类稳定编码 (channel_rejected:渠道明确拒绝, credential_invalid:渠道凭证失效, insufficient_balance:渠道余额不足, timeout_unknown:超时或结果未知, approval_rejected:企业微信驳回或关闭, revoked_after_approved:企业微信通过后撤销, payment_fact_invalid:本地原支付事实不可用),空表示无失败"`
|
||||
FailureReasonName string `json:"failure_reason_name" description:"失败分类中文名称"`
|
||||
FailureMessage string `json:"failure_message" description:"失败安全摘要,供人工排查;不含渠道凭证等敏感内容"`
|
||||
AnomalyFlag int `json:"anomaly_flag" description:"异常标记 (0:无异常, 1:有异常,需人工处理)"`
|
||||
AnomalyReason string `json:"anomaly_reason" description:"异常原因说明,无异常时为空"`
|
||||
LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID,仅用于展示"`
|
||||
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID,仅用于展示"`
|
||||
Attempts []RefundAttemptResponse `json:"attempts" description:"审批尝试记录,按提交顺序排列,历史材料不被覆盖;无尝试记录时为空数组"`
|
||||
ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"`
|
||||
ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"`
|
||||
RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"`
|
||||
Remark string `json:"remark,omitempty" description:"审批备注"`
|
||||
CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"`
|
||||
AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"`
|
||||
SubmitterID uint `json:"submitter_id" description:"提交人账号ID"`
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名称"`
|
||||
ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID"`
|
||||
ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道,企业微信为wecom"`
|
||||
ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
|
||||
ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"`
|
||||
Creator uint `json:"creator" description:"创建人ID"`
|
||||
Updater uint `json:"updater" description:"更新人ID"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
RefundPackageUsedMB int64 `json:"refund_package_used_mb" description:"当前退款套餐已用量(MB,真实流量;解析不到套餐时为0)"`
|
||||
RefundPackageTotalMB int64 `json:"refund_package_total_mb" description:"当前退款套餐总量(MB,真实流量;解析不到套餐时为0)"`
|
||||
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
|
||||
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
|
||||
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"`
|
||||
ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"`
|
||||
RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表(最多5个)"`
|
||||
RefundReason string `json:"refund_reason" description:"退款原因"`
|
||||
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
Method string `json:"method" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包),空表示未接入方式的存量申请"`
|
||||
MethodName string `json:"method_name" description:"退款方式中文名称"`
|
||||
FrozenActualReceivedAmount int64 `json:"frozen_actual_received_amount" description:"系统派生并冻结的权威实收金额(分),作为可退金额上限"`
|
||||
CustomerAccountInfo string `json:"customer_account_info" description:"客户收款信息自由文本快照,仅客户收款信息退款方式有值"`
|
||||
ChannelRefundStatus int `json:"channel_refund_status" description:"渠道原路退款状态 (0:未发起, 1:处理中, 2:已成功, 3:已失败),0 表示未发起渠道退款或不适用该退款方式"`
|
||||
ChannelRefundStatusName string `json:"channel_refund_status_name" description:"渠道原路退款状态中文名称"`
|
||||
ChannelRefundNo string `json:"channel_refund_no" description:"渠道退款流水号,渠道明确成功或失败后回填"`
|
||||
ChannelRefundRequestNo string `json:"channel_refund_request_no" description:"渠道退款请求号快照,用于幂等与对账"`
|
||||
ChannelRefundAmount int64 `json:"channel_refund_amount" description:"提交渠道的退款金额快照(分)"`
|
||||
ChannelRefundedAt string `json:"channel_refunded_at,omitempty" description:"渠道明确退款成功时间"`
|
||||
// 结算标识:前两项是冻结的来源支付事实,第三项是财务补录的线下处理流水号,语义互不相同。
|
||||
SourcePaymentNo string `json:"source_payment_no" description:"冻结的来源支付单号,取创建申请时的原成功支付记录;线下订单无线上支付记录时为空字符串"`
|
||||
OriginalChannelTradeNo string `json:"original_channel_trade_no" description:"冻结的原支付渠道交易流水号,取创建申请时的原成功支付记录;线下订单无线上支付记录时为空字符串"`
|
||||
OfflineSettlementNo string `json:"offline_settlement_no" description:"线下退款处理流水号或凭证编号,由授权账号补录或更正;与渠道退款流水号语义不同"`
|
||||
OfflineSettledAt string `json:"offline_settled_at,omitempty" description:"线下退款处理流水号最近一次登记或更正时间"`
|
||||
OfflineSettledBy uint `json:"offline_settled_by" description:"线下退款处理流水号最近一次登记或更正的操作账号ID,0 表示未登记"`
|
||||
FailureReason string `json:"failure_reason" description:"结构化失败分类稳定编码 (channel_rejected:渠道明确拒绝, credential_invalid:渠道凭证失效, insufficient_balance:渠道余额不足, timeout_unknown:超时或结果未知, approval_rejected:企业微信驳回或关闭, revoked_after_approved:企业微信通过后撤销, payment_fact_invalid:本地原支付事实不可用),空表示无失败"`
|
||||
FailureReasonName string `json:"failure_reason_name" description:"失败分类中文名称"`
|
||||
FailureMessage string `json:"failure_message" description:"失败安全摘要,供人工排查;不含渠道凭证等敏感内容"`
|
||||
AnomalyFlag int `json:"anomaly_flag" description:"异常标记 (0:无异常, 1:有异常,需人工处理)"`
|
||||
AnomalyReason string `json:"anomaly_reason" description:"异常原因说明,无异常时为空"`
|
||||
LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID,仅用于展示"`
|
||||
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID,仅用于展示"`
|
||||
Attempts []RefundAttemptResponse `json:"attempts" description:"审批尝试记录,按提交顺序排列,历史材料不被覆盖;无尝试记录时为空数组"`
|
||||
ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"`
|
||||
ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"`
|
||||
RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"`
|
||||
Remark string `json:"remark,omitempty" description:"审批备注"`
|
||||
CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"`
|
||||
AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"`
|
||||
SubmitterID uint `json:"submitter_id" description:"提交人账号ID"`
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名称"`
|
||||
ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID"`
|
||||
ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道,企业微信为wecom"`
|
||||
ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
|
||||
ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"`
|
||||
Creator uint `json:"creator" description:"创建人ID"`
|
||||
Updater uint `json:"updater" description:"更新人ID"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// RefundListResponse 退款申请列表分页响应
|
||||
@@ -135,6 +145,7 @@ type RefundAttemptResponse struct {
|
||||
RefundAmount int64 `json:"refund_amount" description:"本次提交冻结的申请退款金额(分)"`
|
||||
FrozenActualReceivedAmount int64 `json:"frozen_actual_received_amount" description:"本次提交冻结的权威实收金额(分)"`
|
||||
RefundReason string `json:"refund_reason" description:"本次提交的退款原因"`
|
||||
Remark string `json:"remark" description:"本次提交冻结的申请人备注快照,未填写时为空;与审批备注(详情响应中的 remark)不是同一字段"`
|
||||
CustomerAccountInfo string `json:"customer_account_info" description:"本次冻结的客户收款信息快照,非客户收款信息退款方式为空"`
|
||||
CustomerVoucherKey []string `json:"customer_voucher_key" description:"客户收款凭证对象存储file_key列表,仅返回对象键引用"`
|
||||
ChannelRefundRequestNo string `json:"channel_refund_request_no" description:"本次提交使用的渠道退款请求号快照,用于幂等与对账"`
|
||||
@@ -144,3 +155,44 @@ type RefundAttemptResponse struct {
|
||||
ApprovalStatusName string `json:"approval_status_name" description:"通用审批实例状态中文名称"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
// RefundOrderOptionsRequest 按来源订单查询可选退款方式请求。
|
||||
type RefundOrderOptionsRequest struct {
|
||||
OrderID uint `json:"order_id" query:"order_id" validate:"required,min=1" required:"true" minimum:"1" description:"来源订单ID"`
|
||||
}
|
||||
|
||||
// RefundMethodOptionResponse 描述一种可选退款方式及其当前可用性。
|
||||
type RefundMethodOptionResponse struct {
|
||||
Method string `json:"method" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包)"`
|
||||
MethodName string `json:"method_name" description:"退款方式中文名称"`
|
||||
Available bool `json:"available" description:"该方式当前是否可用"`
|
||||
Unavailable string `json:"unavailable_reason" description:"不可用原因;可用时为空字符串"`
|
||||
}
|
||||
|
||||
// RefundCapabilityResponse 是原路退款能力校验结果投影。
|
||||
type RefundCapabilityResponse struct {
|
||||
Available bool `json:"available" description:"原路退款能力校验是否通过(凭证完整性、服务商类型与渠道可退时限)"`
|
||||
Unavailable string `json:"unavailable_reason" description:"不通过原因;通过时为空字符串"`
|
||||
}
|
||||
|
||||
// RefundOrderOptionsResponse 按来源订单查询可选退款方式响应。
|
||||
type RefundOrderOptionsResponse struct {
|
||||
OrderID uint `json:"order_id" description:"来源订单ID"`
|
||||
OrderNo string `json:"order_no" description:"来源订单号"`
|
||||
OrderPaymentType string `json:"order_payment_type" description:"来源订单支付方式 (wechat:微信, alipay:支付宝, wallet:钱包, offline:后台线下)"`
|
||||
Methods []RefundMethodOptionResponse `json:"methods" description:"可选退款方式集合,含每种方式的可用性与不可用原因;判定事实缺失时为空数组"`
|
||||
MerchantID *uint `json:"merchant_id,omitempty" description:"原收款商户ID,无实际收款商户时为空"`
|
||||
MerchantName string `json:"merchant_name" description:"原收款商户名称快照,无实际收款商户时为空字符串"`
|
||||
OriginalChannelTradeNo string `json:"original_channel_trade_no" description:"原支付渠道交易流水号,无线上支付记录时为空字符串"`
|
||||
RefundCapability RefundCapabilityResponse `json:"refund_capability" description:"原路退款能力校验结果"`
|
||||
// UnavailableReason 在判定所需事实缺失(如无原成功支付记录或订单支付方式不支持退款)时说明原因,
|
||||
// 使调用方得到不可用原因而不是请求失败。
|
||||
UnavailableReason string `json:"unavailable_reason" description:"整体判定不可用时的原因,可用时为空字符串"`
|
||||
}
|
||||
|
||||
// OfflineSettlementRequest 登记或更正线下退款处理流水号请求。
|
||||
type OfflineSettlementRequest struct {
|
||||
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
|
||||
// OfflineSettlementNo 记录财务实际执行的线下退款处理流水号或凭证编号;重复提交即更正,历史值由审计留存。
|
||||
OfflineSettlementNo string `json:"offline_settlement_no" validate:"required,max=128" required:"true" maxLength:"128" description:"线下退款处理流水号或凭证编号(必填,去除首尾空白后不得为空)"`
|
||||
}
|
||||
|
||||
@@ -117,8 +117,10 @@ type ShopResponse struct {
|
||||
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
ClientLoginDisabled bool `json:"client_login_disabled" description:"是否禁止该店铺资产发起新的 C 端登录"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
// 直接下级代理数量为只读投影:未删除且上级店铺为该店铺的店铺数,无下级返回 0。
|
||||
DownstreamAgentCount int64 `json:"downstream_agent_count" description:"直接下级代理数量(未删除且上级店铺标识等于该店铺的店铺数),无下级返回 0"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// ShopPageResult 店铺分页响应
|
||||
|
||||
@@ -32,6 +32,10 @@ type WeComBusinessFieldResponse struct {
|
||||
Name string `json:"name" description:"字段中文名称"`
|
||||
ValueType string `json:"value_type" description:"字段值类型 (string:字符串, integer:整数, money:两位小数的元金额字符串, file_list:对象存储文件引用列表)"`
|
||||
Description string `json:"description" description:"字段取值说明"`
|
||||
// RequiredMapping 为 true 表示该字段必须配置控件映射:场景映射校验在缺失映射时明确失败,
|
||||
// 表单构建在快照缺值时明确失败并提示缺失控件,MUST NOT 静默丢弃。
|
||||
// 仅本变更新增的字段标记为 true,既有未映射的可选控件保持既有静默跳过行为。
|
||||
RequiredMapping bool `json:"required_mapping" description:"是否必须配置控件映射;true 表示缺失映射或快照缺值时提交明确失败"`
|
||||
}
|
||||
|
||||
// WeComBusinessFieldListResponse 企业微信审批场景可映射业务字段列表响应。
|
||||
|
||||
@@ -58,8 +58,13 @@ type CommissionWithdrawalRequestAttempt struct {
|
||||
InvoiceKeys datatypes.JSON `gorm:"column:invoice_keys;type:jsonb;not null;default:'[]';comment:本次申请级发票对象存储Key列表" json:"invoice_keys"`
|
||||
SubmittedByAccountID uint `gorm:"column:submitted_by_account_id;type:bigint;not null;comment:本次实际提交账号ID" json:"submitted_by_account_id"`
|
||||
ApprovalInstanceID *uint `gorm:"column:approval_instance_id;type:bigint;comment:本次尝试关联的通用审批实例ID" json:"approval_instance_id,omitempty"`
|
||||
ReleasedAt *time.Time `gorm:"column:released_at;comment:本次冻结释放时间,空表示未结算" json:"released_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
// 资料资格校验留痕:本次提交依据的有效资格版本与当次校验结果,随尝试冻结,历史尝试不被改写。
|
||||
QualificationVersionID uint `gorm:"column:qualification_version_id;type:bigint;not null;default:0;comment:本次依据的有效提现资料资格版本ID,0-资格不存在或历史尝试" json:"qualification_version_id"`
|
||||
QualificationCheckedAt *time.Time `gorm:"column:qualification_checked_at;comment:本次资料资格校验时间" json:"qualification_checked_at,omitempty"`
|
||||
QualificationPassed int `gorm:"column:qualification_passed;type:smallint;not null;default:0;comment:本次资料资格校验是否通过 0-未通过 1-通过" json:"qualification_passed"`
|
||||
QualificationFailureReason string `gorm:"column:qualification_failure_reason;type:varchar(64);not null;default:'';comment:未通过时的稳定原因编码" json:"qualification_failure_reason"`
|
||||
ReleasedAt *time.Time `gorm:"column:released_at;comment:本次冻结释放时间,空表示未结算" json:"released_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定提现审批尝试记录表名。
|
||||
|
||||
@@ -12,9 +12,12 @@ import (
|
||||
// Version 是配置版本而非乐观锁,更新事务内递增并参与频率去重键,旧版本通知保留原快照不被改写。
|
||||
// 范围四维统一用 JSONB 字符串数组:空数组表示该维度未配置即全量,已配置而资产该维度无值时不命中。
|
||||
type H5PopupConfiguration struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Title string `gorm:"column:title;type:varchar(100);not null" json:"title"`
|
||||
Content string `gorm:"column:content;type:varchar(2000);not null" json:"content"`
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Title string `gorm:"column:title;type:varchar(100);not null" json:"title"`
|
||||
Content string `gorm:"column:content;type:varchar(2000);not null" json:"content"`
|
||||
// PopupType 是运营弹窗类型(套餐政策推广/通用公告),必填单选。
|
||||
// 它决定候选排序的第一顺位类别,类别顺序由查询侧的显式排序表达式表达,不依赖取值字典序。
|
||||
PopupType string `gorm:"column:popup_type;type:varchar(20);not null;default:'announcement'" json:"popup_type"`
|
||||
Pages StringJSONBArray `gorm:"column:pages;type:jsonb;not null;default:'[]'" json:"pages"`
|
||||
ShopIDs StringJSONBArray `gorm:"column:shop_ids;type:jsonb;not null;default:'[]'" json:"shop_ids"`
|
||||
DeviceTypes StringJSONBArray `gorm:"column:device_types;type:jsonb;not null;default:'[]'" json:"device_types"`
|
||||
|
||||
@@ -8,26 +8,26 @@ import (
|
||||
|
||||
// OrderPackageInvalidateTask 订单套餐批量失效任务模型
|
||||
type OrderPackageInvalidateTask struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex;not null;comment:任务编号" json:"task_no"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)" json:"status"`
|
||||
TotalCount int `gorm:"column:total_count;type:int;not null;default:0;comment:总行数" json:"total_count"`
|
||||
SuccessCount int `gorm:"column:success_count;type:int;not null;default:0;comment:成功数" json:"success_count"`
|
||||
FailCount int `gorm:"column:fail_count;type:int;not null;default:0;comment:失败数" json:"fail_count"`
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex;not null;comment:任务编号" json:"task_no"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)" json:"status"`
|
||||
TotalCount int `gorm:"column:total_count;type:int;not null;default:0;comment:总行数" json:"total_count"`
|
||||
SuccessCount int `gorm:"column:success_count;type:int;not null;default:0;comment:成功数" json:"success_count"`
|
||||
FailCount int `gorm:"column:fail_count;type:int;not null;default:0;comment:失败数" json:"fail_count"`
|
||||
FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;not null;default:'[]';comment:失败记录详情(jsonb)" json:"failed_items"`
|
||||
FileName string `gorm:"column:file_name;type:varchar(255);not null;default:'';comment:原始文件名" json:"file_name"`
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);not null;default:'';comment:CSV 文件对象存储路径" json:"storage_key"`
|
||||
VoucherKeys StringJSONBArray `gorm:"column:voucher_keys;type:jsonb;not null;default:'[]';comment:支付凭证对象存储Key列表(最多5个)" json:"voucher_keys"`
|
||||
Remark string `gorm:"column:remark;type:text;not null;default:'';comment:运营备注(创建时填写,只读)" json:"remark"`
|
||||
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"`
|
||||
ErrorMessage string `gorm:"column:error_message;type:text;not null;default:'';comment:任务级错误信息" json:"error_message"`
|
||||
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
|
||||
Creator uint `gorm:"column:creator;not null;default:0;comment:创建人ID" json:"creator"`
|
||||
Updater uint `gorm:"column:updater;not null;default:0;comment:更新人ID" 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:"deleted_at,omitempty"`
|
||||
FileName string `gorm:"column:file_name;type:varchar(255);not null;default:'';comment:原始文件名" json:"file_name"`
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);not null;default:'';comment:CSV 文件对象存储路径" json:"storage_key"`
|
||||
VoucherKeys StringJSONBArray `gorm:"column:voucher_keys;type:jsonb;not null;default:'[]';comment:支付凭证对象存储Key列表(最多5个)" json:"voucher_keys"`
|
||||
Remark string `gorm:"column:remark;type:text;not null;default:'';comment:运营备注(创建时填写,只读)" json:"remark"`
|
||||
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"`
|
||||
ErrorMessage string `gorm:"column:error_message;type:text;not null;default:'';comment:任务级错误信息" json:"error_message"`
|
||||
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
|
||||
Creator uint `gorm:"column:creator;not null;default:0;comment:创建人ID" json:"creator"`
|
||||
Updater uint `gorm:"column:updater;not null;default:0;comment:更新人ID" 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:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
// 说明:记录微信用户使用过哪些设备号/IMEI,一个设备号可以被多个微信用户使用过
|
||||
type PersonalCustomerDevice struct {
|
||||
gorm.Model
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
|
||||
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);not null;comment:设备虚拟号/IMEI" json:"virtual_no"`
|
||||
BindAt time.Time `gorm:"column:bind_at;type:timestamp;not null;comment:绑定时间" json:"bind_at"`
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
|
||||
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);not null;comment:设备虚拟号/IMEI" json:"virtual_no"`
|
||||
BindAt time.Time `gorm:"column:bind_at;type:timestamp;not null;comment:绑定时间" json:"bind_at"`
|
||||
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;comment:最后使用时间" json:"last_used_at"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
// 说明:记录微信用户使用过哪些ICCID,一个ICCID可以被多个微信用户使用过
|
||||
type PersonalCustomerICCID struct {
|
||||
gorm.Model
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
|
||||
ICCID string `gorm:"column:iccid;type:varchar(20);not null;comment:ICCID(20位数字)" json:"iccid"`
|
||||
BindAt time.Time `gorm:"column:bind_at;type:timestamp;not null;comment:绑定时间" json:"bind_at"`
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
|
||||
ICCID string `gorm:"column:iccid;type:varchar(20);not null;comment:ICCID(20位数字)" json:"iccid"`
|
||||
BindAt time.Time `gorm:"column:bind_at;type:timestamp;not null;comment:绑定时间" json:"bind_at"`
|
||||
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;comment:最后使用时间" json:"last_used_at"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
ICCID19 string `gorm:"column:iccid_19;type:varchar(19);comment:ICCID前19位(用于19位运营商精确查询)" json:"iccid_19,omitempty"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
ICCID19 string `gorm:"column:iccid_19;type:varchar(19);comment:ICCID前19位(用于19位运营商精确查询)" json:"iccid_19,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
// 说明:一个微信用户可以绑定多个手机号
|
||||
type PersonalCustomerPhone struct {
|
||||
gorm.Model
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
|
||||
Phone string `gorm:"column:phone;type:varchar(20);not null;comment:手机号" json:"phone"`
|
||||
IsPrimary bool `gorm:"column:is_primary;type:boolean;not null;default:false;comment:是否主手机号" json:"is_primary"`
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
|
||||
Phone string `gorm:"column:phone;type:varchar(20);not null;comment:手机号" json:"phone"`
|
||||
IsPrimary bool `gorm:"column:is_primary;type:boolean;not null;default:false;comment:是否主手机号" json:"is_primary"`
|
||||
VerifiedAt *time.Time `gorm:"column:verified_at;type:timestamp;comment:验证通过时间" json:"verified_at"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -22,6 +22,9 @@ type PhoneAssetAssociation struct {
|
||||
InvalidationMethod string `gorm:"column:invalidation_method;type:varchar(30);not null;default:'';comment:失效方式" json:"invalidation_method"`
|
||||
InvalidationReason string `gorm:"column:invalidation_reason;type:varchar(500);not null;default:'';comment:失效原因" json:"invalidation_reason"`
|
||||
Invalidator uint `gorm:"column:invalidator;type:bigint;not null;default:0;comment:失效操作人账号ID" json:"invalidator"`
|
||||
// InvalidatorNameSnapshot 是解绑当时的后台账号名称快照,只读投影字段。
|
||||
// 仅 status = 0 时写入;有效关系为空,MUST NOT 以创建人或更新时间填充(账号改名后无法由账号ID反推名称)。
|
||||
InvalidatorNameSnapshot string `gorm:"column:invalidator_name_snapshot;type:varchar(100);not null;default:'';comment:最近解绑人名称快照,仅已失效关系写入" json:"invalidator_name_snapshot"`
|
||||
}
|
||||
|
||||
// TableName 指定手机号—资产关联表名。
|
||||
|
||||
@@ -158,31 +158,50 @@ func (PollingManualTriggerLog) TableName() string {
|
||||
// PollingPriorityItem 是「某卡某轮询任务类型被加急」的持久化事实。
|
||||
//
|
||||
// 活动项粒度为 (card_id, task_type),由部分唯一索引保证至多一条:Status 取
|
||||
// pending-待执行 / processing-执行中 时为活动项,取 completed-已完成 / failed-失败出队 时为终态,
|
||||
// pending-待执行 / processing-执行中 时为活动项,取
|
||||
// completed-已完成 / failed-失败出队 / closed-人工关闭 / expired-有效期到期 时为终态,
|
||||
// 终态行不占用唯一键。TriggerType 是首次触发场景,TriggerTypes 是去重后的全部触发场景集合;
|
||||
// ClaimedAt 是认领租约基准,AttemptCount 只在真正发起执行后累加,Result 与 FailureReason
|
||||
// 只承载可安全展示的结果与原因,ShopIDSnapshot 是消费时点解析的卡所属店铺快照(平台卡为 NULL),
|
||||
// 读侧数据范围按它下推,卡改归属不追溯已入队的加急事实。
|
||||
//
|
||||
// 资产身份(AssetType/AssetID)与设备号、代理归属同样在入队时冻结:卡绑定有效设备时资产为
|
||||
// 该设备,独立卡为卡自身;AgentShopIDSnapshot 是卡所属店铺的直接上级代理店铺(一级代理与平台卡为空)。
|
||||
// AttemptLimit 取代码常量,既有行随数据库默认值获得上限。StartedAt/FinishedAt 只在真正发起执行
|
||||
// 后写入,NextRunAt 是下一次计划执行时间(未排期为 NULL,读侧 MUST NOT 据此判定已过期),
|
||||
// DequeuedAt 在任一终态写入,PriorityEffectiveFrom 是有效期起算时间(新增行取入队时间)。
|
||||
// IntegrationLogID 只保存集成交互日志标识,不复制凭证或响应原文。
|
||||
// 本模型不声明任何关联标签与数据库外键,关联一律只存 ID 并由应用层显式查询。
|
||||
type PollingPriorityItem struct {
|
||||
gorm.Model
|
||||
CardID uint `gorm:"column:card_id;type:bigint;not null" json:"card_id"`
|
||||
TaskType string `gorm:"column:task_type;type:varchar(50);not null" json:"task_type"`
|
||||
Status string `gorm:"column:status;type:varchar(16);not null;default:'pending'" json:"status"`
|
||||
TriggerType string `gorm:"column:trigger_type;type:varchar(30);not null" json:"trigger_type"`
|
||||
TriggerTypes string `gorm:"column:trigger_types;type:varchar(255);not null;default:''" json:"trigger_types"`
|
||||
TriggerCount int `gorm:"column:trigger_count;type:int;not null;default:1" json:"trigger_count"`
|
||||
LastTriggeredAt time.Time `gorm:"column:last_triggered_at;type:timestamptz;not null;default:now()" json:"last_triggered_at"`
|
||||
SourceOrderID *uint `gorm:"column:source_order_id;type:bigint" json:"source_order_id,omitempty"`
|
||||
SourcePackageUsageID *uint `gorm:"column:source_package_usage_id;type:bigint" json:"source_package_usage_id,omitempty"`
|
||||
AttemptCount int `gorm:"column:attempt_count;type:int;not null;default:0" json:"attempt_count"`
|
||||
ClaimedAt *time.Time `gorm:"column:claimed_at;type:timestamptz" json:"claimed_at,omitempty"`
|
||||
ManualReason string `gorm:"column:manual_reason;type:varchar(500);not null;default:''" json:"manual_reason"`
|
||||
ManualOperatorID uint `gorm:"column:manual_operator_id;type:bigint;not null;default:0" json:"manual_operator_id"`
|
||||
ManualOperatorName string `gorm:"column:manual_operator_name;type:varchar(100);not null;default:''" json:"manual_operator_name"`
|
||||
ShopIDSnapshot *uint `gorm:"column:shop_id_snapshot;type:bigint" json:"shop_id_snapshot,omitempty"`
|
||||
Result string `gorm:"column:result;type:varchar(16);not null;default:''" json:"result"`
|
||||
FailureReason string `gorm:"column:failure_reason;type:varchar(500);not null;default:''" json:"failure_reason"`
|
||||
CardID uint `gorm:"column:card_id;type:bigint;not null" json:"card_id"`
|
||||
TaskType string `gorm:"column:task_type;type:varchar(50);not null" json:"task_type"`
|
||||
Status string `gorm:"column:status;type:varchar(16);not null;default:'pending'" json:"status"`
|
||||
TriggerType string `gorm:"column:trigger_type;type:varchar(30);not null" json:"trigger_type"`
|
||||
TriggerTypes string `gorm:"column:trigger_types;type:varchar(255);not null;default:''" json:"trigger_types"`
|
||||
TriggerCount int `gorm:"column:trigger_count;type:int;not null;default:1" json:"trigger_count"`
|
||||
LastTriggeredAt time.Time `gorm:"column:last_triggered_at;type:timestamptz;not null;default:now()" json:"last_triggered_at"`
|
||||
SourceOrderID *uint `gorm:"column:source_order_id;type:bigint" json:"source_order_id,omitempty"`
|
||||
SourcePackageUsageID *uint `gorm:"column:source_package_usage_id;type:bigint" json:"source_package_usage_id,omitempty"`
|
||||
AttemptCount int `gorm:"column:attempt_count;type:int;not null;default:0" json:"attempt_count"`
|
||||
AttemptLimit int `gorm:"column:attempt_limit;type:int;not null;default:3" json:"attempt_limit"`
|
||||
ClaimedAt *time.Time `gorm:"column:claimed_at;type:timestamptz" json:"claimed_at,omitempty"`
|
||||
StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `gorm:"column:finished_at;type:timestamptz" json:"finished_at,omitempty"`
|
||||
NextRunAt *time.Time `gorm:"column:next_run_at;type:timestamptz" json:"next_run_at,omitempty"`
|
||||
DequeuedAt *time.Time `gorm:"column:dequeued_at;type:timestamptz" json:"dequeued_at,omitempty"`
|
||||
IntegrationLogID string `gorm:"column:integration_log_id;type:varchar(64);not null;default:''" json:"integration_log_id"`
|
||||
PriorityEffectiveFrom *time.Time `gorm:"column:priority_effective_from;type:timestamptz" json:"priority_effective_from,omitempty"`
|
||||
ManualReason string `gorm:"column:manual_reason;type:varchar(500);not null;default:''" json:"manual_reason"`
|
||||
ManualOperatorID uint `gorm:"column:manual_operator_id;type:bigint;not null;default:0" json:"manual_operator_id"`
|
||||
ManualOperatorName string `gorm:"column:manual_operator_name;type:varchar(100);not null;default:''" json:"manual_operator_name"`
|
||||
ShopIDSnapshot *uint `gorm:"column:shop_id_snapshot;type:bigint" json:"shop_id_snapshot,omitempty"`
|
||||
AgentShopIDSnapshot *uint `gorm:"column:agent_shop_id_snapshot;type:bigint" json:"agent_shop_id_snapshot,omitempty"`
|
||||
AssetType string `gorm:"column:asset_type;type:varchar(20);not null;default:''" json:"asset_type"`
|
||||
AssetID *uint `gorm:"column:asset_id;type:bigint" json:"asset_id,omitempty"`
|
||||
DeviceNoSnapshot string `gorm:"column:device_no_snapshot;type:varchar(100);not null;default:''" json:"device_no_snapshot"`
|
||||
Result string `gorm:"column:result;type:varchar(16);not null;default:''" json:"result"`
|
||||
FailureReason string `gorm:"column:failure_reason;type:varchar(500);not null;default:''" json:"failure_reason"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -48,6 +48,13 @@ type RefundRequest struct {
|
||||
FrozenActualReceivedAmount int64 `gorm:"column:frozen_actual_received_amount;type:bigint;not null;default:0;comment:冻结的权威实收金额(分)" json:"frozen_actual_received_amount"`
|
||||
CustomerAccountInfo string `gorm:"column:customer_account_info;type:text;not null;default:'';comment:客户收款信息自由文本快照" json:"customer_account_info"`
|
||||
|
||||
// 结算标识:来源支付事实在创建或重提时冻结,线下处理流水号由财务补录(写审计),二者语义不同。
|
||||
SourcePaymentNo string `gorm:"column:source_payment_no;type:varchar(64);not null;default:'';comment:冻结的来源支付单号,线下订单为空" json:"source_payment_no"`
|
||||
OriginalChannelTradeNo string `gorm:"column:original_channel_trade_no;type:varchar(100);not null;default:'';comment:冻结的原支付渠道交易流水号(列宽与 tb_payment.third_party_trade_no 一致),线下订单为空" json:"original_channel_trade_no"`
|
||||
OfflineSettlementNo string `gorm:"column:offline_settlement_no;type:varchar(128);not null;default:'';comment:线下退款处理流水号或凭证编号,由授权账号补录或更正" json:"offline_settlement_no"`
|
||||
OfflineSettledAt *time.Time `gorm:"column:offline_settled_at;comment:线下退款处理流水号最近登记或更正时间" json:"offline_settled_at,omitempty"`
|
||||
OfflineSettledBy uint `gorm:"column:offline_settled_by;not null;default:0;comment:线下退款处理流水号最近登记或更正的操作账号ID,0-未登记" json:"offline_settled_by"`
|
||||
|
||||
// 审批尝试引用,仅用于列表与详情展示
|
||||
LatestAttemptID uint `gorm:"column:latest_attempt_id;type:bigint;not null;default:0;comment:最新审批尝试记录ID,仅用于展示" json:"latest_attempt_id"`
|
||||
LatestApprovalInstanceID uint `gorm:"column:latest_approval_instance_id;type:bigint;not null;default:0;comment:最新通用审批实例ID,仅用于展示" json:"latest_approval_instance_id"`
|
||||
@@ -81,20 +88,22 @@ func (RefundRequest) TableName() string {
|
||||
// 每次提交或重提新增一条不可变记录,冻结当次方式、金额、冻结实收、原因、客户收款信息、
|
||||
// 凭证与套餐使用快照;主键同时作为通用审批业务标识,使同一退款单每次提交持有独立审批实例。
|
||||
type RefundRequestAttempt struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
RefundID uint `gorm:"column:refund_id;not null;index" json:"refund_id"`
|
||||
AttemptNo int `gorm:"column:attempt_no;type:int;not null" json:"attempt_no"`
|
||||
Method string `gorm:"column:method;type:varchar(20);not null" json:"method"`
|
||||
RefundAmount int64 `gorm:"column:refund_amount;type:bigint;not null" json:"refund_amount"`
|
||||
FrozenActualReceivedAmount int64 `gorm:"column:frozen_actual_received_amount;type:bigint;not null" json:"frozen_actual_received_amount"`
|
||||
RefundReason string `gorm:"column:refund_reason;type:text;not null;default:''" json:"refund_reason"`
|
||||
CustomerAccountInfo string `gorm:"column:customer_account_info;type:text;not null;default:''" json:"customer_account_info"`
|
||||
CustomerVoucherKeys StringJSONBArray `gorm:"column:customer_voucher_keys;type:jsonb;not null" json:"customer_voucher_keys"`
|
||||
PackageUsageSnapshot datatypes.JSON `gorm:"column:package_usage_snapshot;type:jsonb;not null" json:"package_usage_snapshot"`
|
||||
ChannelRefundRequestNo string `gorm:"column:channel_refund_request_no;type:varchar(64);not null;default:''" json:"channel_refund_request_no"`
|
||||
SubmittedByAccountID uint `gorm:"column:submitted_by_account_id;not null" json:"submitted_by_account_id"`
|
||||
ApprovalInstanceID *uint `gorm:"column:approval_instance_id" json:"approval_instance_id,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
RefundID uint `gorm:"column:refund_id;not null;index" json:"refund_id"`
|
||||
AttemptNo int `gorm:"column:attempt_no;type:int;not null" json:"attempt_no"`
|
||||
Method string `gorm:"column:method;type:varchar(20);not null" json:"method"`
|
||||
RefundAmount int64 `gorm:"column:refund_amount;type:bigint;not null" json:"refund_amount"`
|
||||
FrozenActualReceivedAmount int64 `gorm:"column:frozen_actual_received_amount;type:bigint;not null" json:"frozen_actual_received_amount"`
|
||||
RefundReason string `gorm:"column:refund_reason;type:text;not null;default:''" json:"refund_reason"`
|
||||
// Remark 是本次提交的申请人备注,逐次冻结;与退款主表 remark(审批备注)语义不同,不得复用。
|
||||
Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"`
|
||||
CustomerAccountInfo string `gorm:"column:customer_account_info;type:text;not null;default:''" json:"customer_account_info"`
|
||||
CustomerVoucherKeys StringJSONBArray `gorm:"column:customer_voucher_keys;type:jsonb;not null" json:"customer_voucher_keys"`
|
||||
PackageUsageSnapshot datatypes.JSON `gorm:"column:package_usage_snapshot;type:jsonb;not null" json:"package_usage_snapshot"`
|
||||
ChannelRefundRequestNo string `gorm:"column:channel_refund_request_no;type:varchar(64);not null;default:''" json:"channel_refund_request_no"`
|
||||
SubmittedByAccountID uint `gorm:"column:submitted_by_account_id;not null" json:"submitted_by_account_id"`
|
||||
ApprovalInstanceID *uint `gorm:"column:approval_instance_id" json:"approval_instance_id,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定审批尝试记录表名。
|
||||
|
||||
122
internal/query/asset/priority_polling.go
Normal file
122
internal/query/asset/priority_polling.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
pollingsvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ResolvePriorityPolling 读取资产或卡的优先轮询状态只读投影,落在既有资产解析端点内。
|
||||
//
|
||||
// 口径:卡取自身,设备取其在用绑定卡;存在待执行或执行中的优先项时「是否处于优先轮询中」为真,
|
||||
// 最近触发场景取该资产最近一条事实的触发类型,最近轮询结果取该条事实的结局,
|
||||
// 最近轮询时间取最近一次执行时间(未执行过时退回最近触发时间),
|
||||
// 失败原因取最近一条非空的可安全展示原因。无任何优先轮询事实时以否或空值返回,
|
||||
// MUST NOT 用普通轮询结果填充。
|
||||
//
|
||||
// 只读:只做查询,不创建、不合并、不推进、不出队任何优先项,也不触发上游调用。
|
||||
// 数据范围:资产本身的可读性由资产解析用例保证;此处再按优先项事实的店铺快照下推一次,
|
||||
// 读侧鉴权不通过(企业账号或范围缺失)时返回空投影,不因投影阻断资产详情,也不泄露范围外事实。
|
||||
func (q *ExchangeTraceQuery) ResolvePriorityPolling(ctx context.Context, assetType string, assetID uint) (*dto.AssetPriorityPollingProjection, error) {
|
||||
projection := &dto.AssetPriorityPollingProjection{}
|
||||
if q == nil || q.db == nil || assetID == 0 {
|
||||
return projection, nil
|
||||
}
|
||||
cardIDs, err := q.assetCardIDs(ctx, assetType, assetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cardIDs) == 0 {
|
||||
return projection, nil
|
||||
}
|
||||
scope, scopeErr := pollingsvc.PriorityPollingReadScope(ctx)
|
||||
if scopeErr != nil {
|
||||
return projection, nil
|
||||
}
|
||||
summaries, err := postgres.NewPollingPriorityItemStore(q.db).ProjectPriorityPolling(ctx, cardIDs, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return projectPriorityPolling(summaries), nil
|
||||
}
|
||||
|
||||
// assetCardIDs 返回该资产对应的轮询对象卡集合:卡为自身,设备为绑定状态有效的在用卡。
|
||||
func (q *ExchangeTraceQuery) assetCardIDs(ctx context.Context, assetType string, assetID uint) ([]uint, error) {
|
||||
switch assetType {
|
||||
case constants.AssetResolveTypeCard:
|
||||
return []uint{assetID}, nil
|
||||
case constants.AssetTypeDevice:
|
||||
var cardIDs []uint
|
||||
if err := q.db.WithContext(ctx).
|
||||
Model(&model.DeviceSimBinding{}).
|
||||
Where("device_id = ? AND bind_status = ?", assetID, constants.BindStatusBound).
|
||||
Order("slot_position ASC").
|
||||
Pluck("iot_card_id", &cardIDs).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备绑定的卡失败")
|
||||
}
|
||||
return cardIDs, nil
|
||||
default:
|
||||
// 其他资产类型没有轮询对象,投影按无事实返回。
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// projectPriorityPolling 把逐卡摘要聚合为资产级投影。
|
||||
// 多卡资产以最近创建的一条事实决定最近触发场景、结果与轮询时间;
|
||||
// 失败原因单独取最近一条非空原因,避免最近一条事实无原因时丢失可展示的失败说明。
|
||||
func projectPriorityPolling(summaries map[uint]postgres.PriorityPollingCardProjection) *dto.AssetPriorityPollingProjection {
|
||||
projection := &dto.AssetPriorityPollingProjection{}
|
||||
var latest *model.PollingPriorityItem
|
||||
var latestFailed *model.PollingPriorityItem
|
||||
for _, summary := range summaries {
|
||||
if summary.HasActive {
|
||||
projection.InPriorityPolling = true
|
||||
}
|
||||
item := summary.Latest
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if latest == nil || item.ID > latest.ID {
|
||||
latest = item
|
||||
}
|
||||
if item.FailureReason != "" && (latestFailed == nil || item.ID > latestFailed.ID) {
|
||||
latestFailed = item
|
||||
}
|
||||
}
|
||||
if latest == nil {
|
||||
return projection
|
||||
}
|
||||
projection.LastTriggerType = latest.TriggerType
|
||||
projection.LastTriggerName = constants.PollingPriorityTriggerName(latest.TriggerType)
|
||||
projection.LastResult = constants.PollingPriorityStatusOutcome(latest.Status)
|
||||
projection.LastResultName = constants.PollingPriorityOutcomeName(projection.LastResult)
|
||||
projection.LastPollingAt = priorityPollingLastPolledAt(latest)
|
||||
if latest.FailureReason != "" {
|
||||
projection.FailureReason = latest.FailureReason
|
||||
} else if latestFailed != nil {
|
||||
projection.FailureReason = latestFailed.FailureReason
|
||||
}
|
||||
return projection
|
||||
}
|
||||
|
||||
// priorityPollingLastPolledAt 返回最近轮询时间:优先取执行时间,未执行过时取最近触发时间。
|
||||
func priorityPollingLastPolledAt(item *model.PollingPriorityItem) *time.Time {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case item.FinishedAt != nil:
|
||||
return item.FinishedAt
|
||||
case item.StartedAt != nil:
|
||||
return item.StartedAt
|
||||
default:
|
||||
triggeredAt := item.LastTriggeredAt
|
||||
return &triggeredAt
|
||||
}
|
||||
}
|
||||
167
internal/query/carrierthreshold/query.go
Normal file
167
internal/query/carrierthreshold/query.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Package carrierthreshold 提供运营商通道阈值命中事实的只读查询投影。
|
||||
//
|
||||
// 读取不经聚合根、不修改任何状态:命中事实是达量判定与周期恢复写入的冻结证据,
|
||||
// 本包只按既有权限与卡数据范围把它投影为可查询的列表,绝不回填或改写历史命中。
|
||||
// 时间筛选只复用统一严格解析器(pkg/utils.ParseTimeRange):判定时间按闭区间筛选,
|
||||
// 计费周期起点按同一解析器的瞬时(退化为闭区间两端相同)精确匹配。
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
)
|
||||
|
||||
// periodStartFieldName 是计费周期起点参数的字段名,用于按本字段重述统一格式错误。
|
||||
const periodStartFieldName = "period_start"
|
||||
|
||||
// Query 通道阈值命中记录只读查询。
|
||||
type Query struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewQuery 创建通道阈值命中记录查询。
|
||||
func NewQuery(db *gorm.DB) *Query {
|
||||
return &Query{db: db}
|
||||
}
|
||||
|
||||
// List 分页查询通道阈值命中记录,按判定时间倒序、同刻按 ID 倒序。
|
||||
//
|
||||
// 权限与数据范围都在本方法内判定:仅超级管理员与平台账号可读(ENG-AUTHZ-001),
|
||||
// 并按卡的数据范围下推过滤,范围外卡的命中记录与不存在的记录同样不可见。
|
||||
func (q *Query) List(ctx context.Context, req dto.CarrierThresholdHitListRequest) (*dto.CarrierThresholdHitPageResult, error) {
|
||||
if q == nil || q.db == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "通道阈值命中记录查询未配置")
|
||||
}
|
||||
if err := requirePlatformOperator(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, end, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
periodStart, err := parsePeriodStart(req.PeriodStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 每次终结都重建查询:Count 与 Find 共用同一个链式对象会互相污染条件。
|
||||
base := func() *gorm.DB {
|
||||
query := applyCardDataScope(ctx, q.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}))
|
||||
if req.CarrierID != nil {
|
||||
query = query.Where("carrier_id = ?", *req.CarrierID)
|
||||
}
|
||||
if req.CardID != nil {
|
||||
query = query.Where("card_id = ?", *req.CardID)
|
||||
}
|
||||
if periodStart != nil {
|
||||
query = query.Where("period_start = ?", *periodStart)
|
||||
}
|
||||
if start != nil {
|
||||
query = query.Where("judged_at >= ?", *start)
|
||||
}
|
||||
if end != nil {
|
||||
query = query.Where("judged_at <= ?", *end)
|
||||
}
|
||||
return query
|
||||
}
|
||||
var total int64
|
||||
if err := base().Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计通道阈值命中记录失败")
|
||||
}
|
||||
page, pageSize := normalizePage(req.Page, req.PageSize)
|
||||
var locks []model.CarrierTrafficThresholdLock
|
||||
if err := base().Order("judged_at DESC, id DESC").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).Find(&locks).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通道阈值命中记录失败")
|
||||
}
|
||||
list := make([]*dto.CarrierThresholdHitItem, 0, len(locks))
|
||||
for index := range locks {
|
||||
list = append(list, toHitItem(&locks[index]))
|
||||
}
|
||||
return &dto.CarrierThresholdHitPageResult{List: list, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
// toHitItem 把命中事实投影为响应项。
|
||||
//
|
||||
// 历史行的空读数与空阈值原样保留为空指针,由读侧按「无」展示,绝不回填零值或当前配置;
|
||||
// 凭证与上游响应原文不落在本表,因此响应天然只含可安全展示的事实。
|
||||
func toHitItem(lock *model.CarrierTrafficThresholdLock) *dto.CarrierThresholdHitItem {
|
||||
return &dto.CarrierThresholdHitItem{
|
||||
ID: lock.ID,
|
||||
CarrierID: lock.CarrierID,
|
||||
CardID: lock.CardID,
|
||||
PeriodStart: lock.PeriodStart,
|
||||
HitTrafficMB: lock.HitTrafficMB,
|
||||
HitThresholdValue: lock.HitThresholdValue,
|
||||
HitThresholdUnit: lock.HitThresholdUnit,
|
||||
TriggerSource: lock.TriggerSource,
|
||||
JudgedAt: lock.JudgedAt,
|
||||
Status: lock.Status,
|
||||
UnlockedAt: lock.UnlockedAt,
|
||||
ResumeResult: lock.ResumeResult,
|
||||
FailureReason: lock.FailureReason,
|
||||
CreatedAt: lock.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// requirePlatformOperator 要求调用者为超级管理员或平台账号。
|
||||
// 其他账号(代理、企业与个人客户)统一返回与资源不可见一致的拒绝,不产生可枚举差异。
|
||||
func requirePlatformOperator(ctx context.Context) error {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyCardDataScope 按卡的数据范围下推过滤。
|
||||
//
|
||||
// 命中事实以 card_id 关联卡事实,卡的店铺归属取自 tb_iot_card.shop_id:
|
||||
// 范围为空表示不受限(超级管理员与平台账号当前无实际过滤,保留显式下推以冻结语义),
|
||||
// 范围外卡的命中记录对受限账号与不存在的记录同样不可见。
|
||||
func applyCardDataScope(ctx context.Context, query *gorm.DB) *gorm.DB {
|
||||
shopIDs := middleware.GetSubordinateShopIDs(ctx)
|
||||
if len(shopIDs) == 0 {
|
||||
return query
|
||||
}
|
||||
return query.Where("card_id IN (SELECT id FROM tb_iot_card WHERE shop_id IN ? AND deleted_at IS NULL)", shopIDs)
|
||||
}
|
||||
|
||||
// parsePeriodStart 解析计费周期起点筛选值;未传与空串等价。
|
||||
//
|
||||
// 周期起点是单个瞬时,这里把同一个值同时作为闭区间两端交给统一严格解析器:格式接受范围、
|
||||
// 归一 UTC 与闭区间语义因此与判定时间筛选完全一致,不复制第二套时间解析。
|
||||
// 开始等于结束恒成立,解析失败只可能是格式错误,故按本字段名重述统一格式错误消息,
|
||||
// 避免把计费周期参数报成 start_time。
|
||||
func parsePeriodStart(value string) (*time.Time, error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, _, err := utils.ParseTimeRange(value, value)
|
||||
if err != nil {
|
||||
return nil, utils.TimeFilterFormatError(periodStartFieldName)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// normalizePage 归一化分页参数并执行既有上限(ENG-PAGE-001)。
|
||||
func normalizePage(page, pageSize int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = constants.DefaultPage
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
if pageSize > constants.MaxPageSize {
|
||||
pageSize = constants.MaxPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
@@ -180,8 +180,17 @@ func (q *Query) WithdrawalDetail(
|
||||
ID: attempt.ID, AttemptNo: attempt.AttemptNo, Amount: attempt.Amount,
|
||||
Fee: attempt.Fee, FeeRate: attempt.FeeRate, ActualAmount: attempt.ActualAmount,
|
||||
WithdrawalMethod: attempt.WithdrawalMethod, SubmittedByID: attempt.SubmittedByAccountID,
|
||||
ApprovalInstanceID: instanceID,
|
||||
CreatedAt: attempt.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
ApprovalInstanceID: instanceID,
|
||||
QualificationVersionID: attempt.QualificationVersionID,
|
||||
QualificationPassed: attempt.QualificationPassed,
|
||||
QualificationFailureReason: attempt.QualificationFailureReason,
|
||||
CreatedAt: attempt.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if attempt.QualificationFailureReason != "" {
|
||||
item.QualificationFailureName = constants.GetWithdrawalQualificationFailureReasonName(attempt.QualificationFailureReason)
|
||||
}
|
||||
if attempt.QualificationCheckedAt != nil {
|
||||
item.QualificationCheckedAt = attempt.QualificationCheckedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if status, ok := instanceStatuses[instanceID]; ok {
|
||||
item.ApprovalStatus = status
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
|
||||
@@ -15,11 +17,27 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
)
|
||||
|
||||
// billDateLayout 是账单筛选使用的自然日格式。
|
||||
// billDateLayout 是核销申请列表筛选使用的自然日格式。
|
||||
// 账单列表与统计已改用统一时间筛选(start_time/end_time 严格解析),不再使用本格式;
|
||||
// 核销申请列表端点不在本次统一时间筛选的受影响端点表内,按 As-Is 保留其既有日期语义。
|
||||
const billDateLayout = "2006-01-02"
|
||||
|
||||
// parseBillDate 解析自然日筛选值;空值表示不筛选。仅供核销申请列表使用。
|
||||
func parseBillDate(value string) (*time.Time, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation(billDateLayout, trimmed, time.Local)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "账单筛选日期格式必须为 YYYY-MM-DD")
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
// BillQuery 查询员工代收款账单列表、统计与详情。
|
||||
// 列表与统计共用 applyBillScope 生成的同一个 *gorm.DB,禁止两处各写一套筛选。
|
||||
type BillQuery struct {
|
||||
@@ -127,11 +145,143 @@ func (q *BillQuery) Detail(ctx context.Context, id uint) (*dto.EmployeeCollectio
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operations, err := q.billOperations(ctx, bill.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceOrder := q.billSourceSnapshot(ctx, &bill)
|
||||
return &dto.EmployeeCollectionBillDetailResponse{
|
||||
Bill: billResponse, Refunds: refunds, Allocations: allocations, Applications: applications,
|
||||
Operations: operations, SourceOrder: sourceOrder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// billOperations 读取该账单维度的既有审计事实投影。
|
||||
//
|
||||
// 审计事实按资源读取而非按平台事件列表读取:账单详情对欠款人本人可见,
|
||||
// 因此不得复用仅超级管理员与平台账号可用的平台审计查询(那会让本人详情的操作日志恒为空)。
|
||||
// 只投影动作、操作账号名称快照、操作时间与白名单化的前后值摘要,不返回凭证内容与完整收款原文。
|
||||
func (q *BillQuery) billOperations(ctx context.Context, billID uint) ([]*dto.EmployeeCollectionBillOperationLogResponse, error) {
|
||||
resourceID := strconv.FormatUint(uint64(billID), 10)
|
||||
var rows []billOperationRow
|
||||
if err := q.db.WithContext(ctx).Table("tb_audit_event AS event").
|
||||
Select("event.id, event.action_code, event.action_name, event.summary, event.actor_name, "+
|
||||
"event.occurred_at, event.result, resource.relation, resource.before_data, resource.after_data").
|
||||
Joins("JOIN tb_audit_event_resource AS resource ON resource.audit_event_id = event.id").
|
||||
Where("resource.resource_type = ? AND resource.resource_id = ?",
|
||||
constants.AuditResourceEmployeeCollectionBill, resourceID).
|
||||
Order("event.occurred_at ASC, event.id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单操作日志失败")
|
||||
}
|
||||
result := make([]*dto.EmployeeCollectionBillOperationLogResponse, 0, len(rows))
|
||||
seen := make(map[uint]int, len(rows))
|
||||
for index := range rows {
|
||||
row := rows[index]
|
||||
// 同一事件可能同时以主要资源与关联资源引用同一张账单:按事件去重并优先保留主要资源的前后值。
|
||||
if position, exists := seen[row.EventID]; exists && row.Relation != "primary" {
|
||||
continue
|
||||
} else if exists {
|
||||
result[position] = billOperationResponse(row)
|
||||
continue
|
||||
}
|
||||
seen[row.EventID] = len(result)
|
||||
result = append(result, billOperationResponse(row))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// billOperationRow 是账单操作日志的审计行扫描结果。
|
||||
type billOperationRow struct {
|
||||
EventID uint `gorm:"column:id"`
|
||||
ActionCode string `gorm:"column:action_code"`
|
||||
ActionName string `gorm:"column:action_name"`
|
||||
Summary string `gorm:"column:summary"`
|
||||
ActorName string `gorm:"column:actor_name"`
|
||||
OccurredAt time.Time `gorm:"column:occurred_at"`
|
||||
Result string `gorm:"column:result"`
|
||||
Relation string `gorm:"column:relation"`
|
||||
BeforeData datatypes.JSON `gorm:"column:before_data"`
|
||||
AfterData datatypes.JSON `gorm:"column:after_data"`
|
||||
}
|
||||
|
||||
// billOperationResponse 把审计行投影为对外操作日志。
|
||||
func billOperationResponse(row billOperationRow) *dto.EmployeeCollectionBillOperationLogResponse {
|
||||
return &dto.EmployeeCollectionBillOperationLogResponse{
|
||||
ActionCode: row.ActionCode, ActionName: row.ActionName, Summary: row.Summary,
|
||||
OperatorName: row.ActorName, OccurredAt: row.OccurredAt, Result: row.Result,
|
||||
BeforeSummary: billOperationSummary(row.BeforeData), AfterSummary: billOperationSummary(row.AfterData),
|
||||
}
|
||||
}
|
||||
|
||||
// billOperationSummaryKeys 是操作日志允许展示的账单字段白名单。
|
||||
// 采用白名单而非黑名单:审计前后值随用例演进会加入新字段,黑名单一旦漏项就会把
|
||||
// 收款凭证或完整收款原文带出接口。
|
||||
var billOperationSummaryKeys = []string{
|
||||
"bill_id", "source_type", "source_no", "status", "receivable_amount", "received_amount",
|
||||
"reserved_amount", "closed_reason", "closed_at", "refund_id", "outcome", "reduced_amount",
|
||||
"application_id", "attempt_no", "amount", "decided_at", "terminal_reason", "payment_method_code",
|
||||
"payment_method_name", "paid_amount",
|
||||
}
|
||||
|
||||
// billOperationSummary 按白名单提取审计前后值摘要;不可解析时返回空摘要而不阻断详情。
|
||||
func billOperationSummary(raw datatypes.JSON) map[string]any {
|
||||
summary := make(map[string]any)
|
||||
if len(raw) == 0 {
|
||||
return summary
|
||||
}
|
||||
decoded := make(map[string]any)
|
||||
if err := sonic.Unmarshal(raw, &decoded); err != nil {
|
||||
return summary
|
||||
}
|
||||
for _, key := range billOperationSummaryKeys {
|
||||
if value, exists := decoded[key]; exists {
|
||||
summary[key] = value
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// billSourceSnapshot 读取来源订单或来源充值记录的只读快照投影。
|
||||
//
|
||||
// 来源记录不可读(已删除或查询失败)或字段缺失时返回空值,且 MUST NOT 阻断详情响应:
|
||||
// 账单本身是权威事实,来源快照只是补充解释,缺来源不应让账单详情不可查看。
|
||||
func (q *BillQuery) billSourceSnapshot(ctx context.Context, bill *model.EmployeeCollectionBill) *dto.EmployeeCollectionBillSourceSnapshotResponse {
|
||||
snapshot := &dto.EmployeeCollectionBillSourceSnapshotResponse{
|
||||
SourceType: bill.SourceType,
|
||||
SourceTypeName: constants.GetEmployeeCollectionSourceTypeName(bill.SourceType),
|
||||
SourceNo: bill.SourceNo,
|
||||
}
|
||||
switch bill.SourceType {
|
||||
case constants.EmployeeCollectionSourceTypeOrder:
|
||||
var order model.Order
|
||||
if err := q.db.WithContext(ctx).
|
||||
Select("id", "order_no", "actual_paid_amount", "asset_identifier", "created_at").
|
||||
Where("id = ?", bill.SourceID).First(&order).Error; err != nil {
|
||||
return snapshot
|
||||
}
|
||||
snapshot.SourceNo = order.OrderNo
|
||||
if order.ActualPaidAmount != nil {
|
||||
snapshot.Amount = *order.ActualPaidAmount
|
||||
}
|
||||
snapshot.AssetIdentifier = order.AssetIdentifier
|
||||
createdAt := order.CreatedAt
|
||||
snapshot.CreatedAt = &createdAt
|
||||
case constants.EmployeeCollectionSourceTypeRecharge:
|
||||
var record model.AgentRechargeRecord
|
||||
if err := q.db.WithContext(ctx).
|
||||
Select("id", "recharge_no", "amount", "created_at").
|
||||
Where("id = ?", bill.SourceID).First(&record).Error; err != nil {
|
||||
return snapshot
|
||||
}
|
||||
snapshot.SourceNo = record.RechargeNo
|
||||
snapshot.Amount = record.Amount
|
||||
createdAt := record.CreatedAt
|
||||
snapshot.CreatedAt = &createdAt
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// applyBillScope 生成同时应用可见性与筛选条件的账单查询。
|
||||
// 列表、统计与关闭后的可见性校验都必须走这一处,避免筛选或数据范围分叉。
|
||||
func (q *BillQuery) applyBillScope(ctx context.Context, filter billFilter) (*gorm.DB, error) {
|
||||
@@ -142,35 +292,44 @@ func (q *BillQuery) applyBillScope(ctx context.Context, filter billFilter) (*gor
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return applyBillFilters(query, filter)
|
||||
return applyBillFilters(ctx, q.db, query, filter)
|
||||
}
|
||||
|
||||
// billFilter 账单列表与统计共用的筛选条件,屏蔽两种请求 DTO 的字段差异。
|
||||
type billFilter struct {
|
||||
BillID *uint
|
||||
SourceType *string
|
||||
SourceNo *string
|
||||
Status *int
|
||||
DebtorAccountID *uint
|
||||
DebtorKeyword *string
|
||||
CustomerKeyword *string
|
||||
CustomerID *uint
|
||||
CreatedFrom *string
|
||||
CreatedTo *string
|
||||
StartTime *string
|
||||
EndTime *string
|
||||
DecidedStart *string
|
||||
DecidedEnd *string
|
||||
}
|
||||
|
||||
// billFilterOfList 投影账单列表请求的筛选字段。
|
||||
func billFilterOfList(request dto.EmployeeCollectionBillListRequest) billFilter {
|
||||
return billFilter{
|
||||
SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
|
||||
DebtorAccountID: request.DebtorAccountID, CustomerID: request.CustomerID,
|
||||
CreatedFrom: request.CreatedFrom, CreatedTo: request.CreatedTo,
|
||||
BillID: request.BillID, SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
|
||||
DebtorAccountID: request.DebtorAccountID, DebtorKeyword: request.DebtorKeyword,
|
||||
CustomerKeyword: request.CustomerKeyword, CustomerID: request.CustomerID,
|
||||
StartTime: request.StartTime, EndTime: request.EndTime,
|
||||
DecidedStart: request.DecidedStartTime, DecidedEnd: request.DecidedEndTime,
|
||||
}
|
||||
}
|
||||
|
||||
// billFilterOfStatistics 投影账单统计请求的筛选字段。
|
||||
func billFilterOfStatistics(request dto.EmployeeCollectionBillStatisticsRequest) billFilter {
|
||||
return billFilter{
|
||||
SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
|
||||
DebtorAccountID: request.DebtorAccountID, CustomerID: request.CustomerID,
|
||||
CreatedFrom: request.CreatedFrom, CreatedTo: request.CreatedTo,
|
||||
BillID: request.BillID, SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
|
||||
DebtorAccountID: request.DebtorAccountID, DebtorKeyword: request.DebtorKeyword,
|
||||
CustomerKeyword: request.CustomerKeyword, CustomerID: request.CustomerID,
|
||||
StartTime: request.StartTime, EndTime: request.EndTime,
|
||||
DecidedStart: request.DecidedStartTime, DecidedEnd: request.DecidedEndTime,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +346,12 @@ func applyBillVisibility(ctx context.Context, query *gorm.DB) (*gorm.DB, error)
|
||||
}
|
||||
|
||||
// applyBillFilters 应用账单列表与统计共用的筛选条件。
|
||||
func applyBillFilters(query *gorm.DB, request billFilter) (*gorm.DB, error) {
|
||||
// db 仅用于构造相关子查询,不得携带外层筛选,避免子查询被账单条件污染。
|
||||
func applyBillFilters(ctx context.Context, db, query *gorm.DB, request billFilter) (*gorm.DB, error) {
|
||||
if request.BillID != nil && *request.BillID > 0 {
|
||||
// 账单编号即账单记录标识(主键),不新增编号列,也不按前缀或范围匹配。
|
||||
query = query.Where("tb_employee_collection_bill.id = ?", *request.BillID)
|
||||
}
|
||||
if request.DebtorAccountID != nil && *request.DebtorAccountID > 0 {
|
||||
query = query.Where("debtor_account_id = ?", *request.DebtorAccountID)
|
||||
}
|
||||
@@ -219,38 +383,97 @@ func applyBillFilters(query *gorm.DB, request billFilter) (*gorm.DB, error) {
|
||||
customerID := strconv.FormatUint(uint64(*request.CustomerID), 10)
|
||||
query = query.Where("customer_snapshot->>'buyer_id' = ? OR customer_snapshot->>'shop_id' = ?", customerID, customerID)
|
||||
}
|
||||
if request.CreatedFrom != nil {
|
||||
from, err := parseBillDate(*request.CreatedFrom)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if from != nil {
|
||||
query = query.Where("created_at >= ?", *from)
|
||||
}
|
||||
debtorKeyword, err := billKeyword(request.DebtorKeyword, "欠款人姓名或账号")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request.CreatedTo != nil {
|
||||
to, err := parseBillDate(*request.CreatedTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if debtorKeyword != "" {
|
||||
pattern := "%" + debtorKeyword + "%"
|
||||
// 欠款人姓名或账号:优先匹配账单创建时冻结的账号名称快照;
|
||||
// 同时匹配后台账号当前登录名,使账号改名后仍可按“账号”口径查到既有账单。
|
||||
query = query.Where(
|
||||
"tb_employee_collection_bill.debtor_snapshot->>'account_name' ILIKE ? OR EXISTS ("+
|
||||
"SELECT 1 FROM tb_account AS debtor_account WHERE debtor_account.deleted_at IS NULL "+
|
||||
"AND debtor_account.id = tb_employee_collection_bill.debtor_account_id "+
|
||||
"AND debtor_account.username ILIKE ?)", pattern, pattern)
|
||||
}
|
||||
customerKeyword, err := billKeyword(request.CustomerKeyword, "客户名称")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if customerKeyword != "" {
|
||||
pattern := "%" + customerKeyword + "%"
|
||||
// 客户名称:来源订单取买家昵称快照;代理线下充值的快照只冻结店铺ID,
|
||||
// 因此按店铺ID关联既有店铺表取当前店铺名称,避免为筛选新增冗余快照列。
|
||||
query = query.Where(
|
||||
"tb_employee_collection_bill.customer_snapshot->>'buyer_nickname' ILIKE ? OR EXISTS ("+
|
||||
"SELECT 1 FROM tb_shop AS customer_shop WHERE customer_shop.deleted_at IS NULL "+
|
||||
"AND customer_shop.id::text = tb_employee_collection_bill.customer_snapshot->>'shop_id' "+
|
||||
"AND customer_shop.shop_name ILIKE ?)", pattern, pattern)
|
||||
}
|
||||
// 产生时间:统一严格解析器 + 闭区间(含两端);旧参数名与旧格式不再接受。
|
||||
startTime, endTime, err := utils.ParseTimeRange(optionalTimeValue(request.StartTime), optionalTimeValue(request.EndTime))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if startTime != nil {
|
||||
query = query.Where("created_at >= ?", *startTime)
|
||||
}
|
||||
if endTime != nil {
|
||||
query = query.Where("created_at <= ?", *endTime)
|
||||
}
|
||||
// 核销通过时间:经「状态为已通过的分摊」关联到申请表的审批终态时间(decided_at)。
|
||||
// MUST NOT 使用分摊表的释放时间:该列同时承担「审批通过转为已核销」与「驳回或释放预占」两种语义。
|
||||
decidedStart, decidedEnd, err := utils.ParseTimeRange(optionalTimeValue(request.DecidedStart), optionalTimeValue(request.DecidedEnd))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if decidedStart != nil || decidedEnd != nil {
|
||||
sub := db.WithContext(ctx).
|
||||
Model(&model.EmployeeCollectionApplicationAllocation{}).
|
||||
Select("1").
|
||||
Joins("JOIN tb_employee_collection_application AS decided_application ON decided_application.id = tb_employee_collection_application_allocation.application_id").
|
||||
Where("tb_employee_collection_application_allocation.bill_id = tb_employee_collection_bill.id").
|
||||
Where("tb_employee_collection_application_allocation.status = ?", constants.EmployeeCollectionAllocationStatusApproved).
|
||||
Where("decided_application.decided_at IS NOT NULL")
|
||||
if decidedStart != nil {
|
||||
sub = sub.Where("decided_application.decided_at >= ?", *decidedStart)
|
||||
}
|
||||
if to != nil {
|
||||
query = query.Where("created_at < ?", to.AddDate(0, 0, 1))
|
||||
if decidedEnd != nil {
|
||||
sub = sub.Where("decided_application.decided_at <= ?", *decidedEnd)
|
||||
}
|
||||
query = query.Where("EXISTS (?)", sub)
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// parseBillDate 解析自然日筛选值;空值表示不筛选。
|
||||
func parseBillDate(value string) (*time.Time, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
// billKeywordMaxLength 限制模糊筛选的输入长度,避免超长子串匹配放大查询代价。
|
||||
const billKeywordMaxLength = 50
|
||||
|
||||
// billKeyword 归一化模糊筛选输入:去两端的空白后按子串匹配,空值表示不筛选。
|
||||
// 超长输入按参数非法拒绝,与 DTO 的长度上限一致,避免查询层成为绕过入口。
|
||||
func billKeyword(value *string, fieldName string) (string, error) {
|
||||
if value == nil {
|
||||
return "", nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
return "", nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation(billDateLayout, trimmed, time.Local)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "账单筛选日期格式必须为 YYYY-MM-DD")
|
||||
if utf8.RuneCountInString(trimmed) > billKeywordMaxLength {
|
||||
return "", errors.New(errors.CodeInvalidParam, fieldName+"筛选最长 "+strconv.Itoa(billKeywordMaxLength)+" 字符")
|
||||
}
|
||||
return &parsed, nil
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// optionalTimeValue 读取可选时间筛选值:nil 与空串等价(该端不筛选)。
|
||||
// MUST NOT 去空白:统一时间筛选契约把前后带空白的取值视为格式非法,
|
||||
// 在这里 trim 会把本应被拒绝的取值静默接受,与既有拒绝集不一致。
|
||||
func optionalTimeValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
// billRefunds 读取账单的来源订单退款冲销关联。
|
||||
@@ -474,8 +697,16 @@ func ProjectBill(bill *model.EmployeeCollectionBill) (*dto.EmployeeCollectionBil
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "解析账单客户快照失败")
|
||||
}
|
||||
}
|
||||
// 未核销金额 = 应收 − 已核销;剩余可核销金额 = 应收 − 已核销 − 审批中预占。
|
||||
// 两者并列返回:前者是运营口径,后者是服务端并发核销防超额校验使用的口径。
|
||||
// 已关闭账单的未核销余额作废,两者都为 0。
|
||||
unsettled := int64(0)
|
||||
remaining := int64(0)
|
||||
if bill.Status != constants.EmployeeCollectionBillStatusClosed {
|
||||
unsettled = bill.ReceivableAmount - bill.ReceivedAmount
|
||||
if unsettled < 0 {
|
||||
unsettled = 0
|
||||
}
|
||||
remaining = bill.ReceivableAmount - bill.ReceivedAmount - bill.ReservedAmount
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
@@ -488,7 +719,7 @@ func ProjectBill(bill *model.EmployeeCollectionBill) (*dto.EmployeeCollectionBil
|
||||
DebtorAccountID: bill.DebtorAccountID, DebtorSnapshot: debtorSnapshot,
|
||||
CustomerSnapshot: customerSnapshot,
|
||||
ReceivableAmount: bill.ReceivableAmount, ReceivedAmount: bill.ReceivedAmount,
|
||||
ReservedAmount: bill.ReservedAmount, RemainingAmount: remaining,
|
||||
UnsettledAmount: unsettled, ReservedAmount: bill.ReservedAmount, RemainingAmount: remaining,
|
||||
Status: bill.Status, StatusName: constants.GetEmployeeCollectionBillStatusName(bill.Status),
|
||||
ApprovalPending: bill.ReservedAmount > 0,
|
||||
ClosedReason: bill.ClosedReason, ClosedAt: bill.ClosedAt,
|
||||
|
||||
@@ -92,12 +92,14 @@ func toConfigurationResponse(record *model.H5PopupConfiguration) *dto.H5PopupCon
|
||||
}
|
||||
return &dto.H5PopupConfigurationResponse{
|
||||
ID: record.ID, Title: record.Title, Content: record.Content,
|
||||
Pages: append([]string{}, record.Pages...),
|
||||
ShopIDs: toShopIDs(record.ShopIDs),
|
||||
DeviceTypes: append([]string{}, record.DeviceTypes...),
|
||||
CardTypes: append([]string{}, record.CardTypes...),
|
||||
Priority: record.Priority,
|
||||
Frequency: record.Frequency,
|
||||
PopupType: record.PopupType,
|
||||
PopupTypeText: constants.GetH5PopupTypeName(record.PopupType),
|
||||
Pages: append([]string{}, record.Pages...),
|
||||
ShopIDs: toShopIDs(record.ShopIDs),
|
||||
DeviceTypes: append([]string{}, record.DeviceTypes...),
|
||||
CardTypes: append([]string{}, record.CardTypes...),
|
||||
Priority: record.Priority,
|
||||
Frequency: record.Frequency,
|
||||
FrequencyText: constants.GetH5PopupFrequencyName(record.Frequency),
|
||||
Enabled: record.Enabled == constants.H5PopupStatusEnabled,
|
||||
EnabledText: constants.GetH5PopupEnabledName(record.Enabled),
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"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/utils"
|
||||
)
|
||||
|
||||
// Query 优先轮询项只读查询。
|
||||
@@ -31,6 +32,7 @@ func NewQuery(db *gorm.DB, store *postgres.PollingPriorityItemStore) *Query {
|
||||
}
|
||||
|
||||
// List 分页查询优先轮询项,按创建时间倒序,并按当前账号数据范围下推。
|
||||
// 时间范围按入队时间(created_at)取闭区间,复用统一严格时间解析器;解析失败按参数非法返回。
|
||||
func (q *Query) List(ctx context.Context, req dto.PriorityPollingItemListRequest) (*dto.PriorityPollingItemListResponse, error) {
|
||||
if q == nil || q.store == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "优先轮询项查询未配置")
|
||||
@@ -39,11 +41,18 @@ func (q *Query) List(ctx context.Context, req dto.PriorityPollingItemListRequest
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createdFrom, createdTo, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter := postgres.PollingPriorityItemListFilter{
|
||||
CardID: req.CardID,
|
||||
TaskType: req.TaskType,
|
||||
Status: req.Status,
|
||||
TriggerType: req.TriggerType,
|
||||
CreatedFrom: createdFrom,
|
||||
CreatedTo: createdTo,
|
||||
AbnormalOnly: req.AbnormalOnly != nil && *req.AbnormalOnly,
|
||||
ShopIDSnapshot: scope,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
@@ -158,35 +167,60 @@ func normalizePage(page, pageSize int) (int, int) {
|
||||
}
|
||||
|
||||
// toPriorityPollingItemResponse 把事实行投影为读侧 DTO,枚举按 constants 提供中文名称。
|
||||
// 有效期与「是否已过期」一律读事实状态,不按 next_run_at 现算:next_run_at 为空只表示未排期。
|
||||
func toPriorityPollingItemResponse(item *model.PollingPriorityItem, iccid string) *dto.PriorityPollingItemResponse {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.PriorityPollingItemResponse{
|
||||
ID: item.ID,
|
||||
CardID: item.CardID,
|
||||
ICCID: iccid,
|
||||
TaskType: item.TaskType,
|
||||
TaskTypeName: constants.PollingPriorityTaskTypeName(item.TaskType),
|
||||
Status: item.Status,
|
||||
StatusName: constants.PollingPriorityStatusName(item.Status),
|
||||
TriggerType: item.TriggerType,
|
||||
TriggerTypeName: constants.PollingPriorityTriggerName(item.TriggerType),
|
||||
TriggerTypes: strings.Trim(item.TriggerTypes, ","),
|
||||
TriggerCount: item.TriggerCount,
|
||||
LastTriggeredAt: item.LastTriggeredAt,
|
||||
SourceOrderID: item.SourceOrderID,
|
||||
SourcePackageUse: item.SourcePackageUsageID,
|
||||
AttemptCount: item.AttemptCount,
|
||||
ClaimedAt: item.ClaimedAt,
|
||||
ManualReason: item.ManualReason,
|
||||
ManualOperator: item.ManualOperatorID,
|
||||
ManualOperatorNm: item.ManualOperatorName,
|
||||
ShopIDSnapshot: item.ShopIDSnapshot,
|
||||
Result: item.Result,
|
||||
ResultName: constants.PollingPriorityResultName(item.Result),
|
||||
FailureReason: item.FailureReason,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
ID: item.ID,
|
||||
CardID: item.CardID,
|
||||
ICCID: iccid,
|
||||
AssetType: item.AssetType,
|
||||
AssetID: item.AssetID,
|
||||
DeviceNo: item.DeviceNoSnapshot,
|
||||
TaskType: item.TaskType,
|
||||
TaskTypeName: constants.PollingPriorityTaskTypeName(item.TaskType),
|
||||
Status: item.Status,
|
||||
StatusName: constants.PollingPriorityStatusName(item.Status),
|
||||
TriggerType: item.TriggerType,
|
||||
TriggerTypeName: constants.PollingPriorityTriggerName(item.TriggerType),
|
||||
TriggerTypes: strings.Trim(item.TriggerTypes, ","),
|
||||
TriggerCount: item.TriggerCount,
|
||||
LastTriggeredAt: item.LastTriggeredAt,
|
||||
SourceOrderID: item.SourceOrderID,
|
||||
SourcePackageUse: item.SourcePackageUsageID,
|
||||
AttemptCount: item.AttemptCount,
|
||||
AttemptLimit: item.AttemptLimit,
|
||||
ClaimedAt: item.ClaimedAt,
|
||||
StartedAt: item.StartedAt,
|
||||
FinishedAt: item.FinishedAt,
|
||||
DurationMS: pollingPriorityDurationMS(item),
|
||||
NextRunAt: item.NextRunAt,
|
||||
PriorityEffectiveFrom: item.PriorityEffectiveFrom,
|
||||
DequeuedAt: item.DequeuedAt,
|
||||
ManualReason: item.ManualReason,
|
||||
ManualOperator: item.ManualOperatorID,
|
||||
ManualOperatorNm: item.ManualOperatorName,
|
||||
ShopIDSnapshot: item.ShopIDSnapshot,
|
||||
AgentShopID: item.AgentShopIDSnapshot,
|
||||
Result: item.Result,
|
||||
ResultName: constants.PollingPriorityResultName(item.Result),
|
||||
FailureReason: item.FailureReason,
|
||||
IntegrationLogID: item.IntegrationLogID,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// pollingPriorityDurationMS 返回最近一次执行的耗时毫秒数。
|
||||
// 只有开始与结束时间都在时才有耗时;结束早于开始等异常数据按 0 处理,不返回负数。
|
||||
func pollingPriorityDurationMS(item *model.PollingPriorityItem) int64 {
|
||||
if item == nil || item.StartedAt == nil || item.FinishedAt == nil {
|
||||
return 0
|
||||
}
|
||||
if item.FinishedAt.Before(*item.StartedAt) {
|
||||
return 0
|
||||
}
|
||||
return item.FinishedAt.Sub(*item.StartedAt).Milliseconds()
|
||||
}
|
||||
|
||||
@@ -158,6 +158,10 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
downstreamCounts, err := q.loadDownstreamAgentCounts(ctx, shops)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responses := make([]*dto.ShopResponse, 0, len(shops))
|
||||
for _, shop := range shops {
|
||||
response := &dto.ShopResponse{
|
||||
@@ -167,8 +171,11 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) (
|
||||
ContactName: shop.ContactName, ContactPhone: shop.ContactPhone, Province: shop.Province,
|
||||
City: shop.City, District: shop.District, Address: shop.Address, Status: shop.Status,
|
||||
ClientLoginDisabled: shop.ClientLoginDisabled,
|
||||
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
StatusName: constants.GetStatusName(shop.Status),
|
||||
// 无下级的店铺返回 0(map 缺省零值),不使用空值。
|
||||
DownstreamAgentCount: downstreamCounts[shop.ID],
|
||||
CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if shop.ParentID != nil {
|
||||
response.ParentShopName = parentNames[*shop.ParentID]
|
||||
@@ -239,6 +246,38 @@ func (q *BusinessOwnerQuery) loadBusinessOwners(ctx context.Context, ids []uint)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadDownstreamAgentCounts 按当页店铺批量聚合直接下级代理数量。
|
||||
//
|
||||
// 口径:未删除且上级店铺标识等于该店铺的店铺数;一次分组查询完成,MUST NOT 逐店查询。
|
||||
// 该数量只读,不影响上下级关系、佣金关系、通知范围或任何既有写入语义。
|
||||
// 数据范围由调用方的列表与详情过滤保证:范围外店铺不进入结果,其下级数量自然不泄露。
|
||||
func (q *BusinessOwnerQuery) loadDownstreamAgentCounts(ctx context.Context, shops []*model.Shop) (map[uint]int64, error) {
|
||||
result := make(map[uint]int64, len(shops))
|
||||
shopIDs := make([]uint, 0, len(shops))
|
||||
for _, shop := range shops {
|
||||
if shop != nil && shop.ID > 0 {
|
||||
shopIDs = append(shopIDs, shop.ID)
|
||||
}
|
||||
}
|
||||
if len(shopIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ParentID uint
|
||||
Total int64
|
||||
}
|
||||
if err := q.db.WithContext(ctx).Model(&model.Shop{}).
|
||||
Select("parent_id, COUNT(*) AS total").
|
||||
Where("parent_id IN ?", shopIDs).
|
||||
Group("parent_id").Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计店铺下级代理数量失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.ParentID] = row.Total
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadBusinessUserGroups 按负责人账号批量推导当前所属业务用户组。
|
||||
// 一账号至多一条未删除成员关系,因此先在成员上按 account_id 定位、再按组 ID 回表,
|
||||
// 停用组与负责人为已软删账号的成员关系都照常返回,保证展示与筛选口径一致。
|
||||
|
||||
@@ -5,10 +5,42 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
func registerCarrierRoutes(router fiber.Router, handler *admin.CarrierHandler, doc *openapi.Generator, basePath string) {
|
||||
// 通道阈值命中记录使用顶层路径,避免与 /carriers/:id 的参数路由冲突;
|
||||
// 门禁沿用超管/平台路由组级 gate 先例(代理、企业与其他账号统一 403),
|
||||
// 并挂在功能路径组上:Fiber 的组中间件按路径前缀生效。
|
||||
hitGate := func(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
hits := router.Group("/carrier-traffic-threshold-hits", hitGate)
|
||||
hitsPath := basePath + "/carrier-traffic-threshold-hits"
|
||||
|
||||
Register(hits, doc, hitsPath, "GET", "", handler.ListThresholdHits, RouteSpec{
|
||||
Summary: "查询通道阈值命中记录",
|
||||
Description: "返回达量停机时冻结的数字证据:命中时该卡当前周期的累计流量读数、本次判定实际生效的阈值与单位、" +
|
||||
"计费周期起点、判定时间与触发来源,以及周期恢复的解锁时间与复机结果;" +
|
||||
"阈值与读数为命中时值,之后修改通道阈值不改写历史命中;" +
|
||||
"本次上线前的历史行读数为空按「无」返回,判定时间取既有创建时间,不阻断查询。" +
|
||||
"支持按运营商通道、卡、计费周期起点精确匹配与判定时间闭区间(start_time/end_time,带时区 RFC3339 秒级)筛选," +
|
||||
"按判定时间倒序分页;仅超级管理员与平台账号可访问,并按卡数据范围过滤,越权与不存在不可区分," +
|
||||
"不返回凭证或上游响应原文",
|
||||
Tags: []string{"运营商管理"},
|
||||
Input: new(dto.CarrierThresholdHitListRequest),
|
||||
Output: new(dto.CarrierThresholdHitPageResult),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
carriers := router.Group("/carriers")
|
||||
groupPath := basePath + "/carriers"
|
||||
|
||||
|
||||
@@ -72,29 +72,38 @@ func registerEmployeeCollectionBillRoutes(router fiber.Router, handler *admin.Em
|
||||
adminOnly := requireEmployeeCollectionConfigAccess
|
||||
|
||||
Register(group, doc, billPath, "GET", "", handler.ListBills, RouteSpec{
|
||||
Summary: "查询员工代收款账单",
|
||||
Description: "欠款人仅返回本人欠款账单,超级管理员返回全部;支持来源、状态、时间、欠款人、客户筛选与分页。",
|
||||
Tags: []string{"员工代收款"},
|
||||
Input: new(dto.EmployeeCollectionBillListRequest),
|
||||
Output: new(dto.EmployeeCollectionBillListResponse),
|
||||
Auth: true,
|
||||
Summary: "查询员工代收款账单",
|
||||
Description: "欠款人仅返回本人欠款账单,超级管理员返回全部;支持账单编号(即账单记录标识)、来源、状态、" +
|
||||
"欠款人姓名或账号(子串模糊)、客户名称(子串模糊)、产生时间与核销通过时间筛选与分页。" +
|
||||
"产生时间参数为 `start_time`/`end_time`,核销通过时间为 `decided_start_time`/`decided_end_time`," +
|
||||
"两类均为带显式时区的 RFC3339 秒级闭区间(含两端);旧参数名 `created_from`/`created_to` 与旧日期格式一律以参数非法拒绝。" +
|
||||
"核销通过时间取该账单已通过分摊关联的申请表审批终态时间。" +
|
||||
"响应并列返回未核销金额(应收减已核销)与剩余可核销金额(应收减已核销减审批中预占),已关闭账单两者为零。",
|
||||
Tags: []string{"员工代收款"},
|
||||
Input: new(dto.EmployeeCollectionBillListRequest),
|
||||
Output: new(dto.EmployeeCollectionBillListResponse),
|
||||
Auth: true,
|
||||
})
|
||||
// 统计必须先于 /:id 注册,保证路径按字面量优先匹配。
|
||||
Register(group, doc, billPath, "GET", "/statistics", handler.StatisticsBills, RouteSpec{
|
||||
Summary: "查询员工代收款账单统计",
|
||||
Description: "与账单列表使用相同筛选条件与可见性范围,返回应收、已核销、未核销金额合计与待处理账单数;不接受分页参数。",
|
||||
Tags: []string{"员工代收款"},
|
||||
Input: new(dto.EmployeeCollectionBillStatisticsRequest),
|
||||
Output: new(dto.EmployeeCollectionBillStatisticsResponse),
|
||||
Auth: true,
|
||||
Summary: "查询员工代收款账单统计",
|
||||
Description: "与账单列表使用相同筛选条件(含核销通过时间与模糊筛选)与可见性范围," +
|
||||
"返回应收、已核销、未核销金额合计与待处理账单数;不接受分页参数。",
|
||||
Tags: []string{"员工代收款"},
|
||||
Input: new(dto.EmployeeCollectionBillStatisticsRequest),
|
||||
Output: new(dto.EmployeeCollectionBillStatisticsResponse),
|
||||
Auth: true,
|
||||
})
|
||||
Register(group, doc, billPath, "GET", "/:id", handler.GetBill, RouteSpec{
|
||||
Summary: "查询员工代收款账单详情",
|
||||
Description: "返回账单、来源退款冲销、分摊、核销申请与企业微信审批历史;附件只返回对象键引用。无权限与不存在返回同一错误。",
|
||||
Tags: []string{"员工代收款"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: new(dto.EmployeeCollectionBillDetailResponse),
|
||||
Auth: true,
|
||||
Summary: "查询员工代收款账单详情",
|
||||
Description: "返回账单、来源退款冲销、分摊、核销申请与企业微信审批历史,并返回操作日志安全投影" +
|
||||
"(动作、操作账号名称、时间与前后值摘要,不含凭证内容与完整收款原文)与来源订单快照" +
|
||||
"(来源单号、业务类型、来源金额、来源创建时间、资产标识;来源不可读时字段为空值且不阻断详情)。" +
|
||||
"附件只返回对象键引用。无权限与不存在返回同一错误。",
|
||||
Tags: []string{"员工代收款"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: new(dto.EmployeeCollectionBillDetailResponse),
|
||||
Auth: true,
|
||||
})
|
||||
Register(group, doc, billPath, "POST", "/:id/close", adminOnly(handler.CloseBill), RouteSpec{
|
||||
Summary: "关闭员工代收款账单",
|
||||
|
||||
@@ -25,7 +25,10 @@ func registerH5PopupConfigurationRoutes(router fiber.Router, handler *admin.H5Po
|
||||
|
||||
Register(configurations, doc, groupPath, "POST", "", handler.CreateH5PopupConfiguration, RouteSpec{
|
||||
Summary: "创建运营弹窗配置",
|
||||
Description: "创建全局运营弹窗配置,初始版本为 1。范围同一维度多选取任一命中,未配置范围即全量;" +
|
||||
Description: "创建全局运营弹窗配置,初始版本为 1。弹窗类型 `popup_type` 必填" +
|
||||
"(promotion:套餐政策推广, announcement:通用公告),取值域外或缺失一律以参数非法拒绝;" +
|
||||
"未传优先级时按类型的缺省优先级取值。候选排序先按类型类别(风险换卡提醒 > 套餐政策推广 > 通用公告)," +
|
||||
"再按显式优先级降序、同优先级取最近更新时间最新。范围同一维度多选取任一命中,未配置范围即全量;" +
|
||||
"页面必选;受控动作只允许套餐购买或资产钱包充值,不接受 URL、前端路由或任意动作;结束时间不得早于开始时间。",
|
||||
Tags: []string{"H5 运营弹窗配置"},
|
||||
Auth: true,
|
||||
@@ -44,7 +47,8 @@ func registerH5PopupConfigurationRoutes(router fiber.Router, handler *admin.H5Po
|
||||
Register(configurations, doc, groupPath, "PUT", "/:id", handler.UpdateH5PopupConfiguration, RouteSpec{
|
||||
Summary: "更新运营弹窗配置",
|
||||
Description: "更新运营弹窗配置并递增版本;旧版本已投放通知的内容与快照不被改写," +
|
||||
"新版本可向原命中客户按频率重新投放一次。",
|
||||
"新版本可向原命中客户按频率重新投放一次。`popup_type` 传入时必须落在" +
|
||||
"(promotion:套餐政策推广, announcement:通用公告)取值域内,否则以参数非法拒绝;不传保持原值。",
|
||||
Tags: []string{"H5 运营弹窗配置"},
|
||||
Auth: true,
|
||||
Input: new(dto.UpdateH5PopupConfigurationParams),
|
||||
|
||||
@@ -43,8 +43,8 @@ func registerPaymentMerchantRoutes(router fiber.Router, handler *admin.PaymentMe
|
||||
Register(group, doc, basePath, "GET", "/payment-merchants/:id", wrap(handler.GetMerchant), RouteSpec{Summary: "查询支付商户详情", Description: "仅超级管理员和平台用户可访问。\n\n" + paymentMerchantCredentialDoc, Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantResponse), Auth: true})
|
||||
Register(group, doc, basePath, "PUT", "/payment-merchants/:id", wrap(handler.UpdateMerchant), RouteSpec{Summary: "更新支付商户", Description: "仅超级管理员和平台用户可访问。\n\n" + paymentMerchantCredentialDoc, Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantUpdateRequest), Output: new(dto.PaymentMerchantResponse), Auth: true})
|
||||
Register(group, doc, basePath, "DELETE", "/payment-merchants/:id", wrap(handler.DeleteMerchant), RouteSpec{Summary: "删除支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantDeleteRequest), Output: nil, Auth: true})
|
||||
Register(group, doc, basePath, "GET", "/payment-merchant-pools", wrap(handler.ListPools), RouteSpec{Summary: "查询商户池", Description: "仅超级管理员和平台用户可访问。支持分页,返回当前页商户池及其有序成员。", Tags: []string{"商户池管理"}, Input: new(dto.PaymentMerchantPoolListRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
|
||||
Register(group, doc, basePath, "GET", "/payment-merchant-pools/:id", wrap(handler.GetPool), RouteSpec{Summary: "查询商户池详情", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
|
||||
Register(group, doc, basePath, "GET", "/payment-merchant-pools", wrap(handler.ListPools), RouteSpec{Summary: "查询商户池", Description: "仅超级管理员和平台用户可访问。支持分页,返回当前页商户池及其有序成员,并附带启用成员数量、成员总数量、当前命中商户与最近配置更新时间;当前命中商户按支付创建的同一规则只读计算,无可用成员时为空。", Tags: []string{"商户池管理"}, Input: new(dto.PaymentMerchantPoolListRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
|
||||
Register(group, doc, basePath, "GET", "/payment-merchant-pools/:id", wrap(handler.GetPool), RouteSpec{Summary: "查询商户池详情", Description: "仅超级管理员和平台用户可访问。返回启用成员数量、成员总数量、当前命中商户与最近配置更新时间;当前命中商户只读计算,不产生支付单也不推进统计世代。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
|
||||
Register(group, doc, basePath, "POST", "/payment-merchant-pools", wrap(handler.CreatePool), RouteSpec{Summary: "创建商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.PaymentMerchantPoolRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
|
||||
Register(group, doc, basePath, "PUT", "/payment-merchant-pools/:id", wrap(handler.UpdatePool), RouteSpec{Summary: "更新商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantPoolRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
|
||||
Register(group, doc, basePath, "POST", "/payment-merchant-pools/:id/enable", wrap(handler.EnablePool), RouteSpec{Summary: "启用商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
|
||||
|
||||
@@ -53,7 +53,7 @@ func registerPhoneAssetAssociationRoutes(router fiber.Router, handler *admin.Pho
|
||||
|
||||
Register(group, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||
Summary: "查询手机号资产关联列表",
|
||||
Description: constants.PhoneAssetAssociationAccessDescription + "按资产数据范围过滤后分页返回资产(类型/ID/标识)、完整手机号、建立时间、建立来源与状态;支持资产标识、手机号、关联状态与创建时间区间筛选。越权与不存在不形成可枚举差异。",
|
||||
Description: constants.PhoneAssetAssociationAccessDescription + "按资产数据范围过滤后分页返回资产(类型/ID/标识)、完整手机号、建立时间、建立来源与状态;支持资产标识、手机号、关联状态与创建时间区间筛选。越权与不存在不形成可枚举差异。列表按当页涉及的手机号集合一次批量聚合返回该手机号当前有效关联数量(只统计有效关系、卡与设备各计一项,与「最多关联十项」上限判定同一口径);已失效关系随既有失效方式与原因一并返回最近解绑人的账号名称快照、账号标识与解绑时间,关系仍有效时这三项为空,不以创建人或最后更新人填充。以上投影字段只读,不改变既有解绑、换绑与上限判定规则。",
|
||||
Tags: []string{"手机号资产关联"},
|
||||
Input: new(dto.ListPhoneAssetAssociationRequest),
|
||||
Output: new(dto.PhoneAssetAssociationPageResult),
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// registerPollingPriorityRoutes 注册卡轮询优先队列的人工入队与只读查询路由。
|
||||
// 仅挂在既有后台认证中间件组下,不新增认证边界。
|
||||
// registerPollingPriorityRoutes 注册卡轮询优先队列的人工入口(入队、关闭、重触发)与只读查询路由。
|
||||
// 仅挂在既有后台认证中间件组下,不新增认证边界;权限在用例内按账号与数据范围判定。
|
||||
func registerPollingPriorityRoutes(router fiber.Router, handler *admin.PriorityPollingHandler, doc *openapi.Generator, basePath string) {
|
||||
group := router.Group("/polling-priority-items")
|
||||
groupPath := basePath + "/polling-priority-items"
|
||||
@@ -18,6 +18,7 @@ func registerPollingPriorityRoutes(router fiber.Router, handler *admin.PriorityP
|
||||
Summary: "人工优先入队",
|
||||
Description: "为该卡的全部纳入轮询任务类型(实名/流量/套餐/卡状态)建立或合并优先轮询项。" +
|
||||
"同一卡同一任务类型至多一条活动项,重复入队合并触发次数与来源集合;原因必填。" +
|
||||
"入队时冻结资产类型与资产 ID、设备号、店铺与代理归属,并以入队时间作为有效期起算时间。" +
|
||||
"不受既有人工触发的每日次数上限与 24 小时去重约束;不修改调度优先级,也不绕过既有并发上限。",
|
||||
Tags: []string{"轮询管理-优先队列"},
|
||||
Input: new(dto.EnqueuePriorityPollingRequest),
|
||||
@@ -27,17 +28,48 @@ func registerPollingPriorityRoutes(router fiber.Router, handler *admin.PriorityP
|
||||
// 字面量路径必须先于 /:id 注册,保证路径按字面量优先匹配。
|
||||
Register(group, doc, groupPath, "GET", "", handler.ListPriorityItems, RouteSpec{
|
||||
Summary: "查询优先轮询项列表",
|
||||
Description: "分页查询卡轮询优先项,按创建时间倒序,支持卡、任务类型、状态与触发类型筛选。" +
|
||||
"数据范围按卡所属店铺快照下推:代理只能看到自身及下级店铺资产,平台卡不可见。",
|
||||
Description: "分页查询卡轮询优先项,按创建时间倒序,支持卡、任务类型、状态、触发类型、" +
|
||||
"入队时间范围(start_time/end_time,带时区的 RFC3339 秒级时间,闭区间含两端)与「异常与重试」视图筛选" +
|
||||
"(失败终态、已过期、尝试次数达到上限或存在失败原因)。" +
|
||||
"返回资产标识、设备号、店铺与代理归属、触发与尝试次数、最大尝试次数、最近与下一次执行时间、" +
|
||||
"执行起止与耗时、入队与出队时间、执行结果与失败原因、集成交互日志标识。" +
|
||||
"不提供优先级分级;查询不触发关闭或重触发。数据范围按卡所属店铺快照下推:" +
|
||||
"代理只能看到自身及下级店铺资产,平台卡不可见。",
|
||||
Tags: []string{"轮询管理-优先队列"},
|
||||
Input: new(dto.PriorityPollingItemListRequest),
|
||||
Output: new(dto.PriorityPollingItemListResponse),
|
||||
Auth: true,
|
||||
})
|
||||
// 参数路由的子路径必须先于 /:id 注册,避免同段匹配歧义。
|
||||
Register(group, doc, groupPath, "POST", "/:id/close", handler.Close, RouteSpec{
|
||||
Summary: "人工关闭优先轮询项",
|
||||
Description: "仅超级管理员与平台账号。仅允许关闭待执行或执行中的优先项:原因必填并写入审计(操作者、时间与原因)," +
|
||||
"未完成项转已关闭终态并出队,不再发起任何上游调用,既有执行结果与失败原因一律保留。" +
|
||||
"越权与不存在返回同一错误。",
|
||||
Tags: []string{"轮询管理-优先队列"},
|
||||
Input: new(dto.IDReq),
|
||||
Body: new(dto.ClosePriorityPollingItemRequest),
|
||||
Output: new(dto.ClosePriorityPollingItemResponse),
|
||||
Auth: true,
|
||||
})
|
||||
Register(group, doc, groupPath, "POST", "/:id/retrigger", handler.Retrigger, RouteSpec{
|
||||
Summary: "人工重触发优先轮询项",
|
||||
Description: "权限与人工入队一致(超级管理员、平台账号与数据范围内的代理账号),仅允许对失败出队或已过期的项重触发。" +
|
||||
"复用人工入队入口为该卡**该任务类型**建立新的活动项(尝试次数从零开始),原因必填并与操作者一同写入审计。" +
|
||||
"不绕过活动项唯一键代表的并发上限、停复机持锁拒绝与任务类型范围;越权与不存在返回同一错误。",
|
||||
Tags: []string{"轮询管理-优先队列"},
|
||||
Input: new(dto.IDReq),
|
||||
Body: new(dto.RetriggerPriorityPollingItemRequest),
|
||||
Output: new(dto.RetriggerPriorityPollingItemResponse),
|
||||
Auth: true,
|
||||
})
|
||||
Register(group, doc, groupPath, "GET", "/:id", handler.GetPriorityItem, RouteSpec{
|
||||
Summary: "查询优先轮询项详情",
|
||||
Description: "查询单条卡轮询优先项。越权与不存在返回同一响应,不产生可枚举差异。" +
|
||||
"不提供优先级分级、有效期与人工重触发入口。",
|
||||
Description: "查询单条卡轮询优先项,字段与列表一致:资产标识、设备号、店铺与代理归属、任务类型、" +
|
||||
"触发类型与来源、触发与尝试次数、最大尝试次数、最近与下一次执行时间、执行起止与耗时、" +
|
||||
"入队与出队时间、执行结果与失败原因、集成交互日志标识,以及只读的有效期起算时间。" +
|
||||
"不提供优先级分级;有效期到期由独立周期任务处理,人工关闭与人工重触发为独立受权限控制的写入口," +
|
||||
"查询不会隐式触发二者。越权与不存在返回同一响应,不产生可枚举差异。",
|
||||
Tags: []string{"轮询管理-优先队列"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: new(dto.PriorityPollingItemResponse),
|
||||
|
||||
@@ -42,6 +42,18 @@ func registerRefundRoutes(router fiber.Router, handler *admin.RefundHandler, doc
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
// 字面量路径必须先于同段参数路由 /:id 注册:Fiber 按注册顺序匹配,若 /:id 在前,
|
||||
// "order-options" 会被当作退款申请 ID 解析而永远命中不到本端点。
|
||||
// 该端点留在 /refunds 组内,以继承既有组级门禁(超管/平台/代理,企业账号拒绝)。
|
||||
Register(refund, doc, groupPath, "GET", "/order-options", handler.OrderOptions, RouteSpec{
|
||||
Summary: "按来源订单查询可选退款方式",
|
||||
Description: "只读查询,不产生任何副作用。复用创建与重提使用的方式判定与原路可退的凭证判定,不新增第二套判定;判定所需事实缺失时返回不可用原因而非报错。查询受订单数据范围约束,越权与订单不存在不可区分。",
|
||||
Tags: []string{"退款管理"},
|
||||
Input: new(dto.RefundOrderOptionsRequest),
|
||||
Output: new(dto.RefundOrderOptionsResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(refund, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{
|
||||
Summary: "退款申请详情",
|
||||
Tags: []string{"退款管理"},
|
||||
@@ -84,10 +96,19 @@ func registerRefundRoutes(router fiber.Router, handler *admin.RefundHandler, doc
|
||||
|
||||
Register(refund, doc, groupPath, "POST", "/:id/resubmit", handler.Resubmit, RouteSpec{
|
||||
Summary: "重新提交退款申请",
|
||||
Description: "仅已拒绝、已退回或原路退款失败且无审批异常的退款申请可重提;重提时实收金额仍由系统重新派生冻结并忽略请求中的 actual_received_amount,每次重提新增一条审批尝试记录并创建新的企业微信审批实例,历史尝试材料与审批结果不被覆盖。",
|
||||
Description: "仅已拒绝、已退回或原路退款失败且无审批异常的退款申请可重提;重提时实收金额仍由系统重新派生冻结并忽略请求中的 actual_received_amount,退款原因去除首尾空白后不得为空,每次重提新增一条审批尝试记录并创建新的企业微信审批实例,历史尝试材料与审批结果不被覆盖。",
|
||||
Tags: []string{"退款管理"},
|
||||
Input: new(dto.ResubmitRefundRequest),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(refund, doc, groupPath, "POST", "/:id/offline-settlement", handler.RegisterOfflineSettlement, RouteSpec{
|
||||
Summary: "登记线下退款处理流水号",
|
||||
Description: "仅客户收款信息退款(线下到账方式)可登记;重复提交即更正,历史值由审计留存。登记只写线下处理流水号与登记人、登记时间,不改变退款状态、实收金额、套餐失效与佣金回溯规则。",
|
||||
Tags: []string{"退款管理"},
|
||||
Input: new(dto.OfflineSettlementRequest),
|
||||
Output: new(dto.RefundResponse),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
|
||||
|
||||
Register(shops, doc, groupPath, "GET", "", coreShopManagement(handler.List), RouteSpec{
|
||||
Summary: "店铺列表",
|
||||
Description: constants.ShopManagementAccessDescription + constants.ShopListPaginationDescription,
|
||||
Description: constants.ShopManagementAccessDescription + constants.ShopListPaginationDescription + shopDownstreamAgentDescription,
|
||||
Tags: []string{"店铺管理"},
|
||||
Input: new(dto.ShopListRequest),
|
||||
Output: new(dto.ShopPageResult),
|
||||
@@ -84,7 +84,7 @@ func registerShopDetailRoute(router fiber.Router, handler *admin.ShopHandler, do
|
||||
groupPath := basePath + "/shops"
|
||||
Register(shops, doc, groupPath, "GET", "/:id", coreShopManagement(handler.Detail), RouteSpec{
|
||||
Summary: "查询店铺详情",
|
||||
Description: constants.ShopManagementAccessDescription + " 返回与店铺列表一致的业务员归属摘要。",
|
||||
Description: constants.ShopManagementAccessDescription + " 返回与店铺列表一致的业务员归属摘要。" + shopDownstreamAgentDescription,
|
||||
Tags: []string{"店铺管理"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: new(dto.ShopResponse),
|
||||
@@ -92,6 +92,9 @@ func registerShopDetailRoute(router fiber.Router, handler *admin.ShopHandler, do
|
||||
})
|
||||
}
|
||||
|
||||
// shopDownstreamAgentDescription 说明店铺列表与详情返回的直接下级代理数量口径。
|
||||
const shopDownstreamAgentDescription = " 同时返回直接下级代理数量(未删除且上级店铺标识等于该店铺的店铺数),按页批量聚合、受店铺数据范围约束,无下级返回 0;该字段只读,不影响上下级关系、佣金关系与通知范围。"
|
||||
|
||||
func coreShopManagement(handler fiber.Handler) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
carrierthreshold "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
carrierthresholdquery "github.com/break/junhong_cmp_fiber/internal/query/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
@@ -22,10 +23,27 @@ import (
|
||||
type Service struct {
|
||||
carrierStore *postgres.CarrierStore
|
||||
audit systemconfigapp.AuditWriter
|
||||
// thresholdHits 是通道阈值命中记录只读查询;与运营商配置共用同一数据库连接。
|
||||
thresholdHits *carrierthresholdquery.Query
|
||||
}
|
||||
|
||||
func New(carrierStore *postgres.CarrierStore, audit systemconfigapp.AuditWriter) *Service {
|
||||
return &Service{carrierStore: carrierStore, audit: audit}
|
||||
service := &Service{carrierStore: carrierStore, audit: audit}
|
||||
if carrierStore != nil {
|
||||
service.thresholdHits = carrierthresholdquery.NewQuery(carrierStore.DB())
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// ListThresholdHits 分页查询通道阈值命中记录。
|
||||
//
|
||||
// 账号门禁与卡数据范围在查询侧判定(ENG-AUTHZ-001);本方法只做装配,
|
||||
// 权限失败与资源不可见都按查询侧的统一拒绝返回。
|
||||
func (s *Service) ListThresholdHits(ctx context.Context, req *dto.CarrierThresholdHitListRequest) (*dto.CarrierThresholdHitPageResult, error) {
|
||||
if s == nil || s.thresholdHits == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "通道阈值命中记录查询未配置")
|
||||
}
|
||||
return s.thresholdHits.List(ctx, *req)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*dto.CarrierResponse, error) {
|
||||
|
||||
@@ -321,7 +321,7 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, assetType stri
|
||||
return nil, errors.Wrap(errors.CodeInternalError, primaryErr, "查询主手机号失败")
|
||||
}
|
||||
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
appErr := verificationCodeFailure(err)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
@@ -413,7 +413,7 @@ func (s *Service) bindExistingPrimaryPhone(
|
||||
return nil, appErr
|
||||
}
|
||||
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
appErr := verificationCodeFailure(err)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, primary, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
@@ -485,12 +485,12 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha
|
||||
}
|
||||
|
||||
if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
appErr := verificationCodeFailure(err)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
appErr := verificationCodeFailure(err)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
@@ -629,6 +629,17 @@ func personalAuditFailureResult(err error) string {
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
// verificationCodeFailure 把验证码校验失败转换为对外错误。
|
||||
// 限流错误码必须原样保留:失败次数达到上限后的锁定是「稍后再试」而不是「验证码错误」,
|
||||
// 客户端据此区分重新获取验证码与等待窗口结束,其余失败仍按验证码无效统一提示。
|
||||
func verificationCodeFailure(err error) error {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) && appErr.Code == errors.CodeTooManyRequests {
|
||||
return appErr
|
||||
}
|
||||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
}
|
||||
|
||||
// Logout A7 退出登录
|
||||
func (s *Service) Logout(ctx context.Context, customerID uint) (*dto.LogoutResponse, error) {
|
||||
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
|
||||
|
||||
@@ -63,7 +63,8 @@ func (s *Service) Unbind(ctx context.Context, request *dto.UnbindPhoneAssetAssoc
|
||||
return err
|
||||
}
|
||||
affected, err := s.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, []uint{locked.ID},
|
||||
constants.PhoneAssetAssociationInvalidateMethodBackendSingle, request.Reason, operatorID, now)
|
||||
constants.PhoneAssetAssociationInvalidateMethodBackendSingle, request.Reason, operatorID,
|
||||
middleware.GetUsernameFromContext(ctx), now)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "解除手机号资产关联失败")
|
||||
}
|
||||
@@ -145,7 +146,8 @@ func (s *Service) unbindAsset(ctx context.Context, asset dto.BatchUnbindAssetIte
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
affected, err := s.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, ids,
|
||||
constants.PhoneAssetAssociationInvalidateMethodBackendBatch, reason, operatorID, now)
|
||||
constants.PhoneAssetAssociationInvalidateMethodBackendBatch, reason, operatorID,
|
||||
middleware.GetUsernameFromContext(ctx), now)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "解除手机号资产关联失败")
|
||||
}
|
||||
@@ -320,6 +322,11 @@ func (s *Service) List(ctx context.Context, request *dto.ListPhoneAssetAssociati
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 当前有效关联数量必须与「最多关联十项」判定同口径,且按当页手机号集合一次聚合,禁止逐行查询。
|
||||
validCounts, err := s.loadValidAssociationCounts(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*dto.PhoneAssetAssociationResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
item := &dto.PhoneAssetAssociationResponse{
|
||||
@@ -327,18 +334,40 @@ func (s *Service) List(ctx context.Context, request *dto.ListPhoneAssetAssociati
|
||||
AssetIdentifier: identifiers[assetIdentifierKey(row.AssetType, row.AssetID)],
|
||||
Status: row.Status, StatusName: constants.GetPhoneAssetAssociationStatusName(row.Status),
|
||||
Source: row.Source, EstablishedAt: row.EstablishedAt.Format(time.RFC3339),
|
||||
ValidAssociationCount: validCounts[row.Phone],
|
||||
}
|
||||
if row.InvalidatedAt != nil {
|
||||
item.InvalidatedAt = row.InvalidatedAt.Format(time.RFC3339)
|
||||
item.InvalidationMethod = row.InvalidationMethod
|
||||
item.InvalidationMethodName = constants.PhoneAssetAssociationInvalidateMethodName(row.InvalidationMethod)
|
||||
item.InvalidationReason = row.InvalidationReason
|
||||
// 最近解绑人只取解绑当时的名称快照与账号标识:有效关系保持为空,不以创建人或更新时间填充。
|
||||
item.InvalidatorID = row.Invalidator
|
||||
item.InvalidatorName = row.InvalidatorNameSnapshot
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return &dto.PhoneAssetAssociationPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil
|
||||
}
|
||||
|
||||
// loadValidAssociationCounts 按当页涉及的手机号集合一次聚合当前有效关联数量,禁止逐手机号查询。
|
||||
func (s *Service) loadValidAssociationCounts(ctx context.Context, rows []*model.PhoneAssetAssociation) (map[string]int64, error) {
|
||||
phones := make([]string, 0, len(rows))
|
||||
seen := make(map[string]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
if _, ok := seen[row.Phone]; ok {
|
||||
continue
|
||||
}
|
||||
seen[row.Phone] = struct{}{}
|
||||
phones = append(phones, row.Phone)
|
||||
}
|
||||
counts, err := s.associationStore.CountValidByPhones(ctx, phones)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量统计手机号有效关联数量失败")
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// loadAssetIdentifiers 按资产类型各一次 IN 批量读取资产当前标识,禁止逐行查询。
|
||||
func (s *Service) loadAssetIdentifiers(ctx context.Context, rows []*model.PhoneAssetAssociation) (map[string]string, error) {
|
||||
result := make(map[string]string, len(rows))
|
||||
|
||||
@@ -53,6 +53,20 @@ func PriorityPollingReadScope(ctx context.Context) ([]uint, error) {
|
||||
return shopIDs, nil
|
||||
}
|
||||
|
||||
// requirePriorityPollingAdmin 判定当前账号是否为可执行人工关闭的超级管理员或平台账号。
|
||||
// 代理账号即使对该卡有数据权限也不能关闭优先项:关闭是不可逆的队列出口,
|
||||
// 与人工入队相比是更窄的授权面。拒绝一律返回统一文案,不暴露资源是否存在。
|
||||
func requirePriorityPollingAdmin(ctx context.Context) error {
|
||||
skip, err := pollingUserTypePermission(ctx)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeForbidden, pollingDeniedMessage)
|
||||
}
|
||||
if !skip {
|
||||
return errors.New(errors.CodeForbidden, pollingDeniedMessage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// canManagePollingCard 检查用户是否有权管理单张卡。
|
||||
func canManagePollingCard(ctx context.Context, iotCardStore *postgres.IotCardStore, cardID uint) error {
|
||||
skip, err := pollingUserTypePermission(ctx)
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// PriorityEnqueueService 人工优先入队用例。
|
||||
//
|
||||
// 与既有人工触发的关系:共用同包权限判定(permission.go),但**不继承**人工触发的每日次数上限与
|
||||
// 24 小时去重——它们是人工触发的防滥用配额,不是队列不变量;重复抑制由活动项合并承担,
|
||||
// 且每次人工入队独立记录审计。本用例也不修改调度优先级、不绕过既有并发上限。
|
||||
type PriorityEnqueueService struct {
|
||||
db *gorm.DB
|
||||
iotCardStore *postgres.IotCardStore
|
||||
priorityStore *postgres.PollingPriorityItemStore
|
||||
publisher priorityapp.PromptPublisher
|
||||
auditWriter *audit.Writer
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPriorityEnqueueService 创建人工优先入队用例。
|
||||
func NewPriorityEnqueueService(
|
||||
db *gorm.DB,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
priorityStore *postgres.PollingPriorityItemStore,
|
||||
publisher priorityapp.PromptPublisher,
|
||||
logger *zap.Logger,
|
||||
) *PriorityEnqueueService {
|
||||
return &PriorityEnqueueService{
|
||||
db: db,
|
||||
iotCardStore: iotCardStore,
|
||||
priorityStore: priorityStore,
|
||||
publisher: publisher,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAudit 注入统一审计 Writer。
|
||||
func (s *PriorityEnqueueService) SetAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// EnqueueResult 描述一次人工优先入队的结果:一次优先需求覆盖该卡的全部纳入任务类型。
|
||||
type EnqueueResult struct {
|
||||
CardID uint
|
||||
TaskTypes []string
|
||||
CreatedCount int
|
||||
MergedCount int
|
||||
Items []EnqueueItemResult
|
||||
}
|
||||
|
||||
// EnqueueItemResult 是单个任务类型的入队结果。
|
||||
type EnqueueItemResult struct {
|
||||
ItemID uint
|
||||
TaskType string
|
||||
Status string
|
||||
TriggerCount int
|
||||
LastTriggeredAt time.Time
|
||||
Created bool
|
||||
}
|
||||
|
||||
// Enqueue 为该卡建立或合并全部纳入轮询任务类型的优先项,并在提交后逐类型下发执行提示。
|
||||
//
|
||||
// 一次优先需求 = 该卡的全部纳入任务类型(constants.PollingPriorityTaskTypes),
|
||||
// 与执行集合一致;入队对象是卡,不做设备维度入队。
|
||||
func (s *PriorityEnqueueService) Enqueue(ctx context.Context, cardID uint, reason string, operatorID uint) (*EnqueueResult, error) {
|
||||
if cardID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
||||
}
|
||||
trimmedReason := strings.TrimSpace(reason)
|
||||
if trimmedReason == "" {
|
||||
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeInvalidParam, "人工优先入队原因不能为空")
|
||||
}
|
||||
if len([]rune(trimmedReason)) > constants.PollingPriorityManualReasonMaxLength {
|
||||
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeInvalidParam, "人工优先入队原因长度超出上限")
|
||||
}
|
||||
|
||||
// 权限判定与既有人工触发共用同一套语义:超管/平台放行、企业拒绝、代理限自身与下级、平台卡不可见。
|
||||
if err := canManagePollingCard(ctx, s.iotCardStore, cardID); err != nil {
|
||||
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil || card == nil {
|
||||
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
|
||||
operatorName := middleware.GetUsernameFromContext(ctx)
|
||||
taskTypes := constants.PollingPriorityTaskTypes()
|
||||
result := &EnqueueResult{CardID: cardID, TaskTypes: taskTypes}
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
store := s.priorityStore.WithTx(tx)
|
||||
for _, taskType := range taskTypes {
|
||||
item := &model.PollingPriorityItem{
|
||||
CardID: cardID,
|
||||
TaskType: taskType,
|
||||
TriggerType: constants.PollingPriorityTriggerManual,
|
||||
ManualReason: trimmedReason,
|
||||
ManualOperatorID: operatorID,
|
||||
ManualOperatorName: operatorName,
|
||||
ShopIDSnapshot: card.ShopID,
|
||||
}
|
||||
// 先读取合并前的活动项作为审计 Before 的真实快照;无活动项则 Before 为空。
|
||||
// 残余近似:并发下相邻执行可能在该读取与合并之间把该行终态化并另建新行,
|
||||
// 此时 Before 描述的是读取时点的活动项而非实际被合并行,属可接受的审计快照。
|
||||
before, beforeErr := store.FindActive(ctx, cardID, taskType)
|
||||
if beforeErr != nil {
|
||||
return beforeErr
|
||||
}
|
||||
created, insertErr := store.InsertOrMerge(ctx, item)
|
||||
if insertErr != nil {
|
||||
return insertErr
|
||||
}
|
||||
active, activeErr := store.FindActive(ctx, cardID, taskType)
|
||||
if activeErr != nil {
|
||||
return activeErr
|
||||
}
|
||||
if active == nil {
|
||||
return errors.New(errors.CodeInternalError, "优先轮询项入队后未能读回活动项")
|
||||
}
|
||||
if created {
|
||||
result.CreatedCount++
|
||||
} else {
|
||||
result.MergedCount++
|
||||
}
|
||||
result.Items = append(result.Items, EnqueueItemResult{
|
||||
ItemID: active.ID,
|
||||
TaskType: active.TaskType,
|
||||
Status: active.Status,
|
||||
TriggerCount: active.TriggerCount,
|
||||
LastTriggeredAt: active.LastTriggeredAt,
|
||||
Created: created,
|
||||
})
|
||||
// 入队事实与审计同事务:合并结果同样落库,避免「加急事实已生效但无审计」。
|
||||
if auditErr := writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityEnqueued,
|
||||
Summary: "人工优先入队",
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceID: active.ID,
|
||||
ResourceKey: pollingPriorityResourceKey(active.ID),
|
||||
DisplayName: "卡轮询优先项",
|
||||
OperatorID: operatorID,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": active.ID, "card_id": active.CardID, "task_type": active.TaskType,
|
||||
"status": active.Status, "trigger_type": active.TriggerType,
|
||||
"trigger_count": active.TriggerCount, "manual_operator_id": active.ManualOperatorID,
|
||||
},
|
||||
BeforeData: pollingPriorityBeforeData(before),
|
||||
AfterData: map[string]any{
|
||||
"status": active.Status, "trigger_type": active.TriggerType, "trigger_types": triggerTypesReadable(active.TriggerTypes),
|
||||
"trigger_count": active.TriggerCount, "last_triggered_at": active.LastTriggeredAt,
|
||||
"manual_reason": active.ManualReason, "manual_operator_id": active.ManualOperatorID,
|
||||
"source": constants.AuditSourceAdminAPI, "occurred_at": time.Now(),
|
||||
},
|
||||
Metadata: map[string]any{"created": created, "task_type": taskType, "card_id": cardID},
|
||||
Cards: []*model.IotCard{card},
|
||||
}); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 提交后逐任务类型下发执行提示:与既有手动触发队列分离的优先提示通道,每类型独立键。
|
||||
if s.publisher == nil {
|
||||
s.logger.Error("优先轮询提示通道未配置,本次入队只依赖普通轮询兜底", zap.Uint("card_id", cardID))
|
||||
} else {
|
||||
for _, taskType := range taskTypes {
|
||||
if publishErr := s.publisher.EnqueuePriority(ctx, cardID, taskType); publishErr != nil {
|
||||
// 提示通道不是权威:下发失败只退化为延迟一个普通轮询周期,不撤销已生效的入队事实。
|
||||
s.logger.Error("下发优先轮询执行提示失败",
|
||||
zap.Uint("card_id", cardID), zap.String("task_type", taskType), zap.Error(publishErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
s.logger.Info("人工优先入队成功",
|
||||
zap.Uint("card_id", cardID), zap.Int("created_count", result.CreatedCount),
|
||||
zap.Int("merged_count", result.MergedCount), zap.Uint("operator_id", operatorID))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// deny 记录一次人工优先入队被拒绝的审计,并返回原错误。
|
||||
// 拒绝发生在业务事务之外(或业务已回滚),因此沿用既有轮询模块的独立短事务模式。
|
||||
func (s *PriorityEnqueueService) deny(ctx context.Context, cardID, operatorID uint, card *model.IotCard, code int, summary string) error {
|
||||
appErr := errors.New(code, summary)
|
||||
identity := map[string]any{"card_id": cardID, "manual_operator_id": operatorID}
|
||||
var cards []*model.IotCard
|
||||
if card != nil {
|
||||
cards = []*model.IotCard{card}
|
||||
}
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityManualDenied,
|
||||
Summary: summary,
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceKey: pollingPriorityAttemptKey(cardID, operatorID),
|
||||
DisplayName: "人工优先入队",
|
||||
OperatorID: operatorID,
|
||||
Result: constants.AuditResultDenied,
|
||||
IdentitySnapshot: identity,
|
||||
Cards: cards,
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
// pollingPriorityResourceKey 返回优先项在审计里的稳定资源键。
|
||||
func pollingPriorityResourceKey(itemID uint) string {
|
||||
return strconv.FormatUint(uint64(itemID), 10)
|
||||
}
|
||||
|
||||
// pollingPriorityAttemptKey 返回无入队对象的拒绝审计资源键(按卡与操作者稳定)。
|
||||
func pollingPriorityAttemptKey(cardID, operatorID uint) string {
|
||||
return "priority:" + strconv.FormatUint(uint64(cardID), 10) + ":" + strconv.FormatUint(uint64(operatorID), 10)
|
||||
}
|
||||
|
||||
// pollingPriorityBeforeData 把合并前的活动项快照投影为审计前后值;无活动项(新建)时返回空对象。
|
||||
func pollingPriorityBeforeData(before *model.PollingPriorityItem) map[string]any {
|
||||
if before == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"status": before.Status,
|
||||
"trigger_type": before.TriggerType,
|
||||
"trigger_types": triggerTypesReadable(before.TriggerTypes),
|
||||
"trigger_count": before.TriggerCount,
|
||||
}
|
||||
}
|
||||
|
||||
// triggerTypesReadable 将存储形式的触发类型集合(两端补逗号)还原为可读的逗号分隔形式。
|
||||
func triggerTypesReadable(raw string) string {
|
||||
return strings.Trim(raw, ",")
|
||||
}
|
||||
137
internal/service/polling/priority_expiry_service.go
Normal file
137
internal/service/polling/priority_expiry_service.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"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"
|
||||
)
|
||||
|
||||
// pollingPriorityExpiryBatchSize 是有效期到期单轮扫描的批量上限。
|
||||
// 只限制每轮处理量,不改变语义:剩余的超期项由下一轮计划任务继续终结。
|
||||
const pollingPriorityExpiryBatchSize = 200
|
||||
|
||||
// PriorityExpiryService 优先轮询项有效期到期处理用例。
|
||||
//
|
||||
// 由独立周期任务承载(查询与读侧 MUST NOT 隐式触发):按固定有效期长度扫描有效期起算时间
|
||||
// 已超期的未完成项,逐条以条件更新转已过期终态并出队;终态写入与审计同事务。
|
||||
// 触发类型、尝试次数与最近失败原因全部保留,不删除任何既有事实,也不发起任何上游调用。
|
||||
type PriorityExpiryService struct {
|
||||
db *gorm.DB
|
||||
priorityStore *postgres.PollingPriorityItemStore
|
||||
auditWriter *audit.Writer
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPriorityExpiryService 创建优先轮询项有效期到期处理用例。
|
||||
func NewPriorityExpiryService(
|
||||
db *gorm.DB,
|
||||
priorityStore *postgres.PollingPriorityItemStore,
|
||||
logger *zap.Logger,
|
||||
) *PriorityExpiryService {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &PriorityExpiryService{db: db, priorityStore: priorityStore, logger: logger}
|
||||
}
|
||||
|
||||
// SetAudit 注入统一审计 Writer。
|
||||
func (s *PriorityExpiryService) SetAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// ExpireOverdue 终结所有已超过固定有效期且仍未完成的优先项,返回本轮实际终结的条数。
|
||||
// 逐条独立事务:单条失败不阻断其余超期项,失败原因由调用方按任务错误处理并重试。
|
||||
func (s *PriorityExpiryService) ExpireOverdue(ctx context.Context, now time.Time) (int, error) {
|
||||
if s == nil || s.db == nil || s.priorityStore == nil {
|
||||
return 0, errors.New(errors.CodeServiceUnavailable, "优先轮询项有效期到期处理未配置")
|
||||
}
|
||||
deadline := now.Add(-constants.PollingPriorityValidity)
|
||||
items, err := s.priorityStore.ListOverdueActive(ctx, deadline, pollingPriorityExpiryBatchSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
expiredCount := 0
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
marked := false
|
||||
err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
hit, markErr := s.priorityStore.WithTx(tx).MarkExpired(ctx, item.ID)
|
||||
if markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
if !hit {
|
||||
// 并发下该项已被执行终结或人工关闭:保留既有结论,不覆盖也不重复审计。
|
||||
return nil
|
||||
}
|
||||
marked = true
|
||||
return s.writeExpiryAudit(ctx, tx, item, now)
|
||||
})
|
||||
if err != nil {
|
||||
return expiredCount, err
|
||||
}
|
||||
if marked {
|
||||
expiredCount++
|
||||
}
|
||||
}
|
||||
if expiredCount > 0 {
|
||||
s.logger.Info("优先轮询项有效期到期处理完成",
|
||||
zap.Int("expired_count", expiredCount), zap.Int("scanned_count", len(items)))
|
||||
}
|
||||
return expiredCount, nil
|
||||
}
|
||||
|
||||
// writeExpiryAudit 以系统任务身份写入一条超期出队审计。
|
||||
// 计划任务没有后台账号,因此走任务审计形态(操作者=计划任务),而不是要求账号操作者的后台入口形态。
|
||||
func (s *PriorityExpiryService) writeExpiryAudit(ctx context.Context, tx *gorm.DB, item *model.PollingPriorityItem, now time.Time) error {
|
||||
if s == nil || s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "统一审计 Writer 未配置")
|
||||
}
|
||||
return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityExpired,
|
||||
Summary: "卡轮询优先项有效期到期出队",
|
||||
TaskID: item.ID,
|
||||
TaskNo: pollingPriorityResourceKey(item.ID),
|
||||
DisplayName: "卡轮询优先项",
|
||||
Actor: audit.ActorInput{
|
||||
Kind: constants.AuditActorSystemTask,
|
||||
ID: constants.TaskTypePollingPriorityExpiry,
|
||||
Name: "优先轮询项有效期到期处理任务",
|
||||
},
|
||||
Source: constants.AuditSourceWorker,
|
||||
ScopeType: constants.AuditScopePlatform,
|
||||
Result: constants.AuditResultSuccess,
|
||||
CorrelationID: constants.TaskTypePollingPriorityExpiry,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": item.ID, "card_id": item.CardID, "task_type": item.TaskType,
|
||||
"status": item.Status, "trigger_type": item.TriggerType,
|
||||
"attempt_count": item.AttemptCount,
|
||||
},
|
||||
BeforeData: map[string]any{
|
||||
"status": item.Status, "result": item.Result, "failure_reason": item.FailureReason,
|
||||
"attempt_count": item.AttemptCount, "next_run_at": item.NextRunAt,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"status": constants.PollingPriorityStatusExpired, "dequeued_at": now,
|
||||
"priority_effective_from": item.PriorityEffectiveFrom,
|
||||
"expired_at": now,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"card_id": item.CardID, "task_type": item.TaskType,
|
||||
"priority_effective_from": item.PriorityEffectiveFrom,
|
||||
},
|
||||
})
|
||||
}
|
||||
540
internal/service/polling/priority_manual_service.go
Normal file
540
internal/service/polling/priority_manual_service.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// PriorityManualService 人工优先轮询入口用例:入队、人工关闭与人工重触发。
|
||||
//
|
||||
// 与既有人工触发的关系:共用同包权限判定(permission.go),但**不继承**人工触发的每日次数上限与
|
||||
// 24 小时去重——它们是人工触发的防滥用配额,不是队列不变量;重复抑制由活动项合并承担,
|
||||
// 且每次人工入队独立记录审计。本用例也不修改调度优先级、不绕过既有并发上限。
|
||||
//
|
||||
// 三个入口的权限口径:入队与重触发按卡校验(超级管理员、平台账号与数据范围内的代理账号),
|
||||
// 人工关闭只允许超级管理员与平台账号。三者对越权与不存在一律返回同一拒绝文案,不产生可枚举差异。
|
||||
type PriorityManualService struct {
|
||||
db *gorm.DB
|
||||
iotCardStore *postgres.IotCardStore
|
||||
priorityStore *postgres.PollingPriorityItemStore
|
||||
publisher priorityapp.PromptPublisher
|
||||
auditWriter *audit.Writer
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPriorityManualService 创建人工优先轮询入口用例。
|
||||
func NewPriorityManualService(
|
||||
db *gorm.DB,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
priorityStore *postgres.PollingPriorityItemStore,
|
||||
publisher priorityapp.PromptPublisher,
|
||||
logger *zap.Logger,
|
||||
) *PriorityManualService {
|
||||
return &PriorityManualService{
|
||||
db: db,
|
||||
iotCardStore: iotCardStore,
|
||||
priorityStore: priorityStore,
|
||||
publisher: publisher,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAudit 注入统一审计 Writer。
|
||||
func (s *PriorityManualService) SetAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// EnqueueResult 描述一次人工优先入队的结果:一次优先需求覆盖给定的任务类型集合。
|
||||
type EnqueueResult struct {
|
||||
CardID uint
|
||||
TaskTypes []string
|
||||
CreatedCount int
|
||||
MergedCount int
|
||||
Items []EnqueueItemResult
|
||||
}
|
||||
|
||||
// EnqueueItemResult 是单个任务类型的入队结果。
|
||||
type EnqueueItemResult struct {
|
||||
ItemID uint
|
||||
TaskType string
|
||||
Status string
|
||||
AttemptCount int
|
||||
TriggerCount int
|
||||
LastTriggeredAt time.Time
|
||||
Created bool
|
||||
}
|
||||
|
||||
// CloseResult 描述一次人工关闭的结果。
|
||||
type CloseResult struct {
|
||||
ItemID uint
|
||||
CardID uint
|
||||
TaskType string
|
||||
Status string
|
||||
DequeuedAt time.Time
|
||||
Reason string
|
||||
}
|
||||
|
||||
// RetriggerResult 描述一次人工重触发的结果:以原终态项为来源,为该卡该任务类型建立或合并活动项。
|
||||
type RetriggerResult struct {
|
||||
SourceItemID uint
|
||||
Reason string
|
||||
Enqueue *EnqueueResult
|
||||
}
|
||||
|
||||
// priorityManualEnqueue 描述一次人工优先入队请求(入队与重触发共用同一实现)。
|
||||
type priorityManualEnqueue struct {
|
||||
CardID uint
|
||||
Reason string
|
||||
OperatorID uint
|
||||
OperatorName string
|
||||
TaskTypes []string
|
||||
// DeniedActionCode 是本次入口被拒绝时登记的审计动作码。
|
||||
DeniedActionCode string
|
||||
// RetriggeredFromItemID 非 0 表示本次入队来自对某终态项的人工重触发:
|
||||
// 除逐任务类型的入队审计外追加一条重触发审计,两者与新活动项同一事务。
|
||||
RetriggeredFromItemID uint
|
||||
}
|
||||
|
||||
// 人工入口的固定中文提示:原因必填与长度上限三项入口共用同一口径。
|
||||
const (
|
||||
priorityEnqueueReasonEmptyMessage = "人工优先入队原因不能为空"
|
||||
priorityCloseReasonEmptyMessage = "人工关闭原因不能为空"
|
||||
priorityRetriggerReasonEmptyMessage = "人工重触发原因不能为空"
|
||||
priorityReasonTooLongMessage = "人工原因长度超出上限"
|
||||
priorityCloseInvalidStateMessage = "仅待执行或执行中的优先轮询项可关闭"
|
||||
priorityRetriggerInvalidStateMessage = "仅失败出队或已过期的优先轮询项可重触发"
|
||||
)
|
||||
|
||||
// Enqueue 为该卡建立或合并全部纳入轮询任务类型的优先项,并在提交后逐类型下发执行提示。
|
||||
//
|
||||
// 一次优先需求 = 该卡的全部纳入任务类型(constants.PollingPriorityTaskTypes),
|
||||
// 与执行集合一致;入队对象是卡,不做设备维度入队。
|
||||
func (s *PriorityManualService) Enqueue(ctx context.Context, cardID uint, reason string, operatorID uint) (*EnqueueResult, error) {
|
||||
trimmedReason, message := normalizePriorityReason(reason, priorityEnqueueReasonEmptyMessage)
|
||||
if message != "" {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityManualDenied, errors.CodeInvalidParam, message,
|
||||
pollingPriorityAttemptKey(cardID, operatorID),
|
||||
map[string]any{"card_id": cardID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
return s.enqueue(ctx, priorityManualEnqueue{
|
||||
CardID: cardID,
|
||||
Reason: trimmedReason,
|
||||
OperatorID: operatorID,
|
||||
OperatorName: middleware.GetUsernameFromContext(ctx),
|
||||
TaskTypes: constants.PollingPriorityTaskTypes(),
|
||||
DeniedActionCode: constants.AuditActionPollingPriorityManualDenied,
|
||||
})
|
||||
}
|
||||
|
||||
// Close 人工关闭未完成的优先项:原因必填,未完成项转已关闭终态并出队,既有执行结果与失败原因保留。
|
||||
//
|
||||
// 只允许超级管理员与平台账号:代理账号即使对该卡有数据权限也不能关闭优先项。
|
||||
// 关闭不发起任何上游调用,也不删除既有事实;关闭原因只进审计,不写入失败原因。
|
||||
func (s *PriorityManualService) Close(ctx context.Context, itemID uint, reason string, operatorID uint) (*CloseResult, error) {
|
||||
trimmedReason, message := normalizePriorityReason(reason, priorityCloseReasonEmptyMessage)
|
||||
if message != "" {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeInvalidParam, message,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
if err := requirePriorityPollingAdmin(ctx); err != nil {
|
||||
// 越权与不存在共用同一文案:不暴露他人资源是否存在。
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
item, err := s.priorityStore.GetByIDScoped(ctx, itemID, nil)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询项失败")
|
||||
}
|
||||
if !constants.PollingPriorityActive(item.Status) {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeInvalidStatus,
|
||||
priorityCloseInvalidStateMessage, pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "status": item.Status, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
|
||||
card := s.loadCard(ctx, item.CardID)
|
||||
closedAt := time.Now()
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
hit, closeErr := s.priorityStore.WithTx(tx).Close(ctx, itemID)
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if !hit {
|
||||
// 条件更新落空:该项已被并发执行终结,本次关闭不生效也不覆盖既有结论。
|
||||
return errors.New(errors.CodeInvalidStatus, "优先轮询项已被并发执行终结,本次关闭未生效")
|
||||
}
|
||||
// 关闭事实与审计同事务:审计写不进去就整体回滚,不出现「已关闭但无审计」。
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityClosed,
|
||||
Summary: "人工关闭卡轮询优先项",
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceID: itemID,
|
||||
ResourceKey: pollingPriorityResourceKey(itemID),
|
||||
DisplayName: "卡轮询优先项",
|
||||
OperatorID: operatorID,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": itemID, "card_id": item.CardID, "task_type": item.TaskType,
|
||||
"status": item.Status, "trigger_type": item.TriggerType,
|
||||
"attempt_count": item.AttemptCount, "manual_operator_id": operatorID,
|
||||
},
|
||||
BeforeData: map[string]any{"status": item.Status, "result": item.Result, "failure_reason": item.FailureReason},
|
||||
AfterData: map[string]any{
|
||||
"status": constants.PollingPriorityStatusClosed, "dequeued_at": closedAt,
|
||||
"reason": trimmedReason, "manual_operator_id": operatorID,
|
||||
"source": constants.AuditSourceAdminAPI, "occurred_at": closedAt,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"reason": trimmedReason, "card_id": item.CardID, "task_type": item.TaskType,
|
||||
"previous_status": item.Status,
|
||||
},
|
||||
Cards: cardSlice(card),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Info("人工关闭优先轮询项成功",
|
||||
zap.Uint("priority_item_id", itemID), zap.Uint("operator_id", operatorID),
|
||||
zap.String("previous_status", item.Status))
|
||||
return &CloseResult{
|
||||
ItemID: itemID, CardID: item.CardID, TaskType: item.TaskType,
|
||||
Status: constants.PollingPriorityStatusClosed, DequeuedAt: closedAt, Reason: trimmedReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Retrigger 对失败出队或已过期的优先项执行人工重触发。
|
||||
//
|
||||
// 复用人工入队同一实现为该卡**该任务类型**建立新的活动项(尝试次数从零开始),
|
||||
// 原因必填并与操作者一同写入审计;不绕过活动项唯一键代表的并发上限,
|
||||
// 停复机持锁拒绝与任务类型范围仍由既有执行路径负责,本入口不发起任何上游调用。
|
||||
func (s *PriorityManualService) Retrigger(ctx context.Context, itemID uint, reason string, operatorID uint) (*RetriggerResult, error) {
|
||||
trimmedReason, message := normalizePriorityReason(reason, priorityRetriggerReasonEmptyMessage)
|
||||
if message != "" {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeInvalidParam, message,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
// 先按主键定位来源项:不存在与越权在后续按卡校验中收敛为同一拒绝文案。
|
||||
item, err := s.priorityStore.GetByIDScoped(ctx, itemID, nil)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询项失败")
|
||||
}
|
||||
// 数据范围按卡在业务边界校验,且必须先于状态判断:否则可依据状态差异枚举范围外资源是否存在。
|
||||
if err := canManagePollingCard(ctx, s.iotCardStore, item.CardID); err != nil {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "card_id": item.CardID, "manual_operator_id": operatorID},
|
||||
operatorID, cardSlice(s.loadCard(ctx, item.CardID)))
|
||||
}
|
||||
if !constants.PollingPriorityRetriggerable(item.Status) {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeInvalidStatus,
|
||||
priorityRetriggerInvalidStateMessage, pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "status": item.Status, "manual_operator_id": operatorID},
|
||||
operatorID, cardSlice(s.loadCard(ctx, item.CardID)))
|
||||
}
|
||||
|
||||
result, err := s.enqueue(ctx, priorityManualEnqueue{
|
||||
CardID: item.CardID,
|
||||
Reason: trimmedReason,
|
||||
OperatorID: operatorID,
|
||||
OperatorName: middleware.GetUsernameFromContext(ctx),
|
||||
TaskTypes: []string{item.TaskType},
|
||||
DeniedActionCode: constants.AuditActionPollingPriorityRetriggered,
|
||||
RetriggeredFromItemID: itemID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Info("人工重触发优先轮询项成功",
|
||||
zap.Uint("source_item_id", itemID), zap.Uint("card_id", item.CardID),
|
||||
zap.String("task_type", item.TaskType), zap.Uint("operator_id", operatorID))
|
||||
return &RetriggerResult{SourceItemID: itemID, Reason: trimmedReason, Enqueue: result}, nil
|
||||
}
|
||||
|
||||
// enqueue 执行人工优先入队:权限校验、逐任务类型建立或合并活动项、同事务写审计,提交后下发执行提示。
|
||||
func (s *PriorityManualService) enqueue(ctx context.Context, input priorityManualEnqueue) (*EnqueueResult, error) {
|
||||
if input.CardID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
||||
}
|
||||
// 权限判定与既有人工触发共用同一套语义:超管/平台放行、企业拒绝、代理限自身与下级、平台卡不可见。
|
||||
if err := canManagePollingCard(ctx, s.iotCardStore, input.CardID); err != nil {
|
||||
return nil, s.denyManual(ctx, input.DeniedActionCode, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityAttemptKey(input.CardID, input.OperatorID),
|
||||
map[string]any{"card_id": input.CardID, "manual_operator_id": input.OperatorID}, input.OperatorID, nil)
|
||||
}
|
||||
card, err := s.iotCardStore.GetByID(ctx, input.CardID)
|
||||
if err != nil || card == nil {
|
||||
return nil, s.denyManual(ctx, input.DeniedActionCode, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityAttemptKey(input.CardID, input.OperatorID),
|
||||
map[string]any{"card_id": input.CardID, "manual_operator_id": input.OperatorID}, input.OperatorID, nil)
|
||||
}
|
||||
|
||||
taskTypes := input.TaskTypes
|
||||
result := &EnqueueResult{CardID: input.CardID, TaskTypes: taskTypes}
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
store := s.priorityStore.WithTx(tx)
|
||||
// 资产身份与代理归属在入队时冻结,口径与迁移回填一致;查询不到卡行时按「资产为卡自身」兜底。
|
||||
snapshots, snapshotErr := store.AssetSnapshots(ctx, []uint{input.CardID})
|
||||
if snapshotErr != nil {
|
||||
return snapshotErr
|
||||
}
|
||||
snapshot := snapshots[input.CardID]
|
||||
for _, taskType := range taskTypes {
|
||||
item := &model.PollingPriorityItem{
|
||||
CardID: input.CardID,
|
||||
TaskType: taskType,
|
||||
TriggerType: constants.PollingPriorityTriggerManual,
|
||||
ManualReason: input.Reason,
|
||||
ManualOperatorID: input.OperatorID,
|
||||
ManualOperatorName: input.OperatorName,
|
||||
ShopIDSnapshot: card.ShopID,
|
||||
AssetType: snapshot.AssetType,
|
||||
AssetID: optionalUintValue(snapshot.AssetID),
|
||||
DeviceNoSnapshot: snapshot.DeviceNo,
|
||||
AgentShopIDSnapshot: snapshot.AgentShopID,
|
||||
}
|
||||
// 先读取合并前的活动项作为审计 Before 的真实快照;无活动项则 Before 为空。
|
||||
// 残余近似:并发下相邻执行可能在该读取与合并之间把该行终态化并另建新行,
|
||||
// 此时 Before 描述的是读取时点的活动项而非实际被合并行,属可接受的审计快照。
|
||||
before, beforeErr := store.FindActive(ctx, input.CardID, taskType)
|
||||
if beforeErr != nil {
|
||||
return beforeErr
|
||||
}
|
||||
created, insertErr := store.InsertOrMerge(ctx, item)
|
||||
if insertErr != nil {
|
||||
return insertErr
|
||||
}
|
||||
active, activeErr := store.FindActive(ctx, input.CardID, taskType)
|
||||
if activeErr != nil {
|
||||
return activeErr
|
||||
}
|
||||
if active == nil {
|
||||
return errors.New(errors.CodeInternalError, "优先轮询项入队后未能读回活动项")
|
||||
}
|
||||
if created {
|
||||
result.CreatedCount++
|
||||
} else {
|
||||
result.MergedCount++
|
||||
}
|
||||
result.Items = append(result.Items, EnqueueItemResult{
|
||||
ItemID: active.ID,
|
||||
TaskType: active.TaskType,
|
||||
Status: active.Status,
|
||||
AttemptCount: active.AttemptCount,
|
||||
TriggerCount: active.TriggerCount,
|
||||
LastTriggeredAt: active.LastTriggeredAt,
|
||||
Created: created,
|
||||
})
|
||||
// 入队事实与审计同事务:合并结果同样落库,避免「加急事实已生效但无审计」。
|
||||
if auditErr := writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityEnqueued,
|
||||
Summary: "人工优先入队",
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceID: active.ID,
|
||||
ResourceKey: pollingPriorityResourceKey(active.ID),
|
||||
DisplayName: "卡轮询优先项",
|
||||
OperatorID: input.OperatorID,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": active.ID, "card_id": active.CardID, "task_type": active.TaskType,
|
||||
"status": active.Status, "trigger_type": active.TriggerType,
|
||||
"trigger_count": active.TriggerCount, "manual_operator_id": active.ManualOperatorID,
|
||||
},
|
||||
BeforeData: pollingPriorityBeforeData(before),
|
||||
AfterData: map[string]any{
|
||||
"status": active.Status, "trigger_type": active.TriggerType, "trigger_types": triggerTypesReadable(active.TriggerTypes),
|
||||
"trigger_count": active.TriggerCount, "last_triggered_at": active.LastTriggeredAt,
|
||||
"manual_reason": active.ManualReason, "manual_operator_id": active.ManualOperatorID,
|
||||
"source": constants.AuditSourceAdminAPI, "occurred_at": time.Now(),
|
||||
},
|
||||
Metadata: map[string]any{"created": created, "task_type": taskType, "card_id": input.CardID},
|
||||
Cards: []*model.IotCard{card},
|
||||
}); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
if input.RetriggeredFromItemID != 0 {
|
||||
if auditErr := s.writeRetriggerAudit(ctx, tx, input, active, taskType, card); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 提交后逐任务类型下发执行提示:与既有手动触发队列分离的优先提示通道,每类型独立键。
|
||||
if s.publisher == nil {
|
||||
s.logger.Error("优先轮询提示通道未配置,本次入队只依赖普通轮询兜底", zap.Uint("card_id", input.CardID))
|
||||
} else {
|
||||
for _, taskType := range taskTypes {
|
||||
if publishErr := s.publisher.EnqueuePriority(ctx, input.CardID, taskType); publishErr != nil {
|
||||
// 提示通道不是权威:下发失败只退化为延迟一个普通轮询周期,不撤销已生效的入队事实。
|
||||
s.logger.Error("下发优先轮询执行提示失败",
|
||||
zap.Uint("card_id", input.CardID), zap.String("task_type", taskType), zap.Error(publishErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
s.logger.Info("人工优先入队成功",
|
||||
zap.Uint("card_id", input.CardID), zap.Int("created_count", result.CreatedCount),
|
||||
zap.Int("merged_count", result.MergedCount), zap.Uint("operator_id", input.OperatorID))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// writeRetriggerAudit 写入一条人工重触发审计,与新建活动项同一事务。
|
||||
func (s *PriorityManualService) writeRetriggerAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
input priorityManualEnqueue,
|
||||
active *model.PollingPriorityItem,
|
||||
taskType string,
|
||||
card *model.IotCard,
|
||||
) error {
|
||||
occurredAt := time.Now()
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityRetriggered,
|
||||
Summary: "人工重触发卡轮询优先项",
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceID: active.ID,
|
||||
ResourceKey: pollingPriorityResourceKey(active.ID),
|
||||
DisplayName: "卡轮询优先项",
|
||||
OperatorID: input.OperatorID,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": active.ID, "card_id": active.CardID, "task_type": active.TaskType,
|
||||
"status": active.Status, "trigger_type": active.TriggerType,
|
||||
"attempt_count": active.AttemptCount, "manual_operator_id": active.ManualOperatorID,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"source_item_id": input.RetriggeredFromItemID, "reason": input.Reason,
|
||||
"status": active.Status, "attempt_count": active.AttemptCount, "attempt_limit": active.AttemptLimit,
|
||||
"trigger_type": active.TriggerType, "created": true,
|
||||
"source": constants.AuditSourceAdminAPI, "occurred_at": occurredAt,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"source_item_id": input.RetriggeredFromItemID, "reason": input.Reason,
|
||||
"card_id": active.CardID, "task_type": taskType,
|
||||
},
|
||||
Cards: cardSlice(card),
|
||||
})
|
||||
}
|
||||
|
||||
// denyManual 记录一次人工入口被拒绝的审计,并返回原错误。
|
||||
// 拒绝发生在业务事务之外(或业务已回滚),因此沿用既有轮询模块的独立短事务模式。
|
||||
func (s *PriorityManualService) denyManual(
|
||||
ctx context.Context,
|
||||
actionCode string,
|
||||
code int,
|
||||
summary string,
|
||||
resourceKey string,
|
||||
identity map[string]any,
|
||||
operatorID uint,
|
||||
cards []*model.IotCard,
|
||||
) error {
|
||||
appErr := errors.New(code, summary)
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: actionCode,
|
||||
Summary: summary,
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceKey: resourceKey,
|
||||
DisplayName: "卡轮询优先项",
|
||||
OperatorID: operatorID,
|
||||
Result: constants.AuditResultDenied,
|
||||
IdentitySnapshot: identity,
|
||||
Cards: cards,
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
// loadCard 读取卡用于审计关联资源;读取失败只损失卡的关联资源,不阻断主流程。
|
||||
func (s *PriorityManualService) loadCard(ctx context.Context, cardID uint) *model.IotCard {
|
||||
if cardID == 0 {
|
||||
return nil
|
||||
}
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
// cardSlice 把可空卡指针转成审计所需的关联资源切片。
|
||||
func cardSlice(card *model.IotCard) []*model.IotCard {
|
||||
if card == nil {
|
||||
return nil
|
||||
}
|
||||
return []*model.IotCard{card}
|
||||
}
|
||||
|
||||
// normalizePriorityReason 校验人工原因:去空白后非空且不超过事实表原因列的长度上限。
|
||||
// 返回的第二个值非空即为拒绝提示,调用方据此记录拒绝审计并拒绝请求。
|
||||
func normalizePriorityReason(reason, emptyMessage string) (string, string) {
|
||||
trimmed := strings.TrimSpace(reason)
|
||||
if trimmed == "" {
|
||||
return "", emptyMessage
|
||||
}
|
||||
if len([]rune(trimmed)) > constants.PollingPriorityManualReasonMaxLength {
|
||||
return "", priorityReasonTooLongMessage
|
||||
}
|
||||
return trimmed, ""
|
||||
}
|
||||
|
||||
// optionalUintValue 把非零 ID 转成可空指针,零值以 NULL 参与写入。
|
||||
func optionalUintValue(value uint) *uint {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
// pollingPriorityResourceKey 返回优先项在审计里的稳定资源键。
|
||||
func pollingPriorityResourceKey(itemID uint) string {
|
||||
return strconv.FormatUint(uint64(itemID), 10)
|
||||
}
|
||||
|
||||
// pollingPriorityAttemptKey 返回无入队对象的拒绝审计资源键(按卡与操作者稳定)。
|
||||
func pollingPriorityAttemptKey(cardID, operatorID uint) string {
|
||||
return "priority:" + strconv.FormatUint(uint64(cardID), 10) + ":" + strconv.FormatUint(uint64(operatorID), 10)
|
||||
}
|
||||
|
||||
// pollingPriorityBeforeData 把合并前的活动项快照投影为审计前后值;无活动项(新建)时返回空对象。
|
||||
func pollingPriorityBeforeData(before *model.PollingPriorityItem) map[string]any {
|
||||
if before == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"status": before.Status,
|
||||
"trigger_type": before.TriggerType,
|
||||
"trigger_types": triggerTypesReadable(before.TriggerTypes),
|
||||
"trigger_count": before.TriggerCount,
|
||||
}
|
||||
}
|
||||
|
||||
// triggerTypesReadable 将存储形式的触发类型集合(两端补逗号)还原为可读的逗号分隔形式。
|
||||
func triggerTypesReadable(raw string) string {
|
||||
return strings.Trim(raw, ",")
|
||||
}
|
||||
@@ -64,6 +64,7 @@ func (s *Service) loadAttemptResponses(ctx context.Context, refunds []*model.Ref
|
||||
RefundAmount: attempt.RefundAmount,
|
||||
FrozenActualReceivedAmount: attempt.FrozenActualReceivedAmount,
|
||||
RefundReason: attempt.RefundReason,
|
||||
Remark: attempt.Remark,
|
||||
CustomerAccountInfo: attempt.CustomerAccountInfo,
|
||||
CustomerVoucherKey: []string(attempt.CustomerVoucherKeys),
|
||||
ChannelRefundRequestNo: attempt.ChannelRefundRequestNo,
|
||||
|
||||
71
internal/service/refund/material.go
Normal file
71
internal/service/refund/material.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package refund
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
// refundAssetType 把订单类型映射为退款资产类型:card 单卡、device 设备。
|
||||
// 与退款列表、详情响应的资产类型口径一致;空值或未知订单类型返回空串。
|
||||
func refundAssetType(orderType string) string {
|
||||
switch orderType {
|
||||
case model.OrderTypeSingleCard:
|
||||
return "card"
|
||||
case model.OrderTypeDevice:
|
||||
return "device"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// loadRefundMaterial 按既有展示口径解析本次提交的企业微信审批材料事实。
|
||||
//
|
||||
// 三个维度各自独立解析且全部尽力而为:
|
||||
// 1. 资产类型沿用退款申请的下单快照推导(refundAssetType,与详情响应同一规则);
|
||||
// 2. 设备类型与型号取退款请求关联设备,无关联设备或设备不可读时留空;
|
||||
// 3. 套餐已用量与总量复用退款展示解析规则(loadRefundPackageUsages,冻结套餐记录 → 订单主套餐 →
|
||||
// 订单任一套餐)与真流量口径,解析不到时为 0;
|
||||
// 4. 原支付渠道交易流水号取创建申请时冻结的原成功支付事实,线下订单为空。
|
||||
//
|
||||
// 审批材料是审批判断依据而非金额与状态判定输入,因此解析失败 MUST NOT 阻断审批提交:
|
||||
// 任一项不可解析时以空值或零值提交,并记录日志供排查。
|
||||
func (s *Service) loadRefundMaterial(ctx context.Context, refund *model.RefundRequest, payment *model.Payment) refundapprovalapp.RefundMaterial {
|
||||
material := refundapprovalapp.RefundMaterial{
|
||||
AssetType: refundAssetType(refund.OrderType),
|
||||
OriginalChannelTradeNo: strings.TrimSpace(refund.OriginalChannelTradeNo),
|
||||
}
|
||||
if payment != nil && strings.TrimSpace(payment.ThirdPartyTradeNo) != "" {
|
||||
material.OriginalChannelTradeNo = strings.TrimSpace(payment.ThirdPartyTradeNo)
|
||||
}
|
||||
if refund.DeviceID != nil && *refund.DeviceID > 0 {
|
||||
var device model.Device
|
||||
if err := s.db.WithContext(ctx).Select("id", "device_type", "device_model").First(&device, *refund.DeviceID).Error; err != nil {
|
||||
s.logMaterialFailure(ctx, refund.ID, "device", err)
|
||||
} else {
|
||||
material.DeviceType = strings.TrimSpace(device.DeviceType)
|
||||
material.DeviceModel = strings.TrimSpace(device.DeviceModel)
|
||||
}
|
||||
}
|
||||
usages, err := s.loadRefundPackageUsages(ctx, []*model.RefundRequest{refund})
|
||||
if err != nil {
|
||||
s.logMaterialFailure(ctx, refund.ID, "package_usage", err)
|
||||
} else if usage, exists := usages[refund.ID]; exists {
|
||||
material.PackageUsedMB = usage.UsedMB
|
||||
material.PackageTotalMB = usage.TotalMB
|
||||
}
|
||||
return material
|
||||
}
|
||||
|
||||
// logMaterialFailure 记录审批材料解析失败:材料按空值或零值降级提交,因此只记录不返回错误。
|
||||
func (s *Service) logMaterialFailure(ctx context.Context, refundID uint, dimension string, err error) {
|
||||
if s.logger == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Warn("退款审批材料解析失败,按空值提交",
|
||||
zap.Uint("refund_id", refundID), zap.String("dimension", dimension), zap.Error(err))
|
||||
}
|
||||
@@ -263,3 +263,29 @@ func buildChannelRefundRequestNo(method string, config *model.WechatConfig) stri
|
||||
}
|
||||
return requestChannelRefundNo(config, time.Now())
|
||||
}
|
||||
|
||||
// frozenSourcePaymentNo 取原成功支付记录的支付单号作为冻结的来源支付单号。
|
||||
// 线下订单没有线上支付记录(payment 为 nil),返回空串以空值保存,不阻断申请创建。
|
||||
func frozenSourcePaymentNo(payment *model.Payment) string {
|
||||
if payment == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(payment.PaymentNo)
|
||||
}
|
||||
|
||||
// frozenOriginalChannelTradeNo 取原成功支付记录的渠道交易流水号作为冻结的原支付渠道交易流水号。
|
||||
// 线下订单没有线上支付记录(payment 为 nil 或流水号缺失),返回空串以空值保存。
|
||||
func frozenOriginalChannelTradeNo(payment *model.Payment) string {
|
||||
if payment == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(payment.ThirdPartyTradeNo)
|
||||
}
|
||||
|
||||
// applicantRemark 归一化请求中的申请人备注:缺省与纯空白一律视为未填写。
|
||||
func applicantRemark(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
|
||||
@@ -152,6 +152,11 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 退款原因必填只在创建与重提用例校验:历史申请与补发历史审批路径不受约束、不追溯。
|
||||
refundReason := strings.TrimSpace(req.RefundReason)
|
||||
if refundReason == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "退款原因不能为空")
|
||||
}
|
||||
if err := validateRefundMethod(decision, req.Method); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -191,8 +196,11 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
|
||||
Method: req.Method,
|
||||
CustomerAccountInfo: req.CustomerAccountInfo,
|
||||
RefundVoucherKey: refundVoucherKey,
|
||||
RefundReason: req.RefundReason,
|
||||
Status: model.RefundStatusPending,
|
||||
RefundReason: refundReason,
|
||||
// 来源支付事实在创建时冻结:线下订单没有线上支付记录,两个字段以空值保存且不阻断申请创建。
|
||||
SourcePaymentNo: frozenSourcePaymentNo(decision.Payment),
|
||||
OriginalChannelTradeNo: frozenOriginalChannelTradeNo(decision.Payment),
|
||||
Status: model.RefundStatusPending,
|
||||
}
|
||||
refund.Creator = userID
|
||||
refund.Updater = userID
|
||||
@@ -203,6 +211,8 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
|
||||
result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{
|
||||
Refund: refund, Order: order, SubmitterAccountID: userID,
|
||||
ChannelRefundRequestNo: buildChannelRefundRequestNo(refund.Method, decision.ChannelConfig),
|
||||
ApplicantRemark: applicantRemark(req.Remark),
|
||||
Material: s.loadRefundMaterial(ctx, refund, decision.Payment),
|
||||
})
|
||||
if err != nil {
|
||||
failedRefund := *refund
|
||||
@@ -290,7 +300,9 @@ func (s *Service) TriggerApproval(ctx context.Context, id uint) (*dto.RefundResp
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
result, err := s.refundApprovalCreation.TriggerHistorical(ctx, refund.ID)
|
||||
// 补发不追溯退款原因必填,但仍按既有展示口径解析审批材料,使新增控件的材料在补发路径同样完整。
|
||||
payment, _ := s.loadPaidPackagePayment(ctx, refund.OrderID)
|
||||
result, err := s.refundApprovalCreation.TriggerHistorical(ctx, refund.ID, s.loadRefundMaterial(ctx, refund, payment))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -832,6 +844,13 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
if req.RefundReason != nil {
|
||||
refundReason = *req.RefundReason
|
||||
}
|
||||
// 重提同样强制退款原因必填(去空白后非空);不填时沿用原有原因,原有原因为空的存量单不允许重提。
|
||||
refundReason = strings.TrimSpace(refundReason)
|
||||
if refundReason == "" {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order,
|
||||
errors.New(errors.CodeInvalidParam, "退款原因不能为空"))
|
||||
return errors.New(errors.CodeInvalidParam, "退款原因不能为空")
|
||||
}
|
||||
customerAccountInfo := refund.CustomerAccountInfo
|
||||
if req.CustomerAccountInfo != nil {
|
||||
customerAccountInfo = *req.CustomerAccountInfo
|
||||
@@ -840,8 +859,14 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err)
|
||||
return err
|
||||
}
|
||||
// 申请人备注缺省沿用该退款单最近一次尝试冻结的备注,填写时替换;两者都只影响本次新增的尝试快照。
|
||||
remark := s.latestAttemptRemark(ctx, refund)
|
||||
if req.Remark != nil {
|
||||
remark = applicantRemark(req.Remark)
|
||||
}
|
||||
|
||||
// 重提的实收金额重新派生并冻结;提交人传入的实收金额一律忽略。
|
||||
// 来源支付事实随本次重提一并重新冻结,与重新派生的冻结实收金额保持同一事实来源。
|
||||
updated := *refund
|
||||
updated.Method = method
|
||||
updated.RequestedRefundAmount = requestedRefundAmount
|
||||
@@ -850,6 +875,8 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
updated.RefundVoucherKey = refundVoucherKey
|
||||
updated.RefundReason = refundReason
|
||||
updated.CustomerAccountInfo = customerAccountInfo
|
||||
updated.SourcePaymentNo = frozenSourcePaymentNo(decision.Payment)
|
||||
updated.OriginalChannelTradeNo = frozenOriginalChannelTradeNo(decision.Payment)
|
||||
updated.Creator = userID
|
||||
|
||||
attemptChannelRefundRequestNo := buildChannelRefundRequestNo(method, decision.ChannelConfig)
|
||||
@@ -858,6 +885,8 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
}
|
||||
if _, err := s.refundApprovalCreation.Resubmit(ctx, id, refundapprovalapp.ResubmitCommand{
|
||||
Refund: &updated, ChannelRefundRequestNo: attemptChannelRefundRequestNo,
|
||||
ApplicantRemark: remark,
|
||||
Material: s.loadRefundMaterial(ctx, &updated, decision.Payment),
|
||||
}); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
return err
|
||||
@@ -1268,20 +1297,13 @@ func normalizeRefundVoucherKey(keys []string) (model.StringJSONBArray, error) {
|
||||
|
||||
// buildRefundResponse 将退款 Model 转换为 DTO 响应
|
||||
func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
|
||||
assetType := ""
|
||||
if r.OrderType == model.OrderTypeSingleCard {
|
||||
assetType = "card"
|
||||
} else if r.OrderType == model.OrderTypeDevice {
|
||||
assetType = "device"
|
||||
}
|
||||
|
||||
resp := &dto.RefundResponse{
|
||||
ID: r.ID,
|
||||
RefundNo: r.RefundNo,
|
||||
OrderID: r.OrderID,
|
||||
OrderNo: r.OrderNo,
|
||||
AssetIdentifier: r.AssetIdentifier,
|
||||
AssetType: assetType,
|
||||
AssetType: refundAssetType(r.OrderType),
|
||||
IotCardID: r.IotCardID,
|
||||
DeviceID: r.DeviceID,
|
||||
PackageUsageID: r.PackageUsageID,
|
||||
@@ -1303,6 +1325,10 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
|
||||
ChannelRefundNo: r.ChannelRefundNo,
|
||||
ChannelRefundRequestNo: r.ChannelRefundRequestNo,
|
||||
ChannelRefundAmount: r.ChannelRefundAmount,
|
||||
SourcePaymentNo: r.SourcePaymentNo,
|
||||
OriginalChannelTradeNo: r.OriginalChannelTradeNo,
|
||||
OfflineSettlementNo: r.OfflineSettlementNo,
|
||||
OfflineSettledBy: r.OfflineSettledBy,
|
||||
FailureReason: r.FailureReason,
|
||||
FailureReasonName: constants.RefundFailureReasonName(r.FailureReason),
|
||||
FailureMessage: r.FailureMessage,
|
||||
@@ -1330,6 +1356,9 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
|
||||
if r.ChannelRefundedAt != nil {
|
||||
resp.ChannelRefundedAt = r.ChannelRefundedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if r.OfflineSettledAt != nil {
|
||||
resp.OfflineSettledAt = r.OfflineSettledAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
203
internal/service/refund/settlement.go
Normal file
203
internal/service/refund/settlement.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package refund
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/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"
|
||||
)
|
||||
|
||||
// latestAttemptRemark 读取该退款单最近一次审批尝试冻结的申请人备注。
|
||||
// 重提时未填写备注则沿用该值,使申请人不必重复填写;读取失败按未填写处理(备注不参与金额与状态判定)。
|
||||
func (s *Service) latestAttemptRemark(ctx context.Context, refund *model.RefundRequest) string {
|
||||
if refund == nil || refund.ID == 0 {
|
||||
return ""
|
||||
}
|
||||
var attempt model.RefundRequestAttempt
|
||||
err := s.db.WithContext(ctx).
|
||||
Select("id", "remark").
|
||||
Where("refund_id = ?", refund.ID).
|
||||
Order("attempt_no DESC, id DESC").
|
||||
First(&attempt).Error
|
||||
if err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
s.logMaterialFailure(ctx, refund.ID, "applicant_remark", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(attempt.Remark)
|
||||
}
|
||||
|
||||
// OrderOptions 按来源订单查询可选退款方式。
|
||||
//
|
||||
// 判定复用创建与重提使用的方式判定实现(decideRefundMethods),其中原路可退的凭证判定
|
||||
// 复用执行前预检所用的同一凭证判定来源(refundchannel.RefundCredentialIssue),
|
||||
// MUST NOT 引入第二套判定或独立开关。审批提交只消费已冻结材料,本查询不产生任何副作用。
|
||||
//
|
||||
// 判定所需事实缺失(无原成功支付记录、金额非正、支付方式不支持退款)时返回不可用原因而非报错;
|
||||
// 数据库或能力未配置等基础设施故障仍按错误返回。查询受订单数据范围约束,越权与订单不存在不可区分。
|
||||
func (s *Service) OrderOptions(ctx context.Context, req *dto.RefundOrderOptionsRequest) (*dto.RefundOrderOptionsResponse, error) {
|
||||
if req == nil || req.OrderID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "订单ID不能为空")
|
||||
}
|
||||
order, err := s.orderStore.GetByID(ctx, req.OrderID)
|
||||
if err != nil {
|
||||
// 数据范围外的订单与不存在的订单返回同一结果,避免形成可枚举差异。
|
||||
return nil, errors.New(errors.CodeNotFound, "订单不存在")
|
||||
}
|
||||
|
||||
response := &dto.RefundOrderOptionsResponse{
|
||||
OrderID: order.ID,
|
||||
OrderNo: order.OrderNo,
|
||||
OrderPaymentType: order.PaymentMethod,
|
||||
Methods: []dto.RefundMethodOptionResponse{},
|
||||
RefundCapability: dto.RefundCapabilityResponse{},
|
||||
}
|
||||
decision, err := s.decideRefundMethods(ctx, order)
|
||||
if err != nil {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) && appErr.Code == errors.CodeInvalidParam {
|
||||
// 判定事实缺失:以不可用原因返回,不让只读查询报错。
|
||||
response.UnavailableReason = appErr.Message
|
||||
response.RefundCapability = dto.RefundCapabilityResponse{Available: false, Unavailable: appErr.Message}
|
||||
return response, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
response.Methods = make([]dto.RefundMethodOptionResponse, 0, len(decision.Options))
|
||||
for _, option := range decision.Options {
|
||||
response.Methods = append(response.Methods, dto.RefundMethodOptionResponse{
|
||||
Method: option.Method, MethodName: option.Name,
|
||||
Available: option.Available, Unavailable: option.Reason,
|
||||
})
|
||||
}
|
||||
response.RefundCapability = refundCapabilityProjection(decision.Options)
|
||||
if decision.Payment != nil {
|
||||
response.OriginalChannelTradeNo = strings.TrimSpace(decision.Payment.ThirdPartyTradeNo)
|
||||
response.MerchantID = decision.Payment.MerchantID
|
||||
response.MerchantName = strings.TrimSpace(decision.Payment.MerchantNameSnapshot)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// refundCapabilityProjection 从方式判定结果投影原路退款能力校验结果。
|
||||
// 订单不含原路方式时能力不可用,原因取该订单方式矩阵的说明,避免展示与方式集合不一致的结论。
|
||||
func refundCapabilityProjection(options []refundMethodOption) dto.RefundCapabilityResponse {
|
||||
for _, option := range options {
|
||||
if option.Method != constants.RefundMethodOriginalRoute {
|
||||
continue
|
||||
}
|
||||
return dto.RefundCapabilityResponse{Available: option.Available, Unavailable: option.Reason}
|
||||
}
|
||||
return dto.RefundCapabilityResponse{Available: false, Unavailable: "该订单的退款方式矩阵不包含原路退款"}
|
||||
}
|
||||
|
||||
// RegisterOfflineSettlement 登记或更正线下退款处理流水号。
|
||||
//
|
||||
// 仅客户收款信息退款(线下到账方式)允许登记;重复调用即更正,历史值由审计留存。
|
||||
// 登记在同一事务内写审计(操作者、时间、前后值),且 MUST NOT 改变退款状态、实收金额、
|
||||
// 套餐失效与佣金回溯规则(ENG-TX-001:事实与要求成功必达的审计同事务)。
|
||||
func (s *Service) RegisterOfflineSettlement(ctx context.Context, id uint, req *dto.OfflineSettlementRequest) (*dto.RefundResponse, error) {
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
if id == 0 || req == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "退款申请ID不能为空")
|
||||
}
|
||||
if s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款统一审计接缝未配置")
|
||||
}
|
||||
settlementNo := strings.TrimSpace(req.OfflineSettlementNo)
|
||||
if settlementNo == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "线下退款处理流水号不能为空")
|
||||
}
|
||||
if len(settlementNo) > 128 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "线下退款处理流水号长度不能超过 128")
|
||||
}
|
||||
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
|
||||
if err != nil {
|
||||
// 数据范围外的退款申请与不存在返回同一结果。
|
||||
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
if refund.Method != constants.RefundMethodCustomerAccount {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "仅客户收款信息退款方式可登记线下退款处理流水号")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo})
|
||||
settledAt := time.Now().UTC()
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current model.RefundRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
|
||||
}
|
||||
if current.Method != constants.RefundMethodCustomerAccount {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅客户收款信息退款方式可登记线下退款处理流水号")
|
||||
}
|
||||
beforeState := refundSettlementState(¤t)
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"offline_settlement_no": settlementNo,
|
||||
"offline_settled_at": settledAt,
|
||||
"offline_settled_by": userID,
|
||||
"updater": userID,
|
||||
"updated_at": settledAt,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "登记线下退款处理流水号失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款申请状态已变化")
|
||||
}
|
||||
if err := tx.WithContext(ctx).First(¤t, id).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款申请登记后快照失败")
|
||||
}
|
||||
primary := audit.RefundResource(¤t, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
|
||||
primary.BeforeData = beforeState
|
||||
primary.AfterData = refundSettlementState(¤t)
|
||||
primary.SubjectVisibility = constants.AuditSubjectResult
|
||||
primary.SubjectSummary = "登记线下退款处理流水号"
|
||||
if err := s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionRefundOfflineSettled, Summary: "登记线下退款处理流水号",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: current.RefundNo,
|
||||
Metadata: map[string]any{"operator_id": userID, "settled_at": settledAt},
|
||||
Resources: []audit.ResourceInput{primary},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
refund = ¤t
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildRefundResponse(refund), nil
|
||||
}
|
||||
|
||||
// refundSettlementState 投影线下退款处理流水号的审计前后值。
|
||||
// 只包含本用例改动的字段,MUST NOT 夹带状态、实收金额等未改动事实,使审计前后值即登记差异。
|
||||
func refundSettlementState(refund *model.RefundRequest) map[string]any {
|
||||
state := map[string]any{
|
||||
"offline_settlement_no": refund.OfflineSettlementNo,
|
||||
"offline_settled_by": refund.OfflineSettledBy,
|
||||
}
|
||||
if refund.OfflineSettledAt != nil {
|
||||
state["offline_settled_at"] = refund.OfflineSettledAt
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -127,15 +129,93 @@ end
|
||||
return 0
|
||||
`)
|
||||
|
||||
// verificationFailureWriteTimeout 是失败计数写入的独立超时:写入与请求生命周期解耦,避免客户端断开绕过计数。
|
||||
const verificationFailureWriteTimeout = 5 * time.Second
|
||||
|
||||
// verificationFailureCountScript 原子累加手机号维度的校验失败计数,并在同一步刷新计数窗口。
|
||||
// INCR 与 PEXPIRE 必须在同一脚本内完成:拆成 SETNX 再 INCR 会让并发提交在两次调用之间丢失过期时间,
|
||||
// 计数键永久驻留即等于手机号被永久锁定;同时 INCR 本身原子,并发提交不会丢失一次失败计数。
|
||||
// 每次失败都刷新窗口(滑动窗口):锁定期间的提交在比对前即被拒绝且不再计数,因此锁定不会续期,
|
||||
// 锁定上限为一个窗口,到期自动恢复。
|
||||
var verificationFailureCountScript = redis.NewScript(`
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
return count
|
||||
`)
|
||||
|
||||
// verificationFailureCount 读取手机号当前窗口内的校验失败计数;键不存在表示窗口内尚未失败。
|
||||
func (s *Service) verificationFailureCount(ctx context.Context, phone string) (int64, error) {
|
||||
value, err := s.redisClient.Get(ctx, constants.RedisVerificationCodeFailKey(phone)).Result()
|
||||
if err == redis.Nil {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
// 计数键被外部写入非数字值:按窗口内无计数处理并记录,不作为限流依据。
|
||||
return 0, fmt.Errorf("校验失败计数取值非法: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// recordVerificationFailure 累加一次校验失败;计数写入失败只记录并放行,不改变本次校验结果。
|
||||
func (s *Service) recordVerificationFailure(ctx context.Context, phone string) {
|
||||
// 计数写入不随请求上下文取消而丢失:客户端提前断开不应成为绕过失败次数限制的通道。
|
||||
countCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), verificationFailureWriteTimeout)
|
||||
defer cancel()
|
||||
_, err := verificationFailureCountScript.Run(
|
||||
countCtx, s.redisClient, []string{constants.RedisVerificationCodeFailKey(phone)},
|
||||
constants.VerificationCodeFailureWindow.Milliseconds(),
|
||||
).Int64()
|
||||
if err != nil {
|
||||
s.logger.Error("累加验证码校验失败计数失败,按放行处理",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// clearVerificationFailures 在校验成功后清零手机号的失败计数;清零失败只记录,不影响校验成功结果。
|
||||
func (s *Service) clearVerificationFailures(ctx context.Context, phone string) {
|
||||
if err := s.redisClient.Del(ctx, constants.RedisVerificationCodeFailKey(phone)).Err(); err != nil {
|
||||
s.logger.Error("清零验证码校验失败计数失败,按成功放行",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckCode 校验验证码但不消费。
|
||||
// 供消费必须晚于业务事实落库的链路复用;不消费时验证码仍受原有 5 分钟有效期约束。
|
||||
// 失败次数限制在验证码比对之前生效:窗口内失败达到上限后,锁定期间的校验一律拒绝(即使验证码正确),
|
||||
// 锁定随窗口到期自动解除;窗口内校验成功清零计数;校验失败不消费验证码。
|
||||
// 计数读写故障一律放行并记录,MUST NOT 因限流计数故障阻断注册、绑定、换绑、换证与登录的成功路径。
|
||||
func (s *Service) CheckCode(ctx context.Context, phone string, code string) error {
|
||||
failureCount, err := s.verificationFailureCount(ctx, phone)
|
||||
if err != nil {
|
||||
s.logger.Error("读取验证码校验失败计数失败,按放行处理",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
if failureCount >= constants.VerificationCodeMaxFailures {
|
||||
s.logger.Warn("验证码校验失败次数达到上限,窗口内拒绝校验",
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
// 提示不含验证码正确性、剩余失败次数与内部键名。
|
||||
return errors.New(errors.CodeTooManyRequests, constants.VerificationCodeLockedMessage)
|
||||
}
|
||||
|
||||
// 从 Redis 获取验证码
|
||||
storedCode, err := s.redisClient.Get(ctx, constants.RedisVerificationCodeKey(phone)).Result()
|
||||
if err == redis.Nil {
|
||||
s.logger.Warn("验证码不存在或已过期",
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
// 已无可用验证码的提交同样是校验失败:计入失败次数,但不产生任何业务事实。
|
||||
s.recordVerificationFailure(ctx, phone)
|
||||
return errors.New(errors.CodeInvalidParam, "验证码不存在或已过期")
|
||||
}
|
||||
if err != nil {
|
||||
@@ -151,9 +231,12 @@ func (s *Service) CheckCode(ctx context.Context, phone string, code string) erro
|
||||
s.logger.Warn("验证码错误",
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
s.recordVerificationFailure(ctx, phone)
|
||||
return errors.New(errors.CodeInvalidParam, "验证码错误")
|
||||
}
|
||||
|
||||
s.clearVerificationFailures(ctx, phone)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -47,12 +47,35 @@ func (s *PhoneAssetAssociationStore) ExistsValid(ctx context.Context, phone, ass
|
||||
}
|
||||
|
||||
// CountValidByPhone 统计手机号当前有效关系数,用于十项上限判定;已失效关系不占用额度。
|
||||
// 实现委托给批量计数,保证「上限判定」与「关联查询投影」共用同一计数口径,不会出现两套漂移的计数。
|
||||
func (s *PhoneAssetAssociationStore) CountValidByPhone(ctx context.Context, phone string) (int64, error) {
|
||||
var count int64
|
||||
if err := s.validScope(ctx).Where("phone = ?", phone).Count(&count).Error; err != nil {
|
||||
counts, err := s.CountValidByPhones(ctx, []string{phone})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
return counts[phone], nil
|
||||
}
|
||||
|
||||
// CountValidByPhones 一次批量统计一批手机号各自的当前有效关系数,返回「手机号 → 数量」。
|
||||
// 口径与十项上限判定完全一致:只统计 status = 有效 且未软删的关系,卡与设备各计一项。
|
||||
// 调用方必须传入整页涉及的手机号集合,禁止逐手机号或逐资产放大查询次数。
|
||||
func (s *PhoneAssetAssociationStore) CountValidByPhones(ctx context.Context, phones []string) (map[string]int64, error) {
|
||||
counts := make(map[string]int64, len(phones))
|
||||
if len(phones) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
var rows []struct {
|
||||
Phone string
|
||||
Total int64
|
||||
}
|
||||
if err := s.validScope(ctx).Select("phone, COUNT(*) AS total").
|
||||
Where("phone IN ?", phones).Group("phone").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
counts[row.Phone] = row.Total
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// PhoneAssetAssociationListFilter 后台关联列表筛选条件。
|
||||
@@ -202,12 +225,14 @@ func (s *PhoneAssetAssociationStore) LockValidByAsset(ctx context.Context, tx *g
|
||||
|
||||
// InvalidateByIDs 以 expected-status 条件把有效关系置为失效,返回实际失效行数。
|
||||
// 状态流转必须带 status = 有效 条件并核对影响行数(ENG-CONC-001),否则并发解除会覆盖已成事实的失效信息。
|
||||
// invalidatorName 是解绑当时的后台账号名称快照,只写进已失效行;有效关系保持空串,MUST NOT 以创建人或更新时间填充。
|
||||
func (s *PhoneAssetAssociationStore) InvalidateByIDs(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
ids []uint,
|
||||
method, reason string,
|
||||
invalidator uint,
|
||||
invalidatorName string,
|
||||
now time.Time,
|
||||
) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
@@ -216,12 +241,13 @@ func (s *PhoneAssetAssociationStore) InvalidateByIDs(
|
||||
result := tx.WithContext(ctx).Model(&model.PhoneAssetAssociation{}).
|
||||
Where("id IN ? AND status = ?", ids, constants.PhoneAssetAssociationStatusValid).
|
||||
Updates(map[string]any{
|
||||
"status": constants.PhoneAssetAssociationStatusInvalid,
|
||||
"invalidated_at": now,
|
||||
"invalidation_method": method,
|
||||
"invalidation_reason": reason,
|
||||
"invalidator": invalidator,
|
||||
"updated_at": now,
|
||||
"status": constants.PhoneAssetAssociationStatusInvalid,
|
||||
"invalidated_at": now,
|
||||
"invalidation_method": method,
|
||||
"invalidation_reason": reason,
|
||||
"invalidator": invalidator,
|
||||
"invalidator_name_snapshot": invalidatorName,
|
||||
"updated_at": now,
|
||||
})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
@@ -24,11 +24,18 @@ const pollingPriorityActiveConstraint = "uq_polling_priority_item_active"
|
||||
// ShopIDSnapshot 是数据范围下推:nil 表示不受限(超级管理员与平台账号),
|
||||
// 非 nil 时按店铺快照过滤,空切片表示空范围(无可见行)。平台卡的店铺快照为空,
|
||||
// 因此空范围与平台卡都不会被代理账号看到,越权与不存在由调用方收敛为同一响应。
|
||||
//
|
||||
// CreatedFrom/CreatedTo 按入队时间(created_at)取闭区间,nil 表示该端不限。
|
||||
// AbnormalOnly 是「异常与重试」视图条件:失败终态、已过期、尝试次数达上限或存在失败原因,
|
||||
// 与其余筛选是「与」关系,视图本身仍复用同一事实表。
|
||||
type PollingPriorityItemListFilter struct {
|
||||
CardID *uint
|
||||
TaskType *string
|
||||
Status *string
|
||||
TriggerType *string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
AbnormalOnly bool
|
||||
ShopIDSnapshot []uint
|
||||
Page int
|
||||
PageSize int
|
||||
@@ -49,6 +56,73 @@ func (s *PollingPriorityItemStore) WithTx(tx *gorm.DB) *PollingPriorityItemStore
|
||||
return &PollingPriorityItemStore{db: tx}
|
||||
}
|
||||
|
||||
// PollingPriorityAssetSnapshot 是入队时冻结的资产身份与代理归属快照。
|
||||
type PollingPriorityAssetSnapshot struct {
|
||||
// AssetType 取值与 constants.AssetType* 一致:卡绑定有效设备时资产为设备,独立卡为卡自身。
|
||||
AssetType string
|
||||
// AssetID 与 AssetType 共同构成资产身份。
|
||||
AssetID uint
|
||||
// DeviceNo 是绑定设备的虚拟号,独立卡为空字符串。
|
||||
DeviceNo string
|
||||
// AgentShopID 是卡所属店铺的直接上级代理店铺,一级代理与平台卡为 nil。
|
||||
AgentShopID *uint
|
||||
}
|
||||
|
||||
// AssetSnapshots 按卡批量解析入队快照,口径与迁移 000234 的回填严格一致:
|
||||
// 卡存在有效设备绑定时资产为该设备(设备号取绑定设备虚拟号),否则资产为卡自身;
|
||||
// 代理归属取卡所属店铺的直接上级代理店铺;同一卡存在多条绑定时取 is_current 优先、其次最新一条。
|
||||
//
|
||||
// 未查到卡行的卡按「资产为卡自身」兜底:入队必写资产身份,同时不因卡已软删而阻断加急事实
|
||||
// (这类项会在执行时以「卡不存在」进入失败终态,与既有语义一致)。
|
||||
func (s *PollingPriorityItemStore) AssetSnapshots(ctx context.Context, cardIDs []uint) (map[uint]PollingPriorityAssetSnapshot, error) {
|
||||
snapshots := make(map[uint]PollingPriorityAssetSnapshot, len(cardIDs))
|
||||
if len(cardIDs) == 0 {
|
||||
return snapshots, nil
|
||||
}
|
||||
type assetSnapshotRow struct {
|
||||
CardID uint `gorm:"column:card_id"`
|
||||
ParentShopID *uint `gorm:"column:parent_shop_id"`
|
||||
DeviceID *uint `gorm:"column:device_id"`
|
||||
DeviceNo string `gorm:"column:device_no"`
|
||||
}
|
||||
var rows []assetSnapshotRow
|
||||
if err := s.db.WithContext(ctx).Table("tb_iot_card AS c").
|
||||
Select("c.id AS card_id, sp.parent_id AS parent_shop_id, b.device_id AS device_id, COALESCE(d.virtual_no, '') AS device_no").
|
||||
Joins("LEFT JOIN tb_shop AS sp ON sp.id = c.shop_id AND sp.deleted_at IS NULL").
|
||||
Joins(`LEFT JOIN (SELECT DISTINCT ON (iot_card_id) iot_card_id, device_id FROM tb_device_sim_binding
|
||||
WHERE bind_status = ? AND deleted_at IS NULL
|
||||
ORDER BY iot_card_id, is_current DESC, id DESC) AS b ON b.iot_card_id = c.id`,
|
||||
constants.BindStatusBound).
|
||||
Joins("LEFT JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL").
|
||||
Where("c.id IN ?", cardIDs).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "解析优先轮询项资产快照失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
snapshots[row.CardID] = PollingPriorityAssetSnapshot{
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: row.CardID,
|
||||
AgentShopID: row.ParentShopID,
|
||||
}
|
||||
if row.DeviceID != nil && *row.DeviceID > 0 {
|
||||
snapshots[row.CardID] = PollingPriorityAssetSnapshot{
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: *row.DeviceID,
|
||||
DeviceNo: row.DeviceNo,
|
||||
AgentShopID: row.ParentShopID,
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, cardID := range cardIDs {
|
||||
if _, exists := snapshots[cardID]; !exists {
|
||||
snapshots[cardID] = PollingPriorityAssetSnapshot{
|
||||
AssetType: constants.AssetTypeIotCard, AssetID: cardID,
|
||||
}
|
||||
}
|
||||
}
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
// InsertOrMerge 以 (card_id, task_type) 为活动项唯一键合并入队。
|
||||
//
|
||||
// 先显式插入;命中活动项部分唯一索引(23505)时把本次触发合并进已有活动行:
|
||||
@@ -171,7 +245,9 @@ func (s *PollingPriorityItemStore) ClaimPending(ctx context.Context, cardID uint
|
||||
Updates(map[string]any{
|
||||
"status": constants.PollingPriorityStatusProcessing,
|
||||
"claimed_at": now,
|
||||
"updated_at": now,
|
||||
// 计划执行时间已兑现:执行中的项不应再显示一个已经过去的下一次执行时间。
|
||||
"next_run_at": nil,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "认领优先轮询项失败")
|
||||
@@ -197,8 +273,9 @@ func (s *PollingPriorityItemStore) TakeOverExpiredClaim(ctx context.Context, car
|
||||
Where("card_id = ? AND task_type = ? AND status = ? AND (claimed_at IS NULL OR claimed_at < ?)",
|
||||
cardID, taskType, constants.PollingPriorityStatusProcessing, deadline).
|
||||
Updates(map[string]any{
|
||||
"claimed_at": now,
|
||||
"updated_at": now,
|
||||
"claimed_at": now,
|
||||
"next_run_at": nil,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "接管超租约优先轮询项失败")
|
||||
@@ -209,14 +286,17 @@ func (s *PollingPriorityItemStore) TakeOverExpiredClaim(ctx context.Context, car
|
||||
return s.FindActive(ctx, cardID, taskType)
|
||||
}
|
||||
|
||||
// IncrementAttempt 累加真实发起执行的次数。调用点必须在发起上游调用之前,
|
||||
// 使崩溃遗留的执行中项在接管后仍能按次数上限收敛。
|
||||
func (s *PollingPriorityItemStore) IncrementAttempt(ctx context.Context, id uint) (bool, error) {
|
||||
// MarkExecutionStarted 标记真正发起一次执行:累加尝试次数并写执行开始时间。
|
||||
// 调用点必须在发起上游调用之前,使崩溃遗留的执行中项在接管后仍能按次数上限收敛;
|
||||
// 同时清空上一次尝试的结束时间,避免读侧把两次尝试拼成一段耗时。
|
||||
func (s *PollingPriorityItemStore) MarkExecutionStarted(ctx context.Context, id uint, now time.Time) (bool, error) {
|
||||
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing).
|
||||
Updates(map[string]any{
|
||||
"attempt_count": gorm.Expr("attempt_count + 1"),
|
||||
"updated_at": time.Now(),
|
||||
"started_at": now,
|
||||
"finished_at": nil,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "累加优先轮询项尝试次数失败")
|
||||
@@ -224,15 +304,34 @@ func (s *PollingPriorityItemStore) IncrementAttempt(ctx context.Context, id uint
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// RecordIntegrationLog 记录本次执行对应的集成交互日志标识。
|
||||
// 只保存日志标识,不复制凭证或上游响应原文;标识为空时忽略,不写入空覆盖。
|
||||
func (s *PollingPriorityItemStore) RecordIntegrationLog(ctx context.Context, id uint, integrationLogID string) error {
|
||||
if strings.TrimSpace(integrationLogID) == "" {
|
||||
return nil
|
||||
}
|
||||
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing).
|
||||
Updates(map[string]any{"integration_log_id": integrationLogID, "updated_at": time.Now()})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "记录优先轮询项集成交互日志标识失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkCompleted 把执行中的优先项置为完成终态并出队。
|
||||
func (s *PollingPriorityItemStore) MarkCompleted(ctx context.Context, id uint) (bool, error) {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing).
|
||||
Updates(map[string]any{
|
||||
"status": constants.PollingPriorityStatusCompleted,
|
||||
"result": constants.PollingPriorityResultSuccess,
|
||||
"failure_reason": "",
|
||||
"updated_at": time.Now(),
|
||||
"finished_at": now,
|
||||
"next_run_at": nil,
|
||||
"dequeued_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新优先轮询项完成状态失败")
|
||||
@@ -242,13 +341,17 @@ func (s *PollingPriorityItemStore) MarkCompleted(ctx context.Context, id uint) (
|
||||
|
||||
// MarkFailed 把执行中的优先项置为失败终态并出队,failureReason 只写可安全对外展示的原因。
|
||||
func (s *PollingPriorityItemStore) MarkFailed(ctx context.Context, id uint, failureReason string) (bool, error) {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing).
|
||||
Updates(map[string]any{
|
||||
"status": constants.PollingPriorityStatusFailed,
|
||||
"result": constants.PollingPriorityResultFailed,
|
||||
"failure_reason": failureReason,
|
||||
"updated_at": time.Now(),
|
||||
"finished_at": now,
|
||||
"next_run_at": nil,
|
||||
"dequeued_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新优先轮询项失败状态失败")
|
||||
@@ -256,9 +359,10 @@ func (s *PollingPriorityItemStore) MarkFailed(ctx context.Context, id uint, fail
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// ReturnToPending 把未达尝试上限的可恢复失败退回活动态,并按既有轮询间隔由调用方重新排期。
|
||||
// 保留本次失败结果与原因,供读侧展示在途重试;释放认领租约。
|
||||
func (s *PollingPriorityItemStore) ReturnToPending(ctx context.Context, id uint, failureReason string) (bool, error) {
|
||||
// ReturnToPending 把未达尝试上限的可恢复失败退回活动态,并写下一次计划执行时间。
|
||||
// 保留本次失败结果与原因,供读侧展示在途重试;释放认领租约并记录本次执行的结束时间。
|
||||
func (s *PollingPriorityItemStore) ReturnToPending(ctx context.Context, id uint, failureReason string, nextRunAt time.Time) (bool, error) {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing).
|
||||
Updates(map[string]any{
|
||||
@@ -266,7 +370,9 @@ func (s *PollingPriorityItemStore) ReturnToPending(ctx context.Context, id uint,
|
||||
"result": constants.PollingPriorityResultFailed,
|
||||
"failure_reason": failureReason,
|
||||
"claimed_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
"finished_at": now,
|
||||
"next_run_at": nextRunAt,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "退回优先轮询项活动状态失败")
|
||||
@@ -274,6 +380,136 @@ func (s *PollingPriorityItemStore) ReturnToPending(ctx context.Context, id uint,
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// Close 人工关闭未完成(待执行或执行中)的优先项:转已关闭终态并出队。
|
||||
// 以条件更新限定「仅未完成项」并被调用方检查行数,避免与并发执行互相覆盖;
|
||||
// 既有执行结果与失败原因一律保留,关闭原因只进审计,不写入失败原因。
|
||||
func (s *PollingPriorityItemStore) Close(ctx context.Context, id uint) (bool, error) {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("id = ? AND status IN ?", id, constants.PollingPriorityActiveStatuses()).
|
||||
Updates(map[string]any{
|
||||
"status": constants.PollingPriorityStatusClosed,
|
||||
"claimed_at": nil,
|
||||
"next_run_at": nil,
|
||||
"dequeued_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭优先轮询项失败")
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// ListOverdueActive 扫描已超过有效期且仍未完成的优先项,供有效期到期周期任务逐条终结。
|
||||
//
|
||||
// 判定依据是有效期起算时间加固定有效期,与下一次计划执行时间无关:
|
||||
// next_run_at 为空只表示「未排期」,读侧与扫描都 MUST NOT 据此判定已过期。
|
||||
// 有效期起算时间缺失的历史行按入队时间(created_at)兜底,避免这类行永不被终结。
|
||||
func (s *PollingPriorityItemStore) ListOverdueActive(ctx context.Context, deadline time.Time, limit int) ([]*model.PollingPriorityItem, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var items []*model.PollingPriorityItem
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("status IN ? AND COALESCE(priority_effective_from, created_at) <= ?",
|
||||
constants.PollingPriorityActiveStatuses(), deadline).
|
||||
Order("COALESCE(priority_effective_from, created_at) ASC, id ASC").
|
||||
Limit(limit).
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描超期优先轮询项失败")
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// MarkExpired 把未完成的优先项转已过期终态并出队。
|
||||
// 触发类型、尝试次数与最近失败原因全部保留:超期是队列出口,不是对既有事实的否定。
|
||||
func (s *PollingPriorityItemStore) MarkExpired(ctx context.Context, id uint) (bool, error) {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("id = ? AND status IN ?", id, constants.PollingPriorityActiveStatuses()).
|
||||
Updates(map[string]any{
|
||||
"status": constants.PollingPriorityStatusExpired,
|
||||
"claimed_at": nil,
|
||||
"next_run_at": nil,
|
||||
"dequeued_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记优先轮询项已过期失败")
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// PriorityPollingCardProjection 是单张卡在资产详情投影里的优先轮询事实摘要。
|
||||
type PriorityPollingCardProjection struct {
|
||||
// HasActive 表示该卡存在待执行或执行中的优先轮询项。
|
||||
HasActive bool
|
||||
// Latest 是该卡最近一条优先轮询事实(含终态),无事实时为 nil。
|
||||
Latest *model.PollingPriorityItem
|
||||
}
|
||||
|
||||
// ProjectPriorityPolling 返回指定卡的优先轮询只读投影摘要:是否存在活动项与最近一条事实。
|
||||
//
|
||||
// 只读:不创建、不合并、不推进、不出队,也不发起任何上游调用。
|
||||
// shopIDs 数据范围语义与 GetByIDScoped 一致:nil 表示不受限,空切片等于空结果。
|
||||
// 活动项受部分唯一索引约束(每卡每任务类型至多一条),因此第一条查询行数有界;
|
||||
// 最近一条事实按卡逐条取一行(卡的集合来自单个资产的绑定关系,规模有界)。
|
||||
func (s *PollingPriorityItemStore) ProjectPriorityPolling(ctx context.Context, cardIDs []uint, shopIDs []uint) (map[uint]PriorityPollingCardProjection, error) {
|
||||
result := make(map[uint]PriorityPollingCardProjection, len(cardIDs))
|
||||
if len(cardIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
activeQuery := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("card_id IN ? AND status IN ?", cardIDs, constants.PollingPriorityActiveStatuses())
|
||||
activeQuery = applyPollingPriorityShopScope(activeQuery, shopIDs)
|
||||
var activeItems []*model.PollingPriorityItem
|
||||
if err := activeQuery.Find(&activeItems).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询活动项投影失败")
|
||||
}
|
||||
for _, item := range activeItems {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
summary := result[item.CardID]
|
||||
summary.HasActive = true
|
||||
summary.Latest = pickNewerPriorityItem(summary.Latest, item)
|
||||
result[item.CardID] = summary
|
||||
}
|
||||
|
||||
for _, cardID := range cardIDs {
|
||||
summary, exists := result[cardID]
|
||||
if exists && summary.Latest != nil {
|
||||
continue
|
||||
}
|
||||
latestQuery := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).Where("card_id = ?", cardID)
|
||||
latestQuery = applyPollingPriorityShopScope(latestQuery, shopIDs)
|
||||
var latest model.PollingPriorityItem
|
||||
if err := latestQuery.Order("id DESC").Limit(1).Find(&latest).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询最近事实投影失败")
|
||||
}
|
||||
if latest.ID == 0 {
|
||||
if !exists {
|
||||
result[cardID] = PriorityPollingCardProjection{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.Latest = &latest
|
||||
result[cardID] = summary
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// pickNewerPriorityItem 取两条事实里更新的一条(按创建顺序,与列表排序口径一致)。
|
||||
func pickNewerPriorityItem(current, candidate *model.PollingPriorityItem) *model.PollingPriorityItem {
|
||||
if candidate == nil {
|
||||
return current
|
||||
}
|
||||
if current == nil || candidate.ID > current.ID {
|
||||
return candidate
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// GetByIDScoped 按主键读取优先项,并应用店铺快照数据范围。
|
||||
// shopIDs 为 nil 表示不受限(超级管理员与平台账号);非 nil 时空切片表示空范围,必然无结果。
|
||||
// 越权与不存在都返回 gorm.ErrRecordNotFound,由调用方收敛为同一响应,不产生可枚举差异。
|
||||
@@ -317,6 +553,19 @@ func (s *PollingPriorityItemStore) List(ctx context.Context, filter PollingPrior
|
||||
if filter.TriggerType != nil && *filter.TriggerType != "" {
|
||||
query = query.Where("trigger_types LIKE ?", "%,"+*filter.TriggerType+",%")
|
||||
}
|
||||
if filter.CreatedFrom != nil {
|
||||
query = query.Where("created_at >= ?", *filter.CreatedFrom)
|
||||
}
|
||||
if filter.CreatedTo != nil {
|
||||
query = query.Where("created_at <= ?", *filter.CreatedTo)
|
||||
}
|
||||
if filter.AbnormalOnly {
|
||||
// 「异常与重试」视图:四个条件取或,全部来自同一事实行的状态、尝试次数与失败原因,
|
||||
// 不引入独立异常事实表,也不改变其余筛选的「与」语义。
|
||||
query = query.Where(
|
||||
"(status IN ? OR attempt_count >= attempt_limit OR failure_reason <> '')",
|
||||
[]string{constants.PollingPriorityStatusFailed, constants.PollingPriorityStatusExpired})
|
||||
}
|
||||
if filter.ShopIDSnapshot != nil {
|
||||
query = applyPollingPriorityShopScope(query, filter.ShopIDSnapshot)
|
||||
}
|
||||
@@ -336,18 +585,34 @@ func (s *PollingPriorityItemStore) List(ctx context.Context, filter PollingPrior
|
||||
}
|
||||
|
||||
// normalizePollingPriorityItem 收敛优先项自身的结构不变量,调用方只需给出业务字段。
|
||||
// 入队时间同时作为有效期起算时间:有效期长度是代码常量,既有活动行不因合并触发延长或重置。
|
||||
func normalizePollingPriorityItem(item *model.PollingPriorityItem) error {
|
||||
if item == nil || item.CardID == 0 || strings.TrimSpace(item.TaskType) == "" || strings.TrimSpace(item.TriggerType) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "优先轮询项缺少卡、任务类型或触发类型")
|
||||
}
|
||||
if strings.TrimSpace(item.AssetType) == "" || item.AssetID == nil || *item.AssetID == 0 {
|
||||
// 资产身份是入队时解析的必填事实:缺失说明调用方跳过了快照解析,读侧将无法给出资产标识。
|
||||
return errors.New(errors.CodeInvalidParam, "优先轮询项缺少资产身份快照")
|
||||
}
|
||||
now := time.Now()
|
||||
item.Status = constants.PollingPriorityStatusPending
|
||||
item.TriggerTypes = "," + item.TriggerType + ","
|
||||
item.TriggerCount = 1
|
||||
if item.LastTriggeredAt.IsZero() {
|
||||
item.LastTriggeredAt = time.Now()
|
||||
item.LastTriggeredAt = now
|
||||
}
|
||||
item.AttemptCount = 0
|
||||
item.AttemptLimit = constants.PollingPriorityMaxAttempts
|
||||
if item.PriorityEffectiveFrom == nil {
|
||||
effectiveFrom := now
|
||||
item.PriorityEffectiveFrom = &effectiveFrom
|
||||
}
|
||||
item.ClaimedAt = nil
|
||||
item.StartedAt = nil
|
||||
item.FinishedAt = nil
|
||||
item.NextRunAt = nil
|
||||
item.DequeuedAt = nil
|
||||
item.IntegrationLogID = ""
|
||||
item.Result = ""
|
||||
item.FailureReason = ""
|
||||
return nil
|
||||
|
||||
@@ -331,8 +331,10 @@ func (h *PhoneAssetUnbindImportHandler) processRow(ctx context.Context, taskReco
|
||||
phones = append(phones, association.Phone)
|
||||
}
|
||||
now := time.Now()
|
||||
// CSV 导入由任务创建人发起,Worker 侧没有请求上下文,名称快照取任务落库时的创建人名称。
|
||||
affected, err := h.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, ids,
|
||||
constants.PhoneAssetAssociationInvalidateMethodCSVImport, taskRecord.UnbindReason, taskRecord.Creator, now)
|
||||
constants.PhoneAssetAssociationInvalidateMethodCSVImport, taskRecord.UnbindReason, taskRecord.Creator,
|
||||
taskRecord.CreatorName, now)
|
||||
if err != nil {
|
||||
return pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "解除手机号资产关联失败")
|
||||
}
|
||||
|
||||
@@ -200,32 +200,43 @@ func (b *PollingBase) requeueCardAt(cardID uint, taskType string, nextCheckAt ti
|
||||
return nil
|
||||
}
|
||||
|
||||
// requeueCard 将卡按匹配配置间隔重新入队分片 Sorted Set
|
||||
// ⚠️ 关键:Lua 脚本原子出队后卡已从队列移除,若并发满时直接 return 会导致卡永久丢失
|
||||
// 调用方必须在 acquireConcurrency 返回 false 时调用此方法入队后再返回
|
||||
func (b *PollingBase) requeueCard(_ context.Context, cardID uint, taskType string) error {
|
||||
ctx, cancel := pollingFallbackContext()
|
||||
// pollingRequeueFallbackInterval 是无法解析轮询间隔时的兜底重入队间隔。
|
||||
// 防止卡或优先项因配置缺失而永久停在待执行状态。
|
||||
const pollingRequeueFallbackInterval = 30 * time.Second
|
||||
|
||||
// pollingNextCheckAt 计算该卡该任务类型的下一次检查时间,与 requeueCard 共用同一间隔解析与兜底口径。
|
||||
// 返回 ok=false 表示该任务类型没有可用轮询间隔(配置为 NULL),调用方不得入队。
|
||||
func (b *PollingBase) pollingNextCheckAt(ctx context.Context, cardID uint, taskType string) (time.Time, bool) {
|
||||
card, err := b.getCardWithCache(ctx, cardID)
|
||||
cancel()
|
||||
if err != nil {
|
||||
b.logger.Warn("重入队:获取卡信息失败,使用30秒兜底间隔",
|
||||
zap.Uint("card_id", cardID), zap.String("task_type", taskType), zap.Error(err))
|
||||
// 获取卡失败时立即重入队(下次仍然尝试处理)
|
||||
return b.requeueCardAt(cardID, taskType, time.Now().Add(30*time.Second))
|
||||
return time.Now().Add(pollingRequeueFallbackInterval), true
|
||||
}
|
||||
|
||||
intervals := b.configMgr.MergedTaskIntervals(card)
|
||||
if len(intervals) == 0 {
|
||||
// 兜底:配置未加载时延迟 30 秒重入队,防止卡从轮询永久消失
|
||||
b.logger.Warn("无匹配轮询配置,30秒后重入队(配置可能未加载)",
|
||||
zap.Uint("card_id", cardID), zap.String("task_type", taskType))
|
||||
return b.requeueCardAt(cardID, taskType, time.Now().Add(30*time.Second))
|
||||
return time.Now().Add(pollingRequeueFallbackInterval), true
|
||||
}
|
||||
info, ok := intervals[taskType]
|
||||
if !ok || info.Interval <= 0 {
|
||||
b.logger.Debug("轮询间隔为 NULL,不入队", zap.Uint("card_id", cardID), zap.String("task_type", taskType))
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Now().Add(time.Duration(info.Interval) * time.Second), true
|
||||
}
|
||||
|
||||
// requeueCard 将卡按匹配配置间隔重新入队分片 Sorted Set
|
||||
// ⚠️ 关键:Lua 脚本原子出队后卡已从队列移除,若并发满时直接 return 会导致卡永久丢失
|
||||
// 调用方必须在 acquireConcurrency 返回 false 时调用此方法入队后再返回
|
||||
func (b *PollingBase) requeueCard(_ context.Context, cardID uint, taskType string) error {
|
||||
ctx, cancel := pollingFallbackContext()
|
||||
nextCheckAt, ok := b.pollingNextCheckAt(ctx, cardID, taskType)
|
||||
cancel()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
nextCheckAt := time.Now().Add(time.Duration(info.Interval) * time.Second)
|
||||
return b.requeueCardAt(cardID, taskType, nextCheckAt)
|
||||
}
|
||||
|
||||
@@ -84,12 +84,12 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e
|
||||
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling)
|
||||
result, err := h.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID})
|
||||
if err != nil {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询流量失败")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询流量失败", priority)
|
||||
priority.FailRetryable(ctx, "查询流量失败")
|
||||
return h.failAndRequeue(ctx, cardID, startedAt, "查询流量失败", err)
|
||||
}
|
||||
if strings.TrimSpace(result.ICCID) == "" {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "流量查询响应缺少 ICCID")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "流量查询响应缺少 ICCID", priority)
|
||||
priority.FailRetryable(ctx, "流量查询响应缺少 ICCID")
|
||||
return h.failAndRequeue(ctx, cardID, startedAt, "流量查询响应缺少 ICCID", nil)
|
||||
}
|
||||
@@ -107,13 +107,13 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用流量观测失败")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用流量观测失败", priority)
|
||||
priority.FailRetryable(ctx, "应用流量观测失败")
|
||||
return h.failAndRequeue(ctx, cardID, startedAt, "应用流量观测失败", err)
|
||||
}
|
||||
changed := decision.IncrementMB > 0 || decision.CrossMonth
|
||||
if changed {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "流量业务事实发生变化")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "流量业务事实发生变化", priority)
|
||||
}
|
||||
if h.base.verboseLog {
|
||||
h.base.logger.Info("流量轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID),
|
||||
|
||||
@@ -84,12 +84,12 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task)
|
||||
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayNetwork, constants.CardObservationSceneNetworkPolling)
|
||||
result, err := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
|
||||
if err != nil {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询卡状态失败")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询卡状态失败", priority)
|
||||
priority.FailRetryable(ctx, "查询卡状态失败")
|
||||
return h.failAndRequeue(ctx, cardID, startedAt, "查询卡状态失败", err)
|
||||
}
|
||||
if strings.TrimSpace(result.ICCID) == "" {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "卡状态查询响应缺少 ICCID")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "卡状态查询响应缺少 ICCID", priority)
|
||||
priority.FailRetryable(ctx, "卡状态查询响应缺少 ICCID")
|
||||
return h.failAndRequeue(ctx, cardID, startedAt, "卡状态查询响应缺少 ICCID", nil)
|
||||
}
|
||||
@@ -107,13 +107,13 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用网络状态观测失败")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用网络状态观测失败", priority)
|
||||
priority.FailRetryable(ctx, "应用网络状态观测失败")
|
||||
return h.failAndRequeue(ctx, cardID, startedAt, "应用网络状态观测失败", err)
|
||||
}
|
||||
changed := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling
|
||||
if changed {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "网络状态发生变化")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "网络状态发生变化", priority)
|
||||
}
|
||||
if h.base.verboseLog || !decision.StatusKnown {
|
||||
h.base.logger.Info("卡状态轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID),
|
||||
|
||||
@@ -39,6 +39,25 @@ func recordGatewayAttempt(ctx context.Context, repository *integrationlog.Reposi
|
||||
return err
|
||||
}
|
||||
|
||||
// recordPriorityGatewayAttempt 记录外部交互日志,并把真正落库的日志标识回写优先项。
|
||||
// 成功且无变化的轮询按既有语义不落库,因此只有落库成功才回写标识,避免悬挂引用;
|
||||
// 其余调用方(无优先项在途)继续使用 recordGatewayAttempt。
|
||||
func recordPriorityGatewayAttempt(
|
||||
ctx context.Context,
|
||||
repository *integrationlog.Repository,
|
||||
attempt integrationlog.Attempt,
|
||||
result string,
|
||||
changed bool,
|
||||
summary string,
|
||||
round *pollingPriorityRound,
|
||||
) error {
|
||||
if err := recordGatewayAttempt(ctx, repository, attempt, result, changed, summary); err != nil {
|
||||
return err
|
||||
}
|
||||
round.RecordIntegrationLog(attempt.IntegrationID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// completeGatewayAttempt 记录 Gateway 查询成功或明确失败,不把响应正文写入日志。
|
||||
func completeGatewayAttempt(ctx context.Context, repository *integrationlog.Repository, attempt *model.IntegrationLog, success bool, startedAt time.Time) error {
|
||||
if repository == nil || attempt == nil {
|
||||
|
||||
@@ -132,7 +132,7 @@ func (b *PollingBase) priorityInPollingScope(_ context.Context, round *pollingPr
|
||||
return false
|
||||
}
|
||||
|
||||
// MarkExecution 标记即将真正发起上游调用,并在调用之前累加尝试次数。
|
||||
// MarkExecution 标记即将真正发起上游调用,并在调用之前累加尝试次数与执行开始时间。
|
||||
// 崩溃遗留的执行中项被接管后据此按尝试上限收敛。
|
||||
func (r *pollingPriorityRound) MarkExecution(_ context.Context) {
|
||||
if r == nil {
|
||||
@@ -140,7 +140,8 @@ func (r *pollingPriorityRound) MarkExecution(_ context.Context) {
|
||||
}
|
||||
ctx, cancel := pollingFallbackContext()
|
||||
defer cancel()
|
||||
hit, err := r.base.priorityStore.IncrementAttempt(ctx, r.item.ID)
|
||||
now := time.Now()
|
||||
hit, err := r.base.priorityStore.MarkExecutionStarted(ctx, r.item.ID, now)
|
||||
if err != nil {
|
||||
r.base.logger.Warn("累加优先轮询项尝试次数失败",
|
||||
zap.Uint("priority_item_id", r.item.ID), zap.Error(err))
|
||||
@@ -151,6 +152,40 @@ func (r *pollingPriorityRound) MarkExecution(_ context.Context) {
|
||||
}
|
||||
r.attempts = r.item.AttemptCount + 1
|
||||
r.item.AttemptCount = r.attempts
|
||||
r.item.StartedAt = &now
|
||||
r.item.FinishedAt = nil
|
||||
r.item.NextRunAt = nil
|
||||
}
|
||||
|
||||
// RecordIntegrationLog 把本次执行对应的集成交互日志标识写回优先项。
|
||||
// 只保存日志标识(不复制凭证或响应原文);写入失败只损失溯源细节,不阻断执行。
|
||||
func (r *pollingPriorityRound) RecordIntegrationLog(integrationLogID string) {
|
||||
if r == nil || r.item == nil || integrationLogID == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := pollingFallbackContext()
|
||||
defer cancel()
|
||||
if err := r.base.priorityStore.RecordIntegrationLog(ctx, r.item.ID, integrationLogID); err != nil {
|
||||
r.base.logger.Warn("记录优先轮询项集成交互日志标识失败",
|
||||
zap.Uint("priority_item_id", r.item.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
r.item.IntegrationLogID = integrationLogID
|
||||
}
|
||||
|
||||
// nextRunAt 计算该项退回活动态后的下一次计划执行时间,复用与重入队完全相同的间隔解析。
|
||||
func (r *pollingPriorityRound) nextRunAt() time.Time {
|
||||
if r == nil || r.base == nil {
|
||||
return time.Now().Add(pollingRequeueFallbackInterval)
|
||||
}
|
||||
ctx, cancel := pollingFallbackContext()
|
||||
defer cancel()
|
||||
nextCheckAt, ok := r.base.pollingNextCheckAt(ctx, r.item.CardID, r.taskType)
|
||||
if !ok {
|
||||
// 该任务类型轮询间隔为 NULL:普通轮询不会接管,仍写下兜底时间让重试路径可见。
|
||||
return time.Now().Add(pollingRequeueFallbackInterval)
|
||||
}
|
||||
return nextCheckAt
|
||||
}
|
||||
|
||||
// Complete 记录优先项执行成功并出队,该卡继续按普通轮询运行。
|
||||
@@ -161,6 +196,7 @@ func (r *pollingPriorityRound) Complete(_ context.Context) {
|
||||
ctx, cancel := pollingFallbackContext()
|
||||
defer cancel()
|
||||
before := r.stateData()
|
||||
finishedAt := time.Now()
|
||||
hit, err := r.base.priorityStore.MarkCompleted(ctx, r.item.ID)
|
||||
if err != nil {
|
||||
r.base.logger.Warn("更新优先轮询项完成状态失败",
|
||||
@@ -174,11 +210,14 @@ func (r *pollingPriorityRound) Complete(_ context.Context) {
|
||||
r.item.Status = constants.PollingPriorityStatusCompleted
|
||||
r.item.Result = constants.PollingPriorityResultSuccess
|
||||
r.item.FailureReason = ""
|
||||
r.item.FinishedAt = &finishedAt
|
||||
r.item.DequeuedAt = &finishedAt
|
||||
r.item.NextRunAt = nil
|
||||
r.writeAudit(constants.AuditActionPollingPriorityCompleted, "卡轮询优先项执行完成并出队", constants.AuditResultSuccess, "", "", before)
|
||||
}
|
||||
|
||||
// FailRetryable 记录一次可恢复失败(上游超时、失败或响应无效)。
|
||||
// 未达尝试上限时退回活动态并按既有轮询间隔由调用方重排;达到上限时记录失败终态并出队。
|
||||
// 未达尝试上限时退回活动态并写下一次计划执行时间;达到上限时记录失败终态并出队。
|
||||
func (r *pollingPriorityRound) FailRetryable(_ context.Context, reason string) {
|
||||
if r == nil {
|
||||
return
|
||||
@@ -186,8 +225,10 @@ func (r *pollingPriorityRound) FailRetryable(_ context.Context, reason string) {
|
||||
ctx, cancel := pollingFallbackContext()
|
||||
defer cancel()
|
||||
before := r.stateData()
|
||||
finishedAt := time.Now()
|
||||
if r.attempts < constants.PollingPriorityMaxAttempts {
|
||||
hit, err := r.base.priorityStore.ReturnToPending(ctx, r.item.ID, reason)
|
||||
nextRunAt := r.nextRunAt()
|
||||
hit, err := r.base.priorityStore.ReturnToPending(ctx, r.item.ID, reason, nextRunAt)
|
||||
if err != nil {
|
||||
r.base.logger.Warn("退回优先轮询项活动状态失败",
|
||||
zap.Uint("priority_item_id", r.item.ID), zap.Error(err))
|
||||
@@ -201,6 +242,8 @@ func (r *pollingPriorityRound) FailRetryable(_ context.Context, reason string) {
|
||||
r.item.Result = constants.PollingPriorityResultFailed
|
||||
r.item.FailureReason = reason
|
||||
r.item.ClaimedAt = nil
|
||||
r.item.FinishedAt = &finishedAt
|
||||
r.item.NextRunAt = &nextRunAt
|
||||
r.writeAudit(constants.AuditActionPollingPriorityFailed, "卡轮询优先项执行失败", constants.AuditResultFailed, "", reason, before)
|
||||
r.writeAudit(constants.AuditActionPollingPriorityRetried, "卡轮询优先项未达尝试上限,退回活动态重新排期", constants.AuditResultFailed, "", reason, before)
|
||||
return
|
||||
@@ -219,6 +262,9 @@ func (r *pollingPriorityRound) FailRetryable(_ context.Context, reason string) {
|
||||
r.item.Status = constants.PollingPriorityStatusFailed
|
||||
r.item.Result = constants.PollingPriorityResultFailed
|
||||
r.item.FailureReason = reason
|
||||
r.item.FinishedAt = &finishedAt
|
||||
r.item.DequeuedAt = &finishedAt
|
||||
r.item.NextRunAt = nil
|
||||
r.writeAudit(constants.AuditActionPollingPriorityFailed, "卡轮询优先项执行失败", constants.AuditResultFailed, "", reason, before)
|
||||
r.writeAudit(constants.AuditActionPollingPriorityDequeued, "卡轮询优先项达到尝试上限,失败出队", constants.AuditResultFailed, "", reason, before)
|
||||
}
|
||||
@@ -241,9 +287,13 @@ func (r *pollingPriorityRound) FailFinal(_ context.Context, reason string) {
|
||||
zap.Uint("priority_item_id", r.item.ID))
|
||||
return
|
||||
}
|
||||
finishedAt := time.Now()
|
||||
r.item.Status = constants.PollingPriorityStatusFailed
|
||||
r.item.Result = constants.PollingPriorityResultFailed
|
||||
r.item.FailureReason = reason
|
||||
r.item.FinishedAt = &finishedAt
|
||||
r.item.DequeuedAt = &finishedAt
|
||||
r.item.NextRunAt = nil
|
||||
r.writeAudit(constants.AuditActionPollingPriorityFailed, "卡轮询优先项执行业务校验失败", constants.AuditResultFailed, "", reason, before)
|
||||
r.writeAudit(constants.AuditActionPollingPriorityDequeued, "卡轮询优先项失败出队且不重试", constants.AuditResultFailed, "", reason, before)
|
||||
}
|
||||
@@ -251,17 +301,23 @@ func (r *pollingPriorityRound) FailFinal(_ context.Context, reason string) {
|
||||
// stateData 返回当前优先项状态的前后值快照,覆盖触发来源、操作来源、时间与执行结果。
|
||||
func (r *pollingPriorityRound) stateData() map[string]any {
|
||||
return map[string]any{
|
||||
"status": r.item.Status,
|
||||
"result": r.item.Result,
|
||||
"trigger_type": r.item.TriggerType,
|
||||
"trigger_types": r.item.TriggerTypes,
|
||||
"trigger_count": r.item.TriggerCount,
|
||||
"last_triggered_at": r.item.LastTriggeredAt,
|
||||
"attempt_count": r.item.AttemptCount,
|
||||
"claimed_at": r.item.ClaimedAt,
|
||||
"failure_reason": r.item.FailureReason,
|
||||
"source": constants.AuditSourceWorker,
|
||||
"occurred_at": time.Now(),
|
||||
"status": r.item.Status,
|
||||
"result": r.item.Result,
|
||||
"trigger_type": r.item.TriggerType,
|
||||
"trigger_types": r.item.TriggerTypes,
|
||||
"trigger_count": r.item.TriggerCount,
|
||||
"last_triggered_at": r.item.LastTriggeredAt,
|
||||
"attempt_count": r.item.AttemptCount,
|
||||
"attempt_limit": r.item.AttemptLimit,
|
||||
"claimed_at": r.item.ClaimedAt,
|
||||
"started_at": r.item.StartedAt,
|
||||
"finished_at": r.item.FinishedAt,
|
||||
"next_run_at": r.item.NextRunAt,
|
||||
"dequeued_at": r.item.DequeuedAt,
|
||||
"integration_log_id": r.item.IntegrationLogID,
|
||||
"failure_reason": r.item.FailureReason,
|
||||
"source": constants.AuditSourceWorker,
|
||||
"occurred_at": time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
57
internal/task/polling_priority_expiry.go
Normal file
57
internal/task/polling_priority_expiry.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
|
||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// PollingPriorityExpiryHandler 执行优先轮询项有效期到期处理任务。
|
||||
//
|
||||
// 该任务只做一件事:把超过固定有效期仍未完成的优先项转已过期终态并出队。
|
||||
// 到期判定与查询、读侧完全解耦,读接口 MUST NOT 隐式触发过期处理;
|
||||
// 本轮扫描未处理完的剩余项由下一轮计划任务继续,任务可重复执行。
|
||||
type PollingPriorityExpiryHandler struct {
|
||||
service *pollingSvc.PriorityExpiryService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPollingPriorityExpiryHandler 创建优先轮询项有效期到期处理任务处理器。
|
||||
func NewPollingPriorityExpiryHandler(service *pollingSvc.PriorityExpiryService, logger *zap.Logger) *PollingPriorityExpiryHandler {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &PollingPriorityExpiryHandler{service: service, logger: logger}
|
||||
}
|
||||
|
||||
// Handle 执行一次有效期到期扫描。
|
||||
// 审计上下文固定为计划任务来源,与审计注册表中该动作允许的操作者与来源一致。
|
||||
func (h *PollingPriorityExpiryHandler) Handle(ctx context.Context, task *asynq.Task) error {
|
||||
if h == nil || h.service == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "优先轮询项有效期到期处理任务未配置")
|
||||
}
|
||||
taskType := constants.TaskTypePollingPriorityExpiry
|
||||
if task != nil && task.Type() != "" {
|
||||
taskType = task.Type()
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
|
||||
ActorName: "优先轮询项有效期到期处理计划任务", Source: constants.AuditSourceScheduler,
|
||||
})
|
||||
expiredCount, err := h.service.ExpireOverdue(ctx, time.Now().UTC())
|
||||
if err != nil {
|
||||
h.logger.Error("优先轮询项有效期到期处理失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if expiredCount > 0 {
|
||||
h.logger.Info("优先轮询项有效期到期处理完成", zap.Int("expired_count", expiredCount))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -75,12 +75,12 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e
|
||||
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayRealname, constants.CardObservationSceneRealnamePolling)
|
||||
result, err := h.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
|
||||
if err != nil {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询实名状态失败")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询实名状态失败", priority)
|
||||
priority.FailRetryable(ctx, "查询实名状态失败")
|
||||
return h.failAndRequeue(ctx, cardID, startedAt, "查询实名状态失败", err)
|
||||
}
|
||||
if strings.TrimSpace(result.ICCID) == "" {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "实名查询响应缺少 ICCID")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "实名查询响应缺少 ICCID", priority)
|
||||
priority.FailRetryable(ctx, "实名查询响应缺少 ICCID")
|
||||
return h.failAndRequeue(ctx, cardID, startedAt, "实名查询响应缺少 ICCID", nil)
|
||||
}
|
||||
@@ -98,13 +98,13 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用实名观测失败")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用实名观测失败", priority)
|
||||
priority.FailRetryable(ctx, "应用实名观测失败")
|
||||
return h.failAndRequeue(ctx, cardID, startedAt, "应用实名观测失败", err)
|
||||
}
|
||||
changed := decision.StatusChanged
|
||||
if changed {
|
||||
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "实名状态发生变化")
|
||||
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "实名状态发生变化", priority)
|
||||
}
|
||||
if h.base.verboseLog {
|
||||
h.base.logger.Info("实名状态轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID),
|
||||
|
||||
14
migrations/000232_add_h5_popup_type.down.sql
Normal file
14
migrations/000232_add_h5_popup_type.down.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 回滚 H5 运营弹窗类型的补齐。
|
||||
-- 先删取值约束再删列,与 up 的创建顺序严格倒序。
|
||||
--
|
||||
-- 不可逆说明(ENG-MIG-001 例外条件):本迁移的 down 会删除 tb_h5_popup_configuration.popup_type。
|
||||
-- 弹窗类型是运营配置事实,删除后无法由数据库自身重建;恢复方式为重新执行 up,
|
||||
-- 但既有配置会统一回落到默认值 announcement,原先的 promotion 类型只能依据
|
||||
-- tb_audit_event 中 h5_popup_configuration 的前后值快照人工复核,因此 down 只在类型事实
|
||||
-- 已无留存需求时执行。
|
||||
|
||||
ALTER TABLE tb_h5_popup_configuration
|
||||
DROP CONSTRAINT IF EXISTS chk_h5_popup_configuration_popup_type;
|
||||
|
||||
ALTER TABLE tb_h5_popup_configuration
|
||||
DROP COLUMN IF EXISTS popup_type;
|
||||
23
migrations/000232_add_h5_popup_type.up.sql
Normal file
23
migrations/000232_add_h5_popup_type.up.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- H5 运营弹窗配置补齐「弹窗类型」。
|
||||
-- 背景:111.md §13.3 要求运营弹窗区分「套餐政策推广」与「通用公告」,并规定类别优先级
|
||||
-- 为「风险换卡提醒 > 套餐政策推广 > 通用公告」。既有 tb_h5_popup_configuration 只有
|
||||
-- 一个 priority 列,无法在数据上区分两类运营弹窗,也无法表达「类别优先于显式优先级」。
|
||||
--
|
||||
-- 设计选择:
|
||||
-- 1. 新增 popup_type,取值仅 promotion(套餐政策推广)与 announcement(通用公告),
|
||||
-- 非空且默认 announcement:既有配置在 up 时全部获得确定类型,不需要额外回填语句。
|
||||
-- 默认值同时兜住回滚期(旧代码不写该列)与手工插入路径,避免出现无类型配置。
|
||||
-- 2. 类型只决定候选排序的第一顺位类别,不新增排序冗余列:类别顺序由查询侧的显式排序
|
||||
-- 表达式(CASE)表达,MUST NOT 依赖取值字典序(announcement 字典序小于 promotion,
|
||||
-- 与业务顺序相反)。既有匹配索引继续服务启用过滤、时间窗口与同类别内排序。
|
||||
-- 3. 既有配置的显式 priority 与同类别内相对顺序不变:本迁移只加列,不改写任何既有值。
|
||||
|
||||
ALTER TABLE tb_h5_popup_configuration
|
||||
ADD COLUMN popup_type VARCHAR(20) NOT NULL DEFAULT 'announcement';
|
||||
|
||||
COMMENT ON COLUMN tb_h5_popup_configuration.popup_type IS '弹窗类型 promotion-套餐政策推广 announcement-通用公告;类别优先级为风险换卡提醒 > 套餐政策推广 > 通用公告,类别顺序由查询侧显式排序表达式表达,不依赖取值字典序';
|
||||
|
||||
-- 取值域在 Schema 层兜住:类型参与候选排序类别判定,落库非法值会让该配置在排序中
|
||||
-- 落到未定义类别,属于必须在数据库层拒绝的半配置状态。
|
||||
ALTER TABLE tb_h5_popup_configuration
|
||||
ADD CONSTRAINT chk_h5_popup_configuration_popup_type CHECK (popup_type IN ('promotion', 'announcement'));
|
||||
13
migrations/000233_extend_refund_settlement_fields.down.sql
Normal file
13
migrations/000233_extend_refund_settlement_fields.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- 回滚 000233:删除退款结算标识与申请人备注新增列。
|
||||
-- 新列在回滚前可能已写入业务事实(来源支付标识、线下补录流水号、申请人备注),
|
||||
-- 回滚会丢弃这些列及其内容;回退前如需保留,应先导出对应列再执行。
|
||||
|
||||
ALTER TABLE tb_refund_request
|
||||
DROP COLUMN IF EXISTS source_payment_no,
|
||||
DROP COLUMN IF EXISTS original_channel_trade_no,
|
||||
DROP COLUMN IF EXISTS offline_settlement_no,
|
||||
DROP COLUMN IF EXISTS offline_settled_at,
|
||||
DROP COLUMN IF EXISTS offline_settled_by;
|
||||
|
||||
ALTER TABLE tb_refund_request_attempt
|
||||
DROP COLUMN IF EXISTS remark;
|
||||
33
migrations/000233_extend_refund_settlement_fields.up.sql
Normal file
33
migrations/000233_extend_refund_settlement_fields.up.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- 退款链路补齐结算标识与申请人备注。
|
||||
-- 背景:退款单需要可追溯「原支付单号」与「原支付渠道交易流水号」,财务需要登记线下退款处理流水号;
|
||||
-- 退款申请还需要支持可选的申请人备注,且备注必须随每次提交或重提冻结进当次审批尝试快照。
|
||||
--
|
||||
-- 设计选择:
|
||||
-- 1. 来源支付单号与原支付渠道交易流水号直接冻结在退款主表:这两项来自创建申请时的原成功支付事实,
|
||||
-- 读取时跨订单与支付单推导无法表达「冻结」语义(支付单后续改挂或缺失会让结果漂移),也不满足对账需要的稳定性。
|
||||
-- 线下订单(后台线下套餐订单、员工代收订单)没有线上支付记录,两个字段以空字符串保存,不阻断申请创建。
|
||||
-- 2. 线下退款处理流水号、登记时间与登记人为财务补录事实:允许在申请创建后由授权账号补录或更正,
|
||||
-- 每次登记写审计(操作者、时间、前后值)。补录不改变退款状态、实收金额、套餐失效与佣金回溯规则,
|
||||
-- 因此与既有渠道退款流水号(channel_refund_no,渠道原路退款结果)是两列不同语义,不得互相覆盖。
|
||||
-- 3. 申请人备注落在审批尝试表而非退款主表:主表既有 remark 列语义为「审批备注」(审批人填写、随审批结果展示),
|
||||
-- 本变更 MUST NOT 复用或改写该列。备注随每次提交或重提写入一条新的尝试记录,历史尝试快照不被改写。
|
||||
-- 4. 新列一律可空或带默认值,既有行零变更,读侧对空值按空处理。
|
||||
|
||||
ALTER TABLE tb_refund_request
|
||||
ADD COLUMN IF NOT EXISTS source_payment_no VARCHAR(64) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS original_channel_trade_no VARCHAR(100) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS offline_settlement_no VARCHAR(128) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS offline_settled_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS offline_settled_by BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN tb_refund_request.source_payment_no IS '冻结的来源支付单号:取创建申请时该订单原成功支付记录的支付单号;线下订单无线上支付记录时为空字符串';
|
||||
COMMENT ON COLUMN tb_refund_request.original_channel_trade_no IS '冻结的原支付渠道交易流水号:取创建申请时原成功支付记录的渠道交易流水号,列宽与来源列 tb_payment.third_party_trade_no 一致;线下订单无线上支付记录时为空字符串';
|
||||
COMMENT ON COLUMN tb_refund_request.offline_settlement_no IS '线下退款处理流水号或凭证编号,由授权账号补录或更正;与渠道原路退款流水号(channel_refund_no)语义不同,不参与资金与状态判定';
|
||||
COMMENT ON COLUMN tb_refund_request.offline_settled_at IS '线下退款处理流水号最近一次登记或更正时间,未登记时为空';
|
||||
COMMENT ON COLUMN tb_refund_request.offline_settled_by IS '线下退款处理流水号最近一次登记或更正的操作账号ID,0-未登记';
|
||||
|
||||
-- 退款审批尝试表新增申请人备注:每次提交或重提一条尝试,备注逐次冻结,历史尝试不被改写。
|
||||
ALTER TABLE tb_refund_request_attempt
|
||||
ADD COLUMN IF NOT EXISTS remark TEXT NOT NULL DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN tb_refund_request_attempt.remark IS '本次提交的申请人备注快照,随提交与重提逐次冻结,历史尝试不被改写;与退款主表 remark(审批备注)语义与展示口径不同,不得复用或互相覆盖';
|
||||
66
migrations/000234_extend_polling_priority_item.down.sql
Normal file
66
migrations/000234_extend_polling_priority_item.down.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- 回滚卡轮询优先队列的事实字段补齐与两个新终态,与 up 严格成对,顺序与 up 的创建顺序严格倒序。
|
||||
--
|
||||
-- 不可逆说明(ENG-MIG-001 例外条件):本迁移的 down 会删除资产类型与资产ID、设备号快照、
|
||||
-- 代理归属、尝试上限、执行起止时间、下一次计划执行时间、出队时间、集成交互日志标识与
|
||||
-- 有效期起算时间等列。这些事实(尤其是「为什么出队、什么时候执行过、上游交互日志标识」)
|
||||
-- 只由本表承载,删除后无法由数据库自身重建,因此 down 只在上述事实已无留存需求时执行。
|
||||
--
|
||||
-- 守卫:存在已关闭或已过期终态的行时拒绝回滚——旧状态约束不允许这两个取值,
|
||||
-- 回滚会连带丢失「人工关闭」与「有效期到期」两个出口留下的终态事实。
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('tb_polling_priority_item') IS NOT NULL THEN
|
||||
-- 同一事务内先取排他锁再检查,避免检查与回滚之间写入新的已关闭/已过期事实。
|
||||
LOCK TABLE tb_polling_priority_item IN ACCESS EXCLUSIVE MODE;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM tb_polling_priority_item
|
||||
WHERE status IN ('closed', 'expired')
|
||||
LIMIT 1
|
||||
) THEN
|
||||
RAISE EXCEPTION '存在已关闭或已过期的卡轮询优先项,拒绝回滚以避免丢失人工关闭与有效期到期终态事实,请人工核对后向前修复';
|
||||
END IF;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DROP INDEX IF EXISTS idx_polling_priority_item_next_run;
|
||||
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
DROP CONSTRAINT IF EXISTS ck_polling_priority_item_attempt_limit,
|
||||
DROP CONSTRAINT IF EXISTS ck_polling_priority_item_asset,
|
||||
DROP CONSTRAINT IF EXISTS ck_polling_priority_item_asset_type;
|
||||
|
||||
-- 恢复 000228 的状态约束与终态-结果约束原文。
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
DROP CONSTRAINT IF EXISTS ck_polling_priority_item_terminal_result;
|
||||
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
ADD CONSTRAINT ck_polling_priority_item_terminal_result CHECK (
|
||||
(status = 'completed' AND result = 'success')
|
||||
OR (status = 'failed' AND result = 'failed')
|
||||
OR status IN ('pending', 'processing')
|
||||
);
|
||||
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
DROP CONSTRAINT IF EXISTS ck_polling_priority_item_status;
|
||||
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
ADD CONSTRAINT ck_polling_priority_item_status CHECK (status IN ('pending', 'processing', 'completed', 'failed'));
|
||||
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
DROP COLUMN IF EXISTS priority_effective_from,
|
||||
DROP COLUMN IF EXISTS integration_log_id,
|
||||
DROP COLUMN IF EXISTS dequeued_at,
|
||||
DROP COLUMN IF EXISTS next_run_at,
|
||||
DROP COLUMN IF EXISTS finished_at,
|
||||
DROP COLUMN IF EXISTS started_at,
|
||||
DROP COLUMN IF EXISTS attempt_limit,
|
||||
DROP COLUMN IF EXISTS agent_shop_id_snapshot,
|
||||
DROP COLUMN IF EXISTS device_no_snapshot,
|
||||
DROP COLUMN IF EXISTS asset_id,
|
||||
DROP COLUMN IF EXISTS asset_type;
|
||||
|
||||
COMMIT;
|
||||
116
migrations/000234_extend_polling_priority_item.up.sql
Normal file
116
migrations/000234_extend_polling_priority_item.up.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- 卡轮询优先队列补齐事实字段与两个新终态。
|
||||
-- 背景:111.md §23.4/§23.6/§23.7/§23.9/§23.10 要求优先项可追溯资产、设备号、店铺与代理归属、
|
||||
-- 尝试上限、执行起止时间、下一次执行时间、出队时间、集成交互日志标识与有效期起算时间,并要求
|
||||
-- 支持「有效期到期出队」与「人工关闭出队」两个出口。既有 tb_polling_priority_item 只有执行结果、
|
||||
-- 失败原因与尝试次数,读侧无法解释「什么时候执行、下一次什么时候执行、为什么出队」,
|
||||
-- 也没有承载已关闭与已过期两个终态的取值空间。
|
||||
--
|
||||
-- 设计选择:
|
||||
-- 1. 只加列与重建受影响的 CHECK,不改活动项唯一键:活动集合仍是 pending/processing,
|
||||
-- 部分唯一索引谓词 status IN ('pending','processing') 保持原样,两个新终态不占键位,
|
||||
-- 同一卡同一任务类型出队后可再次入队或人工重触发。
|
||||
-- 2. 资产列(asset_type/asset_id)按既有设备—卡绑定回填:卡已绑定设备时资产为该设备,
|
||||
-- 独立卡回填为卡自身;设备号快照取绑定设备的虚拟号,未绑定设备时为空字符串。
|
||||
-- 两列允许空值/空串(CHECK 兼容旧代码回滚期不写这两列的插入),新代码入队必写。
|
||||
-- 3. priority_effective_from 是有效期起算时间:既有活动项在 up 中回填为迁移时刻,
|
||||
-- 使上线后首轮到期扫描不会把既有未完成项批量判为已过期;终态行留空(已出队,不再计算有效期)。
|
||||
-- 新增行取入队时间。有效期长度与尝试上限均为代码常量,本迁移不新增可维护配置项。
|
||||
-- 4. attempt_limit 默认 3 与既有代码常量一致,既有行随默认值获得尝试上限。
|
||||
-- 5. next_run_at 可空:既有行没有下次计划执行时间,读侧 MUST NOT 据此判定已过期。
|
||||
-- 为它建部分索引以支撑到期扫描与读侧筛选。
|
||||
-- 6. 状态 CHECK 重建并新增 closed-已关闭 expired-已过期 两个终态;终态与结果约束同步放宽,
|
||||
-- 使两个新终态可以保留既有执行结果与失败原因而不被约束拒绝。
|
||||
-- 7. 不使用数据库外键;新增列由应用层显式写入。
|
||||
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
ADD COLUMN asset_type VARCHAR(20) NOT NULL DEFAULT '',
|
||||
ADD COLUMN asset_id BIGINT,
|
||||
ADD COLUMN device_no_snapshot VARCHAR(100) NOT NULL DEFAULT '',
|
||||
ADD COLUMN agent_shop_id_snapshot BIGINT,
|
||||
ADD COLUMN attempt_limit INT NOT NULL DEFAULT 3,
|
||||
ADD COLUMN started_at TIMESTAMPTZ,
|
||||
ADD COLUMN finished_at TIMESTAMPTZ,
|
||||
ADD COLUMN next_run_at TIMESTAMPTZ,
|
||||
ADD COLUMN dequeued_at TIMESTAMPTZ,
|
||||
ADD COLUMN integration_log_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
ADD COLUMN priority_effective_from TIMESTAMPTZ;
|
||||
|
||||
-- 资产列回填:优先取该卡当前有效的设备绑定(同一卡存在多条绑定时取 is_current 优先、其次最新一条)。
|
||||
UPDATE tb_polling_priority_item AS i
|
||||
SET asset_type = 'device',
|
||||
asset_id = b.device_id,
|
||||
device_no_snapshot = COALESCE(d.virtual_no, '')
|
||||
FROM (
|
||||
SELECT DISTINCT ON (iot_card_id) iot_card_id, device_id
|
||||
FROM tb_device_sim_binding
|
||||
WHERE bind_status = 1 AND deleted_at IS NULL
|
||||
ORDER BY iot_card_id, is_current DESC, id DESC
|
||||
) AS b
|
||||
LEFT JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL
|
||||
WHERE b.iot_card_id = i.card_id;
|
||||
|
||||
-- 未被上一步覆盖的行即独立卡:资产为卡自身。
|
||||
UPDATE tb_polling_priority_item
|
||||
SET asset_type = 'iot_card',
|
||||
asset_id = card_id
|
||||
WHERE asset_id IS NULL;
|
||||
|
||||
-- 代理归属回填:卡所属店铺的直接上级代理店铺(一级代理与平台卡为空)。
|
||||
UPDATE tb_polling_priority_item AS i
|
||||
SET agent_shop_id_snapshot = s.parent_id
|
||||
FROM tb_iot_card AS c
|
||||
JOIN tb_shop AS s ON s.id = c.shop_id AND s.deleted_at IS NULL
|
||||
WHERE c.id = i.card_id
|
||||
AND c.deleted_at IS NULL
|
||||
AND s.parent_id IS NOT NULL;
|
||||
|
||||
-- 既有活动项的有效期自迁移时刻起算,避免上线后被首轮到期扫描批量过期。
|
||||
UPDATE tb_polling_priority_item
|
||||
SET priority_effective_from = NOW()
|
||||
WHERE deleted_at IS NULL AND status IN ('pending', 'processing');
|
||||
|
||||
COMMENT ON COLUMN tb_polling_priority_item.asset_type IS '资产类型 iot_card-物联网卡 device-设备,与 pkg/constants.AssetTypeIotCard/AssetTypeDevice 一致;卡绑定设备时资产为设备,独立卡为卡自身';
|
||||
COMMENT ON COLUMN tb_polling_priority_item.asset_id IS '资产ID,与 asset_type 共同构成资产身份;入队时按设备—卡绑定解析';
|
||||
COMMENT ON COLUMN tb_polling_priority_item.device_no_snapshot IS '设备号快照(绑定设备的虚拟号),独立卡为空字符串';
|
||||
COMMENT ON COLUMN tb_polling_priority_item.agent_shop_id_snapshot IS '代理归属快照-卡所属店铺的直接上级代理店铺ID,一级代理与平台卡为空';
|
||||
COMMENT ON COLUMN tb_polling_priority_item.attempt_limit IS '尝试上限,入队时取代码常量(3),既有行随默认值获得上限;不新增可维护配置项';
|
||||
COMMENT ON COLUMN tb_polling_priority_item.started_at IS '最近一次执行的开始时间,只在真正发起执行后写入';
|
||||
COMMENT ON COLUMN tb_polling_priority_item.finished_at IS '最近一次执行的结束时间,与开始时间共同表达执行耗时';
|
||||
COMMENT ON COLUMN tb_polling_priority_item.next_run_at IS '下一次计划执行时间,可空;为空表示未排期,读侧 MUST NOT 据此判定已过期';
|
||||
COMMENT ON COLUMN tb_polling_priority_item.dequeued_at IS '出队时间,终态(已完成、失败出队、已关闭、已过期)写入';
|
||||
COMMENT ON COLUMN tb_polling_priority_item.integration_log_id IS '最近一次上游交互的集成交互日志标识,不复制凭证或响应原文';
|
||||
COMMENT ON COLUMN tb_polling_priority_item.priority_effective_from IS '有效期起算时间,新增行取入队时间;既有活动项在迁移时刻回填,终态行留空';
|
||||
|
||||
-- 取值域约束:允许空串/空值以兼容旧代码回滚期的插入(旧版本不写资产列);
|
||||
-- 一旦写入则必须是合法资产类型与正数资产ID。
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
ADD CONSTRAINT ck_polling_priority_item_asset_type CHECK (asset_type IN ('', 'iot_card', 'device')),
|
||||
ADD CONSTRAINT ck_polling_priority_item_asset CHECK (asset_id IS NULL OR asset_id > 0),
|
||||
ADD CONSTRAINT ck_polling_priority_item_attempt_limit CHECK (attempt_limit > 0);
|
||||
|
||||
-- 状态约束重建:新增 closed-已关闭 与 expired-已过期 两个终态。
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
DROP CONSTRAINT ck_polling_priority_item_status;
|
||||
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
ADD CONSTRAINT ck_polling_priority_item_status CHECK (
|
||||
status IN ('pending', 'processing', 'completed', 'failed', 'closed', 'expired')
|
||||
);
|
||||
|
||||
-- 终态与结果的约束同步放宽:两个新终态保留既有执行结果与失败原因,不参与「必须带结果」的强约束。
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
DROP CONSTRAINT ck_polling_priority_item_terminal_result;
|
||||
|
||||
ALTER TABLE tb_polling_priority_item
|
||||
ADD CONSTRAINT ck_polling_priority_item_terminal_result CHECK (
|
||||
(status = 'completed' AND result = 'success')
|
||||
OR (status = 'failed' AND result = 'failed')
|
||||
OR status IN ('pending', 'processing', 'closed', 'expired')
|
||||
);
|
||||
|
||||
-- 到期扫描与读侧筛选按下一次计划执行时间取活动项。
|
||||
CREATE INDEX idx_polling_priority_item_next_run
|
||||
ON tb_polling_priority_item (next_run_at)
|
||||
WHERE deleted_at IS NULL AND status IN ('pending', 'processing') AND next_run_at IS NOT NULL;
|
||||
|
||||
COMMENT ON INDEX idx_polling_priority_item_next_run IS '优先队列活动项的下一次计划执行时间部分索引,服务到期扫描与读侧筛选';
|
||||
25
migrations/000235_extend_carrier_threshold_hit.down.sql
Normal file
25
migrations/000235_extend_carrier_threshold_hit.down.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- 回滚运营商通道阈值命中事实留痕,与 up 严格成对,顺序与 up 的创建顺序严格倒序。
|
||||
--
|
||||
-- 不可逆说明(ENG-MIG-001 例外条件):本迁移的 down 会删除命中累计流量、阈值数值与单位、
|
||||
-- 判定时间、触发来源、解锁时间与复机结果等列。命中数字证据(尤其是「按旧阈值命中」与
|
||||
-- 「周期恢复结果」)只由本表承载,tb_audit_event 与集成日志不能还原完整命中读数,
|
||||
-- 删除后无法由数据库自身重建,因此 down 只在上述事实已无留存需求时执行。
|
||||
|
||||
DROP INDEX IF EXISTS idx_carrier_traffic_threshold_lock_card_judged;
|
||||
DROP INDEX IF EXISTS idx_carrier_traffic_threshold_lock_carrier_judged;
|
||||
|
||||
ALTER TABLE tb_carrier_traffic_threshold_lock
|
||||
DROP CONSTRAINT IF EXISTS ck_carrier_traffic_threshold_lock_resume_result,
|
||||
DROP CONSTRAINT IF EXISTS ck_carrier_traffic_threshold_lock_trigger_source,
|
||||
DROP CONSTRAINT IF EXISTS ck_carrier_traffic_threshold_lock_hit_unit,
|
||||
DROP CONSTRAINT IF EXISTS ck_carrier_traffic_threshold_lock_hit_threshold,
|
||||
DROP CONSTRAINT IF EXISTS ck_carrier_traffic_threshold_lock_hit_traffic;
|
||||
|
||||
ALTER TABLE tb_carrier_traffic_threshold_lock
|
||||
DROP COLUMN IF EXISTS resume_result,
|
||||
DROP COLUMN IF EXISTS unlocked_at,
|
||||
DROP COLUMN IF EXISTS trigger_source,
|
||||
DROP COLUMN IF EXISTS judged_at,
|
||||
DROP COLUMN IF EXISTS hit_threshold_unit,
|
||||
DROP COLUMN IF EXISTS hit_threshold_value,
|
||||
DROP COLUMN IF EXISTS hit_traffic_mb;
|
||||
74
migrations/000235_extend_carrier_threshold_hit.up.sql
Normal file
74
migrations/000235_extend_carrier_threshold_hit.up.sql
Normal file
@@ -0,0 +1,74 @@
|
||||
-- 运营商通道阈值命中事实留痕。
|
||||
-- 背景:111.md 要求阈值命中可解释、可查询:停机后运维必须能看到「命中时该卡累计流量多少、
|
||||
-- 判定用的阈值是多少(含改阈值前的历史值)、属于哪个计费周期、什么时候判定、由什么触发」,
|
||||
-- 并能在周期恢复时看到解锁时间与复机结果。既有 tb_carrier_traffic_threshold_lock 只有
|
||||
-- 锁生命周期与停复机子任务状态,没有任何数字证据,改阈值后历史命中无法解释。
|
||||
--
|
||||
-- 设计选择:
|
||||
-- 1. 加列到既有锁表而不新建命中事实表:锁表本身即「一次命中一条」(一个卡一个计费周期至多一条),
|
||||
-- 新建表会与锁表 1:1 冗余并需要额外一致性约束。
|
||||
-- 2. hit_* 三列冻结命中时值:hit_traffic_mb 取与停机判定同一来源的网关累计读数换算值(MB,
|
||||
-- 不使用本地用量或套餐真流量);hit_threshold_value/hit_threshold_unit 取该次判定实际生效的
|
||||
-- 通道配置值,因此之后修改通道阈值不会改写历史命中。
|
||||
-- 3. judged_at 是判定时间:既有行以创建时间回填(历史读数与阈值不可重建),回填后置为非空。
|
||||
-- hit_traffic_mb 与 hit_threshold_value 允许为空,读侧按「无」展示而不阻断查询。
|
||||
-- 4. trigger_source 记录触发本次判定的流量观测来源(与 pkg/constants.CardObservationSource*
|
||||
-- 一致),使自动判定与重放可区分;允许空串以兼容回滚期旧代码插入。
|
||||
-- 5. unlocked_at/resume_result 记录周期恢复结果,MUST NOT 覆盖上面的停机命中事实:
|
||||
-- resumed-已发起复机 skipped-仅解锁未复机 manual-周期归属不可判定转人工;跳过原因沿用既有失败原因列。
|
||||
-- 6. 新增按通道与按卡的判定时间索引,服务命中记录查询的筛选与排序。
|
||||
-- 7. 本迁移不改动既有达量判定、停机调用、持锁拒绝复机与新周期解锁语义。
|
||||
|
||||
ALTER TABLE tb_carrier_traffic_threshold_lock
|
||||
ADD COLUMN hit_traffic_mb NUMERIC(18,2),
|
||||
ADD COLUMN hit_threshold_value NUMERIC(18,2),
|
||||
ADD COLUMN hit_threshold_unit VARCHAR(8) NOT NULL DEFAULT '',
|
||||
ADD COLUMN judged_at TIMESTAMPTZ,
|
||||
ADD COLUMN trigger_source VARCHAR(20) NOT NULL DEFAULT '',
|
||||
ADD COLUMN unlocked_at TIMESTAMPTZ,
|
||||
ADD COLUMN resume_result VARCHAR(30) NOT NULL DEFAULT '';
|
||||
|
||||
-- 历史行判定时间取创建时间;命中数值与阈值留空(历史读数不可重建,读侧按「无」展示)。
|
||||
UPDATE tb_carrier_traffic_threshold_lock
|
||||
SET judged_at = created_at
|
||||
WHERE judged_at IS NULL;
|
||||
|
||||
ALTER TABLE tb_carrier_traffic_threshold_lock
|
||||
ALTER COLUMN judged_at SET NOT NULL;
|
||||
|
||||
-- judged_at 保留默认值 NOW():本次硬规则要求「新列可空或带默认值」,而 judged_at 需要非空
|
||||
-- (判定时间是命中记录的查询与排序依据)。若不给默认值,回滚到上一版本二进制时,旧代码写入
|
||||
-- 停机锁不带该列会直接违反 NOT NULL,使达量停机事实无法落库。默认值让旧代码插入的命中事实
|
||||
-- 以插入时刻作为判定时间,与「既有行以创建时间回填」的语义自洽。
|
||||
ALTER TABLE tb_carrier_traffic_threshold_lock
|
||||
ALTER COLUMN judged_at SET DEFAULT NOW();
|
||||
|
||||
ALTER TABLE tb_carrier_traffic_threshold_lock
|
||||
ADD CONSTRAINT ck_carrier_traffic_threshold_lock_hit_traffic CHECK (hit_traffic_mb IS NULL OR hit_traffic_mb >= 0),
|
||||
ADD CONSTRAINT ck_carrier_traffic_threshold_lock_hit_threshold CHECK (hit_threshold_value IS NULL OR hit_threshold_value > 0),
|
||||
ADD CONSTRAINT ck_carrier_traffic_threshold_lock_hit_unit CHECK (hit_threshold_unit IN ('', 'MB', 'GB')),
|
||||
ADD CONSTRAINT ck_carrier_traffic_threshold_lock_trigger_source CHECK (
|
||||
trigger_source IN ('', 'polling', 'manual_sync', 'manual_override', 'carrier_callback', 'business_event')
|
||||
),
|
||||
ADD CONSTRAINT ck_carrier_traffic_threshold_lock_resume_result CHECK (
|
||||
resume_result IN ('', 'resumed', 'skipped', 'manual')
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.hit_traffic_mb IS '命中时该卡当前计费周期的累计流量读数(MB),取与停机判定同一来源的网关累计读数换算值,不使用本地用量或套餐真流量;历史行为空表示读数不可重建';
|
||||
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.hit_threshold_value IS '本次判定实际生效的通道阈值数值,冻结命中时值,改阈值后历史命中不被改写;历史行为空';
|
||||
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.hit_threshold_unit IS '本次判定阈值的单位 MB-兆字节 GB-吉字节,空字符串-历史行未记录';
|
||||
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.judged_at IS '判定时间,既有行以创建时间回填;命中记录查询与排序以此为业务时间。列默认值为 NOW(),使回滚期不写该列的旧代码插入的命中事实以插入时刻作为判定时间';
|
||||
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.trigger_source IS '触发本次判定的流量观测来源 polling-周期轮询 manual_sync-手动同步 manual_override-人工纠偏 carrier_callback-运营商回调 business_event-业务成功后的观测;空字符串-历史行未记录';
|
||||
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.unlocked_at IS '周期恢复时的解锁时间,只在跨期解锁成功时写入;不覆盖停机命中事实';
|
||||
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.resume_result IS '周期恢复的复机结果 resumed-已发起复机 skipped-仅解锁未复机 manual-周期归属不可判定转人工;空字符串-未处理或历史行,跳过原因沿用失败原因列';
|
||||
|
||||
-- 命中记录查询按判定时间倒序:分别按运营商通道与按卡提供索引,避免查询走顺序扫描。
|
||||
CREATE INDEX idx_carrier_traffic_threshold_lock_carrier_judged
|
||||
ON tb_carrier_traffic_threshold_lock (carrier_id, judged_at DESC, id DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_carrier_traffic_threshold_lock_card_judged
|
||||
ON tb_carrier_traffic_threshold_lock (card_id, judged_at DESC, id DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
COMMENT ON INDEX idx_carrier_traffic_threshold_lock_carrier_judged IS '通道阈值命中记录按通道与判定时间分页索引';
|
||||
COMMENT ON INDEX idx_carrier_traffic_threshold_lock_card_judged IS '通道阈值命中记录按卡与判定时间分页索引';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user