Compare commits
204 Commits
Iteration/
...
4967f4df43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4967f4df43 | ||
|
|
94f86ab217 | ||
|
|
76d0f38b9e | ||
|
|
0138a3fb9c | ||
|
|
7b13431f36 | ||
|
|
b2fc74c021 | ||
|
|
8b0a01753d | ||
|
|
d3face6f66 | ||
|
|
226a15573a | ||
|
|
eb0d86df4f | ||
|
|
3f593b912d | ||
|
|
7d705cf1e8 | ||
|
|
1214181e78 | ||
|
|
7e0e37933e | ||
|
|
94cbf2adab | ||
|
|
8eb7d403c4 | ||
|
|
b7a7430cc3 | ||
|
|
96ff9c1e35 | ||
|
|
b0a7004cf9 | ||
|
|
f0b54b8b52 | ||
|
|
ce2977ce22 | ||
|
|
b3f66a01a8 | ||
|
|
afc7e5285e | ||
|
|
dc816b44d0 | ||
|
|
f274f8bb58 | ||
|
|
4f404a11b0 | ||
|
|
c36c9fcd84 | ||
|
|
0b6b664a7a | ||
|
|
5cf67b98de | ||
|
|
a7428c7924 | ||
|
|
6266cf9d86 | ||
|
|
9c3c258f16 | ||
|
|
c33cc6f6c3 | ||
|
|
af57de0d4d | ||
|
|
69ec98aa5f | ||
|
|
94b6cfb47a | ||
|
|
62ec94adec | ||
|
|
909f4d0ec2 | ||
|
|
d2b0d2f534 | ||
|
|
c9e5d65590 | ||
|
|
82a2fcc306 | ||
|
|
7963e5dadd | ||
|
|
71fb91f9bf | ||
|
|
eb9f12d160 | ||
|
|
3618fc2b20 | ||
|
|
06ff3aabbd | ||
|
|
d4fde99cc3 | ||
|
|
f6a740b9f3 | ||
|
|
0769db8cc8 | ||
|
|
9b058f422c | ||
|
|
f35d2a8196 | ||
|
|
5efe0026c0 | ||
|
|
ef5274864f | ||
|
|
4139732719 | ||
|
|
7ddb9312e3 | ||
|
|
a4e3fe4d8f | ||
|
|
934a3a27a1 | ||
|
|
1369fed642 | ||
|
|
466cb1f9e8 | ||
|
|
555ddac725 | ||
|
|
9183863fab | ||
|
|
c741f1751d | ||
|
|
0d966249b8 | ||
|
|
2fe7c1f8ac | ||
|
|
e7f8c4bae9 | ||
|
|
dae44bed30 | ||
|
|
f49baff7c2 | ||
|
|
457abaabd7 | ||
|
|
af8249158d | ||
|
|
fad7dbfdff | ||
|
|
79b7580554 | ||
|
|
4de67f6dc3 | ||
|
|
a855742752 | ||
|
|
2ddaffe373 | ||
|
|
8fc3485061 | ||
|
|
ec5c1996a7 | ||
|
|
98017ac053 | ||
|
|
02a842f066 | ||
|
|
9b0930db9f | ||
|
|
78220deb0e | ||
|
|
42e881576d | ||
|
|
9e45097aa2 | ||
|
|
437d1c2c2c | ||
|
|
1f28ac3151 | ||
|
|
6954ad6693 | ||
|
|
96771a204f | ||
|
|
44a05cf687 | ||
|
|
b1064ded76 | ||
|
|
975a211efe | ||
|
|
4f16eb1834 | ||
|
|
fa6fb2fd78 | ||
|
|
08a7a00cd9 | ||
|
|
b434eda5c7 | ||
|
|
1d6c212d04 | ||
|
|
7e54fe2ba7 | ||
|
|
bcb1ce0653 | ||
|
|
713045a657 | ||
|
|
eb98478b09 | ||
|
|
465016a333 | ||
|
|
868d969d8e | ||
|
|
0f1ea5bc3e | ||
|
|
9ced14fa1b | ||
|
|
6298a9061f | ||
|
|
25c081fe60 | ||
|
|
260fec7114 | ||
|
|
f56113ba55 | ||
|
|
d5826700d5 | ||
| b09275a0d0 | |||
| 390769ab7a | |||
| 34f91f129f | |||
| 89d0a67aad | |||
| c1c78d5008 | |||
| 3610b776ca | |||
| 9959168e22 | |||
| 622e99b543 | |||
| 2ba483db18 | |||
| 4330b8a40c | |||
| 22c3e7cc1a | |||
| 4a16eb0b1e | |||
| b063617153 | |||
| 4a6cec2730 | |||
| 28ab910e59 | |||
| 863607639f | |||
| 1964c12ffa | |||
| 98acc51241 | |||
| 58a81b245d | |||
| 54c4ec7ba4 | |||
| 1f2fe4a61a | |||
| d24e777a4c | |||
| 5ed6b39deb | |||
| 5e78809b93 | |||
| 6333f4ad13 | |||
| 62419d4b17 | |||
| e8ab1f471e | |||
| 398a5e4282 | |||
| d52be16802 | |||
| 70e6b186df | |||
| aab56a6998 | |||
| e7b93e4634 | |||
| 33826c3443 | |||
| d5bcda94fe | |||
| ef4d3696d4 | |||
| 15bbb953db | |||
| 59b3df868a | |||
| 41722760b1 | |||
| 333ba4b647 | |||
| 70e680eb0a | |||
| 93e072e1e2 | |||
| c7f9e005af | |||
| 957a235585 | |||
| 18796b16ff | |||
| 1aa4eacee2 | |||
| 67893617fe | |||
| 09abee9778 | |||
| ba0855d9eb | |||
| 48c85a4916 | |||
| bb06cc89c5 | |||
| 575d056f54 | |||
| 30c6e5bb78 | |||
| 315a7de3e4 | |||
| 5ee8e3cb4a | |||
| 7891189712 | |||
| e687a266e6 | |||
| ff1362df3f | |||
| 9c3e3fe32b | |||
| fe07df0b3e | |||
| 69b37eb89b | |||
| ce24d5612e | |||
| dc4e0d4103 | |||
| 1e776da292 | |||
| b9e8592cc4 | |||
| 54823290c3 | |||
| b38b2b39c9 | |||
| 6f8db180fb | |||
| a9e2302f7c | |||
| bcb1304937 | |||
| 88d7965641 | |||
| e09c4632fb | |||
| 98c145fe70 | |||
| 3b4afd287e | |||
| 999acc128e | |||
| f5f03d1803 | |||
| ff25586dc9 | |||
| 3c371ab88a | |||
| a48ff5d782 | |||
| 696120ab38 | |||
| c7c2b17d78 | |||
| 3a093ecd6b | |||
| 5424751993 | |||
| 370fd3e67f | |||
| dbfeeee253 | |||
| 395e5fb47c | |||
| 62f3d25e81 | |||
| 5797fd0e94 | |||
| ba677a35e1 | |||
| 22b95db2f9 | |||
| 143df60485 | |||
| 656a921ff0 | |||
| 46c8e819df | |||
| 247d7d9f6e | |||
| d256f6d176 | |||
| 7029104e5c | |||
| a0de08d789 | |||
| 1efb665619 |
@@ -3,9 +3,7 @@ name: 构建并部署到测试环境(无 SSH)
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- Iteration/7-11
|
||||
- dev
|
||||
- test
|
||||
- Iteration/8-11
|
||||
|
||||
env:
|
||||
REGISTRY: registry.boss160.cn
|
||||
@@ -30,15 +28,7 @@ jobs:
|
||||
- name: 设置镜像标签
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/Iteration/7-11" ]; then
|
||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||
echo "tag=dev" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.ref }}" = "refs/heads/test" ]; then
|
||||
echo "tag=test" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=unknown" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 登录 Docker Registry
|
||||
run: |
|
||||
@@ -47,32 +37,33 @@ jobs:
|
||||
- name: 构建 API 镜像
|
||||
run: |
|
||||
docker build -f Dockerfile.api -t ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }} .
|
||||
docker tag ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }} ${{ env.API_IMAGE }}:${{ github.sha }}
|
||||
|
||||
- name: 构建 Worker 镜像
|
||||
run: |
|
||||
docker build -f Dockerfile.worker -t ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }} .
|
||||
docker tag ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }} ${{ env.WORKER_IMAGE }}:${{ github.sha }}
|
||||
|
||||
- name: 推送镜像到 Registry
|
||||
run: |
|
||||
docker push ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }}
|
||||
docker push ${{ env.API_IMAGE }}:${{ github.sha }}
|
||||
docker push ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }}
|
||||
docker push ${{ env.WORKER_IMAGE }}:${{ github.sha }}
|
||||
|
||||
- name: 部署到本地(仅 Iteration/7-11 分支)
|
||||
if: github.ref == 'refs/heads/Iteration/7-11'
|
||||
- name: 部署到测试环境(仅八月迭代分支)
|
||||
if: github.ref == 'refs/heads/Iteration/8-11'
|
||||
run: |
|
||||
# 确保部署目录存在(仅需日志目录,配置已嵌入二进制文件)
|
||||
mkdir -p ${{ env.DEPLOY_DIR }}/logs
|
||||
# 确保部署目录存在:api、worker 各自的日志目录与 worker 的采集记录归档目录(配置已嵌入二进制文件)
|
||||
mkdir -p ${{ env.DEPLOY_DIR }}/api/logs ${{ env.DEPLOY_DIR }}/worker/logs ${{ env.DEPLOY_DIR }}/worker/archive
|
||||
# 容器以 uid 1000(appuser)运行,新目录需可写;无权限修改属主时保持原样
|
||||
chown 1000:1000 ${{ env.DEPLOY_DIR }}/api/logs ${{ env.DEPLOY_DIR }}/worker/logs ${{ env.DEPLOY_DIR }}/worker/archive 2>/dev/null || true
|
||||
|
||||
umask 077
|
||||
{
|
||||
printf 'IMAGE_TAG=%s\n' '${{ github.sha }}'
|
||||
printf 'JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=true\n'
|
||||
printf 'JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=true\n'
|
||||
printf 'JUNHONG_WORKER_ROLE=all\n'
|
||||
printf 'JUNHONG_WORKER_INSTANCE_NAME=test-worker-all-1\n'
|
||||
# 测试环境只读模式:tb_gateway_readonly_card 中的生产卡与设备只允许查询类网关调用(add-gateway-readonly-prod-import)
|
||||
printf 'JUNHONG_GATEWAY_READONLY_ENABLED=true\n'
|
||||
} > ${{ env.DEPLOY_DIR }}/.env
|
||||
|
||||
# 强制更新 docker-compose.prod.yml(确保使用最新配置)
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -111,6 +111,7 @@ scripts/batch_package_purchase/assets.example_购买结果_20260715_115804.csv
|
||||
scripts/batch_package_purchase/assets.example_购买结果_20260715_115814.csv
|
||||
scripts/migration/output
|
||||
|
||||
# LongHorizon 本地执行证据
|
||||
.lh-harness/
|
||||
.scratch/go-build-cache
|
||||
|
||||
# 本地数据库备份(生产 dump,禁止入库)
|
||||
back/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,10 +38,10 @@
|
||||
- 行为变化必须通过独立 OpenSpec Change;主 Specs 只描述当前行为。
|
||||
- 当前 Bug 与兼容行为按实际结果记录,禁止在基线任务中顺手修复。
|
||||
- 不修改既有迁移;新 Schema 变化使用新的成对迁移。
|
||||
- 不访问生产服务、真实支付渠道或外部审批系统进行自动验证。
|
||||
- 生产环境为 systemd 管理的手工二进制发布,和仓库 Docker/CI 测试环境不同;生产发布、迁移与回滚事实见 [`docs/deployment/production-runbook.md`](docs/deployment/production-runbook.md)。Agent 不连接生产主机或数据库,生产操作由维护者执行并提供结果。
|
||||
- 不访问真实支付渠道或外部审批系统进行自动验证。
|
||||
- 生产环境为 systemd 管理的手工二进制发布,和仓库 Docker/CI 测试环境不同;生产发布、迁移与回滚事实见 [`docs/deployment/production-runbook.md`](docs/deployment/production-runbook.md)。Agent 可仅通过 dbhub 对生产数据库执行只读诊断查询;不连接生产主机,生产发布、迁移、回滚及其他写操作由维护者执行并提供结果,除非明确要求。
|
||||
- 不把密钥、Token、证书或个人敏感数据写入代码、文档和日志。
|
||||
- `.lh-harness/` 仅保存本地执行证据,不是事实源且不得纳入 Git。
|
||||
- `docs/verification/context-reset/` 仅保存上下文健康检查证据,不是业务事实源;证据应随项目文档维护。
|
||||
- 自动化测试当前为 N/A(用户决策);不恢复旧测试,也不写虚假测试入口。
|
||||
|
||||
## 架构选择
|
||||
@@ -84,6 +84,7 @@ openspec validate --all
|
||||
```
|
||||
|
||||
启动、隔离数据库重置、smoke 与日志读取见 [`README.md`](README.md)。迁移使用 `scripts/migrate.sh` 和显式 `DB_*` 参数;生产迁移仅按生产运行说明由维护者手工执行。
|
||||
- 测试验证:涉及迁移、Redis、部署或集成 Smoke 时,MUST 读取 [`docs/engineering/工程约束.md`](docs/engineering/工程约束.md) 的 ENG-TEST-001;维护者指定的测试环境是唯一验证面。
|
||||
|
||||
## 渐进披露
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| `/health`、`/ready` | 公开,只返回进程健康/就绪状态 | `internal/routes/health.go` | `openspec/specs/operations-audit/spec.md` |
|
||||
| `/api/auth` | 后台账号登录、刷新、登出与当前身份;认证中间件在路由组装配 | `internal/routes/auth.go` | `openspec/specs/identity-access/spec.md` |
|
||||
| `/api/admin` | 后台认证、角色权限、店铺/企业数据范围;业务层仍需资源级校验 | `internal/routes/admin.go` | `identity-access`、`asset-device`、`package-lifecycle`、`order-payment-wallet`、`operations-audit` |
|
||||
| `/api/admin` | 后台认证、角色权限、店铺/企业数据范围;业务层仍需资源级校验 | `internal/routes/admin.go` | `identity-access`、`asset-device`、`package-lifecycle`、`order-payment-wallet`、`operations-audit`、`operations-report` |
|
||||
| `/api/c/v1` | 个人客户 Token 与资产归属边界 | `internal/routes/personal.go` | `openspec/specs/personal-customer/spec.md` |
|
||||
| `/api/open/v1` | 代理 Open API 独立认证/签名,不复用后台账号权限 | `internal/routes/open.go` | `openspec/specs/agent-open-api/spec.md` |
|
||||
| `/api/callback` | 无登录认证;每类渠道必须在 Handler/Adapter 内验签、解密、校验金额或事件身份 | `internal/routes/order.go`、`wecom_callback.go` 及运营商回调注册 | `openspec/specs/external-integration/spec.md`、`order-payment-wallet` |
|
||||
@@ -72,6 +72,7 @@ HTTP 入参、回调 Body/Header、上传文件和代理签名材料均是不可
|
||||
| 个人客户 | `routes/personal.go` 及 C 端子路由 | `handler/app`;`service/client_*|personal_customer|customer_binding`;通知 Application/Query | Token、资产绑定、钱包、支付、Gateway | [`personal-customer`](openspec/specs/personal-customer/spec.md) |
|
||||
| 代理 Open API | `routes/open.go` | `handler/openapi` → `service/agent_open_api` | 独立认证、店铺数据范围、卡/套餐/钱包 Store | [`agent-open-api`](openspec/specs/agent-open-api/spec.md) |
|
||||
| 轮询、通知、导出、配置与审计调查 | `polling_*.go`、`notification.go`、`export_task.go`、`system_config.go`、`audit.go`;Scheduler/Task/Outbox | `application/notification|systemconfig|auditarchive|outbox`;`query/audit|notification|outbox|systemconfig`;旧 polling/export Service | Asynq、Outbox、审计库、Integration Log、对象存储 | [`operations-audit`](openspec/specs/operations-audit/spec.md) |
|
||||
| 运营报表(设备激活与套餐续费日报快照) | `routes/operations_report.go`(汇总/趋势/受控导出);异步 `TaskTypeOperationsReportSnapshot` 每日 03:30 | `application/operationsreport`(生成用例)→ `domain/operationsreport`(口径域);读取 `query/operationsreport`;导出 `exporter/operations_report_scene.go` | `tb_operations_report_snapshot`、`tb_operations_report_activation_row`、`tb_operations_report_renewal_row`(迁移 `000231`,整日替换);只读适配 `infrastructure/operationsreport` | [`operations-report`](openspec/specs/operations-report/spec.md) |
|
||||
| 外部集成接点 | 支付/运营商/企微回调,Gateway 调用,对象存储、短信 | `internal/gateway`、`internal/infrastructure/wecom|payment|carriercallback|integrationlog`、`pkg/alipay|wechat|fuiou|sms|storage` | 第三方网络和凭证;协议事实见 integrations | [`external-integration`](openspec/specs/external-integration/spec.md) |
|
||||
|
||||
任务 7 会把当前过大的 Spec 索引拆成完整业务能力;拆分后必须同步本表链接。
|
||||
@@ -114,15 +115,8 @@ rg -o 'constants\.OutboxEventType[A-Za-z0-9_]+' cmd/worker/main.go internal | so
|
||||
|
||||
需要业务事实与可靠异步副作用原子提交的用例应同事务写 Outbox;是否已经做到必须逐调用链核对。Relay 和消费者按至少一次投递设计,消费者必须容忍重复。
|
||||
|
||||
## 外部调用与回调
|
||||
|
||||
- 支付:创建本地支付事实后调用渠道;回调需要验签、金额/业务号/状态校验,重复回调不得重复入账。
|
||||
- 企业微信:本地审批实例 → Outbox 提交 → 渠道审批;加密回调、轮询和人工同步进入统一状态同步语义。
|
||||
- Gateway/运营商:查询与控制结果来自外部;超时或未知结果不得理想化为成功。
|
||||
- 对象存储:上传/预签名 URL 是外部副作用,数据库只保存对象引用。
|
||||
- SMS:发送结果是外部通知结果,不等同业务事务成功。
|
||||
|
||||
外部网络调用不应持有长数据库事务;确需提交后可靠执行时使用 Outbox/Task。实际例外按 As-Is Spec 记录。
|
||||
- 支付:创建本地支付事实、冻结商户池路由并写入一次不可变路由尝试事实后,事务外调用渠道;回调需要验签、金额/业务号/状态校验,重复回调不得重复入账或重复统计。预下单明确失败条件禁用冻结商户并写入脱敏失败留痕;超时、连接中断、响应无法确认和上下文取消保持结果未知,进入查单/恢复边界,不自动禁用或创建第二个渠道支付。
|
||||
- 商户池金额/笔数轮询按支付创建提交时的路由尝试事实统计,后续成功、失败、取消、过期或未知不新增、不回冲;历史支付成功事实只作兼容读取。
|
||||
|
||||
## 事务、审计与日志边界
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ RUN GOOS=linux GOARCH=amd64 go build \
|
||||
./cmd/api
|
||||
|
||||
# 下载 golang-migrate 工具(使用 GOPROXY 加速)
|
||||
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
||||
# 固定 v4.19.1:其 go.mod 仅要求 go 1.24.0,与基础镜像 Go 1.25.6 兼容;
|
||||
# @latest(如 v4.20.1)要求 Go >= 1.25.11,在 GOTOOLCHAIN=local 的 1.25.6 环境下构建失败
|
||||
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
|
||||
|
||||
# ================================
|
||||
# 阶段 2: 运行阶段
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
@@ -27,9 +28,30 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
|
||||
handlers := openapi.BuildDocHandlers()
|
||||
handlers.Audit = admin.NewAuditHandler(nil, nil)
|
||||
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
|
||||
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
|
||||
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
|
||||
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
|
||||
// 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil)
|
||||
handlers.CarrierScheduler = admin.NewCarrierSchedulerHandler(nil, nil)
|
||||
handlers.PollingPause = admin.NewPollingPauseHandler(nil, nil, nil)
|
||||
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
|
||||
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
|
||||
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
|
||||
handlers.StopResumeRule = admin.NewStopResumeRuleHandler(nil, nil, nil)
|
||||
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
|
||||
handlers.ClientPackageSilence = apphandler.NewClientPackageSilenceHandler(nil, nil, nil)
|
||||
handlers.PackageSilence = admin.NewPackageSilenceHandler(nil, nil, nil, nil, nil)
|
||||
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
|
||||
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
|
||||
handlers.WeCom = admin.NewWeComHandler(nil, nil)
|
||||
handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)
|
||||
handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil)
|
||||
handlers.AgentDistributionRegistration = admin.NewAgentDistributionRegistrationHandler(nil, nil, nil)
|
||||
handlers.WithdrawalQualification = admin.NewWithdrawalQualificationHandler(nil, nil, nil)
|
||||
handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.CUCCRealnameRemovalCallback = callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil)
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
stdErrors "errors"
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"os"
|
||||
"os/signal"
|
||||
"reflect"
|
||||
@@ -22,9 +23,11 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
|
||||
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/verification"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
pkgbootstrap "github.com/break/junhong_cmp_fiber/pkg/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
@@ -70,7 +73,12 @@ func main() {
|
||||
storageSvc := initStorage(cfg, appLogger)
|
||||
|
||||
// 9. 初始化 Gateway 客户端(可选)
|
||||
gatewayClient := initGateway(cfg, appLogger)
|
||||
gatewayClient := initGateway(cfg, db, appLogger)
|
||||
// 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。
|
||||
gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger)
|
||||
if gatewayClient != nil {
|
||||
gatewayClient.SetCallGuard(gatewayLimit.Guard)
|
||||
}
|
||||
|
||||
// 10. 初始化所有业务组件(通过 Bootstrap)
|
||||
result, err := bootstrap.Bootstrap(&bootstrap.Dependencies{
|
||||
@@ -83,6 +91,7 @@ func main() {
|
||||
QueueClient: queueClient,
|
||||
StorageService: storageSvc,
|
||||
GatewayClient: gatewayClient,
|
||||
GatewayLimit: gatewayLimit,
|
||||
})
|
||||
if err != nil {
|
||||
appLogger.Fatal("初始化业务组件失败", zap.Error(err))
|
||||
@@ -138,6 +147,7 @@ func initLogger(cfg *config.Config) *zap.Logger {
|
||||
|
||||
appLogger := logger.GetAppLogger()
|
||||
appLogger.Info("应用程序启动中...", zap.String("address", cfg.Server.Address))
|
||||
applyAuditPersistOverrides(cfg, appLogger)
|
||||
return appLogger
|
||||
}
|
||||
|
||||
@@ -441,7 +451,7 @@ func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
|
||||
return storage.NewService(provider, &cfg.Storage)
|
||||
}
|
||||
|
||||
func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
|
||||
func initGateway(cfg *config.Config, db *gorm.DB, appLogger *zap.Logger) *gateway.Client {
|
||||
if cfg.Gateway.BaseURL == "" {
|
||||
appLogger.Info("Gateway 未配置,跳过初始化")
|
||||
return nil
|
||||
@@ -453,6 +463,10 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
|
||||
cfg.Gateway.AppSecret,
|
||||
appLogger,
|
||||
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
|
||||
if cfg.Gateway.ReadonlyEnabled {
|
||||
// 测试环境只读模式:只读资产表中的卡与设备只允许查询类调用(add-gateway-readonly-prod-import)。
|
||||
client.WithReadonlyChecker(postgres.NewGatewayReadonlyCardStore(db).IsReadonly)
|
||||
}
|
||||
|
||||
appLogger.Info("Gateway 客户端初始化成功",
|
||||
zap.String("base_url", cfg.Gateway.BaseURL),
|
||||
@@ -460,3 +474,10 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// applyAuditPersistOverrides 加载审计落库策略的运行时覆盖;非法项忽略并告警。
|
||||
func applyAuditPersistOverrides(cfg *config.Config, appLogger *zap.Logger) {
|
||||
if invalid := auditInfra.SetPersistOverrides(cfg.Audit.PersistOverrides); len(invalid) > 0 {
|
||||
appLogger.Warn("审计落库策略覆盖存在非法项,已忽略", zap.Strings("invalid", invalid))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Command audit-retention-simulate 在测试环境演练完整自然月归档与清理边界。
|
||||
// Command audit-retention-simulate 在测试环境演练逐日归档与清理边界。
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -6,22 +6,21 @@ import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
auditarchive "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
|
||||
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
taskapp "github.com/break/junhong_cmp_fiber/internal/task"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||
logpkg "github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
||||
)
|
||||
|
||||
@@ -67,6 +66,11 @@ func run(ctx context.Context) error {
|
||||
return fmt.Errorf("仅允许显式确认的测试数据库,当前数据库为 %q", cfg.Database.DBName)
|
||||
}
|
||||
logger := zap.NewNop()
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.Logging.RetentionLog.Filename), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
retentionLogger, syncRetentionLogger := logpkg.NewRetentionLogger(cfg.Logging.Level, logpkg.LogRotationConfig{Filename: cfg.Logging.RetentionLog.Filename, MaxSize: cfg.Logging.RetentionLog.MaxSize, MaxBackups: cfg.Logging.RetentionLog.MaxBackups, MaxAge: cfg.Logging.RetentionLog.MaxAge, Compress: cfg.Logging.RetentionLog.Compress})
|
||||
defer func() { _ = syncRetentionLogger() }()
|
||||
db, err := database.InitPostgreSQL(&cfg.Database, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -81,8 +85,7 @@ func run(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auditWriter := auditinfra.NewWriter(auditinfra.NewRegistry(), nil)
|
||||
service, err := auditarchive.NewService(db, provider, simulationInstance, auditWriter)
|
||||
service, err := auditarchive.NewService(db, provider, simulationInstance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -108,14 +111,17 @@ func run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
payload, _ := sonic.Marshal(taskapp.AuditMonthlyRetentionPayload{ArchiveMonth: simulationMonth})
|
||||
dryRunTask := asynq.NewTask(constants.TaskTypeAuditMonthlyRetention, payload)
|
||||
if err := taskapp.NewAuditMonthlyRetentionHandler(service, logger, false).Handle(ctx, dryRunTask); err != nil {
|
||||
return fmt.Errorf("月度只读演练失败: %w", err)
|
||||
}
|
||||
dryRun, err := service.ValidateMonth(ctx, monthStart)
|
||||
if err != nil {
|
||||
return err
|
||||
var dryRun auditarchive.RetentionResult
|
||||
for date := monthStart; date.Before(monthEnd); date = date.AddDate(0, 0, 1) {
|
||||
result, retainErr := service.RetainDate(ctx, date, false)
|
||||
if retainErr != nil {
|
||||
return fmt.Errorf("逐日只读演练失败: %w", retainErr)
|
||||
}
|
||||
retentionLogger.Info("日留存仿真只读校验通过", zap.String("archive_date", result.ArchiveDate))
|
||||
dryRun.EventCount += result.EventCount
|
||||
dryRun.ResourceCount += result.ResourceCount
|
||||
dryRun.IntegrationCount += result.IntegrationCount
|
||||
dryRun.EstimatedBatches += result.EstimatedBatches
|
||||
}
|
||||
summary, err := collectBeforeCleanup(ctx, db, monthStart, monthEnd, dryRun)
|
||||
if err != nil {
|
||||
@@ -125,16 +131,39 @@ func run(ctx context.Context) error {
|
||||
return fmt.Errorf("只归档模式写入了 %d 个清理断点", summary.CleanupMarkersBefore)
|
||||
}
|
||||
|
||||
cleanupTask := asynq.NewTask(constants.TaskTypeAuditMonthlyRetention, payload)
|
||||
if err := taskapp.NewAuditMonthlyRetentionHandler(service, logger, true).Handle(ctx, cleanupTask); err != nil {
|
||||
return fmt.Errorf("隔离测试库物理清理演练失败: %w", err)
|
||||
for date := monthStart; date.Before(monthEnd); date = date.AddDate(0, 0, 1) {
|
||||
result, retainErr := service.RetainDate(ctx, date, true)
|
||||
if retainErr != nil {
|
||||
return fmt.Errorf("隔离测试库逐日物理清理演练失败: %w", retainErr)
|
||||
}
|
||||
retentionLogger.Info("日留存仿真物理清理完成", zap.String("archive_date", result.ArchiveDate))
|
||||
}
|
||||
if err := collectAfterCleanup(ctx, db, monthStart, monthEnd, &summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.TargetRowsAfterCleanup != 0 || summary.BoundaryRowsAfterCleanup != 6 || summary.CleanupMarkersAfter != int64(summary.Days*2) || !summary.RetentionAuditRecorded {
|
||||
return fmt.Errorf("清理范围复核失败: target=%d boundary=%d markers=%d audit=%t",
|
||||
summary.TargetRowsAfterCleanup, summary.BoundaryRowsAfterCleanup, summary.CleanupMarkersAfter, summary.RetentionAuditRecorded)
|
||||
if summary.TargetRowsAfterCleanup != 1 || summary.BoundaryRowsAfterCleanup != 6 || summary.CleanupMarkersAfter != int64(summary.Days*2) {
|
||||
return fmt.Errorf("清理范围复核失败: target=%d boundary=%d markers=%d", summary.TargetRowsAfterCleanup, summary.BoundaryRowsAfterCleanup, summary.CleanupMarkersAfter)
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&model.IntegrationLog{}).
|
||||
Where("integration_id = ?", "int_"+simulationPrefix+"-"+monthStart.Format("20060102")).
|
||||
Updates(map[string]any{"result": constants.IntegrationResultSuccess, "updated_at": time.Now()}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := service.RetainDate(ctx, monthStart, true); err != nil {
|
||||
return fmt.Errorf("pending 终结后的续跑清理演练失败: %w", err)
|
||||
}
|
||||
var remaining int64
|
||||
if err := db.WithContext(ctx).Model(&model.IntegrationLog{}).Where("created_at >= ? AND created_at < ?", monthStart, monthEnd).Count(&remaining).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining != 0 {
|
||||
return fmt.Errorf("pending 终结后的续跑清理未完成,剩余 %d 条记录", remaining)
|
||||
}
|
||||
if err := syncRetentionLogger(); err != nil {
|
||||
return fmt.Errorf("刷新日留存仿真日志失败: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(cfg.Logging.RetentionLog.Filename); err != nil {
|
||||
return fmt.Errorf("独立日留存日志未生成: %w", err)
|
||||
}
|
||||
encoded, _ := sonic.MarshalIndent(summary, "", " ")
|
||||
fmt.Println(string(encoded))
|
||||
@@ -256,12 +285,7 @@ func archiveMonth(ctx context.Context, db *gorm.DB, service *auditarchive.Servic
|
||||
if err := service.ArchiveDate(ctx, start); err != nil {
|
||||
return fmt.Errorf("Audit 故障重试演练失败: %w", err)
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&model.IntegrationLog{}).
|
||||
Where("integration_id = ?", "int_"+simulationPrefix+"-"+start.Format("20060102")).
|
||||
Updates(map[string]any{"result": constants.IntegrationResultSuccess, "updated_at": time.Now()}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return service.FinalizeIntegrationMonth(ctx, start)
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectBeforeCleanup(ctx context.Context, db *gorm.DB, start, end time.Time, dryRun auditarchive.RetentionResult) (simulationSummary, error) {
|
||||
@@ -311,10 +335,5 @@ func collectAfterCleanup(ctx context.Context, db *gorm.DB, start, end time.Time,
|
||||
Count(&summary.CleanupMarkersAfter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var auditCount int64
|
||||
if err := db.WithContext(ctx).Model(&model.AuditEvent{}).Where("event_id = ?", "evt_retention_"+strings.ReplaceAll(simulationMonth, "-", "_")).Count(&auditCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
summary.RetentionAuditRecorded = auditCount == 1
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
@@ -36,8 +37,29 @@ func generateAdminDocs(outputPath string) error {
|
||||
handlers := openapi.BuildDocHandlers()
|
||||
handlers.Audit = admin.NewAuditHandler(nil, nil)
|
||||
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
|
||||
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
|
||||
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
|
||||
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
|
||||
// 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil)
|
||||
handlers.CarrierScheduler = admin.NewCarrierSchedulerHandler(nil, nil)
|
||||
handlers.PollingPause = admin.NewPollingPauseHandler(nil, nil, nil)
|
||||
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
|
||||
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
|
||||
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
|
||||
handlers.StopResumeRule = admin.NewStopResumeRuleHandler(nil, nil, nil)
|
||||
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
|
||||
handlers.ClientPackageSilence = apphandler.NewClientPackageSilenceHandler(nil, nil, nil)
|
||||
handlers.PackageSilence = admin.NewPackageSilenceHandler(nil, nil, nil, nil, nil)
|
||||
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
|
||||
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
|
||||
handlers.WeCom = admin.NewWeComHandler(nil, nil)
|
||||
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)
|
||||
handlers.AgentDistributionRegistration = admin.NewAgentDistributionRegistrationHandler(nil, nil, nil)
|
||||
handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil)
|
||||
handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
115
cmd/silence-backfill/main.go
Normal file
115
cmd/silence-backfill/main.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// silence-backfill 是沉默期与首次购买套餐时间历史回填工具(add-package-silence-period,技术方案 §6、§17.4)。
|
||||
//
|
||||
// 用法(数据库连接只读取显式传入的 DB_* 环境变量):
|
||||
//
|
||||
// DB_HOST=... DB_PORT=... DB_USER=... DB_PASSWORD=... DB_NAME=... DB_SSLMODE=disable \
|
||||
// go run ./cmd/silence-backfill -target silence # 只输出影响清单(默认 dry-run)
|
||||
// go run ./cmd/silence-backfill -target silence -execute # 业务确认清单后执行
|
||||
// go run ./cmd/silence-backfill -target first_purchase # 首次购买套餐时间:只输出可回填数与无订单数
|
||||
// go run ./cmd/silence-backfill -target first_purchase -execute
|
||||
//
|
||||
// 两种回填都只处理为空的记录、分批执行、可重复执行;可在沉默期总开关关闭时运行。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func main() {
|
||||
target := flag.String("target", "", "回填对象:silence(套餐沉默期)/ first_purchase(资产首次购买套餐时间)")
|
||||
execute := flag.Bool("execute", false, "执行写入;不传时只输出影响清单")
|
||||
flag.Parse()
|
||||
if *target != "silence" && *target != "first_purchase" {
|
||||
fail("必须指定 -target silence 或 -target first_purchase")
|
||||
}
|
||||
db, err := openDB()
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
ctx := auditcontext.With(context.Background(), auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: "silence-backfill", ActorName: "沉默期历史回填工具",
|
||||
Source: constants.AuditSourceWorker,
|
||||
})
|
||||
backfill := packagesilenceapp.NewBackfill(db, auditInfra.NewWriter(auditInfra.NewRegistry(), nil))
|
||||
started := time.Now()
|
||||
mode := "dry-run(只出清单)"
|
||||
if *execute {
|
||||
mode = "execute(执行写入)"
|
||||
}
|
||||
fmt.Printf("目标库:%s,对象:%s,模式:%s\n", os.Getenv("DB_NAME"), *target, mode)
|
||||
var report any
|
||||
switch *target {
|
||||
case "silence":
|
||||
silence, err := backfill.SilenceBackfill(ctx, *execute)
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
printSilence(silence)
|
||||
report = silence
|
||||
case "first_purchase":
|
||||
firstPurchase, err := backfill.FirstPurchaseBackfill(ctx, *execute)
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
report = firstPurchase
|
||||
}
|
||||
encoded, _ := json.MarshalIndent(report, "", " ")
|
||||
fmt.Println(string(encoded))
|
||||
fmt.Printf("耗时:%s\n", time.Since(started).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
func printSilence(report *packagesilenceapp.SilenceBackfillReport) {
|
||||
fmt.Printf("待回填记录 %d,可回填 %d,未配置默认沉默期 %d(不回填)\n", report.Candidates, report.Backfillable, report.UnconfiguredCount)
|
||||
fmt.Printf("期内已实名(记为结束)%d,其中实名时间为空 %d;仍在期内 %d;打开总开关后将过期 %d,其中无法付费延期 %d\n",
|
||||
report.EndedInPeriod, report.RealnamedWithoutTime, report.Waiting, report.WillExpire, report.WillExpireNoPaidExtend)
|
||||
fmt.Println("按代理分布:")
|
||||
for _, item := range packagesilenceapp.SortedCounts(report.ByShop) {
|
||||
fmt.Printf(" %s:%d\n", item.Name, item.Count)
|
||||
}
|
||||
fmt.Println("按套餐分布:")
|
||||
for _, item := range packagesilenceapp.SortedCounts(report.ByPackage) {
|
||||
fmt.Printf(" %s:%d\n", item.Name, item.Count)
|
||||
}
|
||||
if len(report.UnconfiguredPackages) > 0 {
|
||||
fmt.Println("未配置默认沉默期的套餐:")
|
||||
for _, item := range packagesilenceapp.SortedCounts(report.UnconfiguredPackages) {
|
||||
fmt.Printf(" %s:%d\n", item.Name, item.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openDB() (*gorm.DB, error) {
|
||||
required := []string{"DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME"}
|
||||
for _, key := range required {
|
||||
if strings.TrimSpace(os.Getenv(key)) == "" {
|
||||
return nil, fmt.Errorf("缺少环境变量 %s(只使用显式传入的数据库连接)", key)
|
||||
}
|
||||
}
|
||||
sslMode := os.Getenv("DB_SSLMODE")
|
||||
if sslMode == "" {
|
||||
sslMode = "disable"
|
||||
}
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"), sslMode)
|
||||
return gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)})
|
||||
}
|
||||
|
||||
func fail(message string) {
|
||||
fmt.Fprintln(os.Stderr, "回填失败:"+message)
|
||||
os.Exit(1)
|
||||
}
|
||||
356
cmd/worker/carrier_scheduler.go
Normal file
356
cmd/worker/carrier_scheduler.go
Normal file
@@ -0,0 +1,356 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/nsqbus"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
|
||||
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
)
|
||||
|
||||
// carrierInitLockKey 新结构初始化锁:多实例时只由一个实例初始化。
|
||||
const carrierInitLockKey = "polling:carrier:init_lock"
|
||||
|
||||
// carrierSchedulerRuntime 是按运营商账户调度在 Worker 内的运行组件:模式监听、初始化、分发器与 NSQ 消费者。
|
||||
type carrierSchedulerRuntime struct {
|
||||
cfg config.CarrierSchedulerConfig
|
||||
runtime *workerRuntime
|
||||
initializer *polling.CarrierInitializer
|
||||
dispatcher *carrierqueue.Dispatcher
|
||||
consumers *nsqbus.ConsumerManager
|
||||
producer *nsqbus.Producer
|
||||
alerts *carrierqueue.AlertWriter
|
||||
executor *task.CarrierCardExecutor
|
||||
// 停复机指令与设备判断的专用消费者:只看停复机出口开关,不受 scheduler_mode 限制。
|
||||
srConsumers *nsqbus.ConsumerManager
|
||||
devConsumers *nsqbus.ConsumerManager
|
||||
srActive bool
|
||||
// ingest 是轮询观测入库消费者与积压监控(add-polling-async-ingest);未配置 nsqd 时为 nil。
|
||||
ingest *pollingIngestRuntime
|
||||
singleton bool
|
||||
instanceID string
|
||||
logger *zap.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
lastMode string
|
||||
reinitQueued bool
|
||||
legacyRestarts func()
|
||||
}
|
||||
|
||||
// newCarrierSchedulerParts 在 Worker 运行时初始化阶段创建调度参数、账户队列存储与模式接缝,
|
||||
// 并注入旧调度相关组件(生命周期、轮询基类),使其在启动前即可按模式写入正确结构。
|
||||
func newCarrierSchedulerParts(runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
settings := carrierqueue.NewDefaultSettingsProvider(runtime.db, runtime.redisClient, appLogger)
|
||||
store := carrierqueue.NewStore(runtime.redisClient)
|
||||
scheduling := polling.NewCarrierScheduling(store, settings, runtime.pollingConfigMgr, appLogger)
|
||||
runtime.carrierSettings = settings
|
||||
runtime.carrierStore = store
|
||||
runtime.carrierScheduling = scheduling
|
||||
runtime.lifecycleSvc.SetCarrierScheduling(scheduling)
|
||||
runtime.pollingBase.SetSchedulerModeReader(func(ctx context.Context) string { return settings.Current(ctx).Mode })
|
||||
if stopResumeSvc, ok := runtime.workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
|
||||
stopResumeSvc.SetCommandQueue(carrierqueue.NewCommandQueue(store, settings))
|
||||
}
|
||||
if runtime.gatewayLimit != nil && runtime.gatewayLimit.Classifier != nil {
|
||||
classifier := runtime.gatewayLimit.Classifier
|
||||
runtime.pollingBase.SetErrorClassifier(func(ctx context.Context, op, message string) string {
|
||||
return classifier.Classify(ctx, op, message).Category
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// startCarrierScheduler 启动模式监听;分发器只在 leader/all 运行(多实例安全),消费者在所有处理任务的实例运行。
|
||||
func startCarrierScheduler(ctx context.Context, cfg *config.Config, runtime *workerRuntime, steps queue.PollingStepHandlers,
|
||||
singleton bool, legacyRestart func(), appLogger *zap.Logger) *carrierSchedulerRuntime {
|
||||
carrierCfg := cfg.Polling.Carrier
|
||||
instanceID := cfg.Worker.InstanceName
|
||||
if instanceID == "" {
|
||||
instanceID, _ = os.Hostname()
|
||||
}
|
||||
instanceID += "-" + strconv.Itoa(os.Getpid())
|
||||
alerts := carrierqueue.NewAlertWriter(runtime.db, runtime.redisClient, runtime.carrierSettings, appLogger)
|
||||
alerts.Start(ctx)
|
||||
executor := task.NewCarrierCardExecutor(runtime.pollingBase, runtime.carrierStore, runtime.carrierSettings, alerts,
|
||||
steps.Realname, steps.Carddata, steps.CardStatus, steps.Package, carrierCfg, instanceID)
|
||||
c := &carrierSchedulerRuntime{
|
||||
cfg: carrierCfg, runtime: runtime, singleton: singleton, instanceID: instanceID, logger: appLogger,
|
||||
alerts: alerts, executor: executor, legacyRestarts: legacyRestart,
|
||||
initializer: polling.NewCarrierInitializer(runtime.carrierScheduling, runtime.pollingIotCardStore, appLogger),
|
||||
}
|
||||
if carrierCfg.NSQDAddress != "" {
|
||||
producer, err := nsqbus.NewProducer(carrierCfg.NSQDAddress, appLogger)
|
||||
if err != nil {
|
||||
appLogger.Error("创建 NSQ 生产者失败,按运营商账户调度不可用", zap.Error(err))
|
||||
} else {
|
||||
c.producer = producer
|
||||
}
|
||||
c.consumers = nsqbus.NewConsumerManager(nsqbus.ConsumerConfig{
|
||||
NSQDAddress: carrierCfg.NSQDAddress, NSQLookupdAddresses: nsqbus.SplitAddresses(carrierCfg.NSQLookupdAddresses),
|
||||
MsgTimeout: carrierCfg.MsgTimeout, MaxAttempts: carrierCfg.MaxAttempts,
|
||||
}, constants.NSQChannelWorker, executor.Handle, appLogger)
|
||||
}
|
||||
c.ingest = startPollingIngest(ctx, cfg, runtime, c.producer, singleton, c.grace(), appLogger)
|
||||
c.setupStopResume(carrierCfg, alerts)
|
||||
var budgets carrierqueue.BudgetProvider
|
||||
if runtime.gatewayLimit != nil && runtime.gatewayLimit.Limiter != nil {
|
||||
budgets = runtime.gatewayLimit.Limiter
|
||||
}
|
||||
if c.producer != nil {
|
||||
c.dispatcher = carrierqueue.NewDispatcher(runtime.db, runtime.carrierStore, runtime.carrierSettings, c.producer, budgets,
|
||||
carrierCfg, instanceID, appLogger)
|
||||
if singleton {
|
||||
go c.dispatcher.Run(ctx)
|
||||
}
|
||||
}
|
||||
go c.watch(ctx)
|
||||
return c
|
||||
}
|
||||
|
||||
// RequestReinit 轮询配置变更时请求按新配置重新初始化新结构(仅 carrier 模式生效)。
|
||||
func (c *carrierSchedulerRuntime) RequestReinit() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.reinitQueued = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *carrierSchedulerRuntime) watch(ctx context.Context) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
c.step(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.shutdown()
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *carrierSchedulerRuntime) step(ctx context.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
c.logger.Error("按运营商账户调度模式监听 panic,已恢复", zap.Any("panic", r))
|
||||
}
|
||||
}()
|
||||
c.syncStopResume(ctx)
|
||||
mode := c.runtime.carrierSettings.Current(ctx).Mode
|
||||
c.mu.Lock()
|
||||
previous := c.lastMode
|
||||
c.lastMode = mode
|
||||
reinit := c.reinitQueued
|
||||
c.reinitQueued = false
|
||||
c.mu.Unlock()
|
||||
if mode == constants.PollingSchedulerModeCarrier {
|
||||
if previous != mode {
|
||||
c.logger.Info("卡轮询调度模式:按运营商账户调度", zap.String("previous", previous))
|
||||
}
|
||||
if c.singleton {
|
||||
c.ensureInitialized(ctx, reinit)
|
||||
}
|
||||
c.syncConsumers(ctx)
|
||||
return
|
||||
}
|
||||
if previous == constants.PollingSchedulerModeCarrier {
|
||||
c.logger.Info("卡轮询调度模式回到旧分片调度:停止领取与消费,重建旧分片队列")
|
||||
if c.consumers != nil {
|
||||
c.consumers.StopAll(c.grace())
|
||||
c.consumers.Reset()
|
||||
}
|
||||
if c.singleton {
|
||||
c.runtime.redisClient.Del(ctx, constants.RedisCarrierSchedulerInitStateKey)
|
||||
if c.legacyRestarts != nil {
|
||||
c.legacyRestarts()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensureInitialized 切到 carrier 后初始化新结构;初始化完成前分发器不领取。多实例时用初始化锁协调。
|
||||
func (c *carrierSchedulerRuntime) ensureInitialized(ctx context.Context, reinit bool) {
|
||||
redisClient := c.runtime.redisClient
|
||||
state, _ := redisClient.Get(ctx, constants.RedisCarrierSchedulerInitStateKey).Result()
|
||||
if state == carrierqueue.InitStateDone && !reinit {
|
||||
return
|
||||
}
|
||||
ok, err := redisClient.SetNX(ctx, carrierInitLockKey, c.instanceID, 30*time.Minute).Result()
|
||||
if err != nil || !ok {
|
||||
return
|
||||
}
|
||||
defer redisClient.Del(context.WithoutCancel(ctx), carrierInitLockKey)
|
||||
if state != carrierqueue.InitStateDone {
|
||||
redisClient.Set(ctx, constants.RedisCarrierSchedulerInitStateKey, carrierqueue.InitStateRunning, 0)
|
||||
}
|
||||
started := time.Now()
|
||||
written, initErr := c.initializer.Run(ctx)
|
||||
if initErr != nil {
|
||||
c.logger.Error("按运营商账户调度初始化失败,下一轮重试", zap.Error(initErr))
|
||||
return
|
||||
}
|
||||
redisClient.Set(ctx, constants.RedisCarrierSchedulerInitStateKey, carrierqueue.InitStateDone, 0)
|
||||
c.logger.Info("按运营商账户调度初始化完成,开始领取", zap.Int64("cards", written), zap.Duration("duration", time.Since(started)),
|
||||
zap.String("metric", "polling.carrier.initialized"))
|
||||
}
|
||||
|
||||
// syncConsumers 按当前运营商账户订阅卡任务 topic,max_in_flight 取账户调度参数。
|
||||
func (c *carrierSchedulerRuntime) syncConsumers(ctx context.Context) {
|
||||
if c.consumers == nil || c.dispatcher == nil {
|
||||
return
|
||||
}
|
||||
desired := map[string]int{}
|
||||
for _, carrierID := range c.dispatcher.CarrierIDs(ctx) {
|
||||
desired[constants.NSQCardTopic(carrierID)] = c.dispatcher.ParamsFor(carrierID).CardMaxInFlight
|
||||
}
|
||||
c.consumers.Sync(desired)
|
||||
}
|
||||
|
||||
func (c *carrierSchedulerRuntime) grace() time.Duration {
|
||||
if c.cfg.ShutdownGrace > 0 {
|
||||
return c.cfg.ShutdownGrace
|
||||
}
|
||||
return 30 * time.Second
|
||||
}
|
||||
|
||||
// setupStopResume 创建停复机指令执行器、设备判断执行器与两个专用消费者。
|
||||
func (c *carrierSchedulerRuntime) setupStopResume(carrierCfg config.CarrierSchedulerConfig, alerts *carrierqueue.AlertWriter) {
|
||||
service, ok := c.runtime.workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService)
|
||||
if !ok || carrierCfg.NSQDAddress == "" {
|
||||
return
|
||||
}
|
||||
var classifier *gatewaylimit.Classifier
|
||||
if c.runtime.gatewayLimit != nil {
|
||||
classifier = c.runtime.gatewayLimit.Classifier
|
||||
}
|
||||
params := func(carrierID uint) carrierqueue.CarrierParams {
|
||||
if c.dispatcher != nil {
|
||||
return c.dispatcher.ParamsFor(carrierID)
|
||||
}
|
||||
return carrierqueue.MergeParams(model.CarrierPollingParam{}, false, carrierCfg)
|
||||
}
|
||||
srExecutor := task.NewStopResumeExecutor(c.runtime.carrierStore, c.runtime.carrierSettings, params, alerts, service, classifier,
|
||||
c.instanceID, c.logger)
|
||||
devExecutor := task.NewDeviceEvalExecutor(c.runtime.carrierStore, service, c.instanceID, c.logger)
|
||||
consumerCfg := nsqbus.ConsumerConfig{
|
||||
NSQDAddress: carrierCfg.NSQDAddress, NSQLookupdAddresses: nsqbus.SplitAddresses(carrierCfg.NSQLookupdAddresses),
|
||||
MsgTimeout: carrierCfg.MsgTimeout, MaxAttempts: carrierCfg.MaxAttempts,
|
||||
}
|
||||
c.srConsumers = nsqbus.NewConsumerManager(consumerCfg, constants.NSQChannelWorker, srExecutor.Handle, c.logger)
|
||||
c.devConsumers = nsqbus.NewConsumerManager(consumerCfg, constants.NSQChannelWorker, devExecutor.Handle, c.logger)
|
||||
}
|
||||
|
||||
// syncStopResume 停复机出口开关开启时订阅各账户停复机 topic 与设备判断 topic;关闭时停止订阅(队列中的指令保留)。
|
||||
func (c *carrierSchedulerRuntime) syncStopResume(ctx context.Context) {
|
||||
if c.srConsumers == nil || c.dispatcher == nil {
|
||||
return
|
||||
}
|
||||
enabled := c.runtime.carrierSettings.Current(ctx).StopResumeQueueEnabled
|
||||
if !enabled {
|
||||
if c.srActive {
|
||||
c.logger.Info("停复机出口开关已关闭:停止停复机指令消费,未执行的指令保留")
|
||||
c.srConsumers.StopAll(c.grace())
|
||||
c.srConsumers.Reset()
|
||||
c.devConsumers.StopAll(c.grace())
|
||||
c.devConsumers.Reset()
|
||||
c.srActive = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if !c.srActive {
|
||||
c.logger.Info("停复机出口开关已开启:自动停复机经停复机指令队列执行")
|
||||
c.srActive = true
|
||||
}
|
||||
desired := map[string]int{}
|
||||
for _, carrierID := range c.dispatcher.CarrierIDs(ctx) {
|
||||
desired[constants.NSQStopResumeTopic(carrierID)] = c.dispatcher.ParamsFor(carrierID).SRMaxInFlight
|
||||
}
|
||||
c.srConsumers.Sync(desired)
|
||||
c.devConsumers.Sync(map[string]int{constants.NSQDeviceEvalTopic: deviceEvalMaxInFlight})
|
||||
}
|
||||
|
||||
// deviceEvalMaxInFlight 是每进程设备判断消费者并发。
|
||||
const deviceEvalMaxInFlight = 10
|
||||
|
||||
// shutdown 优雅退出:停止订阅,宽限期内等待执行中的卡任务完成;未完成的由租约到期后重新领取。
|
||||
func (c *carrierSchedulerRuntime) shutdown() {
|
||||
// 三类消费者并行停止,共用同一宽限期。
|
||||
var wg sync.WaitGroup
|
||||
for _, manager := range []*nsqbus.ConsumerManager{c.consumers, c.srConsumers, c.devConsumers} {
|
||||
if manager == nil {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(m *nsqbus.ConsumerManager) {
|
||||
defer wg.Done()
|
||||
m.StopAll(c.grace())
|
||||
}(manager)
|
||||
}
|
||||
wg.Wait()
|
||||
// 卡任务消费者停止后不再产生入库消息,再停入库与采集记录消费者,最后停生产者。
|
||||
c.ingest.shutdown()
|
||||
if c.producer != nil {
|
||||
c.producer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// InitProgress 返回新结构初始化进度。
|
||||
func (c *carrierSchedulerRuntime) InitProgress() polling.InitProgress {
|
||||
return c.initializer.Progress()
|
||||
}
|
||||
|
||||
// carrierAwareSeriesConsumer 是业务事件观测序列 Outbox 消费者的模式接缝:按运营商账户调度时把业务事件改为按场景请求检查
|
||||
// (事件在原业务事务中写入,消费即在事务提交之后),旧分片调度时沿用原观测序列。
|
||||
type carrierAwareSeriesConsumer struct {
|
||||
legacy outbox.EventConsumer
|
||||
requester *polling.CheckRequester
|
||||
}
|
||||
|
||||
// Consume 消费业务观测序列请求事件。
|
||||
func (c *carrierAwareSeriesConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c.requester == nil || !c.requester.Active(ctx) {
|
||||
return c.legacy.Consume(ctx, envelope)
|
||||
}
|
||||
var event cardObservationApp.SeriesRequestedEvent
|
||||
if err := json.Unmarshal(envelope.Payload, &event); err != nil {
|
||||
return nil
|
||||
}
|
||||
cardIDs := []uint{event.ResourceID}
|
||||
if event.ResourceType == constants.CardObservationResourceTypeDevice {
|
||||
cardIDs = event.ResourceIDs
|
||||
}
|
||||
c.requester.RequestCards(ctx, businessEventCheckScene(event), cardIDs, event.ExpectedValue)
|
||||
return nil
|
||||
}
|
||||
|
||||
// businessEventCheckScene 把业务观测事件映射为请求检查场景:订单支付、套餐激活、自动续费购包按事件来源区分,停复机成功按观测场景。
|
||||
func businessEventCheckScene(event cardObservationApp.SeriesRequestedEvent) string {
|
||||
switch {
|
||||
case strings.HasPrefix(event.EventID, "card-observation:order:"):
|
||||
return constants.PollingCheckSceneOrderPaid
|
||||
case strings.HasPrefix(event.EventID, "card-observation:auto-purchase:"), strings.HasPrefix(event.EventID, "asset-auto-renewal:"):
|
||||
return constants.PollingCheckSceneAutoRenewalPurchase
|
||||
case event.Scene == constants.CardObservationSceneBusinessStop || event.Scene == constants.CardObservationSceneBusinessResume:
|
||||
return constants.PollingCheckSceneStopResumeSucceeded
|
||||
default:
|
||||
return constants.PollingCheckScenePackageActivated
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
@@ -16,26 +17,39 @@ import (
|
||||
|
||||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||||
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
|
||||
auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
|
||||
distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
|
||||
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
|
||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
|
||||
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
|
||||
assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal"
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
|
||||
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
|
||||
commissionDelivery "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
|
||||
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
|
||||
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
|
||||
shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop"
|
||||
walletInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
|
||||
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
|
||||
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"
|
||||
@@ -82,6 +96,21 @@ type workerRuntime struct {
|
||||
pollingIotCardStore *postgres.IotCardStore
|
||||
pollingBase *task.PollingBase
|
||||
lifecycleSvc *polling.PollingLifecycleService
|
||||
// refundChannelService 是渠道原路退款的唯一用例实例:执行、恢复与退款完成通知共用它,
|
||||
// 使恢复确认的成功与直接调用确认的成功走同一回写路径。
|
||||
refundChannelService *refundchannelApp.Service
|
||||
// carrierThresholdService 是通道流量阈值停复机与两个计划任务的唯一用例实例。
|
||||
carrierThresholdService *carrierThresholdApp.Service
|
||||
// assetAutoRenewalService 是资产钱包自动续费的每日扫描、复机消费者与恢复扫描的唯一用例实例。
|
||||
assetAutoRenewalService *assetAutoRenewalApp.Service
|
||||
// gatewayLimit 是卡网关运营商账户限流组件(限流、统计、停复机记录)。
|
||||
gatewayLimit *gatewaylimit.Runtime
|
||||
// 按运营商账户调度:调度参数、账户队列存储与模式接缝(模式为 carrier 时各入口写新结构)。
|
||||
carrierSettings *carrierqueue.SettingsProvider
|
||||
carrierStore *carrierqueue.Store
|
||||
carrierScheduling *polling.CarrierScheduling
|
||||
// carrierReinit 在 carrier 模式下轮询配置变更时请求重新初始化新结构。
|
||||
carrierReinit func()
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -122,6 +151,14 @@ func runWorker(cfg *config.Config) {
|
||||
defer func() {
|
||||
_ = logger.Sync() // 忽略 sync 错误
|
||||
}()
|
||||
if invalid := auditInfra.SetPersistOverrides(cfg.Audit.PersistOverrides); len(invalid) > 0 {
|
||||
logger.GetAppLogger().Warn("审计落库策略覆盖存在非法项,已忽略", zap.Strings("invalid", invalid))
|
||||
}
|
||||
retentionLogger, syncRetentionLogger := logger.NewRetentionLogger(cfg.Logging.Level, logger.LogRotationConfig{
|
||||
Filename: cfg.Logging.RetentionLog.Filename, MaxSize: cfg.Logging.RetentionLog.MaxSize,
|
||||
MaxBackups: cfg.Logging.RetentionLog.MaxBackups, MaxAge: cfg.Logging.RetentionLog.MaxAge, Compress: cfg.Logging.RetentionLog.Compress,
|
||||
})
|
||||
defer func() { _ = syncRetentionLogger() }()
|
||||
|
||||
appLogger := logger.GetAppLogger()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -140,17 +177,39 @@ func runWorker(cfg *config.Config) {
|
||||
|
||||
if runsSingletonModules(cfg.Worker.Role) {
|
||||
pollingInitializer = startPollingInitializer(ctx, runtime, appLogger)
|
||||
startTrafficCollectionPartitionMaintainer(ctx, runtime, appLogger)
|
||||
pollingScheduler = startPollingScheduler(ctx, runtime, pollingInitializer, appLogger)
|
||||
asynqScheduler = startAsynqScheduler(cfg, runtime.redisAddr, appLogger)
|
||||
}
|
||||
|
||||
taskHandler := createTaskHandler(runtime, appLogger)
|
||||
taskHandler.RegisterHandlers()
|
||||
carrierRuntime := startCarrierScheduler(ctx, cfg, runtime, taskHandler.PollingSteps(), runsSingletonModules(cfg.Worker.Role), func() {
|
||||
if pollingInitializer != nil {
|
||||
pollingInitializer.Restart(ctx)
|
||||
}
|
||||
}, appLogger)
|
||||
runtime.carrierReinit = carrierRuntime.RequestReinit
|
||||
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
|
||||
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
|
||||
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, appLogger)
|
||||
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)
|
||||
taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle)
|
||||
partitionMaintainer := gatewaylimit.NewPartitionMaintainer(postgres.NewCardStopResumeRecordStore(runtime.db), runtime.gatewayLimit.Settings, appLogger)
|
||||
taskHandler.GetMux().HandleFunc(constants.TaskTypeStopResumeRecordPartition, partitionMaintainer.Handle)
|
||||
if runtime.workerResult.Services.AssetPolling != nil {
|
||||
importHandler := task.NewPollingStatusImportHandler(runtime.db, runtime.storageSvc, runtime.workerResult.Services.AssetPolling, appLogger)
|
||||
taskHandler.GetMux().HandleFunc(constants.TaskTypePollingStatusImport, importHandler.Handle)
|
||||
}
|
||||
dueRecalculator := polling.NewDueRecalculator(runtime.db, runtime.redisClient, runtime.pollingConfigMgr, runtime.pollingQueueMgr, appLogger)
|
||||
dueRecalculator.SetCarrierScheduling(runtime.carrierScheduling)
|
||||
recalcHandler := polling.NewRecalculateTaskHandler(dueRecalculator, runtime.redisClient, appLogger)
|
||||
taskHandler.GetMux().HandleFunc(constants.TaskTypePollingRecalculateDue, recalcHandler.Handle)
|
||||
startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger)
|
||||
rescuePendingImportTasks(ctx, runtime, appLogger)
|
||||
|
||||
@@ -238,7 +297,12 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
|
||||
}
|
||||
|
||||
storageSvc := initStorage(cfg, appLogger)
|
||||
gatewayClient := initGateway(cfg, appLogger)
|
||||
gatewayClient := initGateway(cfg, db, appLogger)
|
||||
// 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。
|
||||
gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger)
|
||||
if gatewayClient != nil {
|
||||
gatewayClient.SetCallGuard(gatewayLimit.Guard)
|
||||
}
|
||||
|
||||
asynqClient := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: redisAddr,
|
||||
@@ -255,6 +319,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
|
||||
QueueClient: workerQueueClient,
|
||||
StorageService: storageSvc,
|
||||
GatewayClient: gatewayClient,
|
||||
GatewayLimit: gatewayLimit,
|
||||
}
|
||||
|
||||
workerResult, err := bootstrap.BootstrapWorker(workerDeps)
|
||||
@@ -262,11 +327,19 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
|
||||
appLogger.Fatal("Worker Bootstrap 失败", zap.Error(err))
|
||||
}
|
||||
|
||||
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger)
|
||||
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger, queue.WithRateLimitedRetry(queue.RateLimitedRetry{
|
||||
Match: gateway.IsRateLimited,
|
||||
Delay: func() time.Duration {
|
||||
return gatewayLimit.Settings.Current(context.Background()).RetryDelay
|
||||
},
|
||||
OnDeferred: gatewayLimit.TrackDeferral,
|
||||
}))
|
||||
outboxQueueClient := workerQueueClient
|
||||
|
||||
pollingConfigStore := postgres.NewPollingConfigStore(db)
|
||||
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, redisClient, appLogger)
|
||||
pollingConfigMgr.SetTierResolver(polling.NewTierResolver(postgres.NewPackageUsageStore(db, redisClient),
|
||||
postgres.NewDeviceSimBindingStore(db, redisClient), postgres.NewCarrierStore(db), appLogger))
|
||||
if err := pollingConfigMgr.Load(ctx); err != nil {
|
||||
appLogger.Warn("加载轮询配置失败,使用空配置继续", zap.Error(err))
|
||||
}
|
||||
@@ -274,15 +347,6 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
|
||||
|
||||
pollingQueueMgr := polling.NewPollingQueueManager(redisClient, constants.PollingShardCount, appLogger)
|
||||
pollingIotCardStore := postgres.NewIotCardStore(db, redisClient)
|
||||
pollingBase := task.NewPollingBase(
|
||||
redisClient,
|
||||
pollingQueueMgr,
|
||||
pollingConfigMgr,
|
||||
pollingIotCardStore,
|
||||
appLogger,
|
||||
cfg.Polling.VerboseLog,
|
||||
)
|
||||
|
||||
pollingDeviceSimBindingStore := postgres.NewDeviceSimBindingStore(db, redisClient)
|
||||
pollingDeviceStore := postgres.NewDeviceStore(db, redisClient)
|
||||
lifecycleSvc := polling.NewPollingLifecycleService(
|
||||
@@ -293,9 +357,32 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
|
||||
pollingDeviceStore,
|
||||
appLogger,
|
||||
)
|
||||
pollingBase := task.NewPollingBase(
|
||||
redisClient,
|
||||
pollingQueueMgr,
|
||||
pollingConfigMgr,
|
||||
pollingIotCardStore,
|
||||
lifecycleSvc,
|
||||
postgres.NewPollingPriorityItemStore(db),
|
||||
auditInfra.NewWriter(auditInfra.NewRegistry(), nil),
|
||||
db,
|
||||
appLogger,
|
||||
cfg.Polling.VerboseLog,
|
||||
cfg.Worker.PollingTotalMaxConcurrency,
|
||||
)
|
||||
pollingBase.SetRateLimitedDelay(func() time.Duration {
|
||||
return gatewayLimit.Settings.Current(context.Background()).RetryDelay
|
||||
})
|
||||
if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
|
||||
stopResumeSvc.SetPollingCallback(lifecycleSvc)
|
||||
}
|
||||
if workerResult.Services.IotCardPolling != nil {
|
||||
// 轮询开关导入与订单自动恢复轮询经该卡服务改开关,开启后按生命周期重新入队。
|
||||
workerResult.Services.IotCardPolling.SetPollingCallback(lifecycleSvc)
|
||||
}
|
||||
if workerResult.Services.CarrierThreshold == nil {
|
||||
appLogger.Fatal("运营商通道流量阈值用例未配置")
|
||||
}
|
||||
|
||||
runtime := &workerRuntime{
|
||||
redisAddr: redisAddr,
|
||||
@@ -303,6 +390,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
|
||||
db: db,
|
||||
storageSvc: storageSvc,
|
||||
gatewayClient: gatewayClient,
|
||||
gatewayLimit: gatewayLimit,
|
||||
asynqClient: asynqClient,
|
||||
workerResult: workerResult,
|
||||
workerServer: workerServer,
|
||||
@@ -313,14 +401,70 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
|
||||
pollingIotCardStore: pollingIotCardStore,
|
||||
pollingBase: pollingBase,
|
||||
lifecycleSvc: lifecycleSvc,
|
||||
// 通道阈值停复机复用既有停复机服务作为唯一执行事实源(重试、Integration Log、统一审计与既有判定)。
|
||||
carrierThresholdService: workerResult.Services.CarrierThreshold,
|
||||
// 自动续费的复机执行同样复用既有停复机单一事实源。
|
||||
assetAutoRenewalService: workerResult.Services.AssetAutoRenewal,
|
||||
}
|
||||
newCarrierSchedulerParts(runtime, appLogger)
|
||||
registerNotificationOutboxConsumer(runtime, appLogger)
|
||||
registerWalletOutboxConsumer(runtime, appLogger)
|
||||
registerCardObservationOutboxConsumer(runtime, appLogger)
|
||||
registerPriorityPollingOutboxConsumer(runtime, appLogger)
|
||||
registerCarrierThresholdOutboxConsumer(runtime, appLogger)
|
||||
registerAssetAutoRenewalOutboxConsumer(runtime, appLogger)
|
||||
registerWeComApprovalOutboxConsumer(runtime, cfg, appLogger)
|
||||
return runtime
|
||||
}
|
||||
|
||||
// registerPriorityPollingOutboxConsumer 注册卡轮询优先队列请求事件消费者。
|
||||
// 消费者按卡 × 纳入任务类型建立或合并优先项并下发执行提示;重复投递只合并。
|
||||
func registerPriorityPollingOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
priorityStore := postgres.NewPollingPriorityItemStore(runtime.db)
|
||||
consumer := prioritypollingInfra.NewPriorityRequestedConsumer(
|
||||
runtime.db,
|
||||
priorityStore,
|
||||
runtime.pollingQueueMgr,
|
||||
auditInfra.NewWriter(auditInfra.NewRegistry(), nil),
|
||||
appLogger,
|
||||
)
|
||||
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypePollingPriorityRequested, consumer); err != nil {
|
||||
appLogger.Fatal("注册卡轮询优先队列 Outbox 消费者失败",
|
||||
zap.String("event_type", constants.OutboxEventTypePollingPriorityRequested), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// registerCarrierThresholdOutboxConsumer 注册运营商通道流量阈值停复机事件消费者。
|
||||
// 两个事件类型共用同一消费者实现:停机与复机各自由锁行认领字段保证至多一次外部调用。
|
||||
func registerCarrierThresholdOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.carrierThresholdService == nil {
|
||||
appLogger.Fatal("运营商通道流量阈值用例未配置")
|
||||
}
|
||||
consumer := carrierThresholdApp.NewConsumer(runtime.carrierThresholdService)
|
||||
for _, eventType := range []string{
|
||||
carrierThresholdApp.EventCarrierThresholdStop,
|
||||
carrierThresholdApp.EventCarrierThresholdResume,
|
||||
} {
|
||||
if err := runtime.outboxConsumers.Register(eventType, consumer); err != nil {
|
||||
appLogger.Fatal("注册运营商通道流量阈值 Outbox 消费者失败",
|
||||
zap.String("event_type", eventType), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerAssetAutoRenewalOutboxConsumer 注册资产钱包自动续费的复机请求事件消费者。
|
||||
// 消费者按尝试记录认领执行权后执行复机,重复投递不会产生第二次外部调用。
|
||||
func registerAssetAutoRenewalOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.assetAutoRenewalService == nil {
|
||||
appLogger.Fatal("资产钱包自动续费用例未配置")
|
||||
}
|
||||
consumer := assetAutoRenewalApp.NewResumeConsumer(runtime.assetAutoRenewalService)
|
||||
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAssetAutoRenewalResumeRequested, consumer); err != nil {
|
||||
appLogger.Fatal("注册资产钱包自动续费 Outbox 消费者失败",
|
||||
zap.String("event_type", constants.OutboxEventTypeAssetAutoRenewalResumeRequested), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。
|
||||
func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
|
||||
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
|
||||
@@ -334,7 +478,9 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
|
||||
cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger,
|
||||
)
|
||||
consumer := wecomInfra.NewApprovalSubmissionConsumer(
|
||||
wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter),
|
||||
wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter).SetDecisionWriters(
|
||||
approvalInfra.NewTerminalEventWriter(outbox.NewRepository()), approvalInfra.NewDecisionDeliveryStore(runtime.db),
|
||||
),
|
||||
wecomInfra.NewApprovalSubmissionClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
|
||||
wecomInfra.NewApprovalAttachmentUploader(tokenProvider, integrationRepository, runtime.storageSvc, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
|
||||
)
|
||||
@@ -342,6 +488,12 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
|
||||
appLogger.Fatal("注册企业微信审批提交 Outbox 消费者失败",
|
||||
zap.String("event_type", constants.OutboxEventTypeApprovalSubmissionRequested), zap.Error(err))
|
||||
}
|
||||
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeApprovalUnknownRecoveryRequested, wecomInfra.NewUnknownRecoveryConsumer(runtime.db, runtime.outboxQueueClient)); err != nil {
|
||||
appLogger.Fatal("注册企业微信未知审批恢复消费者失败", zap.Error(err))
|
||||
}
|
||||
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeApprovalManualSyncRequested, wecomInfra.NewManualSyncConsumer(runtime.db, runtime.outboxQueueClient)); err != nil {
|
||||
appLogger.Fatal("注册企业微信手动同步消费者失败", zap.Error(err))
|
||||
}
|
||||
owner := cfg.Worker.InstanceName
|
||||
if owner == "" {
|
||||
owner = fmt.Sprintf("worker-%d", os.Getpid())
|
||||
@@ -370,7 +522,23 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
|
||||
walletApp.NewRefundService(walletInfra.NewRefundEventWriter(outbox.NewRepository()), nil),
|
||||
)
|
||||
refundService.SetNotificationOutbox(outbox.NewRepository())
|
||||
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient))
|
||||
refundService.SetLifecycleAudit(auditWriter)
|
||||
// 员工代收款退款冲销与建账共用同一审计 Writer,接入点仅在企微退款成功事务内。
|
||||
refundService.SetEmployeeCollectionRefundOffset(
|
||||
employeecollectionApp.NewRefundOffsetService(auditWriter),
|
||||
)
|
||||
refundChannelService := refundchannelApp.NewService(
|
||||
runtime.db,
|
||||
merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient),
|
||||
paymentInfra.NewRefundAdapter(wechat.NewRedisCache(runtime.redisClient), appLogger),
|
||||
auditWriter,
|
||||
).SetLogger(appLogger).SetCompletionNotifier(refundService)
|
||||
refundService.SetChannelRefundService(refundChannelService)
|
||||
runtime.refundChannelService = refundChannelService
|
||||
if err := runtime.outboxConsumers.Register(refundchannelApp.EventRefundChannelRefund, refundchannelApp.NewConsumer(refundChannelService)); err != nil {
|
||||
appLogger.Fatal("注册渠道原路退款 Outbox 消费者失败", zap.Error(err))
|
||||
}
|
||||
if err := runtime.outboxConsumers.Register(commissionDelivery.EventRefundCommissionDeduct, commissionDelivery.NewRefundConsumer(refundService.ProcessCommissionDeduction, refundService.ProcessAssetPostProcessing)); err != nil {
|
||||
appLogger.Fatal("注册退款佣金回扣 Outbox 消费者失败", zap.Error(err))
|
||||
}
|
||||
@@ -384,8 +552,25 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
|
||||
decisionDispatcher := approvalApp.NewDecisionDispatcher(
|
||||
approvalInfra.NewDecisionDeliveryStore(runtime.db),
|
||||
map[string]approvalApp.BusinessDecisionHandler{
|
||||
constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(runtime.db, walletPosting, runtime.workerResult.Services.RechargeAudit),
|
||||
constants.ApprovalBusinessTypeRefund: refundService,
|
||||
constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(
|
||||
runtime.db, walletPosting, runtime.workerResult.Services.RechargeAudit,
|
||||
employeecollectionApp.NewBillCreationService(auditWriter),
|
||||
),
|
||||
constants.ApprovalBusinessTypeRefund: refundService,
|
||||
constants.ApprovalBusinessTypeEmployeeCollection: employeecollectionApp.NewApprovalDecisionHandler(
|
||||
runtime.db, auditWriter,
|
||||
),
|
||||
constants.ApprovalBusinessTypeAgentDistribution: distributionwithdrawalApp.NewDistributionApprovalHandler(
|
||||
runtime.db, auditWriter, shopInfra.NewSubordinateCache(runtime.redisClient),
|
||||
),
|
||||
constants.ApprovalBusinessTypeWithdrawalQualification: distributionwithdrawalApp.NewQualificationApprovalHandler(
|
||||
runtime.db, auditWriter,
|
||||
),
|
||||
constants.ApprovalBusinessTypeCommissionWithdrawal: distributionwithdrawalApp.NewWithdrawalApprovalHandler(
|
||||
runtime.db, auditWriter,
|
||||
),
|
||||
// 沉默期审批(购买时调整、线下付费延期、特殊延期):总开关关闭时照常处理
|
||||
constants.ApprovalBusinessTypePackageSilence: packagesilenceApp.NewDecisionHandler(runtime.workerResult.Services.SilenceExtensions),
|
||||
},
|
||||
owner,
|
||||
appLogger,
|
||||
@@ -416,7 +601,9 @@ func registerWeComApprovalTasks(mux *asynq.ServeMux, runtime *workerRuntime, cfg
|
||||
approvalInfra.NewDecisionDeliveryStore(runtime.db), nil,
|
||||
)
|
||||
decisionSync.SetAuditWriter(auditWriter)
|
||||
contexts := wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter)
|
||||
contexts := wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter).SetDecisionWriters(
|
||||
approvalInfra.NewTerminalEventWriter(outbox.NewRepository()), approvalInfra.NewDecisionDeliveryStore(runtime.db),
|
||||
)
|
||||
detailHandler := wecomInfra.NewApprovalDetailTaskHandler(
|
||||
wecomInfra.NewApprovalDetailClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
|
||||
contexts, decisionSync, integrationRepository,
|
||||
@@ -442,8 +629,10 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
|
||||
)
|
||||
recovery := agentrechargeApp.NewRecoverOnlinePaymentService(
|
||||
runtime.db,
|
||||
merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient),
|
||||
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger),
|
||||
paymentInfra.NewAlipayWapAdapter(integration, appLogger),
|
||||
paymentInfra.NewFuiouScanAdapter(integration, appLogger),
|
||||
confirm,
|
||||
runtime.workerResult.Services.PaymentAudit,
|
||||
)
|
||||
@@ -452,6 +641,74 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
|
||||
appLogger.Info("注册代理在线充值支付恢复任务处理器", zap.String("task_type", constants.TaskTypeAgentRechargeRecovery))
|
||||
}
|
||||
|
||||
// registerRefundCommissionRecoveryTask 注册退款佣金回溯后处理的周期性补偿任务。
|
||||
// 该任务只重投稳定的退款后处理 Outbox 事件,绝不直接改动资金;重复执行由消费端幂等兜底。
|
||||
func registerRefundCommissionRecoveryTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.db == nil {
|
||||
appLogger.Fatal("退款佣金回溯补偿任务缺少数据库依赖")
|
||||
}
|
||||
handler := commissionDelivery.NewRefundRecoveryTaskHandler(runtime.db, outbox.NewRepository(), appLogger)
|
||||
mux.HandleFunc(constants.TaskTypeRefundCommissionRecovery, handler.Handle)
|
||||
appLogger.Info("注册退款佣金回溯补偿任务处理器", zap.String("task_type", constants.TaskTypeRefundCommissionRecovery))
|
||||
}
|
||||
|
||||
// registerRefundChannelRecoveryTask 注册渠道原路退款结果恢复任务。
|
||||
// 该任务只查询渠道并回填结果,绝不重复发起资金动作。
|
||||
// 必须复用执行路径的同一用例实例:恢复确认的成功同样需要补写退款完成通知。
|
||||
func registerRefundChannelRecoveryTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.refundChannelService == nil {
|
||||
appLogger.Fatal("渠道原路退款用例未配置")
|
||||
}
|
||||
handler := paymentInfra.NewRefundChannelRecoveryTaskHandler(runtime.refundChannelService)
|
||||
mux.HandleFunc(constants.TaskTypeRefundChannelRecovery, handler.Handle)
|
||||
appLogger.Info("注册渠道原路退款结果恢复任务处理器", zap.String("task_type", constants.TaskTypeRefundChannelRecovery))
|
||||
}
|
||||
|
||||
// registerCarrierThresholdTasks 注册运营商通道流量阈值的周期处理与结果恢复任务。
|
||||
// 两个任务共用同一用例实例:周期处理负责跨期解锁与条件复机,恢复扫描只查询状态回填,绝不重复发起停复机。
|
||||
func registerCarrierThresholdTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.carrierThresholdService == nil {
|
||||
appLogger.Fatal("运营商通道流量阈值用例未配置")
|
||||
}
|
||||
cycleHandler := carrierThresholdInfra.NewCycleTaskHandler(runtime.carrierThresholdService)
|
||||
mux.HandleFunc(constants.TaskTypeCarrierThresholdCycle, cycleHandler.Handle)
|
||||
appLogger.Info("注册运营商通道流量阈值周期处理任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdCycle))
|
||||
recoveryHandler := carrierThresholdInfra.NewRecoveryTaskHandler(runtime.carrierThresholdService)
|
||||
mux.HandleFunc(constants.TaskTypeCarrierThresholdRecovery, recoveryHandler.Handle)
|
||||
appLogger.Info("注册运营商通道流量阈值结果恢复任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdRecovery))
|
||||
}
|
||||
|
||||
// registerAssetAutoRenewalTasks 注册资产钱包自动续费的每日扫描与复机结果恢复任务。
|
||||
// 两个任务共用同一用例实例:每日扫描负责终态收敛与续购执行,恢复扫描只查询状态回填,绝不重复发起复机。
|
||||
func registerAssetAutoRenewalTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.assetAutoRenewalService == nil {
|
||||
appLogger.Fatal("资产钱包自动续费用例未配置")
|
||||
}
|
||||
scanHandler := assetAutoRenewalInfra.NewDailyScanTaskHandler(runtime.assetAutoRenewalService)
|
||||
mux.HandleFunc(constants.TaskTypeAssetAutoRenewalScan, scanHandler.Handle)
|
||||
appLogger.Info("注册资产钱包自动续费每日扫描任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalScan))
|
||||
recoveryHandler := assetAutoRenewalInfra.NewRecoveryTaskHandler(runtime.assetAutoRenewalService)
|
||||
mux.HandleFunc(constants.TaskTypeAssetAutoRenewalRecovery, recoveryHandler.Handle)
|
||||
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)
|
||||
@@ -485,7 +742,10 @@ func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *za
|
||||
queue.NewCardObservationSeriesScheduler(runtime.outboxQueueClient),
|
||||
cardObservationInfra.NewSeriesAttemptLogger(integrationlog.NewRepository(runtime.db)),
|
||||
)
|
||||
seriesConsumer := cardObservationInfra.NewSeriesRequestedConsumer(seriesTrigger)
|
||||
seriesConsumer := &carrierAwareSeriesConsumer{
|
||||
legacy: cardObservationInfra.NewSeriesRequestedConsumer(seriesTrigger),
|
||||
requester: polling.NewCheckRequester(runtime.carrierScheduling, runtime.db, appLogger),
|
||||
}
|
||||
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeCardSeriesRequested, seriesConsumer); err != nil {
|
||||
appLogger.Fatal("注册业务卡观测序列 Outbox 消费者失败",
|
||||
zap.String("event_type", constants.OutboxEventTypeCardSeriesRequested), zap.Error(err))
|
||||
@@ -603,6 +863,43 @@ func startOutboxRelay(ctx context.Context, runtime *workerRuntime, instanceName
|
||||
appLogger.Info("Outbox Relay 已启动", zap.String("lease_owner", owner))
|
||||
}
|
||||
|
||||
// startTrafficCollectionPartitionMaintainer 为 leader/all 维护卡流量采集记录(成功与失败)月分区并扫描采集告警:
|
||||
// 每 12 小时预建本月起 2 个月的分区,到期分区导出为 csv.gz、校验后删除;每天扫描一次前一天的采集告警。
|
||||
func startTrafficCollectionPartitionMaintainer(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
archiver := cardObservationInfra.NewCollectionArchiver(runtime.db, constants.TrafficCollectionArchiveDir, constants.TrafficCollectionArchiveKeepFiles, appLogger)
|
||||
alerts := cardObservationInfra.NewTrafficAlertScanner(runtime.db, appLogger)
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
var lastMaintained time.Time
|
||||
var lastScannedDay string
|
||||
for {
|
||||
now := time.Now()
|
||||
if now.Sub(lastMaintained) >= 12*time.Hour {
|
||||
if err := archiver.Maintain(ctx, now); err != nil && ctx.Err() == nil {
|
||||
appLogger.Error("维护卡流量采集记录分区失败", zap.Error(err))
|
||||
} else {
|
||||
lastMaintained = now
|
||||
}
|
||||
}
|
||||
// 每天扫描一次前一天(上海日期)的采集告警。
|
||||
yesterday := cardflow.ShanghaiDate(now).AddDate(0, 0, -1)
|
||||
if day := yesterday.Format("2006-01-02"); day != lastScannedDay {
|
||||
if err := alerts.Scan(ctx, yesterday); err != nil && ctx.Err() == nil {
|
||||
appLogger.Error("扫描卡流量采集告警失败", zap.Error(err))
|
||||
} else {
|
||||
lastScannedDay = day
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// startPollingInitializer 为 leader/all 启动渐进式初始化器,并保留配置变更重启逻辑。
|
||||
func startPollingInitializer(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) *polling.PollingInitializer {
|
||||
pollingInitializer := polling.NewPollingInitializer(
|
||||
@@ -615,6 +912,13 @@ func startPollingInitializer(ctx context.Context, runtime *workerRuntime, appLog
|
||||
pollingInitializer.StartBackground(ctx)
|
||||
|
||||
runtime.pollingConfigMgr.WatchChanges(ctx, func(hadConfigs, hasConfigs bool) {
|
||||
if runtime.carrierScheduling.Active(ctx) {
|
||||
// 按运营商账户调度时按新配置重新初始化新结构,旧分片队列在回退时再重建。
|
||||
if runtime.carrierReinit != nil {
|
||||
runtime.carrierReinit()
|
||||
}
|
||||
return
|
||||
}
|
||||
if hasConfigs {
|
||||
appLogger.Info("轮询配置已变更,触发队列重新初始化",
|
||||
zap.Bool("had_configs", hadConfigs))
|
||||
@@ -667,6 +971,7 @@ func startPollingScheduler(
|
||||
dataResetHandler,
|
||||
)
|
||||
pollingScheduler.SetInitializer(pollingInitializer)
|
||||
pollingScheduler.SetCarrierScheduling(runtime.carrierScheduling)
|
||||
|
||||
if err := pollingScheduler.Start(ctx); err != nil {
|
||||
appLogger.Error("启动轮询调度器失败", zap.Error(err))
|
||||
@@ -687,7 +992,7 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
|
||||
&asynq.SchedulerOpts{Location: time.Local},
|
||||
)
|
||||
|
||||
if err := registerAsynqScheduleTasks(asynqScheduler); err != nil {
|
||||
if err := registerAsynqScheduleTasks(asynqScheduler, cfg.Worker.AuditArchiveTasksEnabled); err != nil {
|
||||
appLogger.Fatal("注册 Asynq 定时任务失败", zap.Error(err))
|
||||
}
|
||||
|
||||
@@ -698,12 +1003,14 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
|
||||
}()
|
||||
|
||||
appLogger.Info("Asynq Scheduler 已启动",
|
||||
zap.Bool("audit_retention_cleanup_enabled", cfg.Worker.AuditRetentionCleanupEnabled))
|
||||
zap.Bool("audit_retention_cleanup_enabled", cfg.Worker.AuditRetentionCleanupEnabled),
|
||||
zap.Bool("audit_archive_tasks_enabled", cfg.Worker.AuditArchiveTasksEnabled),
|
||||
zap.Int("polling_total_max_concurrency", cfg.Worker.PollingTotalMaxConcurrency))
|
||||
return asynqScheduler
|
||||
}
|
||||
|
||||
// registerAsynqScheduleTasks 注册 Worker 入口需要的全部定时任务。
|
||||
func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
|
||||
func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEnabled bool) error {
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypeAgentRechargeRecovery,
|
||||
nil,
|
||||
@@ -714,6 +1021,56 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册代理在线充值支付恢复定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypeRefundChannelRecovery,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Unique(10*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeRefundChannelRecovery)),
|
||||
)); 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,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Unique(10*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeCarrierThresholdCycle)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册运营商通道流量阈值周期处理定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypeCarrierThresholdRecovery,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Unique(10*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeCarrierThresholdRecovery)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册运营商通道流量阈值结果恢复定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypeRefundCommissionRecovery,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Unique(10*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeRefundCommissionRecovery)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册退款佣金回溯补偿定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypeOrderExpire,
|
||||
nil,
|
||||
@@ -733,6 +1090,8 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(2*time.Minute),
|
||||
// 同一时刻只保留一个恢复任务:队列拥堵时不再堆积重复的恢复任务。
|
||||
asynq.Unique(2*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeWeComApprovalRecovery)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册企业微信审批主动恢复定时任务失败: %w", err)
|
||||
@@ -744,6 +1103,14 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册数据清理定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 30 2 * * *", asynq.NewTask(
|
||||
constants.TaskTypeStopResumeRecordPartition,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Queue(constants.QueueDataCleanup),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册停复机记录分区维护定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("15 2 * * *", asynq.NewTask(
|
||||
constants.TaskTypeNotificationCleanup,
|
||||
nil,
|
||||
@@ -761,6 +1128,56 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册每日套餐临期提醒扫描定时任务失败: %w", err)
|
||||
}
|
||||
// 沉默期延期订单恢复:每 5 分钟查单、超时关闭、异常订单原路退款;不受总开关影响。
|
||||
if _, err := asynqScheduler.Register(constants.SilenceOrderRecoverCron, asynq.NewTask(
|
||||
constants.TaskTypePackageSilenceOrderRecover,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(4*time.Minute),
|
||||
asynq.Unique(4*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageSilenceOrderRecover)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册沉默期延期订单恢复定时任务失败: %w", err)
|
||||
}
|
||||
// 沉默期扫描:每 10 分钟;总开关关闭时任务直接返回(技术方案 §5.4)。
|
||||
if _, err := asynqScheduler.Register(constants.SilenceScanCron, asynq.NewTask(
|
||||
constants.TaskTypePackageSilenceScan,
|
||||
nil,
|
||||
asynq.MaxRetry(constants.SilenceScanMaxRetry),
|
||||
asynq.Timeout(constants.SilenceScanUniqueTTL),
|
||||
asynq.Unique(constants.SilenceScanUniqueTTL),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageSilenceScan)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册沉默期扫描定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 * * *", asynq.NewTask(
|
||||
constants.TaskTypePackageTrafficAlertScan,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageTrafficAlertScan)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册每日套餐真流量达量预警扫描定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 7 * * *", asynq.NewTask(
|
||||
constants.TaskTypeAssetAutoRenewalScan,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(30*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalScan)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册每日资产钱包自动续费扫描定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 5m", asynq.NewTask(
|
||||
constants.TaskTypeAssetAutoRenewalRecovery,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Unique(5*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalRecovery)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册资产钱包自动续费复机结果恢复定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register(
|
||||
"0 2 * * *",
|
||||
asynq.NewTask(
|
||||
@@ -773,6 +1190,24 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
|
||||
); err != nil {
|
||||
return fmt.Errorf("注册每日流量落盘定时任务失败: %w", err)
|
||||
}
|
||||
// 运营报表日报快照:上海时区 03:30,晚于每日流量落盘(02:00)、套餐临期提醒(03:00)与 10 秒级到期处理之后。
|
||||
// 定时调度不带载荷,目标日在处理器内按上海时区取前一自然日(asynq 的 Scheduler 只能注册静态 Task,
|
||||
// 无法按次生成 payload);失败重试沿用同一目标日期的依据是「cron 时点 + 有界重试窗口」
|
||||
// (默认重试延迟 n^4+15+rand(0..29)*(n+1) 秒,MaxRetry(3) 合计 ≤236 秒,叠加 Timeout 仍落在同一上海自然日内)。
|
||||
// 调整本 cron 时点、MaxRetry 或 Timeout 必须重新评估该前提。
|
||||
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 30 3 * * *", asynq.NewTask(
|
||||
constants.TaskTypeOperationsReportSnapshot,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(30*time.Minute),
|
||||
asynq.Unique(23*time.Hour),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeOperationsReportSnapshot)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册每日运营报表日报快照生成定时任务失败: %w", err)
|
||||
}
|
||||
if !auditArchiveEnabled {
|
||||
return nil
|
||||
}
|
||||
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 4 * * *", asynq.NewTask(
|
||||
constants.TaskTypeAuditDailyArchive,
|
||||
nil,
|
||||
@@ -793,59 +1228,46 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册 Integration Log 每日冷归档定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 5 1 * *", asynq.NewTask(
|
||||
constants.TaskTypeIntegrationMonthlyFinalize,
|
||||
nil,
|
||||
asynq.MaxRetry(10),
|
||||
asynq.Timeout(6*time.Hour),
|
||||
asynq.Unique(27*24*time.Hour),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeIntegrationMonthlyFinalize)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册 Integration Log 月度最终版本复核任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 1 * *", asynq.NewTask(
|
||||
constants.TaskTypeAuditMonthlyRetention,
|
||||
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 5 * * *", asynq.NewTask(
|
||||
constants.TaskTypeAuditDailyRetention,
|
||||
nil,
|
||||
asynq.MaxRetry(10),
|
||||
asynq.Timeout(12*time.Hour),
|
||||
asynq.Unique(27*24*time.Hour),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditMonthlyRetention)),
|
||||
asynq.Unique(23*time.Hour),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditDailyRetention)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册月度日志留存演练或清理任务失败: %w", err)
|
||||
return fmt.Errorf("注册日志日留存演练或清理任务失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerAuditArchiveTask 注册 Audit 与 Integration 冷归档任务处理器。
|
||||
func registerAuditArchiveTask(mux *asynq.ServeMux, runtime *workerRuntime, cleanupEnabled bool, appLogger *zap.Logger) {
|
||||
func registerAuditArchiveTask(mux *asynq.ServeMux, runtime *workerRuntime, cleanupEnabled, enabled bool, appLogger, retentionLogger *zap.Logger) {
|
||||
if runtime.storageSvc == nil {
|
||||
appLogger.Warn("对象存储未配置,审计归档任务将在执行时重试")
|
||||
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(nil, appLogger).Handle)
|
||||
integrationHandler := task.NewIntegrationArchiveHandler(nil, appLogger)
|
||||
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(nil, appLogger, enabled).Handle)
|
||||
integrationHandler := task.NewIntegrationArchiveHandler(nil, appLogger, enabled)
|
||||
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
|
||||
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
|
||||
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(nil, appLogger, cleanupEnabled).Handle)
|
||||
mux.HandleFunc(constants.TaskTypeAuditDailyRetention, task.NewAuditRetentionHandler(nil, retentionLogger, cleanupEnabled, enabled).Handle)
|
||||
return
|
||||
}
|
||||
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
|
||||
if !ok || auditWriter == nil {
|
||||
appLogger.Fatal("初始化月度日志留存清理失败:统一审计 Writer 未配置")
|
||||
appLogger.Fatal("初始化日志日留存清理失败:统一审计 Writer 未配置")
|
||||
}
|
||||
service, err := auditArchiveApp.NewService(runtime.db, runtime.storageSvc.Provider(), constants.AuditArchiveInstanceID, auditWriter)
|
||||
if err != nil {
|
||||
appLogger.Fatal("初始化统一审计归档服务失败", zap.Error(err))
|
||||
}
|
||||
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(service, appLogger).Handle)
|
||||
integrationHandler := task.NewIntegrationArchiveHandler(service, appLogger)
|
||||
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(service, appLogger, enabled).Handle)
|
||||
integrationHandler := task.NewIntegrationArchiveHandler(service, appLogger, enabled)
|
||||
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
|
||||
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
|
||||
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(service, appLogger, cleanupEnabled).Handle)
|
||||
mux.HandleFunc(constants.TaskTypeAuditDailyRetention, task.NewAuditRetentionHandler(service, retentionLogger, cleanupEnabled, enabled).Handle)
|
||||
appLogger.Info("注册审计归档任务处理器",
|
||||
zap.String("audit_task_type", constants.TaskTypeAuditDailyArchive),
|
||||
zap.String("integration_daily_task_type", constants.TaskTypeIntegrationDailyArchive),
|
||||
zap.String("integration_monthly_task_type", constants.TaskTypeIntegrationMonthlyFinalize),
|
||||
zap.String("retention_task_type", constants.TaskTypeAuditMonthlyRetention),
|
||||
zap.Bool("retention_cleanup_enabled", cleanupEnabled))
|
||||
zap.String("retention_task_type", constants.TaskTypeAuditDailyRetention),
|
||||
zap.Bool("retention_cleanup_enabled", cleanupEnabled), zap.Bool("archive_tasks_enabled", enabled))
|
||||
}
|
||||
|
||||
// createTaskHandler 创建并返回包含全部任务处理器的 Asynq Handler。
|
||||
@@ -869,6 +1291,44 @@ func createTaskHandler(runtime *workerRuntime, appLogger *zap.Logger) *queue.Han
|
||||
func rescuePendingImportTasks(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
rescuePendingIotCardImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
|
||||
rescuePendingDeviceImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
|
||||
rescuePendingShopBusinessOwnerImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
|
||||
rescuePendingPhoneAssetUnbindImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
|
||||
}
|
||||
|
||||
// rescuePendingShopBusinessOwnerImportTasks 补偿仍停留在待处理状态的店铺负责人导入任务。
|
||||
// 只扫描本 Change 自己的任务表,补偿键按任务类型与任务 ID 隔离,与设备导入补偿互不影响。
|
||||
func rescuePendingShopBusinessOwnerImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) {
|
||||
var importTasks []model.ShopBusinessOwnerImportTask
|
||||
if err := db.WithContext(ctx).
|
||||
Where("status = ?", model.ImportTaskStatusPending).
|
||||
Limit(importRescueLimit).
|
||||
Find(&importTasks).Error; err != nil {
|
||||
appLogger.Warn("扫描待补偿店铺负责人导入任务失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, importTask := range importTasks {
|
||||
payload := task.ShopBusinessOwnerImportPayload{TaskID: importTask.ID}
|
||||
enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypeShopBusinessOwnerImport, payload, importTask.ID, appLogger)
|
||||
}
|
||||
}
|
||||
|
||||
// rescuePendingPhoneAssetUnbindImportTasks 补偿仍停留在待处理状态的手机号资产解绑导入任务。
|
||||
// 只扫描本 Change 自己的任务表,补偿键按任务类型与任务 ID 隔离,与其他导入补偿互不影响。
|
||||
func rescuePendingPhoneAssetUnbindImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) {
|
||||
var importTasks []model.PhoneAssetUnbindImportTask
|
||||
if err := db.WithContext(ctx).
|
||||
Where("status = ?", model.ImportTaskStatusPending).
|
||||
Limit(importRescueLimit).
|
||||
Find(&importTasks).Error; err != nil {
|
||||
appLogger.Warn("扫描待补偿手机号资产解绑导入任务失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, importTask := range importTasks {
|
||||
payload := task.PhoneAssetUnbindImportPayload{TaskID: importTask.ID}
|
||||
enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypePhoneAssetUnbindImport, payload, importTask.ID, appLogger)
|
||||
}
|
||||
}
|
||||
|
||||
// rescuePendingIotCardImportTasks 补偿仍停留在待处理状态的 IoT 卡导入任务。
|
||||
@@ -988,7 +1448,7 @@ func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
|
||||
}
|
||||
|
||||
// initGateway 初始化 Gateway 客户端
|
||||
func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
|
||||
func initGateway(cfg *config.Config, db *gorm.DB, appLogger *zap.Logger) *gateway.Client {
|
||||
if cfg.Gateway.BaseURL == "" {
|
||||
appLogger.Info("Gateway 未配置,跳过初始化(轮询任务将无法查询真实数据)")
|
||||
return nil
|
||||
@@ -1000,6 +1460,10 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
|
||||
cfg.Gateway.AppSecret,
|
||||
appLogger,
|
||||
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
|
||||
if cfg.Gateway.ReadonlyEnabled {
|
||||
// 测试环境只读模式:只读资产表中的卡与设备只允许查询类调用(add-gateway-readonly-prod-import)。
|
||||
client.WithReadonlyChecker(postgres.NewGatewayReadonlyCardStore(db).IsReadonly)
|
||||
}
|
||||
|
||||
appLogger.Info("Gateway 客户端初始化成功",
|
||||
zap.String("base_url", cfg.Gateway.BaseURL),
|
||||
|
||||
160
cmd/worker/polling_ingest.go
Normal file
160
cmd/worker/polling_ingest.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/nsqbus"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// pollingIngestRuntime 是轮询观测入库异步化在 Worker 内的运行组件(add-polling-async-ingest):
|
||||
// 入库消费者、采集记录批量消费者、活动优先项索引重建与积压监控。入库消费者常驻订阅,
|
||||
// 使切回同步模式后队列中已有的入库消息仍会被处理完。
|
||||
type pollingIngestRuntime struct {
|
||||
runtime *workerRuntime
|
||||
nsqdAddress string
|
||||
executor *task.IngestExecutor
|
||||
collector *task.CollectionBatchConsumer
|
||||
ingest *nsqbus.ConsumerManager
|
||||
collection *nsqbus.ConsumerManager
|
||||
rebuilder *task.PriorityIndexRebuilder
|
||||
stopCollect context.CancelFunc
|
||||
grace time.Duration
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// startPollingIngest 注入入库发布器并启动入库与采集记录消费者;未配置 nsqd 时返回 nil,轮询保持同步入库。
|
||||
func startPollingIngest(ctx context.Context, cfg *config.Config, runtime *workerRuntime, producer *nsqbus.Producer,
|
||||
singleton bool, grace time.Duration, logger *zap.Logger) *pollingIngestRuntime {
|
||||
carrierCfg := cfg.Polling.Carrier
|
||||
if producer == nil || carrierCfg.NSQDAddress == "" {
|
||||
return nil
|
||||
}
|
||||
checkIngestConnBudget(cfg, logger)
|
||||
publisher := task.NewIngestPublisher(producer)
|
||||
runtime.pollingBase.SetIngest(publisher, func(ctx context.Context) bool {
|
||||
return runtime.carrierSettings.Current(ctx).IngestAsync()
|
||||
})
|
||||
observation := runtime.workerResult.Services.CardObservation
|
||||
if observation != nil {
|
||||
observation.SetCollectionSink(publisher)
|
||||
}
|
||||
r := &pollingIngestRuntime{
|
||||
runtime: runtime, nsqdAddress: carrierCfg.NSQDAddress, grace: grace, logger: logger,
|
||||
executor: task.NewIngestExecutor(runtime.pollingBase, observation, cfg.Polling.Ingest.EffectiveTimeout()),
|
||||
collector: task.NewCollectionBatchConsumer(runtime.db, cfg.Polling.Collection.EffectiveBatchSize(), logger),
|
||||
rebuilder: task.NewPriorityIndexRebuilder(postgres.NewPollingPriorityItemStore(runtime.db), runtime.redisClient, logger),
|
||||
}
|
||||
collectCtx, stopCollect := context.WithCancel(context.Background())
|
||||
r.stopCollect = stopCollect
|
||||
r.collector.Start(collectCtx)
|
||||
consumerCfg := nsqbus.ConsumerConfig{
|
||||
NSQDAddress: carrierCfg.NSQDAddress, NSQLookupdAddresses: nsqbus.SplitAddresses(carrierCfg.NSQLookupdAddresses),
|
||||
MsgTimeout: carrierCfg.MsgTimeout, UnlimitedAttempts: true,
|
||||
}
|
||||
r.ingest = nsqbus.NewRetryConsumerManager(consumerCfg, constants.NSQChannelWorker, r.executor.Handle, logger)
|
||||
r.collection = nsqbus.NewRetryConsumerManager(consumerCfg, constants.NSQChannelWorker, r.collector.Handle, logger)
|
||||
r.ingest.Sync(map[string]int{constants.NSQCardIngestTopic: cfg.Polling.Ingest.EffectiveConcurrency()})
|
||||
r.collection.Sync(map[string]int{constants.NSQCardCollectionTopic: constants.PollingCollectionMaxInFlight})
|
||||
logger.Info("轮询观测入库消费者已启动", zap.Int("ingest_concurrency", cfg.Polling.Ingest.EffectiveConcurrency()),
|
||||
zap.Duration("ingest_timeout", cfg.Polling.Ingest.EffectiveTimeout()),
|
||||
zap.Int("collection_batch_size", cfg.Polling.Collection.EffectiveBatchSize()))
|
||||
if singleton {
|
||||
go r.loop(ctx)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// checkIngestConnBudget 启动时检查入库并发与采集记录写并发不超过数据库连接数减去预留,超过只记 ERROR、不阻止启动。
|
||||
func checkIngestConnBudget(cfg *config.Config, logger *zap.Logger) {
|
||||
maxOpen := cfg.Database.MaxOpenConns
|
||||
if maxOpen <= 0 {
|
||||
maxOpen = constants.DefaultMaxOpenConns
|
||||
}
|
||||
need := cfg.Polling.Ingest.EffectiveConcurrency() + constants.PollingIngestCollectionWriters
|
||||
if budget := maxOpen - constants.PollingIngestConnReserve; need > budget {
|
||||
logger.Error("入库并发超出数据库连接预算,请调小 polling.ingest.concurrency 或调大数据库连接数",
|
||||
zap.Int("ingest_concurrency", cfg.Polling.Ingest.EffectiveConcurrency()),
|
||||
zap.Int("collection_writers", constants.PollingIngestCollectionWriters),
|
||||
zap.Int("max_open_conns", maxOpen), zap.Int("reserved", constants.PollingIngestConnReserve))
|
||||
}
|
||||
}
|
||||
|
||||
// loop 在 leader 上定期重建活动优先项索引并检查入库积压。
|
||||
func (r *pollingIngestRuntime) loop(ctx context.Context) {
|
||||
r.rebuilder.Rebuild(ctx)
|
||||
rebuild := time.NewTicker(constants.PollingPriorityIndexRebuildEvery)
|
||||
monitor := time.NewTicker(constants.PollingIngestMonitorInterval)
|
||||
defer rebuild.Stop()
|
||||
defer monitor.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-rebuild.C:
|
||||
r.rebuilder.Rebuild(ctx)
|
||||
case <-monitor.C:
|
||||
r.checkBacklog(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkBacklog 记录入库与采集记录积压,超过告警阈值或等待过久记 ERROR;入库积压超过反压阈值时暂停派发新卡任务,降到一半以下恢复。
|
||||
func (r *pollingIngestRuntime) checkBacklog(ctx context.Context) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
r.logger.Error("入库积压检查 panic,已恢复", zap.Any("panic", recovered))
|
||||
}
|
||||
}()
|
||||
settings := r.runtime.carrierSettings.Current(ctx)
|
||||
ingestDepth, ingestErr := nsqbus.TopicBacklog(ctx, r.nsqdAddress, constants.NSQCardIngestTopic)
|
||||
collectionDepth, collectionErr := nsqbus.TopicBacklog(ctx, r.nsqdAddress, constants.NSQCardCollectionTopic)
|
||||
if ingestErr != nil || collectionErr != nil {
|
||||
r.logger.Warn("读取入库积压失败", zap.NamedError("ingest_error", ingestErr), zap.NamedError("collection_error", collectionErr))
|
||||
return
|
||||
}
|
||||
lag := r.executor.LastLag()
|
||||
fields := []zap.Field{zap.Int64("ingest_backlog", ingestDepth), zap.Int64("collection_backlog", collectionDepth),
|
||||
zap.Duration("ingest_lag", lag), zap.String("ingest_mode", settings.IngestMode)}
|
||||
r.logger.Info("轮询入库积压", append(fields, zap.String("metric", "polling.ingest.backlog"))...)
|
||||
alertDepth := int64(settings.IngestAlertDepth)
|
||||
if ingestDepth > alertDepth || collectionDepth > alertDepth || lag > constants.PollingIngestLagAlert {
|
||||
r.logger.Error("轮询入库积压超过告警阈值", append(fields, zap.Int64("alert_depth", alertDepth))...)
|
||||
}
|
||||
r.applyBackpressure(ctx, ingestDepth, int64(settings.IngestPauseDepth))
|
||||
}
|
||||
|
||||
// applyBackpressure 按积压置位或清除暂停标记;标记带 TTL,leader 失联时自动解除。
|
||||
func (r *pollingIngestRuntime) applyBackpressure(ctx context.Context, depth, pauseDepth int64) {
|
||||
redisClient := r.runtime.redisClient
|
||||
paused, _ := redisClient.Exists(ctx, constants.RedisPollingIngestPausedKey).Result()
|
||||
ttl := 3 * constants.PollingIngestMonitorInterval
|
||||
switch {
|
||||
case depth > pauseDepth:
|
||||
redisClient.Set(ctx, constants.RedisPollingIngestPausedKey, depth, ttl)
|
||||
if paused == 0 {
|
||||
r.logger.Error("入库积压超过反压阈值,暂停派发新卡任务", zap.Int64("ingest_backlog", depth), zap.Int64("pause_depth", pauseDepth))
|
||||
}
|
||||
case paused > 0 && depth > pauseDepth/2:
|
||||
redisClient.Expire(ctx, constants.RedisPollingIngestPausedKey, ttl)
|
||||
case paused > 0:
|
||||
redisClient.Del(ctx, constants.RedisPollingIngestPausedKey)
|
||||
r.logger.Info("入库积压已降到反压阈值一半以下,恢复派发卡任务", zap.Int64("ingest_backlog", depth), zap.Int64("pause_depth", pauseDepth))
|
||||
}
|
||||
}
|
||||
|
||||
// shutdown 先停入库消费者(处理中的消息在宽限期内完成),再停采集记录消费者,最后停止攒批循环(写完已缓冲的一批)。
|
||||
func (r *pollingIngestRuntime) shutdown() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.ingest.StopAll(r.grace)
|
||||
r.collection.StopAll(r.grace)
|
||||
r.stopCollect()
|
||||
}
|
||||
@@ -29,31 +29,34 @@
|
||||
|
||||
services:
|
||||
api:
|
||||
image: registry.boss160.cn/junhong/cmp-fiber-api:latest
|
||||
image: registry.boss160.cn/junhong/cmp-fiber-api:${IMAGE_TAG:-latest}
|
||||
container_name: junhong-cmp-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# 数据库配置(必填)
|
||||
- JUNHONG_DATABASE_HOST=cxd.whcxd.cn
|
||||
- JUNHONG_DATABASE_PORT=16159
|
||||
- JUNHONG_DATABASE_HOST=kt.whcxd.cn
|
||||
- JUNHONG_DATABASE_PORT=15434
|
||||
- JUNHONG_DATABASE_USER=erp_pgsql
|
||||
- JUNHONG_DATABASE_PASSWORD=erp_2025
|
||||
- JUNHONG_DATABASE_DBNAME=junhong_cmp_test
|
||||
- JUNHONG_DATABASE_SSLMODE=disable
|
||||
# Redis 配置(必填)
|
||||
- JUNHONG_REDIS_ADDRESS=cxd.whcxd.cn
|
||||
- JUNHONG_REDIS_PORT=16299
|
||||
- JUNHONG_REDIS_ADDRESS=kt.whcxd.cn
|
||||
- JUNHONG_REDIS_PORT=16380
|
||||
- JUNHONG_REDIS_PASSWORD=cpNbWtAaqgo1YJmbMp3h
|
||||
- JUNHONG_REDIS_DB=6
|
||||
# JWT 配置(必填)
|
||||
- JUNHONG_JWT_SECRET_KEY=dev-secret-key-for-testing-only-32chars!
|
||||
# 客户端配置(可选,默认 true;false=不强制绑定手机号)
|
||||
- JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=false
|
||||
- JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=true
|
||||
# 日志配置
|
||||
- JUNHONG_LOGGING_LEVEL=info
|
||||
- JUNHONG_LOGGING_DEVELOPMENT=false
|
||||
# 应用日志轮换:单文件 100MB(默认),保留 40 个压缩备份,api 与 worker 合计约 1GB
|
||||
- JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=40
|
||||
- JUNHONG_LOGGING_APP_LOG_MAX_AGE=14
|
||||
# 跨域配置
|
||||
- JUNHONG_MIDDLEWARE_CORS_ENABLED=true
|
||||
- JUNHONG_MIDDLEWARE_CORS_ALLOW_ORIGINS=*
|
||||
@@ -72,11 +75,13 @@ services:
|
||||
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
|
||||
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
|
||||
- JUNHONG_GATEWAY_TIMEOUT=30
|
||||
- JUNHONG_GATEWAY_READONLY_ENABLED=${JUNHONG_GATEWAY_READONLY_ENABLED:-false}
|
||||
# 七月迭代:企业微信 Adapter 与旧审批入口切换
|
||||
- JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com
|
||||
- JUNHONG_WECOM_TIMEOUT=10s
|
||||
- JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=${JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED:-true}
|
||||
- JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=${JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED:-true}
|
||||
# 卡流量日快照计量(API 与 Worker 必须一致;默认与未配置时行为不变)
|
||||
# 短信服务配置
|
||||
- JUNHONG_SMS_GATEWAY_URL=https://gateway.sms.whjhft.com:8443
|
||||
- JUNHONG_SMS_USERNAME=JH0001
|
||||
@@ -86,7 +91,7 @@ services:
|
||||
- JUNHONG_MIDDLEWARE_CORS_ALLOW_ORIGINS=https://cmp-admin.boss160.cn,https://cmp-agent.boss160.cn,https://cmp-c.boss160.cn
|
||||
- JUNHONG_MIDDLEWARE_CORS_ALLOW_CREDENTIALS=true
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./api/logs:/app/logs
|
||||
networks:
|
||||
- junhong-network
|
||||
healthcheck:
|
||||
@@ -102,29 +107,39 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
worker:
|
||||
image: registry.boss160.cn/junhong/cmp-fiber-worker:latest
|
||||
image: registry.boss160.cn/junhong/cmp-fiber-worker:${IMAGE_TAG:-latest}
|
||||
container_name: junhong-cmp-worker
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# 数据库配置(必填)
|
||||
- JUNHONG_DATABASE_HOST=cxd.whcxd.cn
|
||||
- JUNHONG_DATABASE_PORT=16159
|
||||
- JUNHONG_DATABASE_HOST=kt.whcxd.cn
|
||||
- JUNHONG_DATABASE_PORT=15434
|
||||
- JUNHONG_DATABASE_USER=erp_pgsql
|
||||
- JUNHONG_DATABASE_PASSWORD=erp_2025
|
||||
- JUNHONG_DATABASE_DBNAME=junhong_cmp_test
|
||||
- JUNHONG_DATABASE_SSLMODE=disable
|
||||
# Redis 配置(必填)
|
||||
- JUNHONG_REDIS_ADDRESS=cxd.whcxd.cn
|
||||
- JUNHONG_REDIS_PORT=16299
|
||||
- JUNHONG_REDIS_ADDRESS=kt.whcxd.cn
|
||||
- JUNHONG_REDIS_PORT=16380
|
||||
- JUNHONG_REDIS_PASSWORD=cpNbWtAaqgo1YJmbMp3h
|
||||
- JUNHONG_REDIS_DB=6
|
||||
# worker 并发轮询与按账户并行分发共用连接池,默认 10 不够
|
||||
- JUNHONG_REDIS_POOL_SIZE=100
|
||||
# 任务并发:默认 10 时流量扣减事件投递跟不上(白天积压 1 万+、延迟 2.5 小时),调到 50
|
||||
- JUNHONG_QUEUE_CONCURRENCY=50
|
||||
# 数据库连接池随任务并发放大(默认 25/10);测试库 max_connections=200,API 另占 25
|
||||
- JUNHONG_DATABASE_MAX_OPEN_CONNS=60
|
||||
- JUNHONG_DATABASE_MAX_IDLE_CONNS=20
|
||||
# JWT 配置(必填)
|
||||
- JUNHONG_JWT_SECRET_KEY=dev-secret-key-for-testing-only-32chars!
|
||||
# 客户端配置(可选,默认 true;false=不强制绑定手机号)
|
||||
- JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=false
|
||||
- JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=true
|
||||
# 日志配置
|
||||
- JUNHONG_LOGGING_LEVEL=info
|
||||
- JUNHONG_LOGGING_DEVELOPMENT=false
|
||||
# 应用日志轮换:单文件 100MB(默认),保留 40 个压缩备份,api 与 worker 合计约 1GB
|
||||
- JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=40
|
||||
- JUNHONG_LOGGING_APP_LOG_MAX_AGE=14
|
||||
# 对象存储配置
|
||||
- JUNHONG_STORAGE_PROVIDER=s3
|
||||
- JUNHONG_STORAGE_S3_ENDPOINT=https://obs-helf.cucloud.cn
|
||||
@@ -139,20 +154,60 @@ services:
|
||||
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
|
||||
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
|
||||
- JUNHONG_GATEWAY_TIMEOUT=30
|
||||
- JUNHONG_GATEWAY_READONLY_ENABLED=${JUNHONG_GATEWAY_READONLY_ENABLED:-false}
|
||||
- JUNHONG_POLLING_VERBOSE_LOG=true
|
||||
# 七月迭代:单 Worker 测试环境同时承担调度和消费
|
||||
- JUNHONG_WORKER_ROLE=${JUNHONG_WORKER_ROLE:-all}
|
||||
- JUNHONG_WORKER_INSTANCE_NAME=${JUNHONG_WORKER_INSTANCE_NAME:-test-worker-all-1}
|
||||
# 卡流量日快照计量(API 与 Worker 必须一致;默认与未配置时行为不变)
|
||||
# Worker 执行企微审批提交、同步和恢复任务
|
||||
- JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com
|
||||
- JUNHONG_WECOM_TIMEOUT=10s
|
||||
# 按运营商账户调度的 NSQ(卡轮询分发;调度模式在受控系统配置 polling.scheduler.mode 切换)
|
||||
- JUNHONG_POLLING_CARRIER_NSQD_ADDRESS=${JUNHONG_POLLING_CARRIER_NSQD_ADDRESS:-nsqd:4150}
|
||||
- JUNHONG_POLLING_CARRIER_NSQLOOKUPD_ADDRESSES=${JUNHONG_POLLING_CARRIER_NSQLOOKUPD_ADDRESSES:-nsqlookupd:4161}
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./worker/logs:/app/logs
|
||||
# 采集记录到期归档压缩包(archive/traffic-collection),由同步服务器拉走
|
||||
- ./worker/archive:/app/archive
|
||||
networks:
|
||||
- junhong-network
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
nsqd:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# NSQ:只承担卡轮询的执行分发,Redis 是唯一权威状态(add-carrier-polling-scheduler)
|
||||
nsqlookupd:
|
||||
image: nsqio/nsq:v1.3.0
|
||||
container_name: junhong-cmp-nsqlookupd
|
||||
restart: unless-stopped
|
||||
command: /nsqlookupd
|
||||
networks:
|
||||
- junhong-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
nsqd:
|
||||
image: nsqio/nsq:v1.3.0
|
||||
container_name: junhong-cmp-nsqd
|
||||
restart: unless-stopped
|
||||
command: /nsqd --lookupd-tcp-address=nsqlookupd:4160 --broadcast-address=nsqd --data-path=/data --mem-queue-size=0 --max-msg-timeout=15m
|
||||
volumes:
|
||||
- ./nsq-data:/data
|
||||
networks:
|
||||
- junhong-network
|
||||
depends_on:
|
||||
- nsqlookupd
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
|
||||
118
docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md
Normal file
118
docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 实名即生效套餐沉默期 — 前端接口变更说明
|
||||
|
||||
> 对应 OpenSpec Change `add-package-silence-period`;规则以 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md` 为准。接口字段以 `docs/admin-openapi.yaml` 为准,本文只说明页面要做什么。
|
||||
|
||||
## 1. 总开关对页面的影响
|
||||
|
||||
沉默期总开关 `package.silence.enabled` **默认关闭**。
|
||||
|
||||
| 页面 / 功能 | 关闭时 | 打开时 |
|
||||
|---|---|---|
|
||||
| 资产套餐列表、H5 套餐历史里的 `silence` 对象 | **不返回**(字段为空),页面不显示沉默期 | 返回,显示状态、截止日 |
|
||||
| "延期"入口 | 不显示 | `silence.extendable = true` 时显示 |
|
||||
| 延期资格、试算、下单、线下登记、特殊延期 | 返回错误码 **1274** | 可用 |
|
||||
| 套餐默认沉默期、延期政策、企微审批场景配置 | 可用 | 可用 |
|
||||
| 购买时调整沉默期 | 可用 | 可用 |
|
||||
| 卡 / 设备首次购买套餐时间 | 不受开关影响 | 不受开关影响 |
|
||||
|
||||
后台"系统配置"通用接口不能修改总开关(只读),请用专用接口:
|
||||
|
||||
- `GET /api/admin/package-silence/switch`:当前值 + 打开前 4 项检查(`precheck[]`:code、name、passed、count)。
|
||||
- `PUT /api/admin/package-silence/switch` `{ "enabled": true|false }`:仅超级管理员。打开前检查不通过返回 **1275**,`data.failed_items` 为未满足项,直接展示即可;关闭不检查。
|
||||
|
||||
## 2. 套餐配置
|
||||
|
||||
- 新增 / 编辑套餐增加 `silence_period_days`(1~90)、`silence_extension_enabled`(默认 true)。
|
||||
- **实名即生效的正式套餐必填**默认沉默期,新建时前端预填 30;不填或越界返回 **1260**。
|
||||
- 购买即生效套餐、加油包不显示、不提交这两个字段。
|
||||
- 套餐响应增加 `silence_period_days`、`silence_extension_enabled`、`silence_config_required`(是否必须配置)。
|
||||
- 实名即生效套餐改为购买即生效、店铺分配(单个、批量、系列授权)里把实名即生效套餐覆盖为购买即生效,返回 **1262**。购买即生效改为实名即生效时,同一请求必须带 `silence_period_days`。
|
||||
- 购买即生效的正式套餐也可以选填 `silence_period_days`(1~90,不强制;`silence_config_required=false`),加油包不能填。店铺分配把购买即生效套餐覆盖为实名即生效时,套餐已配置默认沉默期才允许,否则返回 **1262**。
|
||||
- "未配置沉默期的实名即生效套餐"清单:套餐列表加 `silence_unconfigured=true`(包含本身是购买即生效、但有店铺分配覆盖为实名即生效且未配置默认沉默期的套餐);导出用套餐导出场景,筛选同样传 `silence_unconfigured`。套餐导出新增"默认沉默期(天)""是否支持付费延期"两列。
|
||||
|
||||
## 3. 后台购买时调整沉默期
|
||||
|
||||
`POST /api/admin/orders`(后台给资产购买套餐)新增可选字段:
|
||||
|
||||
- `silence_period_days`(≥ 1,不设上限)、`silence_reason`(设置时必填)。
|
||||
- 需要 `package_silence:override` 权限,无权限传值返回 **1261**。
|
||||
- 不超过默认值:直接生效;超过默认值:按延期政策直接生效,或**先按默认值购买并提交企微审批**(审批中套餐不会过期)。发起人未绑定企微成员时购买被拒。
|
||||
- 只对"资产未实名时购买的实名即生效主套餐"生效,其他情况忽略。
|
||||
|
||||
## 4. 资产套餐展示
|
||||
|
||||
后台资产套餐历史 / 当前套餐、H5 套餐历史(`/api/c/v1/asset/package-history`)每笔套餐增加 `silence` 对象(总开关打开且适用沉默期时返回):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `silence_status` / `silence_status_name` | `waiting` 待实名(延期后显示"延期待实名")/ `expired` 沉默期已过期 / `ended` 沉默期已结束;待实名但已过截止的直接按 `expired` 返回 |
|
||||
| `silence_deadline_at`、`silence_start_at`、`silence_period_days` | 截止时间(截止日 23:59:59)、起算时间、天数 |
|
||||
| `silence_extended_days`、`silence_paid_extended_days`、`silence_extension_count`、`silence_free_extension_used` | 延期累计 |
|
||||
| `silence_expired_at`、`silence_adjust_pending` | 过期时间、调整审批中 |
|
||||
| `extendable` | 是否显示"延期"入口 |
|
||||
|
||||
套餐沉默期流水:后台 `GET /api/admin/package-silence/records?package_usage_id=`,H5 `GET /api/c/v1/package-silence/records?package_usage_id=`。
|
||||
|
||||
## 5. 延期(后台)
|
||||
|
||||
均在 `/api/admin/package-silence` 下:
|
||||
|
||||
| 接口 | 权限 | 说明 |
|
||||
|---|---|---|
|
||||
| `GET /eligibility?package_usage_id=` | — | 资格:`can_extend`、`special_extension_available`、`reason`、`min_days`/`max_days`、`min_months`/`max_months`、`unit_price`、`options[]`(每档计费天数、金额、新截止日)。**金额全部由后端算,前端只传天数** |
|
||||
| `GET /quote?package_usage_id=&days=` | — | 输入天数试算(按 30 天向上取整:1→30、31→60) |
|
||||
| `POST /orders/qrcode` | `package_silence:extend` | 运营生成支付二维码:入参 `package_usage_id`、`days`、`payment_method`(wechat/alipay)、`request_id`、`evidence_refs`;返回 `qr_content` 渲染为二维码。已有客户 H5 待支付订单时后端先查单,未付自动关闭旧单 |
|
||||
| `POST /orders/offline` | `package_silence:offline` | 登记线下付款:`offline_payment_method_id`(线下收款方式字典)、`offline_trade_no`、`paid_amount`(必须等于计算金额,否则 **1273**)、`paid_at`、`payment_voucher_keys`(1~5)、`evidence_refs`、`request_id`。交易单号重复 **1272**。默认进企微审批 |
|
||||
| `GET /orders`、`GET /orders/:id` | — | 延期订单列表 / 详情,含 `payment_method`、`payment_merchant_name`、`third_party_trade_no`;导出场景 `package_silence_order` |
|
||||
| `POST /special` | `package_silence:extend`(批量另需 `package_silence:extend_batch`) | 特殊延期:`extension_type`(free/discount/complaint_compensation/system_compensation/special_customer)、`items[]`(package_usage_id + days)、`reason`、`basis`、`evidence_refs`、`amount`(免费为 0)、优惠价有收款时填收款方式 / 交易单号 / 凭证。批量明细逐行校验,不通过返回 `data.rows[]`(行号与原因)。免费次数用完 **1267** |
|
||||
| `GET /batches`、`GET /batches/:id` | — | 批次与明细(`has_exception=true` 只看含异常的) |
|
||||
| `POST /extensions/:id/handle` | `package_silence:exception` | 异常明细 `action=retry|close` + `reason`;在线订单关闭时原路退款 |
|
||||
| `GET /usages?view=expiring|expired|all` | — | 即将过期(默认 7 天内)/ 已过期套餐;导出场景 `package_silence_usage` |
|
||||
|
||||
延期政策(`package_silence:policy`):`GET/POST /policies`、`GET/PUT /policies/:id`、`PUT /policies/:id/status`、`GET /policies/match?package_id=`。同级重叠返回 **1269**。
|
||||
|
||||
## 6. 延期(H5)
|
||||
|
||||
均在 `/api/c/v1/package-silence` 下,资产须属于当前客户:
|
||||
|
||||
- `GET /eligibility`、`GET /quote`:同后台。
|
||||
- `POST /orders`:自助下单(`package_usage_id`、`days`、`payment_method`、`request_id`),返回 `qr_content`(支付链接或二维码内容);支付成功自动延期。
|
||||
- `GET /orders`、`GET /orders/:id`:我的延期订单。
|
||||
- `GET /records`:沉默期流水。
|
||||
|
||||
## 7. 退款
|
||||
|
||||
沉默期已过期的套餐申请退款返回 **1268**(总开关打开时)。
|
||||
|
||||
## 8. 卡 / 设备首次购买套餐时间
|
||||
|
||||
- 卡列表、设备列表返回 `first_purchase_at`,支持 `first_purchase_start`、`first_purchase_end`(RFC3339)筛选;卡详情、设备详情、资产解析返回 `first_purchase_at`。
|
||||
- 卡、设备导出末尾新增"首次购买套餐时间"列。
|
||||
- 首次购买套餐记录:`GET /api/admin/package-silence/assets/:asset_type/:id/first-purchase-records`(asset_type 为 card / device),按世代倒序,含来源(首次购买套餐 / 历史回填)、订单号、结束时间与原因。
|
||||
- 换货后的新资产不会因换货得到首次购买套餐时间,要等它在当前世代真正支付套餐订单;旧资产换货回库后该时间清空,旧世代记录带结束时间与原因"换货回库"。
|
||||
- 绑定在设备上的卡不记录首次购买套餐时间;只读,不提供修改。
|
||||
|
||||
## 9. 错误码
|
||||
|
||||
| 码 | 名称 | 说明 |
|
||||
|---|---|---|
|
||||
| 1260 | silence_period_out_of_range | 沉默期天数不在允许范围 |
|
||||
| 1261 | silence_override_forbidden | 无权限调整沉默期 |
|
||||
| 1262 | package_expiry_base_change_forbidden | 不允许变更该套餐的生效条件 |
|
||||
| 1263 | silence_extension_not_expired | 套餐未过期 / 无沉默期 / 已激活 / 已退款(`data.reason` 细分) |
|
||||
| 1264 | silence_extension_disabled | 该套餐不支持付费延期(可申请特殊延期) |
|
||||
| 1265 | silence_extension_exceeds_limit | 超过可延期期限(`data.min_months`、`data.max_months`) |
|
||||
| 1266 | silence_extension_months_invalid | 延期天数不在允许范围(`data.min_days`、`data.max_days`) |
|
||||
| 1267 | silence_free_extension_used_up | 免费延期次数已用完 |
|
||||
| 1268 | silence_expired_not_refundable | 沉默期已过期的套餐不能退款 |
|
||||
| 1269 | silence_policy_overlap | 延期政策重叠 |
|
||||
| 1270 | silence_extension_no_policy | 未匹配到延期政策 |
|
||||
| 1271 | silence_extension_order_pending | 已有未结束的延期订单或申请 |
|
||||
| 1272 | silence_offline_trade_no_duplicate | 线下交易单号已登记 |
|
||||
| 1273 | silence_offline_amount_mismatch | 实收金额与计算金额不一致(`data.expected_amount`) |
|
||||
| 1274 | silence_feature_disabled | 沉默期功能未开启 |
|
||||
| 1275 | silence_switch_precheck_failed | 总开关打开前检查未通过(`data.failed_items`) |
|
||||
|
||||
## 10. 企微审批模板
|
||||
|
||||
超级管理员在"企微审批场景"配置业务类型 **沉默期审批(package_silence_approval)**,模板需有"申请类型"单选控件,选项映射:`购买时调整沉默期`、`特殊延期`、`线下付费延期`。可映射字段见场景配置页的业务字段列表(申请单号、资产、套餐、原截止日期、延期天数、计费天数、费用、原因、附件、收款方式、交易单号、凭证等)。
|
||||
246
docs/2026-10-27-订单拆分至沉默期-前端修改汇总.md
Normal file
246
docs/2026-10-27-订单拆分至沉默期-前端修改汇总.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# 订单按类别拆分 → 套餐沉默期:前端修改汇总
|
||||
|
||||
日期:2026-10-27
|
||||
范围:`Iteration/8-11` 提交 `eb9f12d1`(后台订单按类别拆分)至 `af57de0d`(沉默期配置口径调整),均已部署测试环境。
|
||||
接口字段以 `docs/admin-openapi.yaml` 为准;本文说明页面要改什么。
|
||||
|
||||
## 一、总览
|
||||
|
||||
| # | 变更 | 前端是否要改 | 章节 |
|
||||
|---|---|---|---|
|
||||
| 1 | 后台订单按平台 / 代理商 / 在线支付拆分列表与导出 | **要改**:拆三个页面 | 二 |
|
||||
| 2 | 企业账号只能访问授权给本企业的资产 | **要改**:企业账号隐藏部分资产操作 | 三 |
|
||||
| 3 | 实名即生效套餐沉默期、延期办理 | **要改**:套餐配置、购买、资产套餐展示、延期页面、总开关、政策 | 四 |
|
||||
| 4 | 卡 / 设备首次购买套餐时间 | **要改**:列表列、筛选、详情、记录弹窗 | 五 |
|
||||
| 5 | 后台线下下单不再绕开实名 | 不改接口,展示需知晓 | 六 |
|
||||
| 6 | 退款 0 元可提交审批 | 不改接口 | 六 |
|
||||
| 7 | 排队激活停复机、孤儿套餐、微信查单 | 不涉及前端 | — |
|
||||
|
||||
---
|
||||
|
||||
## 二、后台订单按类别拆分
|
||||
|
||||
### 2.1 新接口
|
||||
|
||||
| 页面 | 列表接口 | 导出场景(`POST /api/admin/export-tasks` 的 `scene`) | 包含 |
|
||||
|---|---|---|---|
|
||||
| 平台订单 | `GET /api/admin/orders/platform` | `order_platform` | 后台下单且线下支付:平台自营、赠送、平台代购代理名下的卡 |
|
||||
| 代理商订单 | `GET /api/admin/orders/agent` | `order_agent` | 后台下单且从代理钱包扣款:代理自购、代理给下级购买、平台代扣代理钱包、代理 OpenAPI 下单 |
|
||||
| 在线支付订单 | `GET /api/admin/orders/online` | `order_online` | 个人客户在客户端下的单:微信、支付宝、卡钱包支付,自动续费,自动购买,待支付 |
|
||||
|
||||
- 三个列表接口的查询参数、返回结构与 `GET /api/admin/orders` 完全相同,**不需要也不接受类别参数**。
|
||||
- 三类互不重叠:相同筛选条件下三个 `total` 之和等于 `GET /orders` 的 `total`。
|
||||
- 导出 `query.filters` 与 `scene=order` 相同,表头相同。导出任务列表里新任务的 `scene` 是新值,显示中文请按上表映射。
|
||||
- 订单详情仍用 `GET /api/admin/orders/{id}`;`GET /orders` 与场景 `order` 保留不变。
|
||||
|
||||
```json
|
||||
{ "scene": "order_online", "format": "xlsx",
|
||||
"query": { "filters": { "payment_status": 2, "start_time": "2026-10-01T00:00:00+08:00", "end_time": "2026-10-26T23:59:59+08:00" } } }
|
||||
```
|
||||
|
||||
### 2.2 账号可见范围
|
||||
|
||||
| 账号 | 平台订单 | 代理商订单 | 在线支付订单 |
|
||||
|---|---|---|---|
|
||||
| 超管、平台 | 全部 | 全部 | 全部 |
|
||||
| 代理 | 平台为本店名下卡代购的单 | 买家是本店的钱包订单 | **始终为空**,隐藏此页面 |
|
||||
| 企业 | 无权限 | 无权限 | 无权限 |
|
||||
|
||||
企业账号调用以下接口返回无权限("企业账号无权访问订单"):`GET /orders`、`/orders/platform|agent|online`、`GET /orders/{id}`、`POST /orders/purchase-check`。
|
||||
|
||||
### 2.3 前端改动
|
||||
|
||||
1. 订单管理下拆三个页面,分别调用对应列表接口,导出按钮传对应场景。
|
||||
2. 原"订单列表"页可保留或下线。
|
||||
3. 代理账号隐藏"在线支付订单";企业账号隐藏订单菜单。
|
||||
|
||||
---
|
||||
|
||||
## 三、企业账号的资产访问范围
|
||||
|
||||
`/api/admin/assets/:identifier/*` 对企业账号收紧:
|
||||
|
||||
- **只能访问当前授权给本企业的卡或设备**。未授权与不存在一样返回"未找到"。涉及资产解析、实时状态、刷新、套餐、当前套餐、钱包、订单、操作日志、停机 / 复机。
|
||||
- 以下操作**企业账号一律无权限**(即使资产已授权),返回 `CodeForbidden`"企业账号无权执行该资产操作":
|
||||
|
||||
| 接口 | 操作 |
|
||||
|---|---|
|
||||
| `PATCH /assets/:identifier/packages/:package_usage_id/expires-at` | 改套餐有效期 |
|
||||
| `PATCH /assets/:identifier/packages/:package_usage_id/used-data` | 改已用量 |
|
||||
| `PATCH /assets/:identifier/deactivate` | 停用 |
|
||||
| `PATCH /assets/:identifier/polling-status` | 开关轮询 |
|
||||
| `PATCH /assets/:identifier/realname-status` | 改实名状态 |
|
||||
|
||||
- 停机 / 复机(`POST /stop`、`/start`)对已授权资产仍可用。
|
||||
|
||||
**前端改动**:企业账号登录时隐藏上表 5 个操作按钮;资产搜索结果为"未找到"时按普通未找到提示,不要提示"无权限"。
|
||||
|
||||
---
|
||||
|
||||
## 四、实名即生效套餐沉默期
|
||||
|
||||
> 规则见 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md` 与 OpenSpec `add-package-silence-period`。
|
||||
|
||||
### 4.1 总开关对页面的影响
|
||||
|
||||
总开关 `package.silence.enabled` **默认关闭**。
|
||||
|
||||
| 页面 / 功能 | 关闭时 | 打开时 |
|
||||
|---|---|---|
|
||||
| 资产套餐列表、H5 套餐历史里的 `silence` 对象 | **不返回**,页面不显示沉默期 | 返回,显示状态、截止日 |
|
||||
| "延期"入口 | 不显示 | `silence.extendable = true` 时显示 |
|
||||
| 延期资格、试算、下单、线下登记、特殊延期 | 返回 **1274** | 可用 |
|
||||
| 套餐默认沉默期、延期政策、企微审批场景配置 | 可用 | 可用 |
|
||||
| 购买时调整沉默期 | 可用 | 可用 |
|
||||
|
||||
总开关只能用专用接口切换(通用"系统配置"接口里只读):
|
||||
|
||||
- `GET /api/admin/package-silence/switch`:返回 `enabled` 与打开前 4 项检查 `precheck[]`(`code`、`name`、`passed`、`count`)。
|
||||
- `PUT /api/admin/package-silence/switch` `{ "enabled": true|false }`:仅超级管理员。打开前检查不通过返回 **1275**,`data.failed_items` 为未满足项,直接展示;关闭不检查。
|
||||
|
||||
**前端改动**:新增"沉默期总开关"设置页(超管可见),展示 4 项检查结果与开关。
|
||||
|
||||
### 4.2 套餐配置
|
||||
|
||||
- 新增 / 编辑套餐增加 `silence_period_days`(1~90)、`silence_extension_enabled`(是否支持付费延期,默认 true)。
|
||||
- **实名即生效的正式套餐必填**默认沉默期,新建时预填 30;不填或越界返回 **1260**。
|
||||
- **购买即生效的正式套餐可选填**(只在店铺分配覆盖为实名即生效时使用)。
|
||||
- 加油包不显示、不提交。
|
||||
- 套餐响应新增 `silence_period_days`、`silence_extension_enabled`、`silence_config_required`(是否必填,用来决定表单必填标记)。
|
||||
- 生效条件变更限制(返回 **1262**):
|
||||
- 实名即生效套餐改为购买即生效:禁止。
|
||||
- 店铺分配(单个、批量、系列授权)把实名即生效套餐覆盖为购买即生效:禁止。
|
||||
- 店铺分配把购买即生效套餐覆盖为实名即生效:**套餐已配置默认沉默期才允许**。
|
||||
- 套餐由购买即生效改为实名即生效:同一请求必须带 `silence_period_days`。
|
||||
- "未配置沉默期的实名即生效套餐"清单:套餐列表加查询参数 `silence_unconfigured=true`(含"本身购买即生效、但有店铺分配覆盖为实名即生效"的套餐);套餐导出同样传该筛选。套餐导出新增"默认沉默期(天)""是否支持付费延期"两列。
|
||||
|
||||
**前端改动**:套餐表单加两个字段(按 `expiry_base`、`package_type` 控制显示与必填);套餐列表加"未配置沉默期"筛选。
|
||||
|
||||
### 4.3 后台购买时调整沉默期
|
||||
|
||||
`POST /api/admin/orders` 新增可选字段:
|
||||
|
||||
- `silence_period_days`(≥ 1,不设上限)、`silence_reason`(填了天数时必填)。
|
||||
- 需要 `package_silence:override` 权限,无权限传值返回 **1261**。
|
||||
- 不超过默认值直接生效;超过默认值时按延期政策直接生效,或**先按默认值购买并提交企微审批**。发起人未绑定企微成员时购买被拒。
|
||||
- 只对"资产未实名时购买的实名即生效主套餐"生效,其他情况忽略。
|
||||
|
||||
**前端改动**:后台下单弹窗增加"本次沉默期(天)""调整原因",有权限才显示。
|
||||
|
||||
### 4.4 资产套餐展示
|
||||
|
||||
后台资产套餐历史 / 当前套餐、H5 套餐历史(`GET /api/c/v1/asset/package-history`)每笔套餐增加 `silence` 对象(总开关打开且适用时返回):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `silence_status` / `silence_status_name` | `waiting` 待实名(延期后为"延期待实名")/ `expired` 沉默期已过期 / `ended` 沉默期已结束;待实名但已过截止的直接按 `expired` 返回 |
|
||||
| `silence_deadline_at`、`silence_start_at`、`silence_period_days` | 截止时间(当日 23:59:59)、起算时间、天数 |
|
||||
| `silence_extended_days`、`silence_paid_extended_days`、`silence_extension_count`、`silence_free_extension_used` | 延期累计 |
|
||||
| `silence_expired_at`、`silence_adjust_pending` | 过期时间、调整审批中 |
|
||||
| `extendable` | 是否显示"延期"入口 |
|
||||
|
||||
沉默期流水:后台 `GET /api/admin/package-silence/records?package_usage_id=`,H5 `GET /api/c/v1/package-silence/records?package_usage_id=`。
|
||||
|
||||
**前端改动**:套餐列表显示沉默期状态与截止日、"延期"入口、"沉默期流水"弹窗(后台与 H5)。
|
||||
|
||||
### 4.5 延期(后台)
|
||||
|
||||
均在 `/api/admin/package-silence` 下:
|
||||
|
||||
| 接口 | 权限 | 说明 |
|
||||
|---|---|---|
|
||||
| `GET /eligibility?package_usage_id=` | — | 资格:`can_extend`、`special_extension_available`、`reason`、`min_days`/`max_days`、`min_months`/`max_months`、`unit_price`、`options[]`(每档计费天数、金额、新截止日)。**金额由后端算,前端只传天数** |
|
||||
| `GET /quote?package_usage_id=&days=` | — | 按天数试算(按 30 天向上取整:1→30、31→60) |
|
||||
| `POST /orders/qrcode` | `package_silence:extend` | 生成支付二维码:`package_usage_id`、`days`、`payment_method`(wechat/alipay)、`request_id`、`evidence_refs`;返回 `qr_content`,前端渲染为二维码 |
|
||||
| `POST /orders/offline` | `package_silence:offline` | 登记线下付款:`package_usage_id`、`days`、`offline_payment_method_id`、`offline_trade_no`、`paid_amount`(必须等于计算金额,否则 **1273**)、`paid_at`、`payment_voucher_keys`(1~5)、`evidence_refs`、`request_id`。交易单号重复 **1272**。默认进企微审批 |
|
||||
| `GET /orders`、`GET /orders/:id` | — | 延期订单列表 / 详情;导出场景 `package_silence_order` |
|
||||
| `POST /special` | `package_silence:extend`(批量另需 `package_silence:extend_batch`) | 特殊延期:`extension_type`(free/discount/complaint_compensation/system_compensation/special_customer)、`items[]`(`package_usage_id` + `days`)、`reason`、`basis`、`evidence_refs`、`amount`;优惠价有收款时填收款方式、交易单号、凭证。批量逐行校验,失败返回 `data.rows[]`(行号与原因)。免费次数用完 **1267** |
|
||||
| `GET /batches`、`GET /batches/:id` | — | 批次与明细(`has_exception=true` 只看含异常的) |
|
||||
| `POST /extensions/:id/handle` | `package_silence:exception` | 异常明细 `action=retry|close` + `reason` |
|
||||
| `GET /usages?view=expiring|expired|all` | — | 即将过期(默认 7 天内)/ 已过期套餐;导出场景 `package_silence_usage` |
|
||||
|
||||
延期政策(`package_silence:policy`):`GET/POST /policies`、`GET/PUT /policies/:id`、`PUT /policies/:id/status`、`GET /policies/match?package_id=`。同级重叠返回 **1269**。
|
||||
|
||||
**前端改动**:新增页面——延期弹窗(资格 + 试算 + 二维码 / 线下登记 / 特殊延期三种方式)、延期订单、延期批次与异常处理、即将过期 / 已过期套餐、延期政策管理。
|
||||
|
||||
### 4.6 延期(H5)
|
||||
|
||||
均在 `/api/c/v1/package-silence` 下,资产须属于当前客户:
|
||||
|
||||
- `GET /eligibility`、`GET /quote`:同后台。
|
||||
- `POST /orders`:自助下单(`package_usage_id`、`days`、`payment_method`、`request_id`),返回 `qr_content`(支付链接或二维码内容);支付成功自动延期。
|
||||
- `GET /orders`、`GET /orders/:id`:我的延期订单。
|
||||
- `GET /records`:沉默期流水。
|
||||
|
||||
### 4.7 退款
|
||||
|
||||
沉默期已过期的套餐申请退款返回 **1268**(总开关打开时)。
|
||||
|
||||
### 4.8 企微审批场景
|
||||
|
||||
在"企微审批场景"配置业务类型 **沉默期审批(`package_silence_approval`)**。模板需有"申请类型"单选控件,选项映射:`购买时调整沉默期`、`特殊延期`、`线下付费延期`。可映射的业务字段见场景配置页。
|
||||
|
||||
**前端改动**:审批场景页的业务类型下拉加入"沉默期审批"。
|
||||
|
||||
### 4.9 错误码
|
||||
|
||||
| 码 | 名称 | 说明 |
|
||||
|---|---|---|
|
||||
| 1260 | silence_period_out_of_range | 沉默期天数不在允许范围 |
|
||||
| 1261 | silence_override_forbidden | 无权限调整沉默期 |
|
||||
| 1262 | package_expiry_base_change_forbidden | 不允许变更该套餐的生效条件 |
|
||||
| 1263 | silence_extension_not_expired | 套餐未过期 / 无沉默期 / 已激活 / 已退款(`data.reason` 细分) |
|
||||
| 1264 | silence_extension_disabled | 该套餐不支持付费延期(可申请特殊延期) |
|
||||
| 1265 | silence_extension_exceeds_limit | 超过可延期期限(`data.min_months`、`data.max_months`) |
|
||||
| 1266 | silence_extension_months_invalid | 延期天数不在允许范围(`data.min_days`、`data.max_days`) |
|
||||
| 1267 | silence_free_extension_used_up | 免费延期次数已用完 |
|
||||
| 1268 | silence_expired_not_refundable | 沉默期已过期的套餐不能退款 |
|
||||
| 1269 | silence_policy_overlap | 延期政策重叠 |
|
||||
| 1270 | silence_extension_no_policy | 未匹配到延期政策 |
|
||||
| 1271 | silence_extension_order_pending | 已有未结束的延期订单或申请 |
|
||||
| 1272 | silence_offline_trade_no_duplicate | 线下交易单号已登记 |
|
||||
| 1273 | silence_offline_amount_mismatch | 实收金额与计算金额不一致(`data.expected_amount`) |
|
||||
| 1274 | silence_feature_disabled | 沉默期功能未开启 |
|
||||
| 1275 | silence_switch_precheck_failed | 总开关打开前检查未通过(`data.failed_items`) |
|
||||
|
||||
### 4.10 新增权限点
|
||||
|
||||
`package_silence:override`、`package_silence:extend`、`package_silence:extend_batch`、`package_silence:offline`、`package_silence:exception`、`package_silence:policy`。前端按权限控制按钮显示,角色配置页需能勾选。
|
||||
|
||||
---
|
||||
|
||||
## 五、卡 / 设备首次购买套餐时间
|
||||
|
||||
- 卡列表、设备列表返回 `first_purchase_at`,支持 `first_purchase_start`、`first_purchase_end`(RFC3339)筛选;卡详情、设备详情、资产解析返回 `first_purchase_at`。
|
||||
- 卡、设备导出末尾新增"首次购买套餐时间"列。
|
||||
- 首次购买套餐记录:`GET /api/admin/package-silence/assets/:asset_type/:id/first-purchase-records`(`asset_type` 为 `card` / `device`),按世代倒序,含来源(首次购买套餐 / 历史回填)、订单号、结束时间与原因。
|
||||
- 换货后的新资产不会因换货得到该时间,要等它在当前世代真正支付套餐订单;旧资产换货回库后该时间清空,旧记录带结束原因"换货回库"。
|
||||
- 绑定在设备上的卡不记录;只读,不提供修改。不受沉默期总开关影响。
|
||||
|
||||
**前端改动**:卡 / 设备列表加列与时间范围筛选,详情显示该时间,加"首次购买套餐记录"弹窗。
|
||||
|
||||
---
|
||||
|
||||
## 六、只影响展示、不改接口
|
||||
|
||||
- **后台线下下单不再绕开实名**(`69ec98aa`):后台给**平台自营资产**线下下单、或线下下单**赠送套餐**时,若资产未实名、套餐是实名即生效,套餐现在是"待生效(等待实名)",实名后才生效;以前会直接"生效中"。订单 `buyer_type` 仍是 `personal`。前端不用改,测试和运营看到状态变化属正常。
|
||||
- **退款 0 元可提交审批**(`c9e5d655`):以前 0 元退款提交审批报数据库错误,现在正常。前端若对退款金额做了 `> 0` 校验,可放宽为 `≥ 0`。
|
||||
|
||||
---
|
||||
|
||||
## 七、前端改动清单
|
||||
|
||||
- [ ] 订单管理拆三页(平台 / 代理商 / 在线支付),各自导出场景;代理隐藏在线支付页;企业隐藏订单菜单
|
||||
- [ ] 企业账号隐藏资产的改有效期、改已用量、停用、开关轮询、改实名状态 5 个按钮
|
||||
- [ ] 沉默期总开关设置页(超管)
|
||||
- [ ] 套餐表单:默认沉默期、是否支持付费延期;套餐列表"未配置沉默期"筛选
|
||||
- [ ] 后台下单弹窗:本次沉默期、调整原因(`package_silence:override`)
|
||||
- [ ] 资产套餐列表(后台 + H5):沉默期状态、截止日、延期入口、沉默期流水
|
||||
- [ ] 后台延期:资格 / 试算、二维码、线下登记、特殊延期(单个 + 批量导入)
|
||||
- [ ] 延期订单、延期批次与异常处理、即将过期 / 已过期套餐页面(含导出)
|
||||
- [ ] 延期政策管理页
|
||||
- [ ] H5:延期资格、试算、自助下单、我的延期订单、流水
|
||||
- [ ] 企微审批场景业务类型加"沉默期审批"
|
||||
- [ ] 角色权限配置加 6 个 `package_silence:*` 权限点
|
||||
- [ ] 卡 / 设备:首次购买套餐时间列、筛选、详情、记录弹窗、导出列
|
||||
- [ ] 错误码 1260~1275 的提示文案
|
||||
440
docs/2026-11-05-审计日志写入改造技术方案.md
Normal file
440
docs/2026-11-05-审计日志写入改造技术方案.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# 审计日志写入改造技术方案
|
||||
|
||||
> 日期:2026-11-05
|
||||
> 状态:方案已定,未实施(结论见第 10 节)
|
||||
> 关联:`openspec/changes/add-audit-integration-write-switches`(全局写入开关,本方案是其后的"按动作分级")
|
||||
> **范围:本方案基于 `Iteration/8-11` 迭代实施,只部署到测试环境**(推送 8-11 由 `.gitea/workflows/deploy.yaml` 自动部署)。本方案不修改生产环境的代码、配置或服务;第 1 节的生产数据只用来说明问题和估算效果。
|
||||
|
||||
## 1. 背景
|
||||
|
||||
### 1.1 现状数据(生产近 14 天,只读统计)
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---|
|
||||
| `tb_audit_event` 日均新增 | 约 122 万条(含停机重试风暴);扣掉风暴约 20 万条 |
|
||||
| 机器发起(`system_task` / `scheduled_job`)占比 | 99% 以上 |
|
||||
| 人、开放接口、个人客户、外部系统发起 | 约 1,210 条/天 |
|
||||
| 每条事件含索引 | 约 1.9 KB;每条资源快照约 1.2 KB;平均 2–3 条资源 |
|
||||
| 日均新增存储 | 约 6 GB(风暴期)/ 约 1 GB(平时) |
|
||||
| 索引数 | `tb_audit_event` 14 个、`tb_audit_event_resource` 7 个、`tb_integration_log` 13 个 |
|
||||
|
||||
测试环境日均约 12.6 万条,几乎全是系统任务:流量同步 5.6 万、扣减 2.6 万、网络状态同步 2.5 万、优先轮询约 1.4 万。
|
||||
|
||||
### 1.2 问题
|
||||
|
||||
1. **审计表被当成过程日志用。** 每次轮询同步、每次扣减、每次对接调用开始、每次失败重试都写一条事件加 2–3 条资源快照。一次业务动作产生多次写入,而且每次写入要维护 21 个索引。
|
||||
2. **失败重试没有收敛。** 退款后处理停机失败(`refund.process_asset`)和自动停机失败(`iot_card.auto_stop`),每重试一次就写一条审计。11-05 10:16–11:47,86 张卡产生了 26.1 万次停机失败,一个风暴就占了当天审计量的六成以上。
|
||||
3. **对接日志顺带写审计。** `integrationlog.Repository.Start` 在没有上游审计事件时,会补写一条 `integration.attempt_started`(`internal/infrastructure/integrationlog/repository.go:119`),这一项每天约 45.6 万条。它与 `tb_integration_log` 本身完全重复。
|
||||
4. **后果。** 11-03 生产 PostgreSQL IO 打满,轮询停摆约 1.7 天,审计和对接日志的写入与留存扫描是主要 IO 来源之一。
|
||||
|
||||
### 1.3 老系统做法(`/home/zoujin/works/golang/kg/qc`,供参考)
|
||||
|
||||
- **人工后台操作**:写 `tbl_system_log`。
|
||||
- **业务过程**:按业务各写一张表,如 `tbl_order_log`、`tbl_card_wallet_auto_renew_log`、`tbl_next_month_log`,以及充值、短信日志。
|
||||
- **下游代理调用我方接口**:只这一类写 `tbl_agent_pool_api_call_log`。
|
||||
- **网关调用和轮询**:全部写 log4j 按天滚动的文本日志,不进数据库。
|
||||
|
||||
## 2. 目标与原则
|
||||
|
||||
**目标**:审计表只回答两个问题:
|
||||
|
||||
1. **人(或代表人的外部系统)做了什么**;
|
||||
2. **钱和套餐、卡的业务状态是怎么变的**。
|
||||
|
||||
审计日均降到几千条,一年不到 10 GB,不再依赖归档和清理来扛量。
|
||||
|
||||
**原则**:
|
||||
|
||||
| 原则 | 说明 |
|
||||
|---|---|
|
||||
| 人工操作全留 | 后台账号、个人客户、开放接口、外部系统发起的动作,成功和失败都写审计 |
|
||||
| 机器动作只留"结果" | 系统任务和定时任务只在资金或业务状态**真正改变**时写审计;无变化、同步、读取不写 |
|
||||
| 每次失败都可查,审计只记结论 | 自动重试的每一次尝试都完整记在业务尝试记录表里(时间、第几次、错误码、上游返回),可分页查询;审计只记这件事的最终结论:成功,或重试用尽转人工 |
|
||||
| 过程数据去业务表 | 已有专门记录表的(停复机记录、采集记录、扣减效果、日流量),审计不再重复写 |
|
||||
| 排查用的走文本日志 | 只用于排查、不需要按业务查询的,写入现有应用日志 `app.log`,沿用现有轮换,由日志同步服务器归集 |
|
||||
| 不改变业务结果 | 写不写审计,都不影响业务事务成功与否(与现状 `Append` 吞错一致) |
|
||||
|
||||
## 3. 数据分层
|
||||
|
||||
| 层 | 存储 | 放什么 | 保留 |
|
||||
|---|---|---|---|
|
||||
| L1 审计 | `tb_audit_event` + `tb_audit_event_resource` | 人工操作;机器发起的资金、套餐生命周期、停复机成功等状态变化 | 按现有留存策略 |
|
||||
| L2 业务记录 | 各业务专表 | 停复机调用 `tb_card_stop_resume_record`、流量采集 `tb_card_traffic_collection`、扣减效果与日流量、对接调用 `tb_integration_log`、退款单上的后处理状态 | 按各表策略 |
|
||||
| L3 文本日志 | 现有应用日志 `app.log`(zap JSON + lumberjack 轮换) | 机器过程类动作的结构化日志,例如停机原因更新、网络状态同步、通知投递、优先轮询、流量重置明细 | 沿用 `logging.app_log` 轮换;长期留存由日志同步服务器负责 |
|
||||
| L4 不写 | — | 与 L1/L2 完全重复的,例如 `integration.attempt_started` | — |
|
||||
|
||||
## 4. 动作去向清单
|
||||
|
||||
### 4.1 保留在审计(L1)
|
||||
|
||||
**人工及外部发起**(约 1,210 条/天,全部保留):
|
||||
|
||||
| 发起方 | 日均 | 主要动作 |
|
||||
|---|---|---|
|
||||
| 后台账号 | 约 404 | 手动刷新卡、设备切卡、回收、设 WiFi、手动复机、改套餐到期、设备重启、下单、退款、审批、登录等 |
|
||||
| 开放接口 | 约 612 | 下单、代理钱包扣款、开放接口复机 |
|
||||
| 个人客户 | 约 182 | 绑资产、建手机号关联、更新微信身份、绑手机号 |
|
||||
| 外部系统 | 约 9 | 支付确认、外部回调接收 |
|
||||
|
||||
**机器发起但改变资金或业务状态**(方案 A 约 3,510 条/天):
|
||||
|
||||
| 动作 | 日均 | 方案 B 去向 |
|
||||
|---|---|---|
|
||||
| `iot_card.auto_start` 自动复机 | 1,127 | 移到 L2 停复机记录 |
|
||||
| `package_usage.reset_traffic` 流量重置 | 1,049 | 移到 L2(套餐自身的重置时间加日流量记录) |
|
||||
| `iot_card.auto_stop` 自动停机,**仅成功** | 380 | 移到 L2 停复机记录 |
|
||||
| `commission.calculate` 分佣计算 | 238 | 保留 |
|
||||
| `package_usage.expire` / `activate` / 退款失效 | 176 / 90 / 6 | 保留 |
|
||||
| 导入建卡、建设备、设备分配 | 93 / 52 / 44 | 保留 |
|
||||
| 系统下单、订单套餐失效明细、员工代收账单 | 77 / 74 / 51 | 保留 |
|
||||
| 外部回调接收、审批同步、退款批准、佣金回溯判定 | 27 / 13 / 6 / 6 | 保留 |
|
||||
| `refund.process_asset`,**仅成功** | 4 | 保留 |
|
||||
|
||||
### 4.2 移出审计
|
||||
|
||||
| 动作 | 现在日均(估算) | 去向 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `integration.attempt_started` | 约 45.6 万 | L4 不写 | `tb_integration_log` 已有同样内容;`audit_event_id` 留空 |
|
||||
| `refund.process_asset` 失败 | 约 32.6 万 | L2 退款后处理尝试记录 | 每次尝试一条,记在新表 `tb_refund_post_process_attempt`;退款单上保留当前状态、次数和最后错误;审计只在最终成功或转人工时写一条 |
|
||||
| `iot_card.auto_stop` 失败 | 约 31.5 万 | L2 停复机记录 | `tb_card_stop_resume_record` 每次实际调用一条,已含错误分类 |
|
||||
| `iot_card.auto_stop_reason_update` | 约 3.8 万 | L3 文本 | 只改停机原因、不改停复机状态 |
|
||||
| `iot_card.worker_traffic_sync` | 约 3.1 万(正常时 6–8 万) | L2 采集记录 | `tb_card_traffic_collection` 已经记录每次采集 |
|
||||
| `iot_card.worker_network_sync` | 约 2.6 万(正常时 6–8 万) | L3 文本 | 结果就是卡上的网络状态字段 |
|
||||
| `package_usage.deduct_traffic` | 约 2.1 万 | L2 | 采集记录、扣减效果、日流量三张表已经能还原 |
|
||||
| `notification.deliver`、`polling_priority.*` 等 | 约 0.5 万 | L3 文本 | — |
|
||||
|
||||
### 4.3 方案选择
|
||||
|
||||
采用**方案 B**。停复机和流量重置都已经有更合适的业务表,审计里只留人工操作和资金、套餐生命周期类动作。
|
||||
|
||||
| 方案 | 审计事件/天 | 占现在 | 每天新增存储 |
|
||||
|---|---|---|---|
|
||||
| 现在 | 约 122 万 | 100% | 约 6 GB |
|
||||
| 方案 A:4.1 全留 | 约 4,700 | 0.39% | 约 24 MB |
|
||||
| **方案 B**:停复机、流量重置移到 L2 | 约 **2,200** | 0.18% | 约 11 MB |
|
||||
|
||||
## 5. 技术设计
|
||||
|
||||
### 5.1 注册表增加"落库策略"
|
||||
|
||||
`internal/infrastructure/audit/registry.go` 的 `ActionDefinition` 新增字段:
|
||||
|
||||
```go
|
||||
// PersistPolicy 决定动作是否写入审计表。
|
||||
type PersistPolicy string
|
||||
|
||||
const (
|
||||
PersistAlways PersistPolicy = "always" // 成功失败都写(人工动作默认)
|
||||
PersistOnSuccess PersistPolicy = "on_success" // 只写成功;失败写文本日志
|
||||
PersistOutcome PersistPolicy = "outcome" // 只写最终结论:成功、或重试用尽转人工;中间失败不写审计(每次尝试在业务尝试记录表)
|
||||
PersistLogOnly PersistPolicy = "log_only" // 不写表,写 L3 文本日志
|
||||
PersistNone PersistPolicy = "none" // 什么都不写
|
||||
)
|
||||
|
||||
type ActionDefinition struct {
|
||||
// ……现有字段
|
||||
Persist PersistPolicy // 为空时按操作者决定,见 5.2
|
||||
}
|
||||
```
|
||||
|
||||
动作的策略在注册表中**集中定义**,调用方不改代码。这样一份清单就能看清哪些动作进审计,评审和后续调整也只需改这一处。
|
||||
|
||||
### 5.2 默认规则
|
||||
|
||||
`Persist` 为空时,按本次写入的操作者类型决定:
|
||||
|
||||
| 操作者 `actor_kind` | 默认策略 |
|
||||
|---|---|
|
||||
| `account`、`personal_customer`、`openapi`、`external_system` | `always` |
|
||||
| `system_task`、`scheduled_job` | `log_only` |
|
||||
|
||||
因此新增机器动作**默认不进审计**,要进审计必须在注册表显式声明 `always`、`on_success` 或 `outcome`。这能防止后面新增的轮询、同步类动作再次把审计表写满。
|
||||
|
||||
同一动作可能同时由人和机器触发,例如 `order.create`、`iot_card.manual_start` 与开放接口复机。策略按"动作 + 操作者"判断:注册表里的 `Persist` 只约束机器操作者,人工操作者始终是 `always`。
|
||||
|
||||
### 5.3 Writer 改造
|
||||
|
||||
只改 `internal/infrastructure/audit/writer.go` 的 `AppendAndGet`,在注册校验通过后、组装事件前分流:
|
||||
|
||||
```go
|
||||
action, ok := w.registry.Action(input.ActionCode)
|
||||
// ……现有校验
|
||||
switch w.persistDecision(ctx, tx, action, input) {
|
||||
case decisionSkip:
|
||||
return nil, nil
|
||||
case decisionLog:
|
||||
w.machineLog.Info("audit", auditLogFields(action, input)...)
|
||||
return nil, nil
|
||||
}
|
||||
// ……现有写表逻辑
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
1. **调用方兼容**:`Append` 本来就吞错并返回 nil,调用方不受影响。`AppendAndGet` 有 11 处调用(`refund/clawback.go`、`refund/service.go`、`businessusergroup`、`packagetrafficalert`、`distribution`、`refund_channel`、`integrationlog`),实施时逐一确认能处理返回的 `nil` 事件。这 11 处大多是人工或资金动作,仍然是 `always`,实际不会拿到 nil。
|
||||
2. **`outcome` 的判定**:由调用方在写审计时标明这次是不是最终结论,`AppendInput` 增加 `Final bool`。
|
||||
- 中间失败(`Final=false`)不写审计表,只写一行应用日志,日志里带尝试记录 ID 方便对照;
|
||||
- 最终成功,或重试用尽转人工(`Final=true`)写一条审计,`metadata` 带总尝试次数、首次失败时间、最后错误码;
|
||||
- 用业务键作 `EventID`(例如 `refund-asset:{refund_id}:outcome`),保证同一件事的结论只有一条。
|
||||
- **每一次尝试的细节都不丢**,都在业务尝试记录表里(见 5.5),可分页查询(见 5.9)。
|
||||
3. **文本日志写入现有应用日志**:不新建日志文件,也不新增轮换配置。Writer 直接使用 `logger.GetAppLogger().Named("audit")`,写入现有的 `app.log`,轮换沿用 `logging.app_log`(测试环境配置见 5.8),由日志同步服务器自动归集。`NewWriter` 签名不变,调用方不用改。
|
||||
- 每行固定 `"logger":"audit"`、`"msg":"audit_event"`,便于在同步端按 logger 名过滤和建索引;
|
||||
- 字段:`action_code`、`actor_kind`、`actor_id`、`source`、`result`、`resource_type`、`resource_key`、`correlation_id`、`request_id`、`error_code`、`summary`;中间失败额外带 `attempt`(第几次)和尝试记录 ID;
|
||||
- 级别:成功为 Info,失败为 Warn;
|
||||
- **不写** before/after 快照与主体数据,避免敏感数据进入文本日志;需要变化值的动作(如流量重置前后用量)只写必要的数值字段。
|
||||
4. **运行时覆盖**:在 `add-audit-integration-write-switches` 的全局开关之外,增加一个按动作覆盖的配置 `audit.persist_overrides`(动作编码 → 策略)。出问题时不发版就能把某个动作临时降为 `log_only`,或恢复为 `always`。
|
||||
|
||||
### 5.4 对接日志不再补写审计
|
||||
|
||||
`internal/infrastructure/integrationlog/repository.go` 的 `Start`:
|
||||
|
||||
- **删除** `input.AuditEventID == nil` 时补写 `integration.attempt_started` 的逻辑(第 118–120 行)。上游有审计事件时照常关联;没有时 `audit_event_id` 留空。
|
||||
- 注册表里的 `integration.attempt_started` 设为 `none`,作为兜底。
|
||||
- `auditEventIDForSeries` 按 `trigger_series` 回查审计的逻辑保留,但需要确认它不会走慢查询(它会查审计表)。
|
||||
|
||||
### 5.5 失败重试收敛(与停机风暴止血配套)
|
||||
|
||||
审计只记结论之后,排查问题靠的是每一次尝试的记录,同时重试本身要收敛。这两件可以单独开 Change,但**必须先于或同时上线**:
|
||||
|
||||
1. **退款后处理**(`internal/service/refund/approval_decision.go` 的 `ProcessAssetPostProcessing`,`stopAsset` → `ManualStopCard`):
|
||||
- 退款单增加或复用后处理字段:`asset_post_status`、`asset_post_attempts`、`asset_post_last_error`、`asset_post_next_at`,表示当前状态;
|
||||
- **新增尝试记录表** `tb_refund_post_process_attempt`,每次尝试一条,不修改、不删除。字段:`refund_id`、`step`(停机、套餐失效、钱包回收等)、`asset_type`、`asset_id`、`iccid`、`attempt`、`trigger`(outbox 投递、补偿扫描、人工重试)、`result`、`error_code`、`error_message`、`stop_resume_record_id`(关联到那次停机调用)、`duration_ms`、`created_at`。按月分区。每单最多 10 次,量可控;
|
||||
- 失败按指数退避,超过 N 次(建议 10 次)标记为"需人工处理",不再自动重试;
|
||||
- outbox 与 `RecoverRefundPostProcessing` 都按这几个字段判断,不再无限重投。
|
||||
2. **自动停机**:8-11 上的停机已经走停复机指令队列(`stop-resume-command-queue`),按错误分类处理:终态不可操作不重试,限流按退避处理。退款停机只请求停机评估。本次不改这部分。
|
||||
- 每次实际调用上游,已经在 `tb_card_stop_resume_record` 记一条,包括第几次、错误类别、上游返回码与信息、耗时。
|
||||
- 指令在调用上游前就结束的情况(已是目标状态、条件不再满足、卡不允许停复机)现在没有记录。改为在停复机记录里写 `result=skipped`,并写明原因,保证"为什么没停"也能查到。限流推迟后还会再执行,不写记录,沿用现有的分钟计数。
|
||||
|
||||
### 5.6 方案 B 的业务表补位
|
||||
|
||||
| 移出的动作 | 补位方式 | 现状 |
|
||||
|---|---|---|
|
||||
| `iot_card.auto_start` / `auto_stop` 成功 | `tb_card_stop_resume_record`(每次实际调用一条,含来源、场景、原因、结果) | Iteration/8-11 已有 |
|
||||
| `package_usage.reset_traffic` | 套餐使用记录的 `last_reset_at` / `next_reset_at` 加日流量记录 | 已有;重置前后用量写一行 `app.log`(`logger=audit`)备查 |
|
||||
| `iot_card.auto_start` / `auto_stop` 的停机原因、触发场景 | `tb_card_stop_resume_record` 的 `source`、`scene`、`reason` | 已有;原审计里的 before/after 卡状态不再保留,卡当前状态以卡表为准 |
|
||||
|
||||
后台"卡操作记录"之类的页面如果要看到停复机时间线,查询侧改为合并审计和停复机记录两个来源,不要回头再写审计。
|
||||
|
||||
### 5.7 审计覆盖治理
|
||||
|
||||
`internal/governance/auditcoverage/scanner.go` 扫描每个入口的 `AuditEvent`、`DomainLedger`、`IntegrationLog` 分类。本次需要做三件事:
|
||||
|
||||
- 对移到 L2、L3、L4 的入口,把 `AuditEvent` 分类改为不写,并在 `DomainLedger` 里写明对应的业务表;
|
||||
- 覆盖扫描增加一条校验:机器操作者的动作必须在注册表显式声明 `Persist`,否则扫描失败;
|
||||
- 同步更新 `openspec/specs/operations-audit` 和 `external-integration` 中"每个入口都写审计"的要求。
|
||||
|
||||
### 5.8 应用日志增量与轮换(测试环境)
|
||||
|
||||
测试环境由 `docker-compose.prod.yml` 部署,api、worker 两个容器的现状:
|
||||
|
||||
- 日志级别是 `LEVEL=info`、`DEVELOPMENT=false`,即 JSON 格式,可以按 `"logger":"audit"` 过滤;
|
||||
- 两个容器都把宿主机 `./logs` 挂载到 `/app/logs`,且都**没有设置** `JUNHONG_LOGGING_APP_LOG_FILENAME`,所以都使用默认的 `/app/logs/app.log`;
|
||||
- 轮换参数也是默认值:100 MB 一个文件、3 个备份、7 天、压缩。
|
||||
|
||||
**问题**:api 和 worker 写的是宿主机上**同一个** `/opt/junhong_cmp/logs/app.log`。两个进程各自用 lumberjack 按大小轮换,会互相把对方正在写的文件改名、压缩,导致丢日志和备份错乱。这个问题与审计改造无关,但 L3 日志要落在 `app.log` 里,所以要一并修。
|
||||
|
||||
**调整原则**:每个服务写在自己运行目录下的 `logs/`。审计日志不新增路径、不改文件名,跟着所在进程的应用日志走。测试环境的问题只在于两个容器共用了同一个宿主机目录,所以只改挂载:
|
||||
|
||||
| 服务 | 挂载(宿主机 → 容器) | 宿主机日志位置 |
|
||||
|---|---|---|
|
||||
| api | `./api/logs:/app/logs` | `/opt/junhong_cmp/api/logs/app.log`、`access.log` |
|
||||
| worker | `./worker/logs:/app/logs` | `/opt/junhong_cmp/worker/logs/app.log` |
|
||||
|
||||
- 容器内仍然是默认的 `/app/logs/app.log`,**不设置** `APP_LOG_FILENAME`;目录结构与生产 `/opt/junhong_cmp/api/logs`、`/opt/junhong_cmp/worker/logs` 一致;
|
||||
- 轮换只加两项:`JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=40`、`JUNHONG_LOGGING_APP_LOG_MAX_AGE=14`,其余用默认值(100 MB、压缩);
|
||||
- 容量:两个进程合计约 1 GB,每个进程是 100 MB 当前文件加 40 个压缩备份。每个备份约 10 MB 是估算值,部署后按实际备份大小微调;
|
||||
- `.gitea/workflows/deploy.yaml` 里 `mkdir -p ${DEPLOY_DIR}/logs` 改为创建 `api/logs`、`worker/logs`;
|
||||
- 原来的 `/opt/junhong_cmp/logs` 不再写入,旧文件保留不动。
|
||||
|
||||
- **新增量**:方案 B 在测试环境移到 L3 的动作,每天约 8 万–10 万行:流量同步改由采集记录承担,不写日志;需要写日志的有网络状态同步约 2.5 万、优先轮询约 1.4 万、停机原因更新与通知若干。按每行约 400 B 计算,每天约 30–40 MB(压缩前),相对单文件 100 MB 没有压力。
|
||||
|
||||
文本日志是排查补充。丢几行不影响业务,也不影响审计表。写日志失败时不重试、不报错。
|
||||
|
||||
### 5.9 记录查询接口
|
||||
|
||||
审计里移出去的数据,要能在后台查得到,否则等于丢了。凡是独立成表的记录,都要提供**分页 + 条件筛选**的查询接口。L3 文本日志不提供接口,由日志同步服务器检索。
|
||||
|
||||
**公共约定**(与现有 `ListStopResumeRecords` 一致):
|
||||
|
||||
| 项 | 约定 |
|
||||
|---|---|
|
||||
| 分页 | `page` 默认 1;`page_size` 默认 20,最大 100(采集记录沿用现有的最大 200);返回 `total`、`page`、`page_size`、`items` |
|
||||
| 时间范围 | `start_time`、`end_time` 必填,跨度不超过 31 天,按上海时间解析;单卡接口为空时默认取最近 31 天 |
|
||||
| 卡标识 | `identifier` 支持虚拟号、ICCID、MSISDN,先解析成卡 ID 再查,保证走"卡 ID + 时间"索引;找不到卡时直接返回空页 |
|
||||
| 排序 | 按记录时间倒序,同一时间按 ID 倒序 |
|
||||
| 单个与全部 | 每类记录提供一个列表接口,`identifier` 选填:传了就查单卡或单资产,不传就查全部。现有的单卡接口保留给前端兼容,与列表接口共用同一个查询 |
|
||||
| 筛选 | 按业务排查需要来定,下面逐表列出,不受现有索引限制;为这些筛选新建或重建索引 |
|
||||
| 时间范围的作用 | 表按月分区,时间范围用于分区裁剪,所以查全部时也只带时间范围、不要求其他条件 |
|
||||
| 权限 | 平台账号;代理和企业账号是否开放、按归属范围过滤,另行确定 |
|
||||
|
||||
**接口清单**:
|
||||
|
||||
| 记录 | 表 | 列表接口 | 筛选条件 | 现状 |
|
||||
|---|---|---|---|---|
|
||||
| 运营商流量采集记录(成功,失败见 5.10) | `tb_card_traffic_collection` | `GET /traffic-collections` | `identifier`、`carrier_id`(运营商账户)、`decision`(扣减判定)、`source`、`scene`、`has_delta`(是否产生增量)、`stale_guarded` | 只有单卡接口 `GET /assets/:identifier/traffic-collections`,只能按时间分页 |
|
||||
| 停复机记录 | `tb_card_stop_resume_record` | `GET /card-stop-resume-records` | `identifier`、`carrier_id`、`action`、`source`、`scene`、`reason`、`result`、`error_category`、`command_id` | 已有跨卡接口,但只能用 ICCID 查卡,也不能按场景、原因、指令号筛选;单卡接口没有筛选 |
|
||||
| 套餐日流量详单 | `tb_package_usage_daily_record` | `GET /package-usage-daily-records` | `identifier`、`package_usage_id`、`package_id`、`carrier_id`、日期范围 | 只有单套餐接口 `GET /package-usages/:id/daily-records`,不分页 |
|
||||
| 卡日流量 | `tb_card_daily_usage` | `GET /card-daily-usages` | `identifier`、`carrier_id`、日期范围、`min_usage_mb`(用量下限,排查异常大流量) | 无接口 |
|
||||
| 套餐流量冲正流水 | `tb_package_usage_adjustment` | `GET /package-traffic-adjustments` | `identifier`、`package_usage_id`、操作人、调整方向(补扣或退回)、时间范围 | 只有单套餐接口,没有时间筛选 |
|
||||
| 对接日志 | `tb_integration_log` | 现有 `GET /audit/integrations` | 现有条件已覆盖 | 不改 |
|
||||
| 退款后处理尝试记录 | `tb_refund_post_process_attempt`(见 5.5) | `GET /refund-post-process-attempts` | `refund_id`、退款单号、`identifier`、`step`、`result`、`error_code`、`trigger`、时间范围 | 新增。退款列表同时增加 `asset_post_status` 筛选(可查"需人工处理"),列表和详情返回后处理次数、最后错误、下次重试时间 |
|
||||
|
||||
**索引**:按上面的筛选设计,需要时新建或重建。分区表的索引建在父表上,会自动应用到各个分区。
|
||||
|
||||
| 表 | 索引 |
|
||||
|---|---|
|
||||
| `tb_card_traffic_collection` | 保留 `(iot_card_id, observed_at DESC)`;新增冗余列 `carrier_id`,采集时写入;新增 `(observed_at DESC)`、`(carrier_id, observed_at DESC)`、`(decision, observed_at DESC)`;失败表索引见 5.10 |
|
||||
| `tb_card_stop_resume_record` | 现有 `(card_id, created_at)`、`(carrier_id, created_at)`、`(result, created_at)` 重建为倒序;新增 `(created_at DESC)`、`(scene, created_at DESC)`、`(command_id)` |
|
||||
| `tb_card_daily_usage` | 现有唯一键 `(iot_card_id, date)`;新增 `(date, usage_mb DESC)`,支撑查全部和按用量下限筛选 |
|
||||
| `tb_package_usage_daily_record` | 新增 `(package_usage_id, date DESC)`、`(date)` |
|
||||
| `tb_package_usage_adjustment` | 新增 `(iot_card_id, created_at DESC)`、`(created_at DESC)` |
|
||||
| `tb_refund_post_process_attempt` | `(refund_id, created_at DESC)`、`(created_at DESC)`、`(result, created_at DESC)`、`(error_code, created_at DESC)` |
|
||||
|
||||
其余低选择性的条件,如 `source`、`stale_guarded`、调整方向,和上面的索引组合使用即可,不单独建索引。上线后在测试环境看慢查询日志,按实际情况调整。
|
||||
|
||||
**说明**:
|
||||
|
||||
- **流量扣减结果**直接从采集记录查:每条采集记录都带 `decision`、`delta_mb`、`month_used_mb`、`event_id`,再结合日流量详单,能还原每次扣了多少。
|
||||
- **扣减效果表** `tb_card_observation_effect` 不对外提供接口。它只保存 outbox 事件的处理进度,没有卡 ID。排查扣减卡住时,在采集记录里按 `event_id` 关联查。
|
||||
- 新增接口都要在路由 `RouteSpec` 中声明输入、输出 DTO,并同步生成接口文档。
|
||||
|
||||
### 5.10 流量采集记录成功与失败分表
|
||||
|
||||
**现状**(测试库近 3 天,只读统计):
|
||||
|
||||
| 读数结果 | 扣减判定 | 条数 | 平均行大小 |
|
||||
|---|---|---|---|
|
||||
| `ok` | `written`(写入增量) | 104.2 万 | 432 B |
|
||||
| `ok` | `queue_not_full`(队列未满,暂不判断) | 17.3 万 | 285 B |
|
||||
| `ok` | `slope_negative`、`baseline`、`settled_existing` | 800 | 约 420 B |
|
||||
| `failed` | — | 14.5 万 | 136 B |
|
||||
| `no_reading` | — | 400 | 144 B |
|
||||
|
||||
- 成功和失败写在同一张 `tb_card_traffic_collection` 里,失败约占 11%,测试环境每天约 4.8 万条。
|
||||
- 失败记录只存了失败类型。`polling_carddata_handler` 在调用 `recordUnreadable` 时**丢掉了具体错误**,查不到失败原因,只能再去对接日志里按 `observation_id` 找。
|
||||
- 失败记录也只给"采集时刻有套餐"的卡写,为此每条失败都要额外查一次有效套餐。
|
||||
|
||||
**调整**:
|
||||
|
||||
| 表 | 写什么 | 保留 |
|
||||
|---|---|---|
|
||||
| `tb_card_traffic_collection`(现有) | 只写成功读数(`result=ok`),包括所有扣减判定;新增原始请求与响应字段 | 在线保留 2 个月,到期月分区导出为压缩包后删除(见下文"到期归档") |
|
||||
| `tb_card_traffic_collection_failure`(新增) | 只写失败:`failed`、`rate_limited`、`invalid_payload`、`no_reading`、`invalid_reading`;带原始请求与响应 | 与成功表相同:在线 2 个月,到期归档后删除 |
|
||||
|
||||
失败表字段:`iot_card_id`、`iccid`、`carrier_id`、`observed_at`、`observation_id`(等于对接日志的 `integration_id`,可直接关联)、`source`、`scene`、`result`(失败类型)、`error_category`(按网关错误分类:超时、限流、上游报错、响应格式错等)、`provider_code`、`provider_message`(截断到 500 字)、`duration_ms`、`created_at`,加上下面的原始数据字段。按 `observed_at` 按月分区,只新增不修改。
|
||||
|
||||
**原始数据**:成功表和失败表都保存运营商网关的原始请求与原始响应,有据可查。
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| `raw_request` | 加密前的业务请求体(明文 JSON,只有卡号等查询参数),jsonb |
|
||||
| `http_status` | HTTP 状态码;没拿到响应(超时、连接失败)时为空 |
|
||||
| `gateway_code`、`gateway_msg`、`gateway_trace_id` | 网关响应外层的 `code`、`msg`、`trace_id` |
|
||||
| `raw_response` | 网关原始响应体,原样保存。能解析成 JSON 的存 jsonb,不能解析的按文本存在 `raw_response_text`。超过 4 KB 时截断,并标记 `raw_truncated=true` |
|
||||
| `gateway_attempts` | 网关客户端内部重试次数;保存的是最后一次的请求和响应 |
|
||||
|
||||
- **获取方式**:网关客户端(`internal/gateway/client.go` 的 `executeWithRetry`)已经读到了完整响应体和外层的 `code`、`msg`、`trace_id`,但只返回 `data`。新增按请求上下文挂载的原始数据收集器 `gateway.WithRawCapture(ctx)`,调用方挂上后,客户端把最后一次的请求体、状态码、响应体和重试次数写进收集器。没挂收集器的调用不受影响。
|
||||
- **写入**:`polling_carddata_handler` 调 `QueryFlow` 前挂上收集器,成功读数和各类失败都把收集器内容带进 `TrafficObservation`,写入对应的表。
|
||||
- **安全**:只存明文业务请求体,不存加密后的报文、签名、`app_id`、密钥和请求头。
|
||||
- **量的估算**:流量查询响应很小(`iccid`、`used`、`unit` 加外层字段,约 150–250 B),每行增加约 300–400 B。测试环境成功读数每天约 40 万条,每天增加约 120–160 MB;在线保留 2 个月,原始数据部分约 7–10 GB。失败每天约 4.8 万条,2 个月约 1 GB。部署后按实际行大小复核。
|
||||
|
||||
**到期归档**:
|
||||
|
||||
- **在线保留**:当月加上最近 2 个整月。月分区的月末早于"当前时间减 2 个月"时,判定为到期。例如 2027-01-01 起,2026-10 的分区到期。常量 `TrafficCollectionRetentionDays=90` 改为 `TrafficCollectionRetentionMonths=2`,成功表和失败表共用。
|
||||
- **执行位置**:沿用 leader worker 现有的分区维护循环 `startTrafficCollectionPartitionMaintainer`,每 12 小时运行一次。原来"到期直接 `DROP TABLE`"的逻辑,改为下面的四步:
|
||||
1. **摘出分区**:`ALTER TABLE ... DETACH PARTITION`,到期分区不再参与在线查询。
|
||||
2. **导出压缩包**:用 `COPY (SELECT * FROM 分区 ORDER BY observed_at, id) TO STDOUT WITH (FORMAT csv, HEADER)` 流式读出,边读边 gzip 写到 worker 运行目录下的 `archive/traffic-collection/` 目录,不在内存里攒整个月的数据。文件名为 `tb_card_traffic_collection_202610.csv.gz` 或 `tb_card_traffic_collection_failure_202610.csv.gz`。原始请求与响应以 JSON 文本形式保存在 CSV 列里。
|
||||
3. **校验**:写完后重新读一遍压缩包,统计行数并与分区 `count(*)` 比对;同时生成同名的 `.manifest.json`,记录表名、月份、行数、最早和最晚采集时间、文件大小、SHA-256 和导出时间。
|
||||
4. **删除分区**:行数一致后才 `DROP TABLE` 已摘出的分区。导出或校验失败时保留已摘出的表,记 Error 日志,下一轮重试;不会出现"数据删了但没有压缩包"的情况。
|
||||
- **文件同步**:压缩包和 manifest 由你们的同步服务器拉走。本地只保留最近 3 个月的压缩包,更早的自动删除,个数可配置。先写成 `.tmp`,校验通过后再改名为正式文件名,避免同步到写了一半的文件。
|
||||
- **测试环境挂载**:worker 容器增加 `./worker/archive:/app/archive`,宿主机目录为 `/opt/junhong_cmp/worker/archive/traffic-collection/`。
|
||||
- **压缩包大小估算**:测试环境成功表每月约 1,200 万行,CSV 约 8–10 GB,gzip 后约 1–1.5 GB;失败表每月约 150 万行,压缩后约 50 MB。
|
||||
- **回查**:要查归档月份的数据,解压后用 CSV 工具查看,或者临时 `COPY FROM` 导回一张临时表。线上接口只查在线的 2 个月。
|
||||
|
||||
|
||||
**写入规则**:
|
||||
|
||||
- 失败记录**不再判断是否有套餐**,所有轮询到的卡都写,省掉每条失败的有效套餐查询。运营商接口出问题和有没有套餐无关,排查时需要看全量。
|
||||
- `TrafficObservation` 增加失败详情(错误类别、上游返回码与信息、耗时),由 `polling_carddata_handler` 在网关调用失败时填入,不再丢弃错误。
|
||||
- 成功表仍在观测事务之外写入,规则不变;写入失败只告警,不影响扣减。
|
||||
- 成功表已有的失败历史数据保留,到期随分区删除;新数据按新规则分表写入。
|
||||
|
||||
**查询接口**(与 5.9 公共约定一致:可查单个也可查全部,分页,时间范围必填):
|
||||
|
||||
| 接口 | 筛选条件 | 索引 |
|
||||
|---|---|---|
|
||||
| `GET /traffic-collections`(成功) | `identifier`、`carrier_id`、`decision`、`source`、`scene`、`has_delta`、`stale_guarded` | 见 5.9 |
|
||||
| `GET /traffic-collection-failures`(失败) | `identifier`、`carrier_id`、`result`、`error_category`、`provider_code`、`source`、`scene` | `(iot_card_id, observed_at DESC)`、`(observed_at DESC)`、`(carrier_id, observed_at DESC)`、`(error_category, observed_at DESC)`、`(observation_id)` |
|
||||
|
||||
成功表不再有失败数据,所以 5.9 里成功表的 `result` 筛选和 `(result, observed_at DESC)` 索引都去掉。单卡接口 `GET /assets/:identifier/traffic-collections` 增加 `include_failures` 参数,默认不含失败;传了就把两张表合并后按时间倒序返回,方便在一张卡上连起来看。
|
||||
|
||||
## 6. 存量数据
|
||||
|
||||
- **不删除、不迁移**已有审计数据,由现有留存和归档任务按策略自然过期。
|
||||
- 如果要尽快释放空间,可以另行评估"按动作编码清理机器过程类历史数据"的一次性任务。本方案不包含它。
|
||||
|
||||
## 7. 分支与范围
|
||||
|
||||
- 基于 **Iteration/8-11** 实施。该分支已有 `tb_card_stop_resume_record`、采集记录和扣减效果表,方案 B 的补位表都存在。
|
||||
- **只部署测试环境**:8-11 推送后自动部署到测试环境;不在生产执行任何代码替换、配置修改或服务重启。生产什么时候、以什么方式合入,不在本方案范围内。
|
||||
- 全局写入开关 `add-audit-integration-write-switches` 已在 8-11 上,本方案与它互不替代:全局开关用于应急**全停**,本方案是常态下的**按动作分级**。
|
||||
|
||||
## 8. 实施步骤
|
||||
|
||||
| 步骤 | 内容 | 涉及 |
|
||||
|---|---|---|
|
||||
| 1 | 注册表增加 `Persist`,补齐全部机器动作的策略,并加上 5.2 的默认规则 | `internal/infrastructure/audit/registry.go`、`pkg/constants/audit.go` |
|
||||
| 2 | Writer 按策略分流:`outcome` 只写最终结论;`log_only` 写现有应用日志(`logger=audit`) | `internal/infrastructure/audit/writer.go` |
|
||||
| 3 | 对接日志去掉 `attempt_started` 补写 | `internal/infrastructure/integrationlog/repository.go` |
|
||||
| 4 | 退款后处理加重试上限和退避,新增尝试记录表;停机业务性失败不重投,调用前被拦截的写 `skipped` 停复机记录 | `internal/service/refund`、`internal/service/iot_card`、`migrations` |
|
||||
| 5 | 增加 `audit.persist_overrides` 运行时覆盖配置 | `pkg/config` |
|
||||
| 6 | 审计覆盖扫描和 OpenSpec 规格同步 | `internal/governance/auditcoverage`、`openspec/specs` |
|
||||
| 7 | 查询侧:卡、设备时间线合并停复机记录(方案 B) | `internal/query/audit` |
|
||||
| 8 | 测试环境 api、worker 各自挂载自己的 `logs/` 目录,轮换改为 40 个备份(见 5.8) | `docker-compose.prod.yml`、`.gitea/workflows/deploy.yaml` |
|
||||
| 9 | 记录查询接口:每类记录一个列表接口,可查单个也可查全部;按 5.9 的筛选条件建立或重建索引;采集记录增加 `carrier_id` 冗余列 | `internal/routes`、`internal/handler/admin`、`internal/model/dto`、`internal/store/postgres`、`migrations` |
|
||||
| 10 | 采集记录成功与失败分表:新增失败表,补全失败详情,失败不再判断有无套餐;两张表保存网关原始请求与响应(网关客户端新增原始数据收集器);提供失败查询接口(见 5.10) | `internal/gateway/client.go`、`internal/application/cardobservation`、`internal/task/polling_carddata_handler.go`、`internal/model`、`internal/store/postgres`、`migrations` |
|
||||
| 11 | 采集记录到期归档:分区维护改为摘出、导出 `csv.gz`、校验、删除四步;测试环境 worker 挂载 `archive` 目录 | `internal/store/postgres/card_traffic_collection_store.go`、`cmd/worker/main.go`、`pkg/constants`、`docker-compose.prod.yml` |
|
||||
|
||||
建议按"1–3 → 4 → 5–11"分三次提交,每次推送 8-11 后在测试环境验证。
|
||||
|
||||
## 9. 测试与验证(测试环境)
|
||||
|
||||
**测试环境逐项验证**(项目规定自动化测试为 N/A,按 ENG-TEST-001 在测试环境验证,以下各项是验证要点):
|
||||
|
||||
- 每种策略在 `account`、`openapi`、`system_task` 下的写入结果;
|
||||
- `outcome`:中间失败不写审计,尝试记录表每次都有一条;最终成功或转人工写一条审计,带总次数;同一件事重复写结论只保留一条;
|
||||
- `log_only` 不写表、文本日志有记录且不含快照;
|
||||
- 覆盖配置能把 `always` 降为 `log_only`,也能反过来恢复。
|
||||
|
||||
- 对接调用没有上游审计时,`tb_integration_log.audit_event_id` 为空,审计表无新增;
|
||||
- 退款后处理失败超过上限后状态为"需人工处理",outbox 不再重投。
|
||||
- 流量查询成功写成功表、各类失败写失败表,两张表都带原始请求与响应;失败记录有错误类别和上游返回码;无套餐的卡失败也写;
|
||||
- 网关响应超过 4 KB 时截断并标记;原始请求里没有密钥、签名或加密报文;
|
||||
- 到期归档:构造一个过期月分区,执行维护后生成 `csv.gz` 和 manifest,行数与 SHA-256 一致后分区被删除;模拟导出失败时,已摘出的表保留,下一轮重试成功;
|
||||
- 记录查询接口:每个接口都能查单卡、也能查全部,分页总数正确,各筛选条件生效。
|
||||
|
||||
**测试环境观察**(部署后 24 小时):
|
||||
|
||||
- 审计日增从约 12.6 万降到约 300 条(只读查询走 `created_at` 索引、按小时统计);
|
||||
- `app.log` 中出现 `logger=audit` 的行,轮换正常,日志同步服务器能收到并按 logger 名过滤;
|
||||
- 停复机记录、扣减效果写入量没有变化,业务结果没有变化;成功表加失败表的写入量合计,与改造前采集记录的量相当(失败表多出无套餐卡的失败);
|
||||
- 新接口在测试库数据量下没有慢查询;
|
||||
- `/opt/junhong_cmp/worker/archive/traffic-collection/` 目录可写,同步服务器能拉到文件。第一次真实归档要等到在线数据满 2 个月之后,在此之前用单元测试和集成测试覆盖。
|
||||
|
||||
## 10. 已定事项(2026-11-05)
|
||||
|
||||
| 事项 | 结论 |
|
||||
|---|---|
|
||||
| 方案 | 方案 B |
|
||||
| 范围 | 基于 Iteration/8-11,只部署测试环境 |
|
||||
| L3 文本日志 | 写入所在服务自己运行目录下的 `logs/app.log`,沿用现有轮换,由日志同步服务器归集 |
|
||||
| 测试环境日志保留 | api、worker 各自的 `logs/` 目录,各保留 40 个压缩备份,合计约 1 GB;部署后按实际备份大小微调 |
|
||||
| 退款后处理重试上限 | 10 次,指数退避,最长间隔 1 小时;超过后标记"需人工处理" |
|
||||
| 失败记录 | 每次尝试都记录在业务尝试记录表(退款后处理尝试、停复机记录),可分页查询;审计只记最终结论,不按首次失败去重 |
|
||||
| 停机业务性失败 | 按网关错误分类判定为不可重试,不再重投 |
|
||||
| 运行时覆盖 | 提供 `audit.persist_overrides`,可按动作临时调整策略 |
|
||||
| 存量审计数据 | 不删除、不迁移,按现有留存策略自然过期 |
|
||||
| 采集记录分表 | 成功写现有表,失败写新表 `tb_card_traffic_collection_failure`,带完整错误详情,所有卡都写;两张表都保存网关原始请求与响应 |
|
||||
| 采集记录保留 | 两张表在线保留 2 个月(当月加最近 2 个整月);到期月分区先导出为 `csv.gz` 压缩包并校验,再删除;压缩包由同步服务器拉走,本地保留 3 个月 |
|
||||
| 记录查询 | 所有独立成表的记录都提供分页加条件筛选接口,可查单个也可查全部;筛选条件按排查需要来定,索引随之新建或重建,清单见 5.9;文本日志不提供接口 |
|
||||
| 页面依赖 | 实施第 7 步(时间线合并停复机记录)前,与前端或运营确认是否有页面依赖机器动作审计 |
|
||||
230
docs/2026-11-06-轮询入库异步化技术方案.md
Normal file
230
docs/2026-11-06-轮询入库异步化技术方案.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# 轮询入库异步化技术方案
|
||||
|
||||
- 日期:2026-11-06
|
||||
- 分支:`Iteration/8-11`(只部署测试环境,不涉及生产配置与重启)
|
||||
- 关联:`add-carrier-polling-scheduler`(按运营商账户调度)、`add-card-traffic-queue-and-observed-deduction`(采集队列与按观测扣减)、`refactor-audit-log-persistence`(采集记录分表)
|
||||
|
||||
## 1. 背景
|
||||
|
||||
### 1.1 测试环境现状(2026-11-06 上午,只读统计)
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---|
|
||||
| worker 报 `context deadline exceeded` 的 SQL | 每分钟 90–220 次,平均约 130 次,即每小时约 8,000 次,部署审计改造前已存在 |
|
||||
| 慢查询告警(> 500ms) | 每小时约 13.8 万条,占 worker 日志行数 53% |
|
||||
| 按主键查单行 `tb_iot_card` | 经常 0.7–2.4 秒 |
|
||||
| worker 日志量 | 每小时约 26 万行,18 分钟写满一个 100MB 文件,40 个备份只能留约 12 小时 |
|
||||
| 数据库连接 | worker 连接池 `MAX_OPEN_CONNS=60`,测试库上 61 个连接,基本占满;有一次 30 个会话同时等锁 |
|
||||
| 采集记录丢失 | 写采集成功表/失败表 3 秒超时,约每 16 分钟丢 60 条 |
|
||||
|
||||
### 1.2 原因:消费并发没有总量限制
|
||||
|
||||
NSQ 只限制了每个运营商账户的并发(保护网关),没有限制全部账户加起来的并发(保护数据库):
|
||||
|
||||
| 来源 | 规则 | 理论上限 | 实测 |
|
||||
|---|---|---|---|
|
||||
| 卡轮询 `polling.card.<账户>` | 每账户 20(`default_card_max_in_flight`,`tb_carrier_polling_param` 无记录) | 41 × 20 = 820 | 约 310 |
|
||||
| 停复机 `polling.stop_resume.<账户>` | 每账户 10 | 41 × 10 = 410 | 约 22 |
|
||||
| 设备评估 `polling.device_eval` | 10 | 10 | 10 |
|
||||
| 异步任务队列 | `JUNHONG_QUEUE_CONCURRENCY=50` | 50 | — |
|
||||
| 合计 | | 约 1,290 | 约 380 |
|
||||
|
||||
`worker.polling_total_max_concurrency: 100` 只传给旧分片轮询(`cmd/worker/main.go:371`),按账户调度的 NSQ 消费者不受它约束。
|
||||
|
||||
每个轮询步骤在**同一个任务里**先调网关、再同步写库,所有写库都在步骤超时内完成。三百多个任务抢 60 个连接,等连接的时间计入超时,数据库稍有抖动就成批超时。
|
||||
|
||||
### 1.3 超时分布(按调用栈,约 16 分钟样本)
|
||||
|
||||
| 占比 | 位置 | 说明 |
|
||||
|---|---|---|
|
||||
| 约 40% | 配置匹配查套餐状态(`TierResolver.Prefill`,`internal/polling/config_manager.go:191`,共用 3 秒) | 绑定设备的卡连查 3 次;失败后把"未知"结果写进缓存 |
|
||||
| 约 20% | `writeTrafficFailureAudit` | 沿用已超时的上下文再查卡,属连带报错 |
|
||||
| 约 20% | 写采集成功表、失败表(`collectionRecordTimeout = 3s`) | 记录直接丢失 |
|
||||
| 其余 | 流量观测事务、网络状态观测、实名缓存失效等 | |
|
||||
|
||||
## 2. 目标与原则
|
||||
|
||||
1. **调上游与入库分开控制**:调网关按账户并发(保持现有每账户上限),入库单独发消息、由单独的消费者处理。
|
||||
2. **数据库并发有总上限**:入库消费者的并发是一个固定、可配置的数,与账户数量无关;加账户不会挤爆数据库。
|
||||
3. **消费慢一点没关系**:消息在 NSQ 里排队,入库按数据库承受的速度处理;不丢、不重复算。
|
||||
4. **轮询步骤只做"调网关 + 发消息"**:调网关前必要的读查询走缓存,尽量不碰数据库。
|
||||
5. **可切回**:保留同步入库路径,用系统配置一键切换,异常时可回退。
|
||||
6. **范围**:只在 `Iteration/8-11` 上实现,只部署测试环境。
|
||||
|
||||
## 3. 现状链路(一次流量步骤)
|
||||
|
||||
```
|
||||
NSQ 卡任务 ──► 步骤(15s 超时)
|
||||
├─ 读:优先轮询项 FindActive(tb_polling_priority_item)
|
||||
├─ 读:卡(先清缓存再查库 tb_iot_card)
|
||||
├─ 读:轮询范围校验(tb_carrier、tb_device、tb_device_sim_binding)
|
||||
├─ 调网关 QueryFlow
|
||||
├─ 写:ApplyTrafficObservation
|
||||
│ ├─ Redis 采集队列 Push
|
||||
│ ├─ 事务:锁卡行、查有效套餐、写日快照、更新卡、写流量事件 outbox、通道阈值评估、审计
|
||||
│ └─ 写采集成功表/失败表(3s)
|
||||
├─ 写:对接日志、优先项完成
|
||||
└─ 返回 TriggeredEvaluation(决定是否跳过本轮套餐步骤)
|
||||
```
|
||||
|
||||
网络状态步骤(`ApplyNetworkObservation`)、实名步骤(`ApplyCardObservation`)结构相同;套餐步骤不调网关,只读写库。
|
||||
|
||||
## 4. 技术设计
|
||||
|
||||
### 4.1 两段式
|
||||
|
||||
```
|
||||
采集段(按账户并发,现有 NSQ 卡任务) 入库段(固定小并发)
|
||||
┌────────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ 读缓存 → 调网关 → 发入库消息 │──NSQ──► │ card.ingest 消费者(并发 N) │
|
||||
│ 网关失败 → 发采集失败消息 │ │ Apply*Observation(事务) │
|
||||
└────────────────────────────────┘ │ → 发采集记录消息 │
|
||||
└──────────────┬───────────────┘
|
||||
│NSQ
|
||||
┌──────────────▼───────────────┐
|
||||
│ card.collection 消费者 │
|
||||
│ 攒批写成功表/失败表 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
- **采集段**:现有卡任务消费者,并发规则不变(每账户 `card_max_in_flight`)。步骤拿到网关结果后发一条入库消息就结束,步骤结果只反映"网关是否成功"。
|
||||
- **入库段**:新 topic `card.ingest`,单独消费者,`max_in_flight` = `polling.ingest.concurrency`(默认 30)。
|
||||
- **采集记录**:新 topic `card.collection`,单独的批量消费者(见 4.5)。
|
||||
|
||||
### 4.2 入库消息
|
||||
|
||||
topic `card.ingest`,JSON:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `kind` | `traffic` / `network` / `realname` |
|
||||
| `card_id`、`carrier_id`、`iccid` | 卡标识 |
|
||||
| `observation_id` | 现有 `attempt.IntegrationID`,幂等键 |
|
||||
| `observed_at` | 网关返回时刻(不是入库时刻) |
|
||||
| `source`、`scene`、`correlation_id` | 现有观测元数据 |
|
||||
| `payload` | 流量:`reading_mb`、`reading_result`、`reset_day`;网络:`status`、`extend`、`imei`;实名:`verified` |
|
||||
| `raw`、`failure` | 网关原始数据(截断规则同采集记录) |
|
||||
| `priority_item_id` | 本轮认领的优先轮询项,入库成功后由入库段完成 |
|
||||
| `published_at` | 发出时刻,用于积压与延迟统计 |
|
||||
|
||||
### 4.3 入库消费者
|
||||
|
||||
处理一条消息:
|
||||
|
||||
1. 幂等检查:Redis `ingest:done:<observation_id>` 存在则直接 FIN。
|
||||
2. 按 `kind` 调用现有 `ApplyTrafficObservation` / `ApplyNetworkObservation` / `ApplyCardObservation`,上下文超时 `polling.ingest.timeout`(默认 30s),从拿到消息开始计时。
|
||||
3. 成功后:写 `ingest:done:<observation_id>`(TTL 48h);流量有变化时写 Redis 标记 `flow_evaluated:<card_id>`(见 4.6)。
|
||||
- 本轮认领了优先轮询项的观测不走消息,仍在卡任务内同步入库,优先项完成与对接日志沿用原逻辑。
|
||||
- 网络观测命中风险停机或已销户时,卡任务按网关结果直接停止该卡轮询,入库段写库时关闭轮询开关。
|
||||
4. 失败:
|
||||
- 数据库超时、连接错误等可重试错误:NSQ 延迟重投,退避 5s、15s、30s、1m、2m,之后每 5 分钟一次,**不设次数上限、不丢弃**(入库消费者的 NSQ `max_attempts` 设为 0);第 10 次起每次重投记 ERROR,带 `observation_id`、卡号与错误,便于排查。
|
||||
- 业务性错误(卡不存在、数据校验失败):不重投,记 WARN 后 FIN。
|
||||
- 本期不做死信表。长时间重试的消息会体现在积压量与最老消息等待时间上(见 4.7)。
|
||||
|
||||
### 4.4 顺序与幂等
|
||||
|
||||
NSQ 不保证顺序,也可能重投。逐项说明现有逻辑能否承受:
|
||||
|
||||
| 风险 | 处理 |
|
||||
|---|---|
|
||||
| 同一张卡两条流量消息乱序 | 采集队列按 `observed_at` 插入,"新点在队尾才写"——旧消息后到时不在队尾,不写快照,只记采集记录。现有规则已覆盖 |
|
||||
| 同一张卡两条消息并发处理 | 事务内 `FOR UPDATE` 锁卡行,串行执行 |
|
||||
| 同一条消息重投 | ① `ingest:done` 键挡掉已成功的;② 事务提交后、写键前崩溃:日快照取当日最大值、增量按"本月最大 − 已结算"计算,重放得到 0 增量;流量事件 outbox ID 由 `observation_id` 生成,重复写入被唯一键挡掉;③ 采集队列 Push 同一个点需改为按 `observed_at` 去重(Lua 内判断) |
|
||||
| 网络/实名状态乱序 | 现有 `ApplyNetworkObservation`、`ApplyCardObservation` 需确认按 `observed_at` 比较"更新的才生效";没有的补上 |
|
||||
| 采集记录重复 | 成功表/失败表按 `(observation_id, observed_at)` 建唯一索引(含分区键),批量写用 `ON CONFLICT DO NOTHING` |
|
||||
|
||||
### 4.5 采集记录批量写
|
||||
|
||||
- 入库段写完事务后,把采集成功记录发到 `card.collection`;采集段网关失败、无读数时直接把失败记录发到 `card.collection`,不经过入库段。
|
||||
- `card.collection` 消费者 `max_in_flight` = 500,攒满 `polling.collection.batch_size`(默认 200)或等满 1 秒就批量写一次(多行 INSERT),成功后统一 FIN,失败统一重投。
|
||||
- 写库并发固定 1–2 个连接。按测试环境每小时约 2.1 万条计算,每秒约 6 条,一次批量写即可。
|
||||
- 现有 3 秒超时和"写失败只记日志"的逻辑删除,不再丢记录。
|
||||
|
||||
### 4.6 采集段剩余读查询
|
||||
|
||||
步骤里调网关前的读查询不能异步,改为走缓存:
|
||||
|
||||
| 读查询 | 现状 | 改为 |
|
||||
|---|---|---|
|
||||
| 卡信息 | 每步先清缓存再查库 | 读 Redis 卡缓存;入库段写完后失效缓存 |
|
||||
| 优先轮询项 `FindActive` | 每步每类型查一次库 | Redis 维护"有活动优先项的卡"集合,不在集合里不查库 |
|
||||
| 运营商实名开关、重置日 | 每步查库 | 进程内缓存 1 分钟 |
|
||||
| 设备轮询开关、绑定关系 | 每步查库 | 并入卡缓存 |
|
||||
| 套餐状态(配置匹配) | 3 次查询共用 3 秒,失败缓存"未知" | 每次查询各自计时;失败不写缓存,下次重查 |
|
||||
|
||||
套餐步骤跳过逻辑:现在根据同一任务里流量步骤的 `TriggeredEvaluation` 跳过。异步后改为读 Redis 标记 `flow_evaluated:<card_id>`(入库段写,TTL = 套餐检查间隔)。
|
||||
|
||||
### 4.7 积压与反压
|
||||
|
||||
- 监控:每分钟读 nsqd `/stats`,记录 `card.ingest`、`card.collection` 的积压量和最老消息等待时间,写应用日志。
|
||||
- 告警:积压超过 `polling.ingest.alert_depth`(默认 200,000)或等待超过 10 分钟,记 ERROR(接现有告警通道)。
|
||||
- 反压:积压超过 `polling.ingest.pause_depth`(默认 500,000,须大于告警阈值)时,调度器暂停发新的卡任务;降到一半以下恢复。账户限流状态不受影响。
|
||||
|
||||
### 4.8 NSQ 持久化
|
||||
|
||||
nsqd 当前参数 `--mem-queue-size=10000`:消息先放内存,nsqd 意外退出会丢掉内存里的部分。
|
||||
|
||||
- 测试环境改为 `--mem-queue-size=0`:所有消息直接落盘(`./nsq-data` 已挂载),按现有量级(每秒几十条)性能没有影响。
|
||||
- 卡任务 topic 也会一起落盘,对现有调度无行为变化。
|
||||
- 磁盘占用:入库消息带网关原始数据,单条约 2KB;积压到反压阈值 50 万条约 1GB,测试服务器剩余 32G,足够。
|
||||
|
||||
### 4.9 不改的同步路径
|
||||
|
||||
以下入口量小、需要立即返回结果,保持同步入库:后台人工刷新、运营商回调(移动/联通/电信实名)、激活前结算、观测序列(series runner)。
|
||||
|
||||
### 4.10 配置项
|
||||
|
||||
| 配置 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `polling.ingest.mode` | `sync` | `sync` 为现在的同步入库;部署确认无误后切 `async` |
|
||||
| `polling.ingest.concurrency` | 30 | 入库消费者并发 |
|
||||
| `polling.ingest.timeout` | 30s | 单条入库超时 |
|
||||
| `polling.collection.batch_size` | 200 | 采集记录批量大小 |
|
||||
| `polling.ingest.alert_depth` / `pause_depth` | 200,000 / 500,000 | 告警与反压阈值 |
|
||||
|
||||
`mode` 与阈值放系统配置(改了几秒内生效),并发放启动配置。启动时检查:入库并发 + 采集记录写并发(2)不超过 `MAX_OPEN_CONNS` − 20(给采集段读查询、异步任务队列与其他查询留出至少 20 个连接),超过记 ERROR。测试环境 `MAX_OPEN_CONNS=60`:入库 30 + 采集记录 2 = 32 ≤ 40,满足。
|
||||
|
||||
### 4.11 发布失败兜底
|
||||
|
||||
采集段发消息失败(nsqd 不可用)时,降级为当前的同步入库,并记 WARN;不因 nsqd 故障丢观测。
|
||||
|
||||
## 5. 影响与代价
|
||||
|
||||
- 入库延迟:正常几秒;积压时更长。卡流量、状态在后台的刷新会比现在晚几秒。
|
||||
- 停复机判断:超量停机、通道阈值停机依赖入库结果,跟着入库段触发,晚几秒。
|
||||
- 优先轮询(加急):完成时间从"网关返回"推迟到"入库成功"。
|
||||
- 同一张卡的数据库写入从"一个任务内"变成"跨两个消费者",排查时按 `observation_id` 串联日志。
|
||||
|
||||
## 6. 分支与范围
|
||||
|
||||
- 基于 `Iteration/8-11`,只部署测试环境;不写生产配置、不涉及生产重启。
|
||||
- 依赖 `add-carrier-polling-scheduler`、`add-card-traffic-queue-and-observed-deduction` 的现有实现,不改它们的业务规则。
|
||||
|
||||
## 7. 实施步骤
|
||||
|
||||
1. 采集队列 Push 按 `observed_at` 去重;网络/实名观测补"更新的才生效"判断(如缺)。
|
||||
2. 采集记录唯一索引迁移;`card.collection` 批量消费者;采集段失败记录改发消息。
|
||||
3. `card.ingest` 消息、入库消费者、幂等键、不限次数的退避重试。
|
||||
4. 三个轮询步骤改为发消息;优先项完成、对接日志、`StopPolling`、`flow_evaluated` 移到入库段;发布失败降级。
|
||||
5. 采集段读查询走缓存;套餐状态查询各自计时、失败不缓存。
|
||||
6. 积压监控、告警、反压;启动并发检查。
|
||||
7. 系统配置与回退开关;测试环境 nsqd 改 `--mem-queue-size=0`。
|
||||
|
||||
## 8. 测试与验证(测试环境)
|
||||
|
||||
| 验证项 | 通过标准 |
|
||||
|---|---|
|
||||
| SQL 超时 | `context deadline exceeded` 从每小时约 8,000 次降到个位数 |
|
||||
| 慢查询 | 每小时告警数明显下降,worker 日志留存能达到 14 天目标的量级 |
|
||||
| 采集记录完整 | 每小时:入库消息数 = 成功表 + 失败表新增行数(按 `observation_id` 对账) |
|
||||
| 计量正确 | 抽样卡日快照、套餐扣减与同步模式结果一致;重投、乱序不重复扣减 |
|
||||
| 积压 | `card.ingest` 等待时间常态 < 30 秒 |
|
||||
| 回退 | `polling.ingest.mode=sync` 后恢复现有行为 |
|
||||
| 降级 | 停掉 nsqd 时采集段改为同步入库,不丢观测 |
|
||||
|
||||
## 9. 已定事项(2026-11-06)
|
||||
|
||||
1. 入库消费者默认并发 30。
|
||||
2. 测试环境 nsqd 改为 `--mem-queue-size=0`,所有消息直接落盘。
|
||||
3. 本期不做死信:可重试错误不限次数退避重试,不丢弃;业务性错误记 WARN 后丢弃。
|
||||
4. 积压告警阈值 200,000 条;反压(暂停派发新卡任务)阈值 500,000 条。
|
||||
133
docs/data-fix/2026-11-04-换货迁移套餐双生效数据修正.md
Normal file
133
docs/data-fix/2026-11-04-换货迁移套餐双生效数据修正.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# 2026-11-04 换货迁移导致卡上两个主套餐同时生效的数据修正
|
||||
|
||||
## 现象
|
||||
|
||||
卡 `89860624630055040571`(`tb_iot_card.id = 93937`)在后台的预计套餐到期时间显示"数据异常"(`expiry_estimate_status = invalid_data`)。
|
||||
|
||||
## 原因
|
||||
|
||||
### 1. 换货迁移把老卡的生效中套餐原样挂到了新卡上
|
||||
|
||||
2026-11-03 17:42,换货单 `EXC20261103174239616885`(`tb_exchange_order.id = 1094`)完成直接换货:
|
||||
|
||||
- 老卡:`89860624630055027198`(`id = 93846`),停机、未实名,从未产生流量。
|
||||
- 新卡:`89860624630055040571`(`id = 93937`)。
|
||||
- 选了迁移数据。换货原因是"未实名卡,给老卡续费"。
|
||||
|
||||
`internal/service/exchange/migration.go` 的 `migratePackageUsageWithTx` 只把老卡套餐使用记录的 `iot_card_id` 改成新卡,状态原样保留,也不检查新卡是否已有生效中的主套餐。迁移后新卡上同时有两个生效中(`status = 1`)的主套餐:
|
||||
|
||||
| 套餐使用记录 | 套餐 | 订单号 | 修正前状态 | 到期时间 |
|
||||
|---|---|---|---|---|
|
||||
| 47051 | Mifi-U省内版(12个月省内流量每月3000G),`package_id = 272` | `MIG-89860624630055040571-1` | 生效中 | 2026-12-14 |
|
||||
| 46981 | 12个月省内流量每月1000G,`package_id = 230` | `MIG-89860624630055027198-1` | 生效中,`activated_at` 为空 | 2027-12-14 |
|
||||
|
||||
46981 的到期时间正好是 47051 到期后再加一年,说明运营的本意是"续费,接在现有套餐后面"。
|
||||
|
||||
`internal/query/packageexpiry/query.go` 的 `Calculate` 要求一个资产只能有一个生效中或已用完的主套餐。出现两个时返回 `invalid_data`。
|
||||
|
||||
### 2. 流量扣到了续费的套餐上
|
||||
|
||||
换货后,新卡的流量全部扣到了 46981 上,47051 从 11-03 17:40 起没有再被扣过:
|
||||
|
||||
| 日期 | 47051 日用量 | 46981 日用量 |
|
||||
|---|---|---|
|
||||
| 11-01 | 941 MB | |
|
||||
| 11-02 | 255 MB | |
|
||||
| 11-03 | 5,393 MB | 52 MB |
|
||||
| 11-04 | | 2,707 MB |
|
||||
|
||||
合计 9,348 MB,和新卡 11 月的 `current_month_usage_mb = 9348.33` 一致。老卡本月和上月的用量都是 0,所以 46981 上的用量全部来自新卡。
|
||||
|
||||
### 3. 第一次修正后仍显示数据异常:排序问题
|
||||
|
||||
46981 改成待生效后,页面仍然显示数据异常。原因是两条记录的 `priority` 都是 1,`created_at` 也完全相同(同一次迁移写入,都是 `2026-09-02 16:40:32.81965`),排序只能按 `id`,46981 就排在了 47051 前面。`Calculate` 不允许待生效套餐排在当前套餐之前,所以仍然判成 `invalid_data`。把 46981 的 `priority` 改成 2 后,它排到了 47051 后面。
|
||||
|
||||
## 已执行的修正(生产库,2026-11-04)
|
||||
|
||||
两步都在单个事务中执行:先锁行,改完后自动校验,校验不通过就整体回滚。
|
||||
|
||||
### 第一步:用量转回 47051,46981 改为待生效
|
||||
|
||||
执行前:46981 的 `data_usage_mb = 2759`,47051 的 `data_usage_mb = 112102`。
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SELECT id, status, data_usage_mb, iot_card_id FROM tb_package_usage WHERE id IN (46981, 47051) ORDER BY id FOR UPDATE;
|
||||
|
||||
-- 46981 的已用量转回 47051
|
||||
UPDATE tb_package_usage a
|
||||
SET data_usage_mb = a.data_usage_mb + b.data_usage_mb, updated_at = now()
|
||||
FROM tb_package_usage b
|
||||
WHERE a.id = 47051 AND b.id = 46981 AND a.status = 1 AND b.status = 1 AND a.iot_card_id = 93937 AND b.iot_card_id = 93937;
|
||||
|
||||
-- 日用量记录并到 47051
|
||||
INSERT INTO tb_package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at)
|
||||
SELECT 47051, d.date, d.daily_usage_mb, 0, now(), now()
|
||||
FROM tb_package_usage_daily_record d WHERE d.package_usage_id = 46981
|
||||
ON CONFLICT (package_usage_id, date) DO UPDATE
|
||||
SET daily_usage_mb = tb_package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb, updated_at = now();
|
||||
|
||||
-- 以 11-02 的累计值为起点,重算 47051 从 11-03 起的累计用量
|
||||
UPDATE tb_package_usage_daily_record r SET cumulative_usage_mb = x.cum, updated_at = now()
|
||||
FROM (
|
||||
SELECT id,
|
||||
(SELECT cumulative_usage_mb FROM tb_package_usage_daily_record WHERE package_usage_id = 47051 AND date = '2026-11-02')
|
||||
+ sum(daily_usage_mb) OVER (ORDER BY date) AS cum
|
||||
FROM tb_package_usage_daily_record
|
||||
WHERE package_usage_id = 47051 AND date >= '2026-11-03'
|
||||
) x
|
||||
WHERE r.id = x.id;
|
||||
|
||||
DELETE FROM tb_package_usage_daily_record WHERE package_usage_id = 46981;
|
||||
|
||||
-- 46981 改为待生效,和现有待生效套餐的数据形态一致;激活时不会把已用量清零,所以这里必须清零
|
||||
UPDATE tb_package_usage
|
||||
SET status = 0, data_usage_mb = 0, activated_at = NULL, expires_at = NULL,
|
||||
last_reset_at = NULL, next_reset_at = NULL, updated_at = now()
|
||||
WHERE id = 46981 AND status = 1 AND iot_card_id = 93937;
|
||||
-- 校验项:47051 的已用量等于两者之和;46981 是待生效、已用量为 0;
|
||||
-- 47051 最后一天的累计用量等于它的已用量;46981 已经没有日用量记录
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### 第二步:调整排队顺序
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
UPDATE tb_package_usage SET priority = 2, updated_at = now()
|
||||
WHERE id = 46981 AND status = 0 AND iot_card_id = 93937 AND priority = 1;
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
## 修正后的状态
|
||||
|
||||
| 套餐使用记录 | 状态 | 优先级 | 已用量 | 生效时间 | 到期时间 | 下次重置 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 47051 | 生效中 | 1 | 114,861 MB | 2025-12-14 | 2026-12-14 | 2026-11-09 |
|
||||
| 46981 | 待生效 | 2 | 0 | 空 | 空 | 空 |
|
||||
|
||||
47051 的日用量记录:11-03 为 5,445 MB,累计 112,154 MB;11-04 为 2,707 MB,累计 114,861 MB。
|
||||
|
||||
预期效果:
|
||||
|
||||
- 后续流量扣到 47051 上。
|
||||
- 预计到期时间推算为约 2027-12-14:47051 到期后,46981 由排队激活接上 365 天。
|
||||
|
||||
## 回滚
|
||||
|
||||
一般不需要回滚。如果必须恢复到修正前的状态:
|
||||
|
||||
1. 46981 恢复为 `status = 1`、`priority = 1`、`data_usage_mb = 2759`、`expires_at = '2027-12-14 00:00:00'`、`last_reset_at = '2026-10-18 01:23:14.86183'`、`next_reset_at = '2026-11-17 00:00:00'`。
|
||||
2. 47051 的 `data_usage_mb` 减去 2759。
|
||||
3. 日用量记录拆回原样:
|
||||
- 46981:11-03 为 52/52,11-04 为 2707/2759;
|
||||
- 47051:11-03 为 5393/112102,并删除 47051 的 11-04 记录。
|
||||
|
||||
回滚前,要先确认修正之后又扣进 47051 的新用量。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- **代码漏洞未修。** 换货迁移不处理新资产已有生效中主套餐的情况,以后同类换货还会再出现两个主套餐同时生效。可选的修法:
|
||||
- 迁移过来的主套餐改为待生效,排在新资产现有套餐之后(`priority` 递增);
|
||||
- 或者在换货前检查,直接拒绝,提示运营先处理。
|
||||
- **同类问题未排查。** 生产中可能还有其他卡或设备同时挂着多个生效中的主套餐,尚未全量排查。
|
||||
@@ -8,7 +8,7 @@
|
||||
- Owner:生产维护者。
|
||||
- 最后核验:2026-08-13(基于维护者提供的信息,未连接生产环境)。
|
||||
- 更新触发:Unit、目录、进程数、Worker 角色、发布顺序、迁移方式或回滚方式变化。
|
||||
- 验证:由维护者在服务器执行本文列出的只读核对命令,并回填结果;Agent 不连接生产环境。
|
||||
- 验证:Agent 可通过 dbhub 对生产数据库执行只读诊断查询;服务器上的只读核对、生产发布、迁移、回滚及其他写操作由维护者执行并回填结果,Agent 不连接生产主机。
|
||||
|
||||
## 与测试环境的边界
|
||||
|
||||
@@ -52,7 +52,7 @@ GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o ./build/worker ./cmd/worker
|
||||
- 二进制只从嵌入默认配置和 `JUNHONG_` 环境变量读取;`.env.prod` 必须由 systemd Unit 显式加载,或由 Unit 启动脚本 `source` 后启动。需要以 Unit 内容核实实际方式。
|
||||
- API 与 Worker 共享数据库、Redis、日志、JWT、对象存储、Gateway、短信、支付等基础配置;只记录键名,不将实际凭据写入仓库文档。
|
||||
- 七月新增的 API/Worker 共用配置:`JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED`、`JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED`、`JUNHONG_WECOM_BASE_URL`、`JUNHONG_WECOM_TIMEOUT`。
|
||||
- 七月新增的 Worker 配置:`JUNHONG_WORKER_ROLE`、`JUNHONG_WORKER_INSTANCE_NAME`、`JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED`。
|
||||
- 本次新增的 Worker 配置:`JUNHONG_WORKER_ROLE`、`JUNHONG_WORKER_INSTANCE_NAME`、`JUNHONG_WORKER_POLLING_TOTAL_MAX_CONCURRENCY`、`JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED`、`JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED`。轮询总并发必须保持在 1-1000,初始生产值建议 100;归档/留存总开关默认关闭。
|
||||
- 首发要求:退款人工入口、线下充值人工确认、企微审批、运营商实名回调、微信/支付宝在线充值均按维护者决定启用;审计物理清理保持关闭。
|
||||
- 企微应用凭据由后台配置写入数据库明文字段;这不是启动环境变量。本文不记录其值。
|
||||
|
||||
@@ -74,6 +74,111 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
|
||||
|
||||
迁移失败时不启动新二进制;按失败迁移的事务状态决定处理,必要时恢复已确认可用的数据库备份。启动失败时覆盖回部署前备份的二进制,再恢复数据库备份(如迁移已改变数据库)。
|
||||
|
||||
|
||||
### 单槽单卡当前卡标识核对与受控修复
|
||||
|
||||
本节仅供生产维护者在已备份、已留存核对清单且具备回滚条件的维护窗口执行。Agent 只可通过 dbhub 做只读核对,不得执行下述写操作。
|
||||
|
||||
先执行只读核对,候选必须同时满足:设备未删除、最大槽位为 1、只有一条未删除的有效绑定、且没有任何有效绑定被标记为当前使用卡。
|
||||
|
||||
```sql
|
||||
SELECT b.id AS binding_id, b.device_id, b.iot_card_id
|
||||
FROM tb_device_sim_binding AS b
|
||||
JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.bind_status = 1
|
||||
AND d.max_sim_slots = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tb_device_sim_binding AS current_binding
|
||||
WHERE current_binding.device_id = b.device_id
|
||||
AND current_binding.deleted_at IS NULL
|
||||
AND current_binding.bind_status = 1
|
||||
AND current_binding.is_current
|
||||
)
|
||||
AND (
|
||||
SELECT count(*)
|
||||
FROM tb_device_sim_binding AS active_binding
|
||||
WHERE active_binding.device_id = b.device_id
|
||||
AND active_binding.deleted_at IS NULL
|
||||
AND active_binding.bind_status = 1
|
||||
) = 1
|
||||
ORDER BY b.device_id, b.id;
|
||||
```
|
||||
|
||||
维护者审核并留存候选清单后,在事务中把批准的 `binding_id` 显式填入 `approved`。更新语句会再次核对全部条件;受影响行数与批准清单不一致时必须回滚,不得扩大条件或直接按设备全量更新。
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
|
||||
WITH approved(binding_id) AS (
|
||||
VALUES
|
||||
(CAST(<binding_id> AS bigint))
|
||||
), eligible AS (
|
||||
SELECT b.id
|
||||
FROM tb_device_sim_binding AS b
|
||||
JOIN approved AS a ON a.binding_id = b.id
|
||||
JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.bind_status = 1
|
||||
AND d.max_sim_slots = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tb_device_sim_binding AS current_binding
|
||||
WHERE current_binding.device_id = b.device_id
|
||||
AND current_binding.deleted_at IS NULL
|
||||
AND current_binding.bind_status = 1
|
||||
AND current_binding.is_current
|
||||
)
|
||||
AND (
|
||||
SELECT count(*)
|
||||
FROM tb_device_sim_binding AS active_binding
|
||||
WHERE active_binding.device_id = b.device_id
|
||||
AND active_binding.deleted_at IS NULL
|
||||
AND active_binding.bind_status = 1
|
||||
) = 1
|
||||
FOR UPDATE OF b
|
||||
)
|
||||
UPDATE tb_device_sim_binding AS target
|
||||
SET is_current = TRUE, updated_at = NOW()
|
||||
FROM eligible
|
||||
WHERE target.id = eligible.id
|
||||
RETURNING target.id AS binding_id, target.device_id, target.iot_card_id;
|
||||
|
||||
-- 核对返回行数、设备卡列表与资产信息;不一致时执行 ROLLBACK。
|
||||
-- 全部一致后才执行 COMMIT。
|
||||
```
|
||||
|
||||
修复后必须确认每个设备至多一条未删除的有效绑定满足 `is_current = TRUE`,并抽样核对设备卡列表和个人客户资产信息均按最新绑定事实返回。回滚时仅对本次留存清单中的绑定恢复 `is_current = FALSE`,不得影响运营商观测或显式切卡产生的其他当前卡事实。
|
||||
### 商户池支付路由发布与回滚
|
||||
|
||||
本节是维护者操作清单,不是已执行证据。迁移、生产发布、Redis 操作和富友真实渠道核验均由维护者执行;本轮未执行,不能以本地构建替代。
|
||||
|
||||
**前置条件**
|
||||
|
||||
1. 留存维护者指定测试环境或本地验证证据,且不得记录密钥或完整报文中的敏感凭证。富友仅沿用现有实现;未进行外部渠道实测不构成开发、测试部署、任务完成、归档或发布前置。
|
||||
2. 确认本次商户池 Schema 迁移已完成可恢复备份及校验;停止服务后确认迁移锁影响、无长事务和可接受维护窗口。
|
||||
3. 上传支持 `merchant_id`/`payment_config_id` 双读的 API 与 Worker 二进制。商户池新支付没有运行时开关。
|
||||
|
||||
**发布后检查**
|
||||
|
||||
1. 由维护者执行迁移并部署双读二进制。C 端套餐购买、C 端资产钱包充值、代理在线预存款充值的后续新支付立即经启用商户池创建并冻结 `merchant_id`、商户池与 `routing_epoch`。
|
||||
2. 无可用商户池、成员缺失或池停用必须稳定失败,不得回退旧综合支付配置或自动换商户;后台线下订单、后台钱包余额支付和员工线下代充值不经过商户池。
|
||||
3. 检查首次成功唯一累计,以及回调/查单/退款 A 对 `merchant_id` 新单和 `payment_config_id` 历史单的双读分流;应用、审计和集成日志不得包含凭证、私钥、Token、证书或完整敏感配置。
|
||||
|
||||
**回滚与记录**
|
||||
|
||||
1. 不存在关闭商户池新支付创建的运行时开关。故障只能在仍支持双读的二进制上前向修复,不得恢复旧综合支付配置创建。
|
||||
2. 只要存在 `merchant_id` 非空支付、成功累计事实或新商户池配置,禁止部署不识别新路由的旧二进制,也禁止执行破坏这些事实的 down 迁移。
|
||||
3. 维护者记录二进制版本、时间、目标 PostgreSQL/Redis 的脱敏标识、备份校验、验证结果与全部阻塞原因。
|
||||
|
||||
### 零金额退款发布后核验
|
||||
|
||||
发布本次退款审批变更后,维护者应先等待既有重试处理稳定事件 `approval:26:approved`;若重试已耗尽,按受控运维流程重放同一事件,不得直接修改退款、订单或钱包数据。随后核验:
|
||||
|
||||
1. 退款单 `RF20260820170954264700` 已通过,关联订单支付状态为已退款。
|
||||
2. 该退款没有代理主钱包或资产钱包的零金额回款流水。
|
||||
|
||||
### 锁的含义与发布影响
|
||||
|
||||
`000171` 会对 `tb_agent_wallet`、`000178` 会对 `tb_iot_card` 使用 PostgreSQL `ACCESS EXCLUSIVE` 锁。该锁执行期间会阻塞该表的读写及其他 DDL,直到迁移事务提交或回滚;若有未结束业务查询/事务,它也会等待。因此必须在 API 和全部 Worker 停止后执行,并在迁移前检查没有长事务。锁持续时间取决于表数据量、索引创建和等待中的旧事务;不能从仓库估算具体秒数。
|
||||
@@ -88,3 +193,12 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
|
||||
## 已确认数据库备份
|
||||
|
||||
每日凌晨 02:00 自动备份 `junhong_cmp_prod`:数据库运行在 Docker 容器 `postgres` 中,备份脚本执行 `pg_dump -Fc -Z 6`,写入 `/data/backups/postgresql/<库名>_<时间>.dump`,同时生成 MD5 文件并以 `pg_restore --list` 校验结构;保留 30 天。发布前仍须人工新建一次备份并确认校验通过,不能只依赖凌晨的最近备份。恢复命令待维护者实际演练或确认后补充。
|
||||
|
||||
## 日审计日志留存启用与恢复
|
||||
|
||||
归档与日留存由调度 Worker 受控处理 Asia/Shanghai 的已结束自然日;`JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=false` 时不注册调度,已入队任务也安全跳过、不扫描在线日志表。开启后每次最多推进一个日期。`JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED=false` 时仅验证归档、对象、manifest、数据库数量和日期连续性,不写清理断点、不删除在线数据。Integration 的 `pending` 记录保留在线,但不会阻断同日终态日志、Audit 数据或后续日期的清理;若 pending 后续终结,Worker 会重建该日 revision 后续跑删除。
|
||||
|
||||
1. 发布新 Worker 后保持 `JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=false` 和物理清理开关关闭;轮询总并发先设为 100。维护者先核对 `tb_log_archive_run` 中 Audit 与 Integration 两个来源从历史最早在线日期起没有缺失账本;缺失日期必须先受控补归档,不能跳过失败日。
|
||||
2. 以关闭开关的 Worker 完成只读演练,观察 `/opt/junhong_cmp/worker/logs/audit-retention.log`:每个日期应有来源计数和耗时;若出现日期、来源、失败分类或安全错误摘要,先修复该来源的归档或校验问题后再演练。仅有 pending 不需要人工终结才可继续。
|
||||
3. 低峰期先将调度 Worker 的 `JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=true`,重启该 Worker;确认单日归档稳定后,再将 `JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED=true`,并持续观察上述独立日志、`tb_log_archive_run.cleanup_started_at` / `cleaned_at` 断点及表大小。DELETE 释放的 PostgreSQL 页面会供后续写入复用,表文件不会立即缩小;维护者按既有运维窗口评估 VACUUM,Worker 不执行 VACUUM 或表重写。
|
||||
4. 异常时立即将开关改回 `false` 并重启调度 Worker。已清理日期按已验证的对象和 manifest 执行归档恢复;尚未清理或阻断的日期仍保留在线,无需数据库恢复。恢复后先重新执行只读演练,再决定是否重新开启清理。
|
||||
|
||||
242
docs/engineering/后台订单按类别拆分列表技术方案.md
Normal file
242
docs/engineering/后台订单按类别拆分列表技术方案.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 后台订单按类别拆分列表技术方案
|
||||
|
||||
最后更新:2026-10-26
|
||||
状态:技术方案(未实现)。已定事项与后续事项见第六部分。
|
||||
|
||||
文档结构:
|
||||
- 第一部分:现状逻辑(As-Is)
|
||||
- 第二部分:老卡管对照
|
||||
- 第三部分:方案(To-Be)
|
||||
- 第四部分:改动范围
|
||||
- 第五部分:验证
|
||||
- 第六部分:已定事项与后续事项
|
||||
|
||||
---
|
||||
|
||||
## 第一部分:现状逻辑(As-Is)
|
||||
|
||||
### 1. 接口
|
||||
|
||||
| 接口 | 实现 | 说明 |
|
||||
|---|---|---|
|
||||
| `GET /api/admin/orders` | `OrderHandler.List`(`internal/handler/admin/order.go:90`) | 订单列表,平台、代理商、在线支付三类订单混在一起返回 |
|
||||
| `GET /api/admin/orders/:id` | `OrderHandler.Get` | 订单详情 |
|
||||
| `POST /api/admin/orders` | `OrderHandler.Create` | 后台下单 |
|
||||
| `POST /api/admin/orders/:id/cancel` | `OrderHandler.Cancel` | 取消订单,仅代理账号 |
|
||||
| `POST /api/admin/orders/purchase-check` | `OrderHandler.PurchaseCheck` | 套餐购买预检 |
|
||||
| `POST /api/admin/export-tasks`(`scene=order`) | `OrderDataSource`(`internal/exporter/order_scene.go`) | 订单导出,与列表一样三类混在一起 |
|
||||
|
||||
路由在 `internal/routes/order.go`,`GET /orders` 在第 22 行,`GET /orders/:id` 在第 30 行。
|
||||
|
||||
### 2. 列表的权限与数据范围
|
||||
|
||||
两层条件,全部在服务端决定,前端不能改:
|
||||
|
||||
1. Handler 层:代理账号固定买家条件 `buyer_type = agent AND buyer_id = 当前店铺`;平台账号与超管不加买家条件。
|
||||
2. Store 层:`OrderStore.List`(`internal/store/postgres/order_store.go:85`)叠加 `applyOrderScope`(第 200 行)。代理账号限定为买家、销售店铺或操作者属于本店及下级店铺;平台账号与超管不限。
|
||||
|
||||
后台认证 `AdminAuth` 允许超管、平台、代理、企业四类账号访问。
|
||||
|
||||
订单导出(`OrderDataSource.applyFilters`,`order_scene.go:130`)复刻了上面两层条件:创建导出任务时冻结账号类型、店铺范围(`ScopeShopIDs`)与发起店铺(`CreatorShopID`),执行时按冻结值过滤;代理账号同样固定"买家是本店"。导出任务只允许超管、平台、代理创建(`internal/service/export_task/service.go`)。
|
||||
|
||||
导出场景的接入点:
|
||||
- 场景常量:`pkg/constants/constants.go:400` `ExportTaskSceneOrder = "order"`。
|
||||
- 注册:`exporter.NewDefaultRegistry`(`internal/exporter/registry.go`)按数据源的 `Scene()` 建表,创建任务时用 `IsSupported` 校验场景。
|
||||
- 时间筛选:`timeFilterScenes`(`internal/exporter/time_filters.go`)登记了 `order`,创建任务时按统一格式冻结 `start_time`/`end_time`。
|
||||
|
||||
### 3. 订单的来源与支付方式
|
||||
|
||||
| 字段 | 取值 | 说明 |
|
||||
|---|---|---|
|
||||
| `source` | `admin` 后台 / `client` 客户端 | 非空,默认 `admin`(`pkg/constants/order_source.go`) |
|
||||
| `payment_method` | `wallet` / `offline` / `wechat` / `alipay` | 后台下单只支持 `wallet` 与 `offline` |
|
||||
| `buyer_type` | `agent` / `personal` | 买家类型 |
|
||||
| `operator_account_type`、`operator_type` | `platform` / `agent` / `personal_customer` / 空 | 操作账号类型;历史订单有空值 |
|
||||
|
||||
### 4. 各下单场景
|
||||
|
||||
后台下单 `CreateAdminOrder`(`internal/service/order/service.go:214`):
|
||||
|
||||
| 场景 | 操作账号 | 支付方式 | 买家 | 扣谁的钱 | 退款方式 |
|
||||
|---|---|---|---|---|---|
|
||||
| 平台自营 | 平台 | 线下 | 个人(id 0) | 不扣钱包,线下收款 | 退到客户账户 |
|
||||
| 赠送 | 平台 | 线下 | 个人(id 0) | 不收钱 | 退到客户账户 |
|
||||
| 平台代购 | 平台 | 线下 | 资源所属代理 | 不扣钱包,线下收款 | 退到客户账户 |
|
||||
| 平台代扣代理钱包 | 平台 | 钱包 | 资源所属代理 | 该代理主钱包 | 退回代理钱包 |
|
||||
| 代理自购 | 代理 | 钱包 | 本代理 | 本代理主钱包 | 退回代理钱包 |
|
||||
| 代理给下级购买 | 代理 | 钱包 | 下级代理 | 本代理(操作方)主钱包 | 退回代理钱包 |
|
||||
| 代理 OpenAPI 下单 | 代理 | 钱包 | 同代理后台下单 | 代理主钱包 | 退回代理钱包 |
|
||||
| 资产套餐批量订购 | 同发起账号 | 同上 | 同上 | 同上 | 同上 |
|
||||
|
||||
客户端下单(`source = client`):个人客户在 H5 下单,用微信、支付宝或卡钱包支付,含待支付订单;自动续费(`internal/application/assetautorenewal/renew.go`)、自动购买(`internal/task/auto_purchase.go`)也记为客户端订单。
|
||||
|
||||
平台代扣代理钱包与代理自购的扣款钱包、买家、成本价、订单角色(都是 `self_purchase`)、退款方式、佣金基准完全相同,只有操作人字段不同。
|
||||
|
||||
### 5. 测试环境数据分布(165 笔)
|
||||
|
||||
| 来源 | 支付方式 | 场景 | 笔数 |
|
||||
|---|---|---|---|
|
||||
| client | 微信、支付宝、卡钱包、空(待支付) | 个人客户在线下单 | 53 |
|
||||
| admin | wallet | 代理操作 | 21 |
|
||||
| admin | wallet | 平台代扣代理钱包 | 31 |
|
||||
| admin | offline | 平台代购 | 41 |
|
||||
| admin | offline | 平台自营、赠送等(买家个人) | 19 |
|
||||
|
||||
生产分布未查到(查询时数据库连接工具断开),实施前需补查,见第五部分。
|
||||
|
||||
### 6. 现状问题
|
||||
|
||||
- 运营无法按"平台订单 / 代理商订单 / 在线支付订单"分页面查看,只能靠支付方式、订单角色间接筛,而且这两个条件都区分不出"平台代扣代理钱包"和"平台代购"。
|
||||
|
||||
---
|
||||
|
||||
## 第二部分:老卡管对照
|
||||
|
||||
源码 `kyhl-src`(`apps/kyhl-crm`)中三类订单是三张表、三个页面:
|
||||
|
||||
| 老页面 | 表 | 内容 | 谁能看 |
|
||||
|---|---|---|---|
|
||||
| 微信订单(`agentWeixinOrder/list`) | `tbl_agent_weixin_order` | C 端公众号订单:微信支付、余额支付(卡钱包)、自动续费 | 平台全部;代理看自己与下级卡上的订单 |
|
||||
| 代理商订单(平台 `agentOrder/syslist`,代理 `agentOrder/list`) | `tbl_agent_order` | 代理账号在后台给卡加套餐,从代理预存款余额扣款(`CardViewComp.agentRecharge`) | 平台全部;代理只看自己的 |
|
||||
| 平台加套餐 | 不生成订单 | 平台账号给卡加套餐只写套餐记录(`addCardOrBatchImportPackage`) | — |
|
||||
|
||||
结论:老系统判断是否为代理商订单,看的是"是否扣代理余额",不看操作人。新系统的平台线下订单(自营、赠送、代购)在老系统里没有订单记录。
|
||||
|
||||
附:老系统首页统计的"平台订单 / 代理商订单"按收款公众号划分(`weixin_agent_id` 是否为空),与列表页口径不同,不作为本方案的依据。
|
||||
|
||||
---
|
||||
|
||||
## 第三部分:方案(To-Be)
|
||||
|
||||
### 1. 接口
|
||||
|
||||
保留 `GET /api/admin/orders`,参数、响应、行为均不变。新增三个列表接口:
|
||||
|
||||
| 接口 | 类别 |
|
||||
|---|---|
|
||||
| `GET /api/admin/orders/platform` | 平台订单 |
|
||||
| `GET /api/admin/orders/agent` | 代理商订单 |
|
||||
| `GET /api/admin/orders/online` | 在线支付订单 |
|
||||
|
||||
- 类别由路径固定,不接收类型参数。
|
||||
- 查询参数、分页、响应结构与原列表相同:入参 `dto.OrderListRequest`,出参 `dto.OrderListResponse`。
|
||||
- 认证、用户类型准入、数据范围与原列表相同。
|
||||
- 订单导出同样分三类,见本部分第 6 节。
|
||||
- 订单详情、取消、购买预检、后台下单不变。
|
||||
|
||||
### 2. 类别划分:按钱从哪来,不按操作人
|
||||
|
||||
| 类别 | 条件 | 包含 |
|
||||
|---|---|---|
|
||||
| 在线支付订单 | `source = 'client'` | 个人客户微信、支付宝、卡钱包支付,自动续费,自动购买,待支付 |
|
||||
| 代理商订单 | `source = 'admin' AND payment_method = 'wallet'` | 代理自购、代理给下级购买、平台代扣代理钱包、代理 OpenAPI 下单 |
|
||||
| 平台订单 | `source = 'admin' AND payment_method <> 'wallet'`(含空) | 平台自营、赠送、平台代购 |
|
||||
|
||||
- `source` 非空且只有两个取值,后台订单按是否用代理钱包支付二分,所以三类互斥且覆盖全部订单。相同条件下,三个新接口的总数之和等于原接口的总数。
|
||||
- 不走代理钱包的后台订单一律不进代理商订单接口。平台代购的买家虽然记为代理,但按线下收款、退款走客户账户,归平台订单;后续如有要求再调整。
|
||||
- 不依赖操作人字段,历史订单操作人为空也不影响分类。
|
||||
|
||||
放弃的方案:按操作账号类型划分(`operator_account_type`,为空回退 `operator_type`)。这样会把平台代扣代理钱包归到平台订单,与资金归属和老卡管口径都不一致,还要兜底历史空值。
|
||||
|
||||
### 3. 实现
|
||||
|
||||
1. 常量:在 `pkg/constants/order_category.go` 定义订单类别 `platform` / `agent` / `online`。
|
||||
2. Store:`OrderStore.List` 支持内部过滤键 `order_category`,类别条件集中在一个函数里,三个接口与导出共用(见第 6 节)。
|
||||
3. Service:`order.Service.List` 增加订单类别入参并透传给 Store;原调用传空类别,行为不变。
|
||||
4. Handler:把现有 `List` 的参数解析、时间范围解析、代理买家条件提取为内部方法 `list(c, category)`。原 `List` 以空类别调用,新增 `ListPlatform`、`ListAgent`、`ListOnline` 分别以固定类别调用。权限逻辑只有一份。
|
||||
5. 路由:三个静态路径必须注册在 `GET /orders/:id`(`internal/routes/order.go:30`)之前,否则会被当作订单 ID 解析,返回"无效的订单ID"。
|
||||
|
||||
类别不进 DTO,所以请求里即使带 `order_category` 等参数也不会被解析。
|
||||
|
||||
### 4. 代理账号看到的结果
|
||||
|
||||
权限不变,代理账号在新接口看到的是原接口结果按类别切开的部分:
|
||||
|
||||
| 接口 | 代理账号能看到 |
|
||||
|---|---|
|
||||
| 平台订单 | 平台为本店名下的卡代购的订单 |
|
||||
| 代理商订单 | 买家是本店的钱包订单:本店自购、平台代扣本店钱包、上级给本店购买 |
|
||||
| 在线支付订单 | 始终为空:原接口对代理只返回买家是本店的订单,客户端订单的买家是个人客户 |
|
||||
|
||||
代理在"给下级购买"时,订单买家是下级,原接口对该代理不可见,新接口也不可见。
|
||||
|
||||
### 4.1 企业账号不能访问订单接口(修复现有问题)
|
||||
|
||||
现状:后台认证允许企业账号,原列表对非代理账号不加买家条件,店铺范围对企业账号也不生效,接口权限中间件未接入路由,所以企业账号调用 `GET /orders` 与 `GET /orders/:id` 会看到全部订单。下单已拒绝企业账号,取消只允许代理,导出任务不允许企业账号创建。
|
||||
|
||||
做法:`OrderHandler` 增加企业账号校验,原列表、三个新列表、订单详情、购买预检对企业账号返回无权限。校验放在 Handler,不放 Store 的 `applyOrderScope`,因为后者被资产订单查询等其他场景共用。资产订单接口 `GET /assets/{identifier}/orders` 本次不改;测试环境实测它对企业账号未按企业授权范围校验(企业 4 无授权卡仍能查到卡 11486 的订单),归入企业账号仅限 H5 的后续收口。
|
||||
|
||||
这是本方案中唯一的破坏性变更,只影响企业账号。
|
||||
|
||||
### 5. 性能
|
||||
|
||||
类别条件只用 `source` 与 `payment_method`,不加专用索引。实施后在测试环境看执行计划;生产订单量明显增长时,再评估 `(source, payment_method, created_at)` 组合索引。
|
||||
|
||||
### 6. 导出分三类
|
||||
|
||||
保留 `scene=order`,行为不变。新增三个导出场景,与三个列表接口一一对应:
|
||||
|
||||
| 场景 | 对应列表 |
|
||||
|---|---|
|
||||
| `order_platform` | `GET /orders/platform` |
|
||||
| `order_agent` | `GET /orders/agent` |
|
||||
| `order_online` | `GET /orders/online` |
|
||||
|
||||
- 与列表一致:类别由场景决定,`filters` 里不加类别参数;筛选条件、表头、列内容、数据范围与 `scene=order` 完全相同,只多一个固定的类别条件。
|
||||
- 用新场景而不是给 `order` 加参数,原因是导出本来就靠 `scene` 区分数据,和列表"路径决定类别"是同一个思路,前端每个页面各传自己的场景即可。
|
||||
|
||||
实现:
|
||||
|
||||
1. 场景常量:在 `pkg/constants` 新增 `ExportTaskSceneOrderPlatform`、`ExportTaskSceneOrderAgent`、`ExportTaskSceneOrderOnline`。
|
||||
2. 数据源:`OrderDataSource` 增加类别字段,`NewOrderDataSource(db, category)`;`Scene()` 按类别返回场景,类别为空时返回 `order`;`applyFilters` 在现有条件后叠加类别条件。`Count`、`Fetch` 共用 `applyFilters`,条数与内容自动一致。
|
||||
3. 类别条件只写一份:放在 `internal/query/ordercategory`,按表别名生成条件(导出查询用别名 `o`,Store 无别名),列表与导出共用,保证口径一致。与不轮询清单共用 `internal/query/pollingpause` 的做法一致。
|
||||
4. 注册:`NewDefaultRegistry` 再注册三个类别的 `OrderDataSource`;`timeFilterScenes` 加入三个新场景,时间格式校验与 `order` 相同;`IsSupportedScene` 同步加入。
|
||||
5. 导出任务创建接口的场景说明(`internal/routes/export_task.go`)补充三个新场景。
|
||||
|
||||
---
|
||||
|
||||
## 第四部分:改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `pkg/constants`(订单相关常量文件) | 新增订单类别常量 |
|
||||
| `internal/store/postgres/order_store.go` | `List` 支持 `order_category` |
|
||||
| `internal/service/order/service.go` | `List` 透传订单类别 |
|
||||
| `internal/handler/admin/order.go` | 提取内部列表方法,新增三个 Handler;企业账号校验 |
|
||||
| `internal/routes/order.go` | 注册三个路由,写明类别口径和"参数、响应、权限同原订单列表" |
|
||||
| `pkg/constants/constants.go` | 新增三个订单导出场景常量 |
|
||||
| `internal/exporter/order_scene.go` | 数据源支持类别,叠加类别条件 |
|
||||
| `internal/exporter/registry.go`、`internal/exporter/time_filters.go` | 注册三个新场景,纳入时间筛选 |
|
||||
| `internal/routes/export_task.go` | 场景列表说明补充三个新场景 |
|
||||
| OpenAPI 文档、前端接口变更说明 | 补充三个列表接口与三个导出场景 |
|
||||
|
||||
- 不新增字段,不需要迁移,不改历史数据。
|
||||
- 原导出场景 `order` 不变;不改订单详情、取消、下单。
|
||||
- 无自动化测试(项目无 `*_test.go`),在测试环境验证。
|
||||
|
||||
---
|
||||
|
||||
## 第五部分:验证(测试环境)
|
||||
|
||||
1. 平台账号,相同条件调用三个新接口与原接口:三个新接口的总数之和等于原接口总数;抽查平台代扣代理钱包在代理商订单、平台代购在平台订单、自动续费在在线支付订单。
|
||||
2. 平台账号,叠加支付状态、时间、资产标识、订单号等原有条件,结果正确;原接口结果与上线前一致。
|
||||
3. 代理账号,三个新接口的结果都是原接口结果的子集,总数之和等于原接口;在线支付订单为空。
|
||||
4. `GET /orders/:id` 仍正常;`/orders/platform` 等路径不会被当作订单 ID。
|
||||
4.1 企业账号调用原列表、三个新列表、订单详情、购买预检返回无权限;`GET /assets/{identifier}/orders` 查询本企业资产不受影响。
|
||||
5. 导出:平台账号与代理账号分别用三个新场景导出,条数与对应列表接口的总数一致,表头与 `scene=order` 相同;`scene=order` 导出结果与上线前一致;新场景传旧时间格式时与 `order` 一样被拒绝。
|
||||
6. 上线前补查生产分布:按 `source`、`payment_method` 统计,确认没有 `source` 为其他值的订单、后台订单只有钱包和线下两种支付方式。
|
||||
|
||||
---
|
||||
|
||||
## 第六部分:已定事项与后续事项
|
||||
|
||||
已定:
|
||||
- 原接口保留,新接口不传类型参数;超管、平台、代理的权限与数据范围不变。
|
||||
- 企业账号不能访问 `/orders` 下的列表、详情、购买预检,修复其可看到全部订单的问题。
|
||||
- 按钱从哪来划分:代理钱包扣款的后台订单(含平台代扣)归代理商订单;不走代理钱包的后台订单归平台订单。
|
||||
- 导出也分三类:新增 `order_platform`、`order_agent`、`order_online` 三个场景,原 `order` 场景保留。
|
||||
|
||||
后续(有要求再做):
|
||||
- 平台代购是否改归代理商订单。
|
||||
- 代理账号是否需要在在线支付订单接口看到自己资产上的个人客户订单(属于权限调整)。
|
||||
- 在线支付订单是否增加老卡管微信订单页的"订单来源、收款方"筛选。
|
||||
@@ -41,6 +41,19 @@
|
||||
- **最后验证日期**:2026-08-07
|
||||
- **更新触发条件**:错误系统或 ErrorHandler 变化
|
||||
|
||||
## ENG-ERR-002
|
||||
- **状态**:生效
|
||||
- **适用范围**:请求 DTO 中来自 URL 路径的字段,以及 Handler 的参数校验失败响应
|
||||
- **规则**:路径来源字段 MUST 在 Handler 内由 `c.Params` 解析后回填,再执行 `validator.Struct`;DTO MUST NOT 依赖 `validate:"required"` 覆盖路径字段而不回填。新增或修改的参数校验点 MUST 让校验失败返回 1001 且在 `msg` 中说明首个失败字段与规则,字段名取自该字段的中文 `description`;未触碰的既有 Handler 的通用提示按 As-Is 保留。
|
||||
- **理由**:`json:"-"` 的路径字段不参与 Body/Query 绑定,不回填则 `required` 恒失败,接口对任何合法请求都返回“参数不合法”,且原提示不指出字段,无法定位。
|
||||
- **最小正例**:`shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)` 后 `req.ShopID = uint(shopID)` 再 `validator.Struct(&req)`;失败时 `errors.New(errors.CodeInvalidParam, validationMessage("提现资料资格参数不合法", &req, err))` 产出“提现资料资格参数不合法:合同附件对象存储 Key 不能为空”。
|
||||
- **最小反例**:`c.BodyParser(&req)` 后直接 `validator.Struct(&req)` 并返回无字段信息的“XX参数不合法”。
|
||||
- **机械检查/人工原因**:对每个被 `validator.Struct` 校验的 DTO,核对携带 `path:"..."` 且 `validate` 含 `required` 的字段是否在调用点赋值;`go build ./cmd/api`。全仓同类 DTO 中存在未被校验的路径字段,不能只靠 grep 判定违规。
|
||||
- **例外条件**:路径字段不带 `validate:"required"` 且调用方显式回填的 DTO 不受本规则约束;未纳入本次触碰范围的 Handler 通用提示不要求整改。
|
||||
- **Owner**:API 负责人
|
||||
- **最后验证日期**:2026-09-14
|
||||
- **更新触发条件**:DTO 绑定方式、校验消息约定或请求绑定工具变化
|
||||
|
||||
## ENG-RESP-001
|
||||
- **状态**:生效
|
||||
- **适用范围**:HTTP Handler
|
||||
@@ -244,9 +257,9 @@
|
||||
- **最小正例**:事务写事实和 Audit Writer;提交后由 Outbox 外发。
|
||||
- **最小反例**:事务中等待第三方网络后再提交。
|
||||
- **机械检查/人工原因**:逐用例人工核对 Transaction 闭包、Audit Writer 和外部调用位置。
|
||||
- **例外条件**:业务回滚后的 failed/denied 审计使用独立短事务。
|
||||
- **例外条件**:业务回滚后的 failed/denied 审计,以及随之记录的回滚后失败状态事实,使用独立短事务;该短事务 MUST NOT 与已回滚的主事务共用连接或事务,且 MUST 以业务单仍处于允许该失败事实的状态为条件更新。
|
||||
- **Owner**:架构与审计负责人
|
||||
- **最后验证日期**:2026-08-07
|
||||
- **最后验证日期**:2026-09-14
|
||||
- **更新触发条件**:高风险写或外部调用变化
|
||||
|
||||
## ENG-AUDIT-001
|
||||
@@ -270,9 +283,9 @@
|
||||
- **最小正例**:外部失败写脱敏 Integration Log,HTTP 请求写 Access Log。
|
||||
- **最小反例**:把完整凭证写 app.log。
|
||||
- **机械检查/人工原因**:人工核对 Logger 字段、Sanitizer 与事实存储;本地按 request_id/correlation_id 查询日志。
|
||||
- **例外条件**:第三方要求回显的非敏感渠道码可保留。
|
||||
- **例外条件**:第三方要求回显的非敏感渠道码可保留。卡网关调用(add-carrier-gateway-rate-limit):普通轮询查询不写 Integration Log,失败原因与调用量由 Redis 按运营商账户的分钟计数与卡最近错误承载(同时是优先执行的轮次仍写);系统自动停复机不写 Integration Log 与 Audit Event,每次上游调用写停复机记录表 `tb_card_stop_resume_record`;后台人工点击、通道阈值、自动续费及账户、个人客户、OpenAPI 发起的停复机保持原写法。
|
||||
- **Owner**:审计与安全负责人
|
||||
- **最后验证日期**:2026-08-07
|
||||
- **最后验证日期**:2026-10-24
|
||||
- **更新触发条件**:日志字段、保留策略或外部集成变化
|
||||
|
||||
## ENG-SECRET-001
|
||||
@@ -300,3 +313,43 @@
|
||||
- **Owner**:基础设施负责人
|
||||
- **最后验证日期**:2026-08-07
|
||||
- **更新触发条件**:新增配置或依赖升级
|
||||
|
||||
## ENG-TEST-001
|
||||
- **状态**:生效
|
||||
- **适用范围**:Agent 执行的迁移、Redis、API/Worker、部署与集成 Smoke 验证。
|
||||
- **规则**:维护者指定的测试 PostgreSQL `junhong_cmp_test`、Redis DB 6、`Iteration/8-11` 测试部署和 `cmp-test` 日志主机构成唯一测试验证面;MUST 使用该环境,不额外要求独立数据库、Redis DB 或 namespace。迁移从本地工作区以明确 `DB_*` 参数执行;测试 fixture 仅可创建、删除当前 Change 自己的记录,MUST NOT 重置整个测试库。测试部署通过 Gitea 工作流完成,SSH 仅用于日志、容器状态与受控 Smoke。
|
||||
- **理由**:同一可控测试面避免每个 Change 重复索取环境,且保留可复现的迁移、缓存、并发和部署证据。
|
||||
- **最小正例**:当前 Change 在 `junhong_cmp_test` 执行迁移 up/down/up,清理自己的 fixture,并在测试部署后读取 `cmp-test` 日志。
|
||||
- **最小反例**:以未提供额外“隔离环境”为由暂停,或重置整个测试数据库。
|
||||
- **机械检查/人工原因**:记录显式目标、迁移命令、fixture 清理范围、Redis 操作与测试部署 SHA;通过 API/Worker 日志和可观察状态核对。
|
||||
- **例外条件**:真实支付渠道和外部审批系统不作自动验证;第三方协议变更以契约文档和维护者提供的证据为准。生产仍按生产运行说明由维护者执行。
|
||||
- **Owner**:基础设施负责人
|
||||
- **最后验证日期**:2026-09-08
|
||||
- **更新触发条件**:测试库、Redis DB、部署分支、测试主机或验证授权变化
|
||||
|
||||
## KNOWN-ISSUE-001
|
||||
- **状态**:已知缺陷,待修复(当前不阻塞归档;标签功能未启用时无实际影响)
|
||||
- **适用范围**:`internal/service/exchange/migration.go` 的标签复制步骤(换货业务数据迁移的「资产标签」迁移项)
|
||||
- **问题**:标签复制使用 `clause.OnConflict{Columns: [resource_type, resource_id, tag_id], DoNothing: true}`,未声明 `tb_resource_tag` 上部分唯一索引 `idx_resource_tag_unique`(`... WHERE deleted_at IS NULL`)的谓词,PostgreSQL 返回 `42P10`。
|
||||
- **理由**:旧资产存在任意 `tb_resource_tag` 行且换货请求要求迁移时,标签步骤必然失败,导致换货完成整体回滚、迁移状态落 `failed`,「已迁移」在该情形不可达。记录于此以便由独立变更修复,避免在其它任务中顺手改动迁移项。
|
||||
- **证据**:2026-09-14 在 `junhong_cmp_test` 的 `tb_audit_event` 实测 4 条 `action_code=exchange.card.complete`、`result=failed`、`error_code=1206`,`error_summary` 含 `复制资产标签失败 ... SQLSTATE 42P10`;`tb_resource_tag` 当前 0 行,故静态库状态下不可观测。该文件自 `add-exchange-data-migration-status` 起未修改(md5 与 `git show HEAD` 一致)。
|
||||
- **例外条件**:标签功能未启用(`tb_resource_tag` 为空)时无实际影响;不影响钱包余额、有效套餐使用记录、累计充值字段、资产归属与个人客户—资产绑定,也不影响无标签资产的换货完成。
|
||||
- **修复方式**:为该 `OnConflict` 声明部分索引谓词(或调整索引),须另立 OpenSpec Change;当前按维护者决策暂不修复,仅登记待办。
|
||||
- **Owner**:数据负责人
|
||||
- **最后验证日期**:2026-09-14
|
||||
- **更新触发条件**:标签功能启用、换货迁移项变更或该缺陷修复
|
||||
|
||||
## KNOWN-ISSUE-002
|
||||
- **状态**:已登记的边界,非缺陷(当前不阻塞归档)
|
||||
- **适用范围**:卡轮询优先队列的三处实现边界:①「资产无有效套餐」的触发范围;②认领接缝探测失败的失败方向;③未被接缝使用的批量探测方法
|
||||
- **问题**:
|
||||
- 边界一(无有效套餐触发范围):`no_valid_package` 只在既有判定点——`internal/service/iot_card/stop_resume_service.go:354` 的「条件B:无有效套餐」被加入停机原因列表时——才会追加触发(调用点 `internal/service/iot_card/stop_resume_service.go:123`,实现 `:209`)。已经处于停机状态的卡走 `EvaluateAndAct` 的离线分支,不再重新判定「无有效套餐」,因此停机卡不会被该场景加急。
|
||||
- 边界二(认领接缝探测失败的失败方向):认领接缝的 `FindActive` 探测失败采用 fail-closed——四个轮询 Handler 按既有失败分支记为轮询失败并按既有间隔延后(`internal/task/polling_priority_claim.go:56` 返回错误;`internal/task/polling_realname_handler.go:47`、`internal/task/polling_carddata_handler.go:55`、`internal/task/polling_cardstatus_handler.go:56`、`internal/task/polling_package_handler.go:59` 各对应分支;carddata 连同放弃当轮流量同步)。
|
||||
- 边界三(未被使用的批量探测方法):`internal/store/postgres/polling_priority_item_store.go:148-149` 的 `ListActiveByCards` 当前没有任何调用方(`grep -rn "ListActiveByCards" internal/` 只命中定义本身)。
|
||||
- **理由**:边界一——行为契约的 WHEN 以「普通套餐轮询判定资产无有效套餐」为前提(该 Scenario 见 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/specs/priority-polling-queue/spec.md:31`),停机卡不发生该判定,故当前实现不违反契约;把它扩展到停机卡属于新的产品口径。边界二——fail-open 会在数据库抖动时让同一卡同一任务类型同时出现两次上游调用,破坏本能力「至多一次上游调用」的 MUST,失败方向必须偏保守。边界三——`tasks.md` 1.4 的执行契约要求存储层提供「按卡批量活动项查询」,先交付后使用。
|
||||
- **证据**:见上述各边界的 `文件:行`;边界三的调用方检索当时结果为空(仅定义处命中)。
|
||||
- **例外条件**:边界一——若产品要求停机卡也因「无有效套餐」获得加急,须先由产品确认收窄该 Scenario 的措辞,再以独立 Change 扩展触发范围。边界二——数据库连接正常时不会触发该失败方向,普通轮询的并发上限与无限重入队策略不受影响。
|
||||
- **修复方式**:边界一——产品确认后另立 Change;在此之前不改判定点。边界二——不修复(刻意设计);如未来引入可信的「探测能力不可用」信号,可在该信号下单独放行并接受重复调用风险,须重新评审。边界三——若认领接缝改为批量探测则启用;否则按归档后的清理流程删除,不长期保留未使用的方法。
|
||||
- **Owner**:异步任务负责人
|
||||
- **最后验证日期**:2026-09-16
|
||||
- **更新触发条件**:触发范围口径变化、认领策略变化、或接缝改为批量探测
|
||||
|
||||
1222
docs/engineering/轮询按运营商账户限流技术方案.md
Normal file
1222
docs/engineering/轮询按运营商账户限流技术方案.md
Normal file
File diff suppressed because it is too large
Load Diff
44
docs/hotfix-main-package-activation-recovery/功能总结.md
Normal file
44
docs/hotfix-main-package-activation-recovery/功能总结.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Main 分支套餐接续饥饿热修
|
||||
|
||||
## 修复边界
|
||||
|
||||
本热修仅适用于 `main` 的纯 Asynq 套餐接续链路,不引入 Outbox、数据库迁移、新任务类型或新依赖。
|
||||
|
||||
- 孤儿扫描先在 PostgreSQL 中按卡或设备选择队首套餐,并排除仍有 `status IN (1,2)` 占位主套餐的载体,最后取 100 个真实孤儿。
|
||||
- 旧主套餐及加油包状态事务提交后,再投递现有 `package:queue:activation` 任务。
|
||||
- Redis 激活锁冲突返回套餐激活冲突错误,由现有 `MaxRetry(3)` 重试。
|
||||
- 只有套餐实际从待生效推进为生效中时,Handler 才记录“套餐激活成功”。
|
||||
|
||||
## 部署观察
|
||||
|
||||
部署 Worker 后至少观察两个套餐轮询周期:
|
||||
|
||||
1. `孤儿套餐扫描完成` 的 `orphan_count` 应能覆盖真实无占位套餐,不再固定被同一批占位载体挡住。
|
||||
2. `已提交套餐激活任务` 后应出现实际激活、明确跳过或可重试错误,不再出现未改状态却打印成功。
|
||||
3. Redis 锁冲突应进入 Asynq 重试,不应确认任务成功。
|
||||
|
||||
可使用以下只读 SQL 检查仍未恢复的真实孤儿数量:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) AS orphan_pending_count
|
||||
FROM tb_package_usage AS pending
|
||||
WHERE pending.status = 0
|
||||
AND pending.master_usage_id IS NULL
|
||||
AND pending.deleted_at IS NULL
|
||||
AND (COALESCE(pending.iot_card_id, 0) > 0 OR COALESCE(pending.device_id, 0) > 0)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tb_package_usage AS occupied
|
||||
WHERE occupied.status IN (1, 2)
|
||||
AND occupied.master_usage_id IS NULL
|
||||
AND occupied.deleted_at IS NULL
|
||||
AND (
|
||||
(COALESCE(pending.iot_card_id, 0) > 0 AND occupied.iot_card_id = pending.iot_card_id)
|
||||
OR (COALESCE(pending.iot_card_id, 0) = 0 AND pending.device_id > 0 AND occupied.device_id = pending.device_id)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## 回滚
|
||||
|
||||
本次无数据库迁移。回滚热修提交并重新部署 Worker 即可;已经正确激活的套餐属于有效业务事实,不执行反向 SQL。
|
||||
@@ -9,20 +9,37 @@
|
||||
|
||||
## 当前实际使用范围
|
||||
|
||||
系统仅使用微信预下单 `POST <ApiURL>/wxPreCreate` 与支付通知。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序);未发现退款、撤销或查单能力。
|
||||
系统使用微信预下单 `POST <ApiURL>/wxPreCreate`、主扫统一下单 `POST <ApiURL>/preCreate` 与支付通知;代理在线充值恢复流程另有本地 `CommonQuery` 调用,用于主动查询支付订单状态。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序),主扫下单的订单类型为 `WECHAT` 或 `ALIPAY`;本地 `CommonQuery` 代码保持现有请求格式、签名算法、状态映射和恢复语义不变。该源码事实仅表示本地候选实现及后续双读配置来源改造接缝,不证明真实富友渠道契约,也不证明验签、状态解释或恢复核验已通过。
|
||||
|
||||
## 原路退款
|
||||
|
||||
退款申请 `POST <ApiURL>/commonRefund`,必填 `version`、`ins_cd`、`mchnt_cd`、`term_id`、`mchnt_order_no`、`random_str`、`sign`、`order_type`、`refund_order_no`、`total_amt`、`refund_amt`;选填 `operator_id`、`reserved_fy_term_id`、`reserved_origi_dt`、`reserved_addn_inf`、`reserved_refund_desc`。响应 `result_code=000000` 表示渠道受理成功,此时取 `refund_id`(富友退款流水号)、`transaction_id`、`reserved_refund_amt`(退款金额,分)、`reserved_fy_settle_dt`(清算日期)。`reserved` 开头字段随报文发出但不参与签名。
|
||||
|
||||
退款查询 `POST <ApiURL>/refundQuery`,入参为 `refund_order_no`;响应 `trans_stat` 取值为 `SUCCESS`(退款成功)或 `PAYERROR`(退款失败),未返回该字段表示仍在办理中。
|
||||
|
||||
全局约束:`mchnt_order_no` 与 `refund_order_no` 均为全局永久唯一,重复提交会被直接拒绝;商户退款单号格式为「机构码(4 位) + 日期(yyyyMMdd) + 随机段(8–18 位字母数字)」,本系统按该规则生成,三渠道共用同一生成器;接口支持全额退款与多次部分退款。
|
||||
|
||||
原交易日期决定可退时限:不传 `reserved_origi_dt` 仅支持 30 天内的原交易,传了可退 360 天内的原交易。本系统始终回传原支付成功时间,因此按 360 天判定可退性,超出该时限的申请在选择退款方式阶段即禁用原路。
|
||||
|
||||
退款查询接口只支持查询 3 日内的退款交易。超出该窗口且结果仍未知时,系统保留原路退款处理中状态、标记审批异常并转人工核对,绝不重复发起退款。
|
||||
|
||||
## 配置、认证与传输
|
||||
|
||||
运行配置包含 API 地址、机构号、商户号、终端号、RSA 私钥、公钥及通知地址。请求先生成 XML,再转换为 GBK,并对请求参数做双重 URL 编码;请求和响应使用 RSA 签名/验签。除 `reserved` 外的请求字段即使为空也参与 XML 与签名。
|
||||
|
||||
关键请求字段包括 `mchnt_order_no`、`order_amt`(分)、`txn_begin_ts`、`notify_url`、`trade_type`、`sub_openid`、`sub_appid`。响应 `result_code=000000` 表示渠道成功,并返回富友流水号和 JSAPI 支付字段。
|
||||
关键支付请求字段包括 `mchnt_order_no`、`order_amt`(分)、`txn_begin_ts`、`notify_url`、`trade_type`、`sub_openid`、`sub_appid`。响应 `result_code=000000` 表示渠道成功,并返回富友流水号和 JSAPI 支付字段。
|
||||
|
||||
## 幂等、失败与重试
|
||||
|
||||
`mchnt_order_no` 是渠道业务幂等键;通知处理还需校验签名、商户订单号、金额及当前支付状态。非 `000000`、验签失败、解码失败或字段不匹配均不得推进支付状态。客户端未实现自动重试,调用方只有在可确认沿用同一商户订单号时才可重试。
|
||||
`mchnt_order_no` 是渠道业务幂等键,退款侧对应 `refund_order_no`;通知处理还需校验签名、商户订单号、金额及当前支付状态。非 `000000`、验签失败、解码失败或字段不匹配均不得推进支付状态。客户端未实现自动重试,调用方只有在可确认沿用同一商户订单号时才可重试。
|
||||
|
||||
退款调用以冻结在审批尝试记录上的渠道退款请求号作为幂等标识:同一次尝试的渠道重试复用同一请求号,重提会生成新请求号。结果未知时只由查询恢复回填,不得重复发起资金动作。
|
||||
|
||||
## 安全与验证
|
||||
|
||||
RSA 私钥、公钥、机构和商户凭证不得进入文档或普通日志;通知日志必须脱敏。可复现静态证据:`pkg/fuiou/client.go`、`pkg/fuiou/wxprecreate.go`、`pkg/fuiou/types.go`、`internal/handler/callback/payment.go`。真实验收需使用隔离商户验证两种交易类型、签名失败、金额不符和重复通知;本次不调用真实渠道。
|
||||
RSA 私钥、公钥、机构和商户凭证不得进入文档或普通日志;通知日志必须脱敏。可复现静态证据:`pkg/fuiou/client.go`、`pkg/fuiou/wxprecreate.go`、`pkg/fuiou/scan.go`、`pkg/fuiou/refund.go`、`pkg/fuiou/types.go`、`internal/handler/callback/payment.go`、`internal/infrastructure/payment/fuiou_scan.go`、`internal/infrastructure/payment/refund_adapter.go`。
|
||||
|
||||
本文按官方契约记录退款接口,**未做真实渠道实测**:未实测只作记录,不作为阻塞、未完成任务或上线前置;真实渠道可退款性由维护者后续手工验证。真实验收需使用隔离商户验证两种交易类型、签名失败、金额不符、重复通知、退款受理与退款查询;本次不调用真实渠道。
|
||||
|
||||
端点、编码、签名字段、成功码、退款字段或通知语义变化时更新本文。
|
||||
|
||||
端点、编码、签名字段、成功码或通知语义变化时更新本文。
|
||||
|
||||
@@ -4,13 +4,27 @@
|
||||
|
||||
- Owner:IoT Gateway 适配维护人
|
||||
- 实现:`internal/gateway/`
|
||||
- 核验日期:2026-08-07
|
||||
- 证据:`internal/gateway/client.go`、`crypto.go`、`card_status.go`、`flow_card.go`、`device.go`
|
||||
- 核验日期:2026-09-11
|
||||
- 证据:`internal/gateway/client.go`、`crypto.go`、`card_status.go`、`flow_card.go`、`device.go`、`payment_voucher.go`
|
||||
|
||||
## 当前实际使用范围
|
||||
|
||||
Gateway 是运营商流量卡、实名、停复机、限速和设备信息的统一封装入口。具体路径、请求字段和响应字段以同目录详细协议与 `internal/gateway/*.go` 的实际调用交集为准;文档中出现但代码未调用的接口不视为系统能力。
|
||||
|
||||
付款凭证识别(`POST /ai/ocr/extract-payment`,入参 `image_base64`)由 `internal/gateway/payment_voucher.go` 封装,当前唯一调用方是代理线下预存款申请的「交易流水号表单预填」。该能力只消费响应中的 `order_number`(作为交易流水号预填值);`amount`、`remark`、`payment_method`、`payee`、`payment_time` 不进入本系统响应、不预填、不落库,识别结果不是资金事实。核验证据:`internal/gateway/payment_voucher.go` 的类型定义只对外暴露支付单号,`internal/application/agentrecharge/payment_voucher_ocr.go` 只返回该字段,接口响应 DTO 仅含 `external_transaction_no`。
|
||||
|
||||
付款凭证识别刻意不走 `doRequest` / `doRequestWithResponse`:前者在 Info 级别打印加密前完整请求体、后者在 Info 级别打印完整原始响应,会把凭证图片内容与识别原始结果写进日志。该能力改用 `Client.doRequestWithoutPayloadLog`,仅记录路径、耗时与结果字节数摘要;既有能力的请求与日志语义保持不变(`internal/gateway/client.go` 中 `executeWithRetry` 的 `logPayload` 分支)。
|
||||
|
||||
### 付款凭证识别的已知限制
|
||||
|
||||
- **长号码可能不完整**:对位数较多的转账单号,该接口可能只返回前若干位,实测存在识别值与凭证图片所示号码不一致的情况(位数少于凭证所示)。连续多次识别同一凭证所得长度与内容稳定,属上游侧确定性截断,而非本系统侧裁剪;预填值**必须**由提交人对照凭证人工核对,系统以人工确认值为准。
|
||||
- **单号缺失即失败**:响应未给出单号时,本系统按识别失败返回明确失败(`CodeGatewayInvalidResp`,中文提示),不返回空值。
|
||||
- **字段类型会漂移**:响应 `data` 中 `amount` 为 JSON 数值而非字符串。本系统只解码 `order_number`,不声明其余字段,故不受类型漂移影响;新增消费字段前必须重新核对上游类型。
|
||||
- **解析失败不回显原文**:响应解码失败时只返回固定中文提示,不携带底层解析错误,避免第三方库的错误消息把识别原始结果带进日志与错误上下文。
|
||||
- **单次识别只接受单个附件键**,图片由后端读取对象存储后编码,Gateway 凭证不下发前端;识别结果不落库、不构成资金事实。
|
||||
|
||||
本条限制的核验方式(可复现、不依赖样本取值):对同一图片凭证**连续三次**调用该识别接口,比较三次返回值的**位数与内容是否一致**——一致说明是上游确定性行为而非随机抖动;再将该位数与凭证图片所示号码的位数(用等长掩码计数,只比位数)对照,得出是否缺位。判定责任方时看本系统的解码路径 `internal/gateway/payment_voucher.go`:它只对返回值做 `strings.TrimSpace`,无截断、无按长度裁剪、无正则截取,因此位数差异只能来自上游。识别结果不落库,复核该接口的返回值需重新发起识别调用,不能从业务表反查。
|
||||
|
||||
## 配置、认证与报文
|
||||
|
||||
配置键为 `gateway.base_url`、`gateway.app_id`、`gateway.app_secret`、`gateway.timeout`。业务参数先包装为 `{"params": ...}`,使用 AppSecret 做 AES-128-ECB 加密;外层请求含 `appId`、`data`、`sign`、`timestamp`,签名使用 MD5。HTTP 方法统一为 POST,内容类型为 `application/json;charset=utf-8`。HTTP 200 且 Gateway `code=200` 才算成功,`data` 再按具体能力解码。
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
|
||||
业务以本地审批实例和企业微信审批单号去重,终态只推进一次;`errcode/errmsg` 保存到集成日志并映射项目错误,不能直接暴露凭证或底层报文。
|
||||
|
||||
## 退款审批模板上线前置
|
||||
|
||||
退款审批场景上线前,维护者 MUST 在当前企业微信模板中完成并核对以下控件映射:
|
||||
|
||||
- 固定必需:退款方式中文名称(单行文本),用于展示“原路退款”“客户收款信息退款”“退回资产钱包”“退回代理主钱包”。
|
||||
- 条件必需:客户收款信息(多行文本)与客户收款凭证(附件)。仅当本次审批尝试冻结的退款方式为 `customer_account` 时,二者必须同时配置且本次值非空;其他退款方式不要求、不提交空白占位材料。
|
||||
|
||||
保存场景时系统会校验固定退款方式映射,并校验已配置条件控件的业务字段与控件类型;提交时按不可变审批尝试快照再次校验。模板缺少客户收款条件控件时,客户收款退款会明确失败并指出缺失字段/控件,不会创建第二张审批单;修复映射后可通过 `POST /api/admin/refunds/{id}/recover-approval` 恢复原审批实例。已有 `sp_no` 只查询同步,结果未知只确认,明确提交失败且无 `sp_no` 才恢复原提交事件。
|
||||
|
||||
本次发布不执行真实企业微信或支付调用;上线验收需由维护者在指定测试企业覆盖四种退款方式、条件材料缺失、模板修复后的原实例恢复、结果未知与重复/并发恢复,并核对审批尝试数、实例数和业务资金事实不变。
|
||||
|
||||
## 安全与验证
|
||||
|
||||
Secret、access_token、回调 AES Key、成员敏感字段、审批正文与附件 URL 必须脱敏。可信 IP、应用可见范围、模板、回调 URL 和素材权限属于部署侧人工配置。
|
||||
|
||||
232
docs/product/2026-08-迭代-PRD-讨论稿.md
Normal file
232
docs/product/2026-08-迭代-PRD-讨论稿.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# 新卡管 2026 年 8 月迭代 PRD(讨论稿)
|
||||
|
||||
> 状态:讨论中
|
||||
>
|
||||
> 本文是本轮需求澄清的唯一决策记录。确认后的需求将重编稳定需求 ID;每项实施前再以独立 OpenSpec Change 固化可观察行为、任务和验收。
|
||||
>
|
||||
> 来源:业务提供的《新卡管 8 月迭代需求》,2026-08-11。
|
||||
|
||||
## 0. 已确认的产品边界
|
||||
|
||||
1. 本文覆盖一个需求池,不承诺整体一次上线。完成完整 PRD 后,按影响范围由小到大拆分为独立 Change 实施。
|
||||
2. 原文编号存在重复和漂移。讨论稿将建立新的稳定需求 ID;原编号仅用于追溯来源。
|
||||
3. 审批类业务必须接入企业微信。企业微信是唯一终审来源;审批节点、审批人、条件和流转规则完全由企业微信模板配置,本系统不读取、校验或固化这些规则。系统只保存本地审批实例、业务状态、审计和幂等闭环,并按企业微信最终通过或驳回结果推进业务;不提供本地人工通过或拒绝来绕过企业微信。企业微信回调延迟、提交失败或结果未知时,使用既有兜底轮询查询渠道状态并同步本地实例。所有审批业务均须将企业微信审批单号返回至卡管并在详情展示;详情仅展示本地或企业微信实际可取得的字段,不展示无法返回、无值或无可靠来源的字段。
|
||||
4. OCR 是已有的外部能力,但本期仅作为交易流水号的预填来源。申请人或审核人必须能更正并最终确认;OCR 识别结果不是资金事实。OCR 协议、字段能力、失败和重试规则等待外部契约文档。
|
||||
|
||||
## 0.1 稳定需求 ID 与原文映射
|
||||
|
||||
| 稳定 ID | 能力 | 原文来源 |
|
||||
| --- | --- | --- |
|
||||
| AUG26-001 | 员工代收款账单与核销 | PRD-08-001、PRD-08-013(核销字段) |
|
||||
| AUG26-002 | 支付商户池与微信授权配置 | PRD-08-002 |
|
||||
| AUG26-003 | 店铺业务员与业务用户组 | PRD-08-004、PRD-08-005 |
|
||||
| AUG26-004 | 套餐真流量预警 | PRD-08-006 |
|
||||
| AUG26-005 | 换货业务数据迁移展示 | PRD-08-007 |
|
||||
| AUG26-006 | 套餐退款与原路退款 | PRD-08-008、PRD-08-013(退款字段) |
|
||||
| AUG26-007 | H5 风险换卡与运营通知 | PRD-08-009 |
|
||||
| AUG26-008 | 代理分销码、资料资格与佣金提现 | PRD-08-010、PRD-08-013(提现/分销审批字段) |
|
||||
| AUG26-009 | 手机号—资产关联 | PRD-08-011 |
|
||||
| AUG26-010 | 自动续费 | PRD-08-012 |
|
||||
| AUG26-011 | 运营商通道流量阈值 | PRD-08-017 |
|
||||
| AUG26-012 | 佣金回溯明细 | PRD-08-015 |
|
||||
| AUG26-013 | 资产套餐层级展示 | PRD-08-016(资产详情) |
|
||||
| AUG26-014 | 导出与统一时间筛选 | PRD-08-014、PRD-08-020 |
|
||||
| AUG26-015 | 报表管理 | PRD-08-016(报表,原编号重复) |
|
||||
| AUG26-016 | 优先轮询通道 | PRD-08-019 |
|
||||
| AUG26-017 | 代理自充收款方式 | PRD-08-021、PRD-08-013(预存款审批字段) |
|
||||
|
||||
## 1. 已确认的领域语言
|
||||
|
||||
### 1.1 员工代收款账单
|
||||
|
||||
员工代客户购买套餐、充值等业务时,系统按来源业务生成的待核销记录。它表示员工经办业务形成的暂挂欠款;员工提交客户付款凭证,企业微信审批人员可通过该凭证在外部第三方收款记录中核验支付,且企业微信审批通过后,欠款相应减少或结清。客户款项可支付至公司微信、银行卡或其他由业务维护并认可的线下收款方式,不要求必须支付给员工或由员工另行回款。
|
||||
|
||||
代理线下预存款/主钱包充值仍按既有企业微信审批决定是否入账;该审批通过并完成入账后,系统再创建相应员工代收款账单,使员工欠款增加。后续账单核销是独立于充值审批的业务流程。
|
||||
|
||||
员工代收款账单的欠款人固定为实际发起线下套餐订单或线下代理充值的后台账号,创建后不可修改;平台业务员和超级管理员实际经办时均生成本人账单。员工离职或账号禁用后,其欠款人身份不变;仅超级管理员可代办创建、修改或重新提交核销申请,且必须记录实际代办人及代办原因。
|
||||
|
||||
已确认:支持一笔外部支付记录分摊核销多张账单,也支持一张账单由多笔支付记录分次核销。员工应按一笔外部付款创建一张核销申请,在申请中上传该笔付款的凭证、填写交易流水号,并选择多张账单及各自分摊金额;一张申请只产生一个企业微信审批实例。每笔分摊必须独立保留账单、外部支付记录标识、本次核销金额、凭证、审批实例和剩余未核销金额。
|
||||
|
||||
员工代收款账单的核销状态独立于核销申请审批状态:账单状态为待核销、部分核销、已核销或已关闭;申请状态为审批中、已通过、已驳回、已撤销/已关闭。只有企业微信审批通过的分摊才减少员工欠款。账单列表需同时展示账单核销状态和是否存在审批中申请,避免部分核销与审批中互相覆盖。
|
||||
|
||||
企业微信最终驳回的核销申请允许员工在原申请上修改后重新提交;系统为同一申请保留每一次企业微信审批实例及其当次业务快照,历史材料和审批结果不得被覆盖。已驳回申请可修改全部申请内容:收款账户、外部付款信息、附件、备注、勾选账单和分摊金额;已通过的分摊不可修改。
|
||||
|
||||
超级管理员可关闭待核销、已驳回或部分核销账单,关闭即作废当时未核销余额且不再计入员工欠款;已核销金额保留。关闭必须填写原因、保留操作审计,且存在审批中核销申请时不得关闭。
|
||||
|
||||
已确认:员工代收款账单按业务场景确定公司应收金额。
|
||||
|
||||
- 代理代购套餐:取订单 `actual_paid_amount`(代理实际结算/成本金额)。对会生成员工代收款账单的后台线下套餐订单,创建订单时不再强制上传付款凭证;订单仍按当前规则立即激活,并同时创建待核销账单。付款凭证、OCR 与外部交易流水号只在后续核销申请提交。赠送套餐等不产生员工账单的线下订单继续保持创建时上传凭证的当前规则。
|
||||
- 无代理归属的自营 C 端套餐购买:取订单 `actual_paid_amount`。当前实现该值等于 `total_amount`;未来优惠券等价格优惠也应使其表示实际收款金额。
|
||||
- 代理线下充值预存款/主钱包:既有充值审批通过并入账后创建员工代收款账单,金额取 `tb_agent_recharge_record.amount`。
|
||||
- 不支持平台代理 C 端客户充值资产钱包;原需求中对此类场景不纳入本期。
|
||||
- “其他”来源第一版不支持手工创建,必须先定义可追溯来源单和金额口径。
|
||||
|
||||
> 待决:`actual_paid_amount` 在未来优惠场景的写入时机与权威性;外部支付记录在本地的最小留存字段、收款账户目录的范围和退款时的冲销规则。
|
||||
|
||||
## 2. 当前发现的需求结构问题
|
||||
|
||||
| 问题 | 处理原则 |
|
||||
| --- | --- |
|
||||
| `PRD-08-013`、`014`、`015`、`016` 在范围表与正文含义不一致 | 后续以新稳定 ID 重编,保留来源编号。 |
|
||||
| 审批字段在第 17 节集中出现,但相关主需求中存在不同口径 | 先定义每个审批业务的业务单、状态、资金生效时点和企业微信模板,再定义页面字段。 |
|
||||
| OCR 被写入字段说明但缺少外部契约 | OCR 仅预填,待提供契约后再定义使用范围。 |
|
||||
| 收款方式被写为固定示例(如某人微信/支付宝) | 收款方式由业务字典维护,不得将名称硬编码为支付方式枚举;是否还需独立收款账户目录待确认。 |
|
||||
| 多处要求“字典维护”,但当前系统没有业务字典 | 新建通用业务字典模块;字典分类由研发固定注册,业务只维护分类下的字典项。已被业务单引用的字典项不得物理删除,只能停用并保留历史名称快照。 |
|
||||
| 自动续费含“有余额自动续费,不停机” | 必须先区分续费订单、钱包扣款、运营商停复机和轮询同步,不能将它们描述为同一动作。 |
|
||||
| 运营商通道阈值、套餐流量预警都使用“阈值” | 前者是通道控制规则,后者是套餐预警规则;二者的流量口径、周期和触发后果必须独立定义。 |
|
||||
|
||||
## 2.1 已确认的外部付款分摊边界
|
||||
|
||||
1. 同一笔外部付款可覆盖多张员工代收款账单,也可由多个员工的多张核销申请引用。
|
||||
2. 本期不对同一外部付款在多张申请中的累计分摊金额做系统防重或金额上限校验;企业微信审批人员以外部第三方记录为准核验。
|
||||
3. 系统仍须逐核销申请保存所选线下收款方式、交易流水号、付款金额、付款方、付款时间、支付凭证、其他凭证、备注及分摊明细,供超级管理员和企业微信审批追溯。交易流水号和付款金额可由 OCR 预填,但必须允许人工确认或更正。
|
||||
4. 员工通过勾选账单创建分摊明细,不手填账单编号、订单、客户或资产字段;系统自动带出这些只读信息。系统按账单产生时间由早至晚,使用本次外部付款金额自动填充分摊,最后一张填剩余金额;员工可以修改本次核销金额。对同一账单的审批中分摊应预占可核销余额,避免并发申请超额核销。
|
||||
5. 本期只做一套固定分类的线下收款方式字典。超级管理员维护字典项名称、稳定编码、排序、启停、备注;核销申请和预存款审批选择字典项并冻结名称快照。不额外建设收款账户目录。
|
||||
|
||||
## 2.2 已确认的历史数据边界
|
||||
|
||||
1. 员工代收款账单仅对功能上线后新创建的符合条件线下套餐订单、功能上线后审批通过并入账的线下代理预存款/主钱包充值自动生成。
|
||||
2. 不回填、不补建任何上线前历史业务;上线前业务不纳入员工账单和欠款统计。
|
||||
|
||||
## 2.6 已确认的商户池覆盖范围
|
||||
|
||||
1. C 端套餐购买、C 端资产钱包充值、代理在线预存款充值三类当前线上收款入口,均应按支付方式通过商户池选择实际收款商户;后台钱包/线下套餐订单不经过商户池。
|
||||
2. 平台范围内每种支付方式最多一个启用商户池;可保留停用历史池,但不得同时启用多个相同支付方式的商户池。未来需要多池时,必须先定义订单/店铺/资产等路由维度。
|
||||
3. 新建独立商户管理模块,收款凭证与商户支付能力从现有综合支付配置中分离,由商户池选择商户;不复用现有支付配置记录作为商户。一个商户仅对应一种支付方式:微信商户和支付宝商户分别建档、分别加入对应商户池。
|
||||
4. 新建独立微信授权配置,平台范围内最多一个启用配置,保存 C 端公众号 H5 登录/JSSDK 和小程序登录所需参数;C 端微信登录、AppID、JSSDK 与 OpenID 只使用该全局配置。微信商户仅保存微信支付或富友收款凭证;商户池命中的微信商户统一使用全局授权配置的 AppID 发起支付,微信侧 AppID 与商户的绑定/授权由业务保证。微信直连商户和富友商户都属于微信支付商户,可混合加入唯一启用的微信商户池;系统按命中商户自身服务商凭证执行支付、回调验签和退款。
|
||||
5. 商户停用后仅对新支付单自动跳过;已命中该商户的历史支付单不换商户,支付回调、状态查询及后续原路退款仍使用该商户凭证。被历史支付单引用的商户不得物理删除,只能停用。商户原路退款能力不提供人工开关,系统仅按服务商类型及退款所需凭证是否完整判定;服务商实现不支持退款的商户固定视为不支持。商户池没有可用商户或商户池停用时,创建支付单失败且不得回退到旧全局支付配置。
|
||||
6. 上线时从当前生效综合支付配置一次性自动迁移:创建一条全局微信授权配置、具备完整凭证的微信/支付宝商户及各自包含一个商户的启用商户池。新订单仅走商户池;旧综合支付配置和历史订单引用保留,仅服务历史回调、查询和退款,不自动改写。缺少完整凭证的支付方式不创建商户池,新支付单按暂无可用商户失败。迁移仅在数据库内复制密钥/证书,不得在日志、审计详情或接口响应暴露敏感值。
|
||||
7. 商户保存名称、支付方式、服务商类型、商户号/应用标识、敏感凭证、状态和备注。每笔新支付单保存实际商户 ID 并冻结名称、支付方式、服务商类型及商户号/应用标识快照,不复制敏感凭证。被支付单引用后,支付方式、服务商类型和商户号/应用标识不得修改;名称、备注、状态及凭证可更新。历史支付单和退款单优先展示快照,回调和退款读取商户当前有效凭证。
|
||||
8. 超级管理员和平台用户均可创建、修改、启用、停用商户、商户池和微信授权配置;其他角色没有管理入口。为使配置人员可核对完整配置,管理 API 的列表和详情向上述已认证角色返回商户及微信授权配置的完整密钥、证书和私钥字段,不做脱敏;不得将这些字段写入日志、审计快照、错误信息或普通业务单据。未被任何支付单命中的商户,超级管理员或平台用户可先将其移出商户池后经二次确认删除;删除审计不得含敏感凭证。被支付单引用的商户一律不得删除。
|
||||
9. 每笔通过商户池创建的支付单,除实际商户 ID 和商户身份快照外,还须保存商户池 ID、商户池名称快照和轮询方式快照;不另建逐次支付路由日志表。
|
||||
10. 金额阈值、笔数阈值和时间周期均为商户池统一配置,池内所有商户共用,不支持成员单独配置。
|
||||
6. 金额/笔数轮询创建池时必须配置统计周期,支持每轮累计、自然日累计、自然月累计。每轮累计在商户重新被轮到时清零;自然日/自然月累计在周期边界重置,达到阈值的商户在当期自动跳过。当前周期所有商户都达到阈值时,创建支付单失败并提示当前周期暂无可用商户。
|
||||
7. 时间轮询创建池时必须配置周期数值、分钟/小时/天单位和起始时间。自起始时间起按固定周期、商户列表顺序轮换;进入新时段时仅在有新支付单时选择并记录商户。当前商户停用时即时跳到下一个可用商户,时间段不重置;修改时间周期、起始时间或商户顺序后,保存成功时间成为新起点并从列表第一项重新计算。时间周期最小为 1 分钟。
|
||||
8. 金额/笔数轮询仅修改阈值时保留当前统计周期内的成功收款累计并立即按新阈值判断;修改统计周期或在金额/笔数两种方式间切换时,保存成功即开始新统计周期、所有商户从零累计。新增商户从零开始;移除/停用商户不再参与新订单选择。调整商户排序时,自然日/自然月累计保留未移除商户的当期累计;每轮累计从新列表第一项开启新一轮、所有商户从零累计。
|
||||
9. 商户预下单失败时不自动切换商户或重试;任一失败按当前错误处理返回,客户再次发起支付时重新按商户池选择。失败订单不计入金额/笔数成功累计。
|
||||
10. 金额/笔数轮询严格仅统计支付成功结果,不在预下单时预占商户额度或笔数;并发预下单可同时命中当前商户,已创建支付单不因后续轮询切换而改挂商户。
|
||||
11. 支付成功后发生的全额或部分退款不回冲商户池金额/笔数成功累计;退款是独立后续资金动作。
|
||||
|
||||
## 2.3 已确认的退款完成规则
|
||||
|
||||
1. 客户提供收款信息的退款以企业微信最终通过为退款完成时点;系统不感知、也不以外部线下实际打款、交易流水号或付款凭证作为退款状态条件。企业微信审批通过即标记已退款。
|
||||
2. 原路退款经企业微信最终通过后,系统必须以原实际收款商户自动调用渠道退款接口,超级管理员不得改选其他商户。只有渠道明确退款成功后退款单才标记已退款并保存渠道退款流水号;调用失败、超时或结果未知时,退款单保持原路退款处理中/失败,保留可恢复事实且不得重复退款。需改为凭证退款时,必须重新提交申请并重新走企业微信审批。
|
||||
3. 原路退款在选择方式、提交申请和实际执行前均重新校验原支付单、原实际收款商户、原渠道交易流水号、商户退款能力/凭证以及可退金额。商户停用不阻断历史订单原路退款。渠道实际调用是退款资格的最终判断;渠道明确拒绝、超期、凭证失效或余额不足时不标记成功、保留失败原因。系统不额外提供退款凭证预探测接口。
|
||||
4. 一笔订单最多只允许一张最终退款成功的退款申请;该申请可部分退款,成功后订单剩余未退款金额不再允许申请退款。
|
||||
5. 退款申请提交金额即企业微信审批授权金额和最终允许退款金额。原路退款按此金额调用渠道;凭证退款实际转出此金额后才可成功。金额需变更时不得继续执行原申请,必须重新提交并重新走企业微信审批。
|
||||
6. 一笔订单同一时间最多一张审批中、原路退款处理中或原路退款失败的退款申请。企业微信驳回、申请撤销/关闭或原路退款明确失败后,允许修改未成功退款申请并重提;可修改金额、原因、退款方式、客户收款信息和附件。每次重提必须新建企业微信审批实例及业务快照,历史材料不可覆盖。任一退款方式最终成功后,申请和订单均不得再发起退款。
|
||||
7. 企业微信最终通过即按当前规则使退款关联套餐失效、接续下一套餐并在必要时停机;原路退款渠道或线下实际打款的后续结果不影响套餐失效,也不恢复权益。
|
||||
8. 凭证退款申请必须提供客户收款账户信息和客户提供的收款凭证,作为企业微信审批材料;不要求、也不提供公司实际线下付款后的交易流水号或付款凭证回填。
|
||||
9. 超级管理员、平台用户和代理均可在各自数据权限内对已支付套餐订单发起退款申请;企业微信是唯一终审来源,本地不提供人工通过、拒绝或退回操作。任一具有该订单数据权限的上述账号,均可修改并重新提交未成功退款申请。
|
||||
10. 凭证退款的客户收款账户信息使用一个必填自由文本字段留存,客户提供的收款凭证附件必填;不新增退款收款账户类型字典、账户表,且不得复用公司线下收款方式字典。
|
||||
11. 退款申请的实收金额必须由系统从来源订单/原支付记录自动带出并冻结,提交人不可填写或修改。线上支付取原成功支付记录金额;资产钱包、代理预存款和后台线下订单取来源订单的实际收款或实际扣款金额。退款金额不得超过冻结实收金额;无法确定权威实收金额时拒绝创建申请。
|
||||
12. 本期不按套餐已用流量自动计算退款金额;提交人填写申请金额,系统仅校验不超过冻结实收金额,套餐使用情况作为退款原因、凭证和企业微信审批判断材料。
|
||||
13. 企业微信在本地收到通过前发生驳回、撤销或删除时,退款申请进入审批未通过/已关闭,未发生退款且可修改重提。企业微信通过后撤销时,不回滚已失效套餐、已发起原路退款或已完成凭证退款;申请标记审批异常、保留审计、禁止自动重提,由超级管理员线下处理。企业微信提交失败或结果未知时,退款申请仍为在途,不允许另建或重提,使用既有查询/重试闭环确认渠道是否受理。
|
||||
14. 对线上支付订单,系统在申请退款时预检可本地确定的原路退款条件:条件满足时同时提供原路退款和凭证退款;条件不满足时禁用原路退款并说明原因,只允许凭证退款。后端在提交及企微通过后的实际执行前均重复校验;后续条件失效时不得强行原路退款。钱包、预存款和后台线下订单不展示不适用的退款方式。
|
||||
15. 所有退款申请必须填写退款原因;退款原因冻结在当次企业微信审批快照中。
|
||||
16. 当前业务实际上限制一张订单仅购买一个套餐;虽数据模型预留多套餐订单能力,但本期退款申请不提供套餐选择,系统自动带出订单唯一关联套餐及其使用情况。企业微信通过后失效该套餐;若为主套餐,仍按现有规则连带失效其加油包。退款上限只按订单冻结实收金额校验。
|
||||
|
||||
## 2.3.1 退款管理补充字段
|
||||
|
||||
退款管理列表、详情及导出新增“当前退款套餐已用量”和“当前退款套餐总量”。当前退款套餐为退款订单自动关联的唯一套餐;两个字段读取该套餐使用记录当前可取得的真流量已用量和套餐总量,仅用于展示、查询和导出,不改变既定的退款金额校验、套餐失效或佣金回溯规则。
|
||||
|
||||
## 2.4 已确认的退款方式矩阵
|
||||
|
||||
| 来源订单的实际支付方式 | 本期允许退款方式 |
|
||||
| --- | --- |
|
||||
| 微信/支付宝等线上支付套餐订单 | 原路退款、客户提供收款信息退款 |
|
||||
| C 端资产钱包余额支付套餐订单 | 仅自动退回原资产钱包 |
|
||||
| 代理预存款/主钱包支付套餐订单 | 仅自动退回原代理预存款钱包 |
|
||||
| 后台线下套餐订单 / 员工代收款套餐订单 | 仅客户提供收款信息退款 |
|
||||
| 代理充值预存款业务单本身 | 当前退款模块不覆盖,需另立需求 |
|
||||
|
||||
## 2.5 已确认的退款联动
|
||||
|
||||
1. 来源订单全额退款且账单从未核销时,系统自动关闭账单,关闭原因记为来源订单全额退款。
|
||||
2. 来源订单部分退款且账单从未核销时,系统按退款金额冲减账单应收金额,并保留来源订单退款冲销记录。
|
||||
3. 账单存在任一已通过核销分摊时,系统不得自动冲销该账单;后续由超级管理员人工处理。
|
||||
4. 已核销账单的来源订单后续退款不恢复员工欠款;账单详情仅提示来源订单已退款并保留退款关联。正常订单退款不等于员工欠款重新产生。
|
||||
|
||||
## 2.8 已确认的手机号绑定基线
|
||||
|
||||
当前系统已有个人客户手机号绑定和全局 H5 强制绑定开关;是否强制绑定由全局配置决定,不存在、也不新增按资产导入批次设置“是否需要绑定手机号”的能力。原 PRD 第 15.4 节与当前行为冲突,不能作为本期新增功能依据。本期保留既有全局强制绑定逻辑,并新增已验证手机号与资产的关联记录:同一手机号最多关联 10 个当前有效资产,资产详情展示关联手机号,后台支持按资产解绑、批量解绑及操作记录。全局强制绑定开启时,客户首次登录一项尚未关联该手机号的资产,必须再次完成短信验证码校验后才建立关联;已关联该手机号的资产不再重复验证。全局强制绑定关闭时,H5 登录不要求短信验证码且不新增手机号—资产关联,已有关系保留并可后台查看、解绑。客户更换手机号并完成旧、新手机号验证码校验后,系统原子迁移旧手机号全部有效资产关联至新手机号;新手机号现有关联数加待迁移数超过 10 时整次换绑失败,原关系不变。资产详情列出全部当前关联手机号,单个解绑须明确选择一条资产手机号关系;批量解绑和 Excel 按资产标识导入解绑时,解除每项资产全部当前有效手机号关联,必须二次确认、填写原因并逐条记录实际解除关系。批量解绑逐资产独立执行:有权限且已绑定的资产成功解绑,其余资产失败;任务返回成功数、失败数和逐行失败明细,无权限项使用统一失败文案。具备资产数据权限的后台账号在资产详情、列表、导出和批量任务结果中均展示完整关联手机号,不做脱敏;操作日志不得记录完整手机号。仅超级管理员和平台用户可在资产数据权限范围内执行单个解绑、批量解绑和 Excel 导入解绑;代理、企业和个人客户没有后台解绑能力。手机号—资产关联只能由 H5 短信验证建立,后台不提供补录,后台仅查看和解绑。上线时不回填既有个人客户手机号与资产关系;功能上线后,既有客户首次登录每项资产仍须重新短信验证建立关联,超过 10 项时阻断本次新关联。换货不迁移手机号—资产关联,新资产首次访问时按全局开关重新验证。
|
||||
|
||||
## 2.7 已确认的业务用户组
|
||||
|
||||
1. 业务用户组用于标记平台用户所属业务,既不是后台权限角色,也不是代理店铺分组。
|
||||
2. 每个启用的平台用户最多属于一个启用的业务用户组;用户组不改变后台角色权限、数据范围或店铺具体业务员归属。
|
||||
3. 店铺所属用户组由当前绑定的平台业务员所属用户组实时推导;更换店铺负责人或负责人更换用户组后,店铺所属组随之变化。店铺未绑定负责人、负责人未分组或所属组已停用时,店铺所属组为空或显示已停用。店铺列表、详情和筛选均支持展示及按该推导用户组查询。
|
||||
4. 用户组停用后不得新增成员;现有成员关系保留,用户及其负责店铺均显示该组已停用。停用不影响用户登录、权限、数据范围和店铺负责人,管理员可后续改组或清空成员。
|
||||
5. 仅无成员的用户组可由超级管理员或平台用户二次确认删除;仍有成员时只能停用或先移走成员。
|
||||
6. 超级管理员和平台用户可勾选多个平台用户,批量设置为某个启用用户组或批量清空所属组;设置会直接替换原所属组,停用组不得作为批量目标。
|
||||
7. 超级管理员和平台用户可勾选多家有数据权限的店铺,批量设置为某个启用的平台业务员或批量清空业务员;店铺所属用户组随负责人实时推导更新。批量操作先校验全部目标店铺,任一店铺无权、不存在、已删除或目标业务员无效时整批不修改并统一失败。
|
||||
8. 用户组维护名称、稳定编码、排序、启用状态和备注;不设上级组、层级或组管理员。编码创建时必填、当前未删除用户组内唯一且创建后不可修改;名称、排序、状态和备注可修改。
|
||||
|
||||
## 2.10 已确认的 H5 风险换卡弹窗
|
||||
|
||||
广电风险停机自动弹窗只在当前 H5 登录并访问的资产同时满足以下条件时展示:资产为广电卡、运营商回传扩展状态为风险停机、且不存在待填写收货信息、待发货、已发货待确认或已完成的物流换货单。命中后向当前客户展示换卡提醒。客户提交地址后自动创建一张关联该旧资产的物流换货单;不另建风险换卡待处理记录。首次提交地址即锁定,客户后续不得修改,只能联系后台处理。风险换卡自动创建的物流换货单不在客户提交地址时预设业务数据迁移;后台发货并选择新资产时,仍由操作人按既有换货流程决定是否迁移。同一客户、同一资产的风险换卡弹窗每天最多展示一次;客户点击稍后处理后当天不再展示,次日仍命中条件时可再次展示。运营主动弹窗按店铺、设备类型、卡类型等已配置维度同时匹配;同一维度多选满足任意一个即可,未配置任何适用范围则面向全量客户。主动弹窗配置必须设置优先级;后端按请求条件仅返回优先级最高的一条,优先级相同取最近更新时间最新的一条,风险换卡通知固定高于主动弹窗。H5 弹窗复用个人客户站内通知记录,投放后同时出现在 H5 通知列表供查看历史内容。后端创建或返回弹窗候选时通知保持未读;H5 在客户关闭弹窗、点击操作按钮或进入通知详情后调用现有已读接口。运营主动弹窗仅在客户请求配置页面的候选弹窗时实时匹配并创建或复用通知,未访问 H5 的客户不预生成通知。后台固定支持首页、资产详情、套餐购买、资产钱包充值四种展示页面。频率仅支持每个客户对每条配置仅一次或每天一次。运营配置修改标题、内容、范围、页面、频率或有效期只影响后续投放,既有通知保留原快照;已修改配置作为新版本,对原“仅一次”配置的命中客户可重新投放一次。运营弹窗可不设操作,或设置一个受控按钮,目标仅可为套餐购买或资产钱包充值;不得配置任意 URL。H5 请求候选必须携带当前页面资产标识,店铺、设备类型和卡类型均按该资产匹配,首页由 H5 传当前选中的资产。运营弹窗配置到期或停用后停止新投放,既有通知在通知中心保留 90 天。风险换卡地址提交后停止新投放,既有风险换卡通知保留 90 天。风险换卡收货信息保留收货人姓名、收货手机号和一个完整地址文本三项;地址不拆分省、市、区及详细地址字段。超级管理员和平台用户可管理全局 H5 弹窗配置,代理、企业和个人客户不可管理。
|
||||
|
||||
## 2.9 已确认的换货迁移展示
|
||||
|
||||
换货列表和详情使用“业务数据迁移”及状态:不迁移、待迁移、已迁移、迁移失败;迁移失败可查看失败原因。详情明确迁移范围仅包括资产钱包余额、有效套餐使用记录、累计充值字段和资产标签。资产归属及个人客户—资产绑定属于换货完成固有动作;手机号—资产关联不属于迁移范围,新资产首次访问时按全局开关重新验证。迁移失败时换货单保持原可完成状态,记录最近一次失败原因;超级管理员或平台用户修复条件后可再次确认完成,整套迁移重新原子执行。
|
||||
|
||||
## 2.11 已确认的自动续费基线
|
||||
|
||||
自动续费仅扣待续费同一资产的资产钱包可用余额;对当前有效主套餐续购同一套餐商品,价格按执行时当前渠道可售续费价计算。第一版仅按最终到期前 N 天触发,不按流量阈值触发。平台设置一个总开关,并配置全部主套餐或指定主套餐及统一的到期前 N 天;进入触发窗口后,每项资产每天最多尝试一次,成功即停止,套餐到期后不再自动尝试。余额不足或套餐不可续费时,向当前个人客户及资产所属店铺当时有效业务员创建站内通知;同一资产同一天不重复通知。自动续费成功后,仅当资产处于可恢复停机状态且运营商状态不是风险停机或已销户时,自动调用既有复机;复机失败不回滚已成功的续费和钱包扣款,记录失败并通知客户和业务员。自动任务与手动续购并发时,手动续购优先;自动任务加锁重读后发现人工已完成续购即跳过,避免重复扣款。客户需要额外购买第二个周期时,须在首笔手动订单成功后再次主动下单。
|
||||
|
||||
## 2.12 已确认的代理分销码与提现资料基线
|
||||
|
||||
每个代理店铺创建时系统生成不可修改、全局唯一的随机分销码;二维码仅编码 H5 注册入口和该码。新代理扫码后以手机号短信验证码注册、自行设置密码,并创建待企业微信审批的下级代理及店铺,审批通过后启用。审批通过时新店铺设为分销码所属店铺的直接下级,并复制上级当时业务员为初始业务员,后续双方可独立调整。代理停用后其分销码立即不可注册,既有下级代理和既有佣金关系不受级联影响。
|
||||
|
||||
代理首次提现前提交提现资料资格申请,合同与法人身份证必填,企业微信审批通过且资料未过期才有效;资料变更或过期必须重审。代理提交提现申请时冻结可提现余额、金额、手续费、收款信息和可选发票快照,并自动创建企业微信提现审批,本地不提供人工通过或驳回。合同与法人身份证通过后长期有效,仅资料被代理替换、被超级管理员作废或代理停用时失效。法人身份证正、反面附件均必传。企业代理必须填写统一社会信用代码,个人代理填写法人身份证号;可选发票仅企业代理可上传并校验统一社会信用代码。营业执照、门头照、发票均为可选。合同资格申请必须填写签约主体统一社会信用代码或身份证号;上传发票时由代理填写发票抬头和统一社会信用代码,系统校验其与合同主体代码一致,企业微信审批人员核验附件真实性。企业微信驳回提现申请后,解冻该申请冻结的佣金余额;代理可修改金额、收款信息及本次可选发票后重新提交,每次创建新的企业微信审批实例,资料资格仍有效。企业微信通过即视为代理提现已到账,系统不登记或等待实际线下打款。发票是每笔提现申请的可选材料,如上传则按当前有效合同主体代码校验,并冻结至当次提现企业微信审批快照。
|
||||
|
||||
## 2.13 已确认的流量预警与通道阈值基线
|
||||
|
||||
套餐真流量预警使用套餐实际真流量,阈值为 1%~100% 的小数百分比;每个套餐商品最多一条当前规则,修改覆盖当前值,既有预警冻结阈值快照。按同一资产全部当前有效套餐的真流量用量和总量汇总计算使用比例,并使用主套餐规则判断;同一套餐使用记录与命中阈值只创建一条预警。以实际消耗流量的套餐记录关联资产为准,插拔卡场景同时展示卡和当前关联设备但不汇总多张卡。达到阈值时仅通知资产所属店铺当时有效业务员。规则停用后停止新触发且保留历史;启用或降低阈值后,下次扫描发现已有有效套餐达到阈值即补建预警。
|
||||
|
||||
运营商通道阈值默认关闭;开启时按运营商回传的卡当前计费周期累计流量判断。通道下每张卡独立判断,达量后系统创建可靠停机任务并自动调用运营商停机;达阈值即写入本地通道阈值停机锁,当前周期内拒绝复机,停机调用通过可靠重试或人工恢复确认最终结果。每个通道配置计费周期起始日(1~28,上海时区)。新周期开始后,仅对仍持有通道阈值停机锁、存在有效主套餐且不存在风险停机、销户或其他停机锁的卡自动复机;不符合条件只解除通道锁,不调用运营商复机。自动复机失败记录结果并按既有可靠机制处理。
|
||||
|
||||
## 2.14 已确认的佣金回溯与套餐层级展示基线
|
||||
|
||||
套餐退款佣金回溯后,原佣金记录保持不变,另建关联原佣金记录及退款单的负数佣金明细,佣金明细新增不可提现的“回溯”终态;回溯明细冻结原订单号及其他原佣金字段。部分退款按本次退款金额与订单冻结实收金额的比例,对每条原佣金等比例回溯,按分向下取整、最后一条补足舍入差,累计不超过原佣金。退款发生时佣金计算尚未完成的,等待其终态后再生成全部应有回溯明细;确认无佣金才标记无需回溯。同一退款业务幂等,不重复生成回溯明细。本期只处理套餐退款回溯,换货回溯待独立定义。佣金回溯时直接扣减佣金钱包,允许余额为负;先拒绝并释放待审核提现,再生成回溯明细和扣款流水。
|
||||
|
||||
后台资产详情及 H5 资产套餐历史均返回主套餐及关联加油包的层级结构,直接依据现有 `PackageUsage.master_usage_id` 分组,不新增关联表。加油包按生效时间正序排列,待生效包按购买创建时间正序并排在已生效包之后。无论主套餐或加油包已失效、过期或用尽,均保留层级关系;仅关联主套餐物理缺失时以“关联主套餐缺失”的异常独立项展示。
|
||||
|
||||
## 2.15 已确认的优先轮询定位
|
||||
|
||||
优先轮询是与现有普通轮询并行的高优先级调度队列,而不是一套新的轮询业务逻辑。资产可同时存在于普通轮询和优先轮询,进入优先队列不移除、暂停或改变普通轮询;优先队列仅使该资产额外优先执行同一套既有轮询内容、外部调用及状态同步。一次优先轮询执行成功后任务退出优先队列,资产仍按普通轮询继续运行;外部调用失败或超时按既有失败重试。第一版纳入无有效套餐、套餐过期续购、流量用完购买加油包、人工触发和普通轮询异常补偿五类场景。同一资产有未完成优先任务时,后续触发合并到该任务,追加触发次数、最近时间及来源,不重复调用同一轮普通轮询。优先队列复用普通轮询既有并发上限、失败重试和外部调用保护,仅调度顺序优先,不另建参数配置。
|
||||
|
||||
> **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 已确认的导出、时间筛选与代理自充收款方式基线
|
||||
|
||||
临期列表、佣金明细和套餐流量达量预警均复用现有异步导出任务,创建时冻结筛选条件、操作者及可见店铺范围。临期导出一行对应一项资产,取其当前生效主套餐最终到期时间和剩余天数;加油包不单独成行。流量达量预警导出一行对应一条预警记录,套餐、用量、总量、阈值和到期时间使用触发快照,店铺、业务员和用户组按导出执行时当前归属补充。佣金明细的入账后金额冻结每次佣金钱包变动后的实际余额,回溯记录可为负。
|
||||
|
||||
所有要求时间筛选的页面使用统一“开始时间—结束时间”组件和 `start_time/end_time` 参数,支持带时区的 RFC3339 秒级时间闭区间,任一端可不传。IoT/设备任务、换货、分配、订单、代理充值、佣金、提现和导出按创建或申请时间筛选;授权按授权发生时间;临期列表按套餐最终到期时间。导出必须复用当前页面全部筛选条件和创建时数据权限快照。
|
||||
|
||||
代理自充方式由超级管理员维护允许范围(仅微信、仅支付宝、同时支持);代理实际可用方式取该允许范围与当前可用商户池方式的交集,交集为空时拒绝创建在线充值单。平台用户和代理只可查询实际可用方式。配置变更不影响已创建未支付充值单的支付方式及商户快照,只影响后续新单。代理充值记录新增交易流水号字段,用于保存该笔充值对应的交易流水号。
|
||||
|
||||
## 2.17 已确认的报表基线
|
||||
|
||||
激活报表的采购数量以成功导入系统的设备数量计算,不另建采购或入库台账。
|
||||
|
||||
> **AUG26-015 实施口径标注(2026-09-17)**:上式「以成功导入系统的设备数量计算」按**系统内实际存在的设备**实施——采购数量 = 截至快照日(上海自然日)系统内未删除的设备数,与 `111.md` §22.4.1「系统录入的设备数量」同读法,且不新建采购或入库台账。不采用「设备导入任务成功行数」(`tb_device_import_task` 中 `operation_type='import'` 且已完成任务的 `success_count` 之和):生产库实测该值为 **474**,而系统内未删除设备为 **18,970**,差额来自老系统迁移脚本直接写入设备、绕过导入任务;按字面口径激活率约 **1,399%**,指标不可用。该口径收敛在一个口径函数内,切换成本为一行。本条其余文字与其余基线确认内容不变。
|
||||
|
||||
功能上线后每日生成稳定日报快照;上线前日期不提供报表或明确显示无快照数据,不回填历史。累计激活设备严格采用任一当前关联卡已实名的口径;每日快照中的累计在网设备为已实名且存在有效主套餐的设备,活跃设备为该套餐周期内任一卡真流量大于零的设备,用量为设备当前套餐周期内全部关联卡真流量之和。报表可选择设备名称、型号、制造商、用户组、代理、店铺、业务员中的一个分组维度;未选择时汇总为一行。套餐续费按资产去重,统计期内有主套餐到期的资产计一次到期,至少成功续购一次主套餐计一次续费,续费率不超过 100%。日报快照冻结当天店铺、业务员及用户组归属。后端提供日/月趋势汇总数据和异步导出,图表渲染由前端负责。
|
||||
|
||||
## 2.18 已确认的店铺批量换绑 Excel 基线
|
||||
|
||||
店铺列表勾选批量换绑保持全量预校验、任一项失败整批不更新。Excel 导入复用现有统一导入任务处理方式,逐行执行:成功行提交,失败行不影响其他行,并输出成功数、失败数和逐行失败明细;不设 1000 行硬上限。两种入口均保留。Excel 使用店铺编码唯一定位店铺;换绑时以目标平台用户登录账号唯一定位业务员。每家实际变更店铺复用现有统一审计,记录原业务员、新业务员、操作人、时间和 Excel 行备注;批量任务同时保留汇总结果。
|
||||
|
||||
## 3. 当前阻塞与待后续独立需求
|
||||
|
||||
以下不是本轮继续扩展的产品设计题:
|
||||
|
||||
1. **OCR 外部契约**:仅可作为交易流水号、付款金额等字段的预填能力;接口、字段置信度、失败和重试规则须以外部契约为准。
|
||||
2. **支付渠道契约**:微信直连、富友和支付宝的原路退款接口、超时查询及可恢复错误处理,须以各渠道实际契约为准。
|
||||
3. **运营商契约**:通道计费周期起始日、停复机实际能力及结果未知后的查询/恢复,以运营商接口及业务提供的通道政策为准。
|
||||
4. **换货佣金回溯**:本期只处理套餐退款;“不同资产换货”何时、按何金额回溯佣金未定义,后续如需实施须单独提出需求。
|
||||
|
||||
除上述依赖及独立后续需求外,本轮原始需求的业务规则已收口;后续工作是重编稳定需求 ID、建立原编号映射,并按影响范围拆分独立 OpenSpec Change,不直接开始编码。
|
||||
@@ -0,0 +1,109 @@
|
||||
# 后台代客下单误判为"C端已实名购买",套餐跳过实名门槛直接生效(Bug,已修复)
|
||||
|
||||
> 2026-10-27 已修复:`activateMainPackage` 的 `isCEndPurchase` 增加 `order.Source == client` 条件,后台平台自营与赠送套餐线下订单不再跳过实名门槛(同时可正常进入沉默期)。
|
||||
|
||||
## 起因
|
||||
|
||||
排查设备 `60003081756`(换货前旧资产)与其换货后新资产 `60003080119` 的换货记录(换货单 `EXC20260922173715169105`)时发现:换货完成后,操作人蒋恒在后台为新设备(`tb_device.id=19452`)代客户下单购买套餐(订单 `ORD20260922175155311314`,`payment_status=paid`),套餐(`tb_package_usage.id=83144`)在下单瞬间(`2026-09-22 17:51:55`)就变成"生效中"(`status=1`),而此时这张卡(`tb_iot_card.id=132322`,iccid `89860624660030801193`)的 `real_name_status` 仍是 `0`(未实名)。本记录仅做事实归档,**未修改任何代码**,全部结论来自对生产库 `junhong_cmp_prod` 的只读查询(`root@116.162.101.91`,仅执行 `SELECT`)与仓库 Git 历史核对。
|
||||
|
||||
## 结论
|
||||
|
||||
- **Bug 真实存在,当前代码未修复,仍会触发。**
|
||||
- 触发条件:后台管理员为**一台当时还没有分配店铺归属(`shop_id` 为空)的设备/卡**,用 `offline` 或 `wallet` 支付方式代客户下单购买套餐时,系统会把订单误判为"客户本人在 App 自助下单",从而跳过套餐本该有的"等实名认证后再激活"逻辑,导致套餐在客户尚未实名的情况下立即生效、开始计时。
|
||||
- 本次涉及的客户(personal customer_id=1008)事后约 18 分钟自行在运营商侧完成了实名(详见下方"实名完成时间核对"),影响较小;但历史上已有 3 起同类真实案例(非数据迁移脚本产生),不能排除客户未及时实名、白白损失部分套餐有效期的情况。
|
||||
|
||||
## 根因
|
||||
|
||||
### 1. 直接原因:`isCEndPurchase` 判断条件遗漏 `Source` 检查
|
||||
|
||||
- **代码位置**:`internal/service/order/service.go:2574-2596`,函数 `activateMainPackage`。
|
||||
- 决定"套餐是否要等实名才激活"的开关:
|
||||
|
||||
```go
|
||||
// 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活)
|
||||
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
|
||||
|
||||
if !isCEndPurchase {
|
||||
// 后台囤货路径:按 expiry_base 决定是否等实名
|
||||
if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase {
|
||||
if !currentlyRealnamed {
|
||||
status = constants.PackageUsageStatusPending
|
||||
pendingRealnameActivation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// C 端购买已完成实名前置校验,直接激活。
|
||||
```
|
||||
|
||||
只要 `order.BuyerType == "personal"` 就认定"客户已在 App 里做过实名前置校验,可以直接激活",**完全没有检查订单来源(`order.Source`)**。
|
||||
|
||||
- 同一文件里另一处判断"是否真的是客户在 App 自助下单"时,写法是**严谨的**(`internal/service/order/service.go:2868`):
|
||||
|
||||
```go
|
||||
if order.Source == constants.OrderSourceClient && order.BuyerType == model.BuyerTypePersonal {
|
||||
```
|
||||
|
||||
两处对"C端购买"的判定标准不一致,`activateMainPackage` 里少了 `Source == client` 这一层。
|
||||
|
||||
### 2. 触发路径:`buyer_type=personal` 是怎么产生的
|
||||
|
||||
- **代码位置**:`internal/service/order/service.go:284-333`,函数 `CreateAdminOrder`(后台代客下单,线下支付分支)。
|
||||
- `resourceShopID = resolvedDevice.ShopID`(取设备当前的 `shop_id`):
|
||||
- 若 `resourceShopID == nil || *resourceShopID == 0`(设备当时没有店铺归属),判定为"平台自营,直接卖给终端客户",**强制**把 `orderBuyerType` 设为 `model.BuyerTypePersonal`。
|
||||
- 本次案例中,设备 `19452` 在下单那一刻(`17:51:55`)确实还没有分配店铺——分配店铺的动作(`device.allocate`,`shop_id: null → 670`)发生在 `17:52:44`,**在下单之后**。于是订单被自动打上 `buyer_type=personal`,进而在 `activateMainPackage` 里命中上述 `isCEndPurchase=true` 分支。
|
||||
|
||||
### 3. 时间线为何这两段代码会"撞车"
|
||||
|
||||
| 时间 | 提交 | 内容 |
|
||||
|---|---|---|
|
||||
| 2026-03-02 | `8efe7952` | 「修复平台自营资源(未分配代理)无法线下下单的问题」——引入"设备无店铺归属 → `buyer_type=personal`"这条路径 |
|
||||
| 2026-03-28 | `7dd55630` | 「activateMainPackage 三维决策重构 REALNAME-02」——引入 `isCEndPurchase := order.BuyerType == Personal` 假设 |
|
||||
|
||||
`7dd55630` 落地时,`8efe7952` 引入的"后台平台自营代客下单也会产出 `buyer_type=personal`"这条路径已经存在近一个月。`isCEndPurchase` 的假设从写下的那一刻起就没有覆盖到这条路径,二者从未真正对齐过。
|
||||
|
||||
## 实名完成时间核对(本案例,非普遍规律)
|
||||
|
||||
```
|
||||
2026-09-22 17:51:55 订单创建,套餐立即激活(status=1,跳过实名等待)
|
||||
2026-09-22 18:10:38 iot_card.worker_realname_sync(后台定时轮询任务,从运营商侧同步)
|
||||
before: {"real_name_status": 0}
|
||||
after: {"real_name_status": 1, "first_realname_at": "2026-09-22T10:10:37Z"(UTC,即北京时间 18:10:37)}
|
||||
```
|
||||
客户是在运营商侧真实完成的实名认证,系统通过定时轮询同步过来,不是人工手动改的状态。套餐已提前激活约 18 分钟,这段时间里客户理论上是在为一个"计时中但用不了"(网络仍是停机状态)的套餐付费/计时。
|
||||
|
||||
## 数据验证:历史命中范围
|
||||
|
||||
用以下条件在生产库上识别同类命中(后台代客下单、`buyer_type=personal`、套餐配置为"需等实名激活"、但落库时直接是生效状态):
|
||||
|
||||
```sql
|
||||
-- 设备类套餐(usage_type='device')
|
||||
SELECT count(*) FROM tb_package_usage pu
|
||||
JOIN tb_order o ON o.id = pu.order_id
|
||||
WHERE o.source = 'admin' AND o.buyer_type = 'personal'
|
||||
AND pu.expiry_base_snapshot = 'from_activation'
|
||||
AND pu.pending_realname_activation = false
|
||||
AND pu.status = 1 AND pu.master_usage_id IS NULL
|
||||
AND pu.usage_type = 'device'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tb_device_sim_binding b
|
||||
JOIN tb_iot_card c2 ON c2.id = b.iot_card_id
|
||||
WHERE b.device_id = pu.device_id AND b.bind_status = 1 AND c2.real_name_status = 1
|
||||
);
|
||||
```
|
||||
|
||||
粗查命中 5210 条,但其中 **5207 条来自一次历史数据迁移脚本**(`order_no` 以 `MIG-` 开头,2026-09-03~09-04 一天多内由 2 个系统账号批量写入),属于把老系统里客户本来就已生效的历史套餐批量导入新系统,跳过实名门槛是迁移脚本的预期行为,**不算本 bug 命中**。
|
||||
|
||||
刨除迁移脚本后,**真正由人工后台操作触发的同类历史案例只有 3 起**,加上本次共 4 起:
|
||||
|
||||
| 订单号 | 操作人 | 设备 | 时间 |
|
||||
|---|---|---|---|
|
||||
| ORD20260905144024517558 | 王鹏 | 18914 | 2026-09-05 14:40 |
|
||||
| ORD20260905144241735458 | 王鹏 | 18913 | 2026-09-05 14:42 |
|
||||
| ORD20260916112035085745 | 王鹏 | 19108 | 2026-09-16 11:20 |
|
||||
| ORD20260922175155311314 | 蒋恒 | 19452 | 2026-09-22 17:51(本次) |
|
||||
|
||||
## 后续整改(代码侧,尚未执行)
|
||||
|
||||
1. **建议修复**:将 `internal/service/order/service.go:2575` 的 `isCEndPurchase` 判断改为与 `service.go:2868` 一致,同时检查 `order.Source == constants.OrderSourceClient && order.BuyerType == model.BuyerTypePersonal`,使"后台代客下单"不再被误判为"客户 App 自助下单"。
|
||||
2. **建议核实**:王鹏那 3 起历史案例(设备 18913、18914、19108)对应的卡后续是否及时完成了实名认证,是否存在客户实际损失套餐有效期天数的情况,需要跟客服/王鹏本人核对。
|
||||
3. 本记录暂未提交 OpenSpec Change,代码修复建议后续单独立项处理;此文档仅作为问题发现与数据取证的归档,供后续排期与客诉复盘引用。
|
||||
@@ -0,0 +1,32 @@
|
||||
# `add-agent-registration-management` 集成验证阻塞证据
|
||||
|
||||
日期:2026-09-21
|
||||
|
||||
## 部署可达性
|
||||
|
||||
- 当前工作分支:`Iteration/8-11`;本地 `HEAD=b0636171530df2cbd468c0f6e25091049ad9aeb1`。
|
||||
- `git ls-remote origin refs/heads/Iteration/8-11` 返回同一 SHA,说明当前未提交 Change 工作区没有进入远端分支。
|
||||
- `.gitea/workflows/deploy.yaml` 仅由 `push` 到 `Iteration/8-11` 触发;本次按用户要求不提交、不推送。
|
||||
- 对 Gitea API `POST https://git.boss160.cn/api/v1/repos/csxj2026/junhong_cmp_fiber/actions/workflows/deploy.yaml/dispatches` 的未认证探测返回 `HTTP/2 401`,`{"message":"token is required","url":"https://git.boss160.cn/api/swagger"}`。环境未提供 Gitea token;不绕过既有工作流。
|
||||
- `cmp-test` SSH 别名可达(`cmp-test -> qycard001@47.111.166.169:52022`),允许读取状态/日志。容器实际为:
|
||||
- API:`registry.boss160.cn/junhong/cmp-fiber-api:b0636171530df2cbd468c0f6e25091049ad9aeb1`,health=`healthy`。
|
||||
- Worker:`registry.boss160.cn/junhong/cmp-fiber-worker:b0636171530df2cbd468c0f6e25091049ad9aeb1`,running=`true`。
|
||||
- 容器环境确认 PostgreSQL `junhong_cmp_test`(`cxd.whcxd.cn:16159`)和 Redis DB 6(`cxd.whcxd.cn:16299`)。
|
||||
- 测试 API 在 `cmp-test` 容器内 `curl http://127.0.0.1:3000/health` 返回 `HTTP/1.1 200 OK`;外部未提供测试 API 地址,未冒充本地/外部 API 可达。
|
||||
- 当前部署 SHA 为 `b063617...`,等于远端基线 SHA;未包含本地未提交的注册管理 Change,因此目标路由验证返回 `HTTP/1.1 404 Not Found`。
|
||||
|
||||
## 受控 fixture
|
||||
|
||||
- 使用临时 GORM 工具 `cmd/agent_registration_fixture_tmp/main.go`,只连接显式 `DB_*` 的 `junhong_cmp_test`,未使用 psql;dbhub MCP 仅做只读诊断。
|
||||
- 唯一前缀:`ARM260921`。创建了 5 个店铺、6 个账号、6 条注册记录、6 个审批实例和 6 个企微上下文;包含直属、深层、其他店铺、停用上级、终态与提交异常状态。
|
||||
- 首次 setup 因测试 fingerprint 超过 `char(64)` 约束回滚,未留下记录;修正为 `arm260921fixture-fingerprint` 后创建成功,IDs 记录在 `/tmp/ARM260921-setup.txt`。
|
||||
- 已执行受控 cleanup,仅按 `ARM260921` 的注册/店铺/账号及关联审批实例/企微上下文删除;未重置数据库,未操作其他记录。清理输出:`cleanup complete for ARM260921`。
|
||||
|
||||
## API 尝试边界
|
||||
|
||||
- 在部署基线容器上以 fixture 账号尝试登录:`ARM260921-super` 返回 `code=1040 用户名或密码错误`(该账号由本地 Change fixture 创建,但容器运行的是基线代码;数据库账号密码本身可由部署 API 按登录查询)。
|
||||
- 直接访问目标路由无 token 返回 `401 code=1002`;带从成功登录获得的现有 fixture token 访问目标路由返回 `404 code=1006 资源未找到`,证明当前部署未装载目标 Change 路由,而非权限断言。
|
||||
|
||||
## 结论
|
||||
|
||||
工作流可访问但不可触发:Gitea API 需要未提供的 token;按用户要求不能提交/推送,故无法把当前 Change 部署到 `cmp-test`。当前已部署版本健康可达但不包含 Change。5.1–5.4 未勾选、未把基线 404/fixture DB 事实冒充端到端验证。
|
||||
114
docs/verification/add-asset-package-hierarchy-implementation.md
Normal file
114
docs/verification/add-asset-package-hierarchy-implementation.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# AUG26-013 实施与验证记录
|
||||
|
||||
## 当前完成范围
|
||||
|
||||
- 已完成并在 Change 任务中勾选:1.1 至 4.2。3.1 至 4.2 的勾选适用下文“用户授权的完成判定”,不等同于所有运行时场景已经实际通过。
|
||||
- 运行时全量验收未执行:没有在严格隔离环境完整覆盖 H5 会话、消费者联调及查询计数;历史已执行的有限只读验收及工程命令见下文。
|
||||
- 未修改 Schema、迁移、套餐状态、金额、退款或外部支付/审批流程。
|
||||
|
||||
## 已执行的脱敏命令与结果
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `gofmt -w internal/query/asset/package_history.go internal/service/asset/service.go internal/handler/app/client_asset.go internal/model/dto/asset_dto.go internal/model/dto/client_asset_dto.go` | 成功,无输出。 |
|
||||
| `go build ./cmd/api ./cmd/worker` | 初次因废弃的 `sort` 导入失败,移除后以临时可写 Go 缓存重新执行成功。 |
|
||||
| `go run cmd/gendocs/main.go` | 成功生成 `docs/admin-openapi.yaml`。 |
|
||||
| `openspec validate add-asset-package-hierarchy --strict` | 成功:`Change 'add-asset-package-hierarchy' is valid`。 |
|
||||
| `openspec doctor --json` | 成功:root healthy,`status: []`。 |
|
||||
|
||||
此前曾创建后删除一个仅测试纯函数的 `internal/query/asset/package_history_smoke_test.go` 并运行 `go test`。该行为不符合项目“自动化测试 N/A”的后续执行约束;文件已删除,结果不作为任务 3.x 的隔离环境入口验收证据。后续不再创建 `*_test.go` 或运行 `go test`。
|
||||
|
||||
## 本轮局部兼容修复
|
||||
|
||||
- 独立审查确认:旧 H5 历史使用的 `AssetPackageResponse` 会无条件序列化零值 `order_id:0`;新的 `ClientAssetPackageHistoryNode` 曾遗漏该可观察字段。
|
||||
- 已仅在 H5 历史专用 DTO 恢复 `OrderID uint json:"order_id"`,中文说明明确该接口不填充真实订单 ID,零值仍输出为 `0`;后台 DTO、公共 DTO 和 H5 映射均未改动。
|
||||
- 第二项审查结论:上述四个历史数组在生成 OpenAPI 中均误标 `nullable:true`,但运行时契约要求始终返回 `[]`。已仅为 `AssetPackageHistoryNode.Children`、`ClientAssetPackageHistoryNode.Children`、`AssetPackagesResult.Items`、`AssetPackageHistoryResponse.List` 添加项目生成器支持的 `nullable:"false"` tag;未扩展生成器行为,未手改 YAML。
|
||||
- 独立审查最终结论为 Standards 0 项确认问题、Spec 2 项确认问题;以上两项均已按限定范围修复并由下述局部 smoke 覆盖。
|
||||
|
||||
### 本轮精确局部验证
|
||||
|
||||
| 命令 | 可观察结果 |
|
||||
| --- | --- |
|
||||
| `gofmt -w internal/model/dto/asset_dto.go internal/model/dto/client_asset_dto.go && go build ./cmd/api ./cmd/worker && go run cmd/gendocs/main.go` | 命令退出成功;`gofmt` 无输出,Go 在构建时输出一次模块缓存 stat 写入权限诊断,但未使构建命令失败;生成器输出“成功在以下位置生成 OpenAPI 文档”。 |
|
||||
| `go run asset_package_history_contract_smoke.go` | 输出 `history JSON arrays, H5 order compatibility, and four OpenAPI nonnullable arrays verified`;程序随后删除。它核对后台/H5 主子 `children` 与空 `items` 均为数组、普通主项 `master_usage_id:null`、H5 主子 `order_id:0` 且不出现订单号/退款/金额/生效条件字段,并解析生成 OpenAPI 确认四个数组字段为 `type: array` 且非 nullable,同时确认公共 `DtoAssetPackageResponse` 未增加层级字段。 |
|
||||
|
||||
此为 DTO/JSON/生成文档的局部契约 smoke,不触发真实 API、数据库或消费者联调;不替代任务 3.x 或 4.1 的实际验收。
|
||||
|
||||
## 真实测试环境只读验收(本轮)
|
||||
|
||||
- 用户已明确 `.env.local` 指向测试环境;本轮按该事实执行,未访问生产环境。
|
||||
- `source .env.local` 仅在子进程内完成且未回显值。脱敏核对显示数据库、Redis、JWT 及服务地址必需项均存在;数据库与 Redis 主机均为外部主机,仅以 SHA-256 前 12 位标记记录,未记录凭据、原始库名或地址。
|
||||
- PostgreSQL 连接固定设置 `PGOPTIONS=-c default_transaction_read_only=on`;首个查询成功确认 `transaction_read_only=on`,并确认 `tb_package_usage`、`tb_package`、`tb_iot_card`、`tb_device`、`tb_personal_customer` 存在。未执行任何迁移、DDL、DML、事务写入或外部支付/审批调用。
|
||||
- 最小 API 首次以 README 所示的 `go run cmd/api/main.go` 启动,因 `undefined: generateOpenAPIDocs` 退出;改为 `go run ./cmd/api` 后监听 `127.0.0.1:18181` 成功。启动前只读确认有 4 个启用超级管理员,因此 `initDefaultAdmin` 只会走存在检查与跳过分支。进程的日志仅写入 `/tmp`,验收后已停止。
|
||||
- 配置 Redis DB `7` 无既有后台或 H5 会话,复用会导致后台历史请求返回 `401/code=1003`。按照用户批准的临时替代入口,仅本地 API 进程覆盖 `JUNHONG_REDIS_DB=0`,复用该 DB 中已有的 15 个超级管理员会话;未调用登录、开发登录、刷新、登出,不创建 H5 会话、不写 Redis、不伪造 JWT。随后后台入口返回 `200/code=0`。
|
||||
|
||||
### 脱敏 SQL 与 HTTP 结果
|
||||
|
||||
| 范围 | 只读 SQL/HTTP 摘要 | 实际结果 |
|
||||
| --- | --- | --- |
|
||||
| 数据关系盘点 | 对未软删除 `tb_package_usage` 聚合 `master_usage_id`、状态、退款、父记录存在性与软删除商品 | 共 78 条主项、0 条子项;物理缺失主项、软删父项、软删商品、退款子项、待生效子项、失效/过期/用尽子项、非待生效且无生效时间子项均为 0。卡与设备均不存在任何主子关系组。 |
|
||||
| 后台候选卡 | 对资产标识 SHA-256 前缀 `1f0b3a3a5ee3` 查询 usage;`GET /api/admin/assets/<hash>/packages` 使用已有超级管理员 token | SQL 得到 3 条顶层主项,状态分布为生效中 1、失效 2,均 `master_usage_id=NULL`。`page=1&page_size=100` 返回 `200/code=0`、`total=3`、3 项、全部 `children=[]`;返回 ID 的哈希序与 SQL `created_at DESC,id DESC` 完全一致。 |
|
||||
| 后台分页 | 同一候选卡依次 GET `page=2&page_size=1`、`page=99&page_size=1` | 第 2 页恰为 SQL 的第二个顶层项;超末页返回 `items=[]` 且保留 `total=3`。 |
|
||||
| 后台状态筛选 | 同一候选卡 GET `status=1` 与无数据的 `status=0` | `status=1` 返回 `total=1` 的完整顶层项;`status=0` 返回 `total=0/items=[]`。 |
|
||||
| 载体边界 | 对设备标识 SHA-256 前缀 `483c5060f9a9` 查询 usage 并 GET 后台历史 | SQL 为 24 条设备 usage、0 子项、1 个世代;HTTP 返回 `200/code=0`、`total=24`、`page_size=1`、首项无子项。卡与设备均在各自资产范围内响应,未见跨载体内容。 |
|
||||
| 当前套餐兼容 | `GET /api/admin/assets/<card-hash>/current-package` | `200/code=0`;实际 JSON 不含 `children` 或 `master_usage_id`,未被历史层级 DTO 连带改变。修改套餐接口是写接口,受本轮只读限制未调用。 |
|
||||
| 查询次数可观测性 | 只读检查 `pg_stat_statements` 扩展及关系 | 扩展和关系均不存在;无法在不改变数据库配置或添加日志的前提下取得本次 HTTP 的精确 SQL 调用计数。 |
|
||||
|
||||
### 先前真实数据覆盖结论(当时)
|
||||
|
||||
| 任务 | 结论 | 未完成的精确原因 |
|
||||
| --- | --- | --- |
|
||||
| 3.1 | 后台已部分验证;当时不勾选 | 实际数据只有多主/无子项,子项总数为 0;没有多子项、子项超过页大小、异常独立项。H5 没有既有会话,不能执行该入口的真实 JSON 验收。 |
|
||||
| 3.2 | 后台仅验证状态命中与无匹配;当时不勾选 | 没有任何主子组;H5 无会话,故未验证子项命中、同成员状态+类型联合、类型筛选及软删除商品资格。 |
|
||||
| 3.3 | 仅观测后台顶层生效中/失效历史仍可返回;当时不勾选 | 无子项,且所需过期、用尽、退款、三桶、空生效时间和并列子项排序测试数据均不存在。 |
|
||||
| 3.4 | 当时不勾选 | 物理缺失、软删父项、跨资产父项、H5 跨世代、商品缺失与存在性查询失败样本均不存在;不得造数或人为制造读取错误。 |
|
||||
| 3.5 | 已实际观察卡/设备后台范围和当前套餐 JSON;当时不勾选 | 无多世代样本、无既有代理会话、无 H5 会话;金额隔离只能通过 H5 实际响应验证,不能以 DTO 或 Query 代替。修改套餐接口为写接口,未调用。 |
|
||||
| 3.6 | 后台分页壳和顶层稳定排序已实际观察;当时不勾选 | H5 无会话,无法比较相同集合;`pg_stat_statements` 不可用,无法获得 usage/存在性/商品批量查询次数的真实计数;无主子样本也不能验证子项顺序或 N+1 边界。 |
|
||||
| 3.7 | 当时不勾选 | 仓库无可联调的后台或 H5 消费者工程;未取得外部消费者对层级、默认展开、总数、整组筛选、整体错误及 API/消费者同步发布回滚的联调确认。 |
|
||||
|
||||
当时 `tasks.md` 的 3.1—3.7、4.1、4.2 均保持未勾选。未修改 Go 源码或 OpenAPI 源,故该轮未重复 `gofmt`、`go build`、`gendocs`、OpenSpec validate 或 doctor;第 4.x 的既有记录见上文,不能替代缺失的 H5 与主子实际验收。
|
||||
|
||||
### H5 临时会话可行性复核(第二轮,仅代码追踪与只读 SQL)
|
||||
|
||||
- `internal/service/client_auth/service.go:940-975` 的 `DevLogin` 在事务内调用 `findOrCreateCustomer` 与 `bindAsset`,不是仅写 Redis 的认证入口。
|
||||
- 即使 OpenID 已存在,`findOrCreateCustomer` 也会在 `service.go:718-749` 读取客户后无条件执行 `customerStore.Update(ctx, customer)`(746 行);昵称、头像为空时不会改变内存字段,但仍不能排除业务表 `UPDATE`。未命中时 799-823 行会创建个人客户和 OpenID 记录。
|
||||
- `bindAsset` 转至 `customer_binding.Service.Bind`(`service.go:891-893`);已有有效绑定的 PCD/PCI 分支会在 `customer_binding/service.go:265-288` 或 313-341 行返回而不创建绑定,但这不能消除前述客户 `UPDATE`。未绑定时相应的 271-302 或 319-349 行会创建绑定、首次绑定可修改资产并写审计。
|
||||
- 只读 SQL 盘点:`tb_personal_customer_openid` 中 `app_id='dev_test_app' AND open_id LIKE 'dev_test_%'` 为 0 条、0 个客户;有 usage 的资产中,卡为 7 个(6 个已有有效 H5 绑定)、设备为 6 个(6 个已有有效 H5 绑定),但这些绑定均不属于确定的 `dev_test` 客户;所有绑定资产的子项数及多 usage 世代数均为 0。
|
||||
|
||||
若需继续完成 H5 真实验收,仅接受以下任一前置:
|
||||
|
||||
1. 提供与候选资产有效绑定对应的既有、可只读复用的 H5 会话或凭据;会话获取路径不得创建客户、绑定或其他业务记录。
|
||||
2. 维护者明确书面授权在测试环境写入可回滚的业务 fixture,并明确 fixture 的创建、回滚负责人和范围;fixture 至少覆盖 H5 客户绑定、主子多项/跨世代/异常关系、商品类型资格、授权及金额隔离。未经该授权不得造数或调用会写业务表的认证入口。
|
||||
- 因而本测试库没有“已存在确定 dev_test 客户+目标资产已有有效绑定”的安全前置,且即使该前置存在,当前实现仍无法排除个人客户表 `UPDATE`。本轮不调用 `DevLogin`、不请求用户凭据、不伪造 JWT;H5 真实 GET 继续保持未验收。
|
||||
|
||||
## 已核对的生成文档
|
||||
|
||||
`docs/admin-openapi.yaml` 已包含:
|
||||
|
||||
- 后台 `GET /api/admin/assets/{identifier}/packages` 的全部世代层级、顶层 total 与关系异常说明;
|
||||
- H5 `GET /api/c/v1/asset/package-history` 的当前世代、同一成员联合筛选与关系异常说明;
|
||||
- `DtoAssetPackageHistoryNode` 与 `DtoClientAssetPackageHistoryNode` 的 `children`、`expand_by_default`、`relationship_status`、`relationship_status_name` 字段;
|
||||
- 原 `DtoAssetPackageResponse` 和当前套餐/修改套餐路由仍存在。
|
||||
|
||||
- `.gitignore` 明确忽略 `docs/admin-openapi.yaml`,且该路径不在 Git 跟踪清单;它是本地生成产物而非提交源文件。
|
||||
- 项目交付方式是 `go run cmd/gendocs/main.go`(等价 Make 目标 `docs`):生成器将路由注册结果写入该固定路径。本轮已重新生成;`DtoClientAssetPackageHistoryNode.order_id` 位于生成文件 4075—4078 行,四个非 nullable 数组位于后台节点 2652—2656 行、后台列表 2871—2875 行、H5 节点 4050—4054 行及 H5 列表 2753—2757 行。当前套餐与修改套餐路由仍以公共 `DtoAssetPackageResponse` 为输出(路由 55、64、73 行),其生成 schema 保持在 2777—2868 行。
|
||||
|
||||
后台 API 的有限真实 JSON 验收已见“真实测试环境只读验收”;H5 会话及主子/异常/授权测试数据缺失,运行时全量验收未执行。
|
||||
|
||||
## 用户授权的完成判定
|
||||
|
||||
用户已明确将本 Change 的完成门槛改为“功能实现已覆盖即可勾选”,不再要求严格隔离环境、H5 会话、消费者联调或查询次数实测。本节据此记录 3.1—4.2 的勾选依据;这些勾选**不表示**完整实际环境、H5 消费者联调或发布回滚已经通过,运行时全量验收未执行。
|
||||
|
||||
| 任务 | 勾选依据 | 未作出的运行时声明 |
|
||||
| --- | --- | --- |
|
||||
| 3.1 | `PackageHistoryQuery.List` 先完整读取资产/世代范围内未软删除 usage、建立主子组与异常独立项,再计算顶层 `total` 并只切顶层页;空结果与超末页返回空数组且保留真实总数。后台、H5 都接入该 Query。 | 未在完整严格隔离环境以多子项和超页数据实际请求两端。 |
|
||||
| 3.2 | 同一 `matchesPackageHistoryUsage` 同时判定 `status` 和类型资格;组内任一成员命中即保留完整组。类型资格查询使用默认软删除范围,故已软删除商品不成为 H5 类型命中。 | 未以真实 H5 会话和对应 fixture 复现全部组合。 |
|
||||
| 3.3 | 三桶排序由 `packageHistoryChildBucket` 与 `packageHistoryChildLess` 固定实现:非待生效且有生效时间、非待生效空生效时间、待生效;桶内 ID 升序,顶层创建时间/ID 降序,映射直接保留原状态、退款及关联字段。 | 未在实际数据中覆盖每种失效/退款及并列排序组合。 |
|
||||
| 3.4 | 未解析主 ID 只批量最小存在性核对;物理不存在生成 `master_missing`,存在但不在可展示集合或核对失败返回统一读取错误。商品批量读取与 usage 关系判定分离,展示映射保留 usage 名称快照并允许商品缺失回退。 | 未人为制造物理缺失、软删除、跨资产/世代或数据库失败。 |
|
||||
| 3.5 | Query 仅接受 card/device 并按对应载体过滤;后台不传世代、H5 传资产当前世代且先做有效绑定校验。后台历史 DTO 仅平台填充成本价,H5 专用 DTO 未映射订单、退款、金额或生效条件;当前套餐和修改路由继续使用公共 DTO。 | 未完成代理、H5、跨世代和修改写接口的全量实际验收;存量企业授权问题未声称修复。 |
|
||||
| 3.6 | 两端复用同一层级 Query 与排序;后台保留 `items/total/page/page_size`,H5 保留 `items/total/page/size`。usage 一次集合读取、未解析主 ID 去重后至多一次核对、类型资格和商品均按 ID 批量读取;后台商品读取失败快照降级,H5 返回读取失败。 | 未实测 SQL 查询计数,未以同一真实集合比对两端顺序。 |
|
||||
| 3.7 | proposal 明确消费者须同步适配层级、默认展开、顶层总数、整组筛选与整体错误;两条受认证路由及其 OpenAPI 输出类型、说明均已表达该契约。design 记录 API/消费者同步发布与共同回滚为原平铺契约,且无 Schema/写入数据回滚。 | 未取得真实后台或 H5 消费者联调、同步发布或回滚确认,不宣称展示验收完成。 |
|
||||
| 4.1 | 本文已有 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go` 成功记录,以及 DTO/JSON/生成 OpenAPI 的局部 smoke;有限后台实际 JSON 和当前套餐兼容观察也已记录。 | 本轮未重跑工程命令;H5 实际 JSON 与两端完整运行时兼容未验收。 |
|
||||
| 4.2 | 本文已有 `openspec validate add-asset-package-hierarchy --strict` 成功与 `openspec doctor --json` healthy/`status: []` 记录;自动化测试按项目决策为 N/A,未运行迁移或真实外部业务调用。 | 未把此前有限 smoke 表述为完整运行时验收。 |
|
||||
|
||||
因此,3.1—4.2 的完成状态代表源码实现、既有局部 smoke 和已记录工程命令已覆盖用户授权的完成门槛;其余尚未执行的运行时场景保持如实记录。
|
||||
@@ -0,0 +1,542 @@
|
||||
# AUG26-014 统一时间筛选与临期导出(add-export-time-filter-standards)范围、契约与前端同步清单
|
||||
|
||||
本文件记录 OpenSpec Change `add-export-time-filter-standards` 的实施范围、端点改造对照、DTO 与 OpenAPI 变更清单以及前端同步清单。
|
||||
行为契约以 [`../../openspec/specs/export-time-filter/spec.md`](../../openspec/specs/export-time-filter/spec.md) 与变更产物
|
||||
[`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/) 为准;本文件是实施记录与同步清单,不是行为契约。
|
||||
|
||||
本文的实测证据节按 T7 分工由独立验证 worker 追加;本文不写未经实跑的结论。
|
||||
|
||||
## 1. 范围与依据
|
||||
|
||||
- 迭代编号:`AUG26-014`。需求依据:`docs/product/2026-08-迭代-PRD-讨论稿.md` §2.16(时间筛选与导出快照两段);导出列定义依据 `111.md` §18。
|
||||
- 执行契约:变更产物 `proposal.md`、`design.md`、`tasks.md` 与 `specs/export-time-filter/spec.md`;冲突时以 spec delta 为准。
|
||||
- 统一契约:被覆盖的列表与导出入口使用可选的 `start_time`/`end_time`,取值 MUST 为带显式时区的 RFC3339 秒级时间,解析结果归一为 UTC 瞬时,筛选为闭区间含两端;任一端可缺省,未传与空串同义;开始晚于结束拒绝。
|
||||
- 共享实现落点:`pkg/utils/time_range.go`(`ParseTimeRange`),创建期 handler 与执行期 `internal/exporter` 共用同一份实现。
|
||||
- 本次边界(不做):不新增迁移、不改既有迁移、不改数据库 Schema、不新增运行时开关、不删除全局宽松解析器(`cmd/api/main.go` 的 `registerTimeParserCompat`)、不新增 `*_test.go`、不重构未列入本次的端点与其导出、不做跨度上限、不改任何导出的记录粒度/列定义/余额口径、不修佣金统计类接口的裸串直传。
|
||||
- 未列入本次的端点保持既有参数名、既有格式接受范围与既有筛选结果,逐项登记见 `design.md`「已知差异登记(本次不修)」;其中达量预警列表与导出本期一律不改(依据端点表「已符合」行)。
|
||||
- 数据承载:无 Schema 变更,冻结值写入既有 `tb_export_task.query_json` / `scope_shop_ids` 与既有操作者、文件、行数、`error_message` 列,详见第 5 节。
|
||||
|
||||
## 2. 端点改造清单
|
||||
|
||||
### 2.1 列表端点
|
||||
|
||||
| 端点 | 改造前参数与格式 | 改造后 | 筛选依据业务时间 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /api/admin/iot-cards/import-tasks` | `start_time`/`end_time`(`*time.Time`,经全局宽松解析:兼容 RFC3339、无时区、date-only) | 参数名不变,`string` + 严格解析 | 导入任务创建时间 |
|
||||
| `GET /api/admin/devices/import/tasks` | 同上 | 参数名不变,`string` + 严格解析 | 导入任务创建时间 |
|
||||
| `GET /api/admin/export-tasks` | 同上 | 参数名不变,`string` + 严格解析 | 导出任务创建时间 |
|
||||
| `GET /api/admin/orders` | 同上 | 参数名不变,`string` + 严格解析 | 订单创建时间 |
|
||||
| `GET /api/admin/exchanges` | `created_at_start`/`created_at_end`(`*time.Time`,宽松) | 改名 `start_time`/`end_time`,`string` + 严格解析;旧参数名不再生效 | 换货单创建时间 |
|
||||
| `GET /api/admin/asset-allocation-records` | `created_at_start`/`created_at_end`(`*time.Time`,宽松) | 改名 `start_time`/`end_time`,`string` + 严格解析 | 分配记录创建时间 |
|
||||
| `GET /api/admin/agent-recharges` | `start_date`/`end_date`(无格式约束 `string`,按日拼接当日 `00:00:00`/`23:59:59`) | 改名 `start_time`/`end_time`,`string` + 严格解析;删除按日补齐 | 代理充值记录创建时间 |
|
||||
| `GET /api/admin/shops/{shop_id}/commission-records` | 无时间参数 | 新增 `start_time`/`end_time`,闭区间 | 佣金明细创建时间(原佣金与回溯两条分支各自的 `created_at`) |
|
||||
| `GET /api/admin/commission/withdrawal-requests` | `start_time`/`end_time`(无时区 `2006-01-02 15:04:05`,解析失败静默忽略该条件) | 参数名不变,严格解析;删除静默跳过,非法参数一律 1001 | 提现申请创建时间 |
|
||||
| `GET /api/admin/shops/{shop_id}/withdrawal-requests` | 同上 | 同上 | 提现申请创建时间 |
|
||||
| `GET /api/admin/authorizations` | `start_time`/`end_time`(date-only `2006-01-02`;起始闭、结束开且结束日加一天) | 参数名不变,严格解析;改为闭区间含两端 | 授权发生时间 |
|
||||
| `GET /api/admin/expiring-assets` | `expires_from`/`expires_to`(date-only,按上海自然日比较) | 改名 `start_time`/`end_time`,按最终到期**时刻**闭区间比较;保留既有剩余天数上下限筛选 | 当前生效主套餐最终到期时刻 |
|
||||
|
||||
### 2.2 导出场景
|
||||
|
||||
| 导出场景 / 入口 | 改造前筛选键 | 改造后 | 口径来源 |
|
||||
| --- | --- | --- | --- |
|
||||
| `exchange`(经 `POST /api/admin/export-tasks`) | `created_at_start`/`created_at_end`(宽松多格式解析) | 改名 `start_time`/`end_time`,共享严格解析器;旧键在创建期被拒绝 | 换货单创建时间 |
|
||||
| `agent_recharge`(同上) | `start_date`/`end_date`(宽松、按日补齐当日末秒) | 改名 `start_time`/`end_time`,共享严格解析器;旧键在创建期被拒绝 | 代理充值记录创建时间 |
|
||||
| `order`(同上) | `start_time`/`end_time`(宽松) | 参数名不变,共享严格解析器 | 订单创建时间 |
|
||||
| `commission_record`(同上) | 无时间筛选 | 新增 `start_time`/`end_time`,两条分支各自的创建时间列闭区间 | 佣金明细创建时间 |
|
||||
| 临期资产(`POST /api/admin/expiring-assets/export`) | 场景此前不存在 | 本次新建场景 `expiring_asset` 与受控入口 | 当前生效主套餐最终到期时刻 |
|
||||
| `package_traffic_alert`(`POST /api/admin/package-traffic-alerts/export`) | `start_time`/`end_time`(RFC3339 秒级,创建时冻结) | 入口参数名与 `*time.Time` 契约、列表 `GET /api/admin/package-traffic-alerts` 行为、列定义与触发快照口径**保持不动**;**执行期改为只按冻结值严格解析**(冻结值非法则任务落失败,依据 Requirement 4) | 预警触发时间 |
|
||||
|
||||
### 2.3 未改造项确认(逐一核对未改动)
|
||||
|
||||
- 端点保持原参数名与宽松解析、本期未新增时间参数:资产钱包流水、客户钱包流水、设备资产列表(`created_at_start`/`created_at_end`)、代理主钱包流水(`start_date`/`end_date`)、手机号—资产关联列表、佣金统计类接口(`commission-stats`、`commission-daily-stats`)、审计类列表、员工代收款账单、轮询告警历史、企业卡与企业设备授权列表、IoT 卡资产列表。
|
||||
- 导出场景入口保持原筛选键与宽松接受面:`device`、`iot_card`、`package`、`agent_wallet_transaction`、`refund`;达量预警导出(`package_traffic_alert`)入口与列表同样保持宽松接受面,仅执行期改为严格解析(见 2.2 行与 Requirement 4)。
|
||||
- 全局宽松解析器 `cmd/api/main.go` 的 `registerTimeParserCompat` 未改动,未列入端点继续由其服务。
|
||||
- 受影响列表端点的旧参数名现已显式拒绝(`created_at_start`/`created_at_end`、`start_date`/`end_date`、`expires_from`/`expires_to` 出现且非空即返回 1001,空值仍按未传处理),因此这些名字不会以「被忽略」的形式留在受影响端点。
|
||||
- 生成文档核对:`docs/admin-openapi.yaml` 中 `created_at_start`、`start_date` 仅残留于未改造端点与未改造的请求 DTO(`DtoBatchSetDeviceSeriesBindngRequest`、`/api/admin/devices`、`/api/admin/phone-asset-associations`、`commission-daily-stats`、主钱包流水与 `/api/open/v1/wallet/transactions`);`expires_from`/`expires_to` 在代码中只作为 `internal/handler/admin/asset.go` 的旧参数拒绝清单字符串出现(并作为改造前参数名记录在本次契约与文档中),生成文档中已完全不再出现;13 个受影响端点均只暴露 `start_time`/`end_time`。
|
||||
|
||||
## 3. DTO 与 OpenAPI 变更清单
|
||||
|
||||
统一说明:受影响端点的时间字段 Go 类型一律由 `*time.Time`(或裸 `string`)改为 `string`,由 handler 调用 `pkg/utils.ParseTimeRange` 严格解析;`*time.Time` 类型会触发全局宽松解析器,因此必须字符串化才能给出指明字段的中文错误消息。错误语义:格式非法与顺序错误统一返回既有参数非法错误码 `1001`,消息固定为
|
||||
`<字段> 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00` 与 `start_time 不能晚于 end_time`,不新增结构化字段级错误载荷。
|
||||
|
||||
| DTO | 字段(json / query tag) | 类型变更 | description 变更 |
|
||||
| --- | --- | --- | --- |
|
||||
| `dto.ListImportTaskRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 改为「带时区的 RFC3339 秒级时间,闭区间含该时刻」 |
|
||||
| `dto.ListDeviceImportTaskRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 同上 |
|
||||
| `dto.ListExportTaskRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 同上 |
|
||||
| `dto.OrderListRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 同上 |
|
||||
| `dto.ExchangeListRequest` | `created_at_start`/`created_at_end` → `start_time`/`end_time` | `*time.Time` → `string`(参数改名) | 同上 |
|
||||
| `dto.ListAssetAllocationRecordRequest` | `created_at_start`/`created_at_end` → `start_time`/`end_time` | `*time.Time` → `string`(参数改名) | 同上 |
|
||||
| `dto.AgentRechargeListRequest` | `start_date`/`end_date` → `start_time`/`end_time` | `string` → `string`(参数改名,取值受限) | 由「YYYY-MM-DD」改为「带时区的 RFC3339 秒级时间」 |
|
||||
| `dto.ShopCommissionRecordListReq` | 新增 `start_time` / `end_time` | 新增 `string` | 新增:佣金明细创建时间闭区间 |
|
||||
| `dto.WithdrawalRequestListReq` | `start_time` / `end_time` | `string`(取值受限) | 由「2006-01-02 15:04:05」改为「带时区的 RFC3339 秒级时间」 |
|
||||
| `dto.ShopWithdrawalRequestListReq` | `start_time` / `end_time` | `string`(取值受限) | 同上 |
|
||||
| `dto.AuthorizationListReq` | `start_time` / `end_time` | `string`(取值受限) | 由「格式:2006-01-02」改为「带时区的 RFC3339 秒级时间,闭区间含该时刻」 |
|
||||
| `dto.ExpiringAssetListRequest` | `expires_from`/`expires_to` → `start_time`/`end_time` | `string` → `string`(参数改名,取值受限) | 由「上海自然日 YYYY-MM-DD」改为「按最终到期时刻的闭区间」 |
|
||||
| `dto.ExportExpiringAssetRequest` | 新增(`format`、`asset_type`、`keyword`、`shop_id`、`package_id`、`days_min`、`days_max`、`start_time`、`end_time`) | 新增 `string` 时间字段 | 新增受控导出入口请求体 |
|
||||
| `dto.CreateExportTaskRequest` | `scene` 白名单与 `query` 说明 | 不变 | 白名单新增 `expiring_asset`;说明补充时间筛选键与取值要求 |
|
||||
| `dto.ListExportTaskRequest` | `scene` 白名单 | 不变 | 白名单新增 `expiring_asset` |
|
||||
|
||||
OpenAPI:`docs/admin-openapi.yaml` 已重新生成(`go run cmd/gendocs/main.go`),新增 `POST /api/admin/expiring-assets/export`,13 个受影响端点的时间参数已按上表更新。
|
||||
|
||||
## 4. 前端同步清单
|
||||
|
||||
无兼容期、无运行时开关:前端 MUST 与后端同迭代上线。
|
||||
|
||||
### 4.1 改参数名的端点与导出
|
||||
|
||||
| 位置 | 旧参数 | 新参数 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/admin/exchanges` 与换货导出(场景 `exchange`) | `created_at_start`、`created_at_end` | `start_time`、`end_time` |
|
||||
| `GET /api/admin/asset-allocation-records` | `created_at_start`、`created_at_end` | `start_time`、`end_time` |
|
||||
| `GET /api/admin/agent-recharges` 与代理充值导出(场景 `agent_recharge`) | `start_date`、`end_date` | `start_time`、`end_time` |
|
||||
| `GET /api/admin/expiring-assets` | `expires_from`、`expires_to` | `start_time`、`end_time` |
|
||||
|
||||
旧参数名在受影响端点不再生效(换货导出与代理充值导出的旧筛选键在创建期直接返回 1001)。
|
||||
|
||||
### 4.2 改传值格式的端点与导出
|
||||
|
||||
格式要求:带显式时区的 RFC3339 秒级时间,UTC 偏移写作 `Z` 或 `+08:00`;整日区间用 `T00:00:00+08:00` 与 `T23:59:59+08:00` 表达。拒绝 date-only、空格分隔、无时区、小数秒(含 `.000`)、`±hhmm` 与未补零写法。
|
||||
|
||||
| 位置 | 旧传值示例 | 新传值示例 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/admin/iot-cards/import-tasks` | `2026-09-01` 或 `2026-09-01 00:00:00` | `2026-09-01T00:00:00+08:00` |
|
||||
| `GET /api/admin/devices/import/tasks` | 同上 | 同上 |
|
||||
| `GET /api/admin/export-tasks` | 同上 | 同上 |
|
||||
| `GET /api/admin/orders` 与订单导出(场景 `order`) | 同上 | 同上 |
|
||||
| `GET /api/admin/authorizations` | `2026-09-01`(结束日按次日开区间) | `2026-09-01T00:00:00+08:00`(结束时间为闭区间,整日需显式写 `23:59:59`) |
|
||||
| `GET /api/admin/commission/withdrawal-requests` | `2026-09-01 00:00:00` | `2026-09-01T00:00:00+08:00`;非法值由「忽略该条件返回全量」改为 1001 |
|
||||
| `GET /api/admin/shops/{shop_id}/withdrawal-requests` | 同上 | 同上 |
|
||||
| `GET /api/admin/expiring-assets` 与临期导出 | `2026-09-01`(上海自然日) | `2026-09-01T00:00:00+08:00`(按最终到期时刻闭区间) |
|
||||
| 换货导出、代理充值导出 | 旧格式同上 | 同上 |
|
||||
|
||||
注意:受影响端点的时间参数不再接受无时区写法,前端不得沿用「按浏览器本地时区补 00:00:00」的旧拼接方式,必须显式带偏移。受影响列表端点(换货、分配记录、代理充值、临期列表)携带旧参数名(`created_at_start`/`created_at_end`、`start_date`/`end_date`、`expires_from`/`expires_to`)且非空时现在返回 1001(消息:`<旧字段名> 已废弃,请改用 start_time 与 end_time`),前端必须同迭代改名,不能依赖旧参数被忽略。
|
||||
|
||||
**达量预警导出经通用入口的传值收紧(`POST /api/admin/export-tasks`,`scene=package_traffic_alert`)**
|
||||
|
||||
- 位置:通用导出入口 `POST /api/admin/export-tasks`,`scene=package_traffic_alert`(达量预警导出场景)。
|
||||
- 变更:`query.filters.start_time` / `query.filters.end_time` 现要求带显式时区的 RFC3339 秒级时间(与本节上表同一格式要求);宽松写法(date-only、空格分隔、无时区、小数秒含 `.000`、`±hhmm`、未补零、前后空白)由变更前的「创建成功 + 执行期静默忽略该条件(等于不加该条件,可能导出全量)」改为**创建期直接返回 1001 参数非法**,消息与其他受影响端点同一套固定中文文案:`start_time 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00`(`end_time` 同构),开始晚于结束为 `start_time 不能晚于 end_time`。
|
||||
- 边界:达量预警的**专用入口** `POST /api/admin/package-traffic-alerts/export` 与其列表 `GET /api/admin/package-traffic-alerts` 的参数名、类型与宽松接受面**本次不变**(仍为 `*time.Time` + 全局兼容解析器,创建期归一为 UTC RFC3339 秒级后冻结);只有**执行期**改为只按冻结值严格解析,冻结值非法时任务落失败并写固定摘要 `导出筛选的时间边界非法,任务已终止`,不放行全量。
|
||||
- 依据:变更 delta 与主 Spec 的「三类异步导出的创建期冻结与不扩大范围」需求;`design.md` 已知差异登记第 5 条(无时区列那条为第 6 条、临期导出取舍为第 7 条)。
|
||||
|
||||
### 4.3 新增参数的端点与导出
|
||||
|
||||
| 位置 | 新增参数 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/admin/shops/{shop_id}/commission-records` | `start_time`、`end_time` | 按佣金明细创建时间闭区间,覆盖原佣金与回溯两条分支 |
|
||||
| 佣金明细导出(场景 `commission_record`) | `query.filters.start_time`、`query.filters.end_time` | 同上;经 `POST /api/admin/export-tasks` 提交 |
|
||||
| 临期导出 `POST /api/admin/expiring-assets/export` | 整个入口为新增 | 请求体:`format`(必填)、`asset_type`、`keyword`、`shop_id`、`package_id`、`days_min`、`days_max`、`start_time`、`end_time` |
|
||||
|
||||
## 5. 无 Schema 变更确认(T6.6)
|
||||
|
||||
- 本变更未新增迁移文件、未修改任何既有迁移、未改变数据库 Schema,因此迁移 `up/down/up` 为**不适用**而非跳过。
|
||||
- 冻结值承载于既有列:`tb_export_task.query_json`(jsonb,写入规范化后的 UTC RFC3339 秒级字符串)、`scope_shop_ids`(jsonb)、操作者四元组与 `creator`/`updater`、`file_key`/`file_size`/`total_rows`/`error_message`。表头冻结沿用既有 `query_json.resolved_headers`。
|
||||
- 失败摘要沿用既有 `error_message` 通道,新增固定摘要文案 `导出筛选的时间边界非法,任务已终止`。
|
||||
- 该严格解析失败即落失败的规则同样适用于达量预警导出(场景 `package_traffic_alert`):其入口与列表的宽松接受面不变,执行期冻结值非法时同样落失败,不放行全量。
|
||||
|
||||
## 6. 实测证据(T7 实跑)
|
||||
|
||||
本节由独立验证 worker 实跑写入,全部结论均有命令与原始输出支撑;未实跑项在 §6.5 逐条列明。
|
||||
|
||||
### 6.0 环境、工作区指纹与执行方式
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| HEAD | `e8ab1f471e9cbf27acd89ccdcfb5073b4fc7baa1`(含维护者提交 `398a5e42`「fix(路由): 修正套餐真流量预警与资产自动续费的超管/平台 gate 作用域」与其后的 `e8ab1f47`;本 Change 的实现改动仍在未提交工作区) |
|
||||
| 工作区内容指纹(本批) | `git diff` 与全部未跟踪文件内容的合并 SHA1:`37d7d69021f264be6f04e31062a18e8aa695c8a0` |
|
||||
| 工作区内容指纹(上一批) | `51c619f8a3791e5c6e3de8878af3be5105dd73f`(`NAMES_FP=d1aa8d512dfb5d43e17b2ded30ae761aaca2578c`,63 项) |
|
||||
| 代码状态 | 未提交工作区(本 Change 实现已落盘);维护者的路由门禁修复以提交形式位于 HEAD |
|
||||
| PostgreSQL | `junhong_cmp_test`(参数取自 `.env`),库内诊断只读走 dbhub MCP;写入一律经临时 Go 工具用仓库自身 GORM/配置执行(遵循 ENG-DB-002) |
|
||||
| Redis | `cxd.whcxd.cn:16299` **DB 7**(`.env.local` 默认值,与 `scripts/run-local.sh` 一致) |
|
||||
| API | `go run ./cmd/api` → `http://127.0.0.1:3000`,就绪日志「服务器已启动」 |
|
||||
| Worker | `go run ./cmd/worker`,就绪日志「Worker 服务器启动中」 |
|
||||
| 鉴权 | 超管:`POST /api/auth/login`(口令取自 README,不回显)。代理视角:**第二批起改用 fixture 代理账号的真实登录**(`tb_account` 自建 `AUG26014AGENT*`,user_type=3,口令为 bcrypt 哈希由工具写入,不回显);手工注入 DB 7 的等价令牌仅用于第一批的根因定位对照,并已在 §6.6 明确其 `user_type` 来源 |
|
||||
|
||||
**与 ENG-TEST-001 的两处偏离(均已在执行中记录):**
|
||||
|
||||
1. **Redis 用 DB 7 而非 DB 6**:DB 6 是 `Iteration/8-11` 测试部署的共享队列,本地 worker 若连 DB 6 会抢占真实测试任务并消费掉它们;`.env.local` 本身即配置 DB 7,与 `scripts/run-local.sh` 的实际行为一致。导出链路的冻结、分片、合并、上传语义与队列 DB 号无关,因此该偏离不影响本次结论。
|
||||
2. **启动命令用 `go run ./cmd/api` 而非 `./scripts/run-local.sh api`**:`scripts/run-local.sh` 执行的是 `go run cmd/api/main.go`(单文件),而 `generateOpenAPIDocs` 定义在同包的另一文件 `cmd/api/docs.go`,单文件编译必然报 `cmd/api/main.go:101:2: undefined: generateOpenAPIDocs` 并退出。这是**既有仓库脚本/README 缺陷**(`scripts/run-local.sh`、`cmd/api/main.go`、`cmd/api/docs.go` 均不在本 Change 改动范围内),本 Change 未引入也未加重;改用包路径 `go run ./cmd/api` 后正常启动。建议另行登记修复。
|
||||
|
||||
### 6.1 逐项结果汇总
|
||||
|
||||
| # | tasks.md 项 | 断言数 | 结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 7.11/7.12 门禁命令(gofmt/build/gendocs/openspec/doctor/context-health) | 7 | **PASS 7 / FAIL 0** |
|
||||
| 2 | 7.1 接受集合与拒绝集合逐条实跑 | 20 | **PASS 20 / FAIL 0** |
|
||||
| 3 | 7.2 区间边界(仅一端/两端/两端缺省/空串/开始等于结束/空结果集) | 5 + 6 | **PASS 11 / FAIL 0** |
|
||||
| 4 | 7.3 顺序校验(开始晚于结束) | 1 | **PASS 1 / FAIL 0** |
|
||||
| 5 | 7.4 未改造端点保持既有宽松行为 | 4 | **PASS 4 / FAIL 0** |
|
||||
| 6 | 7.5 提现记录非法参数不再返回全量 | 5 | **PASS 5 / FAIL 0** |
|
||||
| 7 | 7.5 授权记录闭区间含两端 | 3 | **PASS 3 / FAIL 0** |
|
||||
| 8 | 7.6 佣金明细列表与其导出行集一致 | 2 | **PASS 2 / FAIL 0** |
|
||||
| 9 | 7.6/7.7 临期列表与其导出一致、表头、加油包不单独成行 | 5 | **PASS 5 / FAIL 0** |
|
||||
| 10 | 受影响端点旧参数名拒绝 + 空值按未传(F1 修复后重跑) | 8 | **PASS 8 / FAIL 0** |
|
||||
| 11 | 7.9 遗留旧格式冻结任务安全失败 | 1 | **PASS 1 / FAIL 0** |
|
||||
| 12 | 7.7 达量预警导出回归(列表宽松、导出严格、表头逐字) | 5 | **PASS 5 / FAIL 0** |
|
||||
| 13 | 7.10 佣金明细导出列定义与金额/余额口径回归 | 1 | **PASS 1 / FAIL 0** |
|
||||
| 14 | 7.7 创建后归属变化不扩大数据集(冻结 scope 执行期生效) | 3 | **PASS 3 / FAIL 0** |
|
||||
| 15 | 7.8 代理账号经 HTTP 访问临期列表/导出(第一批:修复前 403/1005) | 2 | 第一批 **FAIL**;根因定位为既有路由门禁作用域缺陷,已由维护者提交 `398a5e42` 修复 → 第二批重跑 **PASS**(见第 16 行) |
|
||||
| 16 | 7.8 代理 HTTP 全链路(真实登录):可见范围过滤 / 导出创建与执行 / 空可见范围被拒 / 越权与不存在统一不可见 | 6 | **PASS 6 / FAIL 0** |
|
||||
| 17 | 7.6 达量预警列表与导出同筛选行集一致(缺口补齐) | 1 | **PASS 1 / FAIL 0** |
|
||||
| — | 合计 | **85 PASS / 0 FAIL** | |
|
||||
|
||||
### 6.2 关键原始输出
|
||||
|
||||
**(1)门禁命令**
|
||||
|
||||
> 下列 7 条在第二批(HEAD=`e8ab1f47`)已原样重跑,输出与本处记录逐字一致;T7 实现内容与第一批相同,仅有维护者的路由门禁修复提交进入 HEAD。
|
||||
|
||||
```
|
||||
$ gofmt -l <63 个改动/新增 .go 文件> # 无输出
|
||||
$ go build ./cmd/api ./cmd/worker # 退出码 0
|
||||
$ go run cmd/gendocs/main.go(连续两次) # cmp -s 一致:gendocs_idempotent=PASS
|
||||
$ openspec validate add-export-time-filter-standards --strict
|
||||
Change 'add-export-time-filter-standards' is valid # 退出码 0
|
||||
$ openspec validate --all
|
||||
Totals: 36 passed, 0 failed (36 items) # 退出码 0
|
||||
$ openspec doctor --json
|
||||
healthy= True status= [] # 退出码 0
|
||||
$ ./scripts/context-health.sh
|
||||
Context 健康检查通过 # 退出码 0
|
||||
```
|
||||
|
||||
**(2)接受集合与拒绝集合(`GET /api/admin/export-tasks`)**
|
||||
|
||||
接受(HTTP 200 / code 0):`2026-09-01T00:00:00Z`、`2026-09-01T00:00:00+08:00`、`2026-09-01T00:00:00-05:30`、同瞬时两种偏移组合、仅 `end_time`、空串两端、两端缺省。
|
||||
|
||||
拒绝(HTTP 400 / code 1001),消息逐条一致:
|
||||
|
||||
```
|
||||
start_time 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00
|
||||
```
|
||||
|
||||
| 拒绝输入 | 归类 |
|
||||
| --- | --- |
|
||||
| `2026-09-01T00:00:00.000Z`、`2026-09-01T00:00:00.5+08:00` | 小数秒 |
|
||||
| `2026-09-01T00:00:00` | 无时区 |
|
||||
| `2026-09-01` | date-only |
|
||||
| `2026-09-01 00:00:00` | 空格分隔 |
|
||||
| `2026-09-01T00:00:00+0800` | `±hhmm` 偏移 |
|
||||
| `2026-9-01T00:00:00Z` | 未补零日期 |
|
||||
| `2026-09-01T0:00:00Z` | 未补零时间 |
|
||||
| `2026-02-30T00:00:00+08:00` | 非法日历日期 |
|
||||
| `2026-09-01T00:00:00+24:00`、`+23:60`、`+00:60`、`-24:00` | 偏移越界(F3 收紧后) |
|
||||
| `␣2026-09-01T00:00:00+08:00␣`、纯空白串、`\t` | 空白包裹/纯空白(F3 收紧后) |
|
||||
|
||||
顺序校验:
|
||||
|
||||
```
|
||||
start_time=2026-09-02T00:00:00Z & end_time=2026-09-01T00:00:00Z
|
||||
→ HTTP 400 code=1001 msg=start_time 不能晚于 end_time
|
||||
```
|
||||
|
||||
**(3)区间边界(`GET /api/admin/export-tasks`)**
|
||||
|
||||
```
|
||||
两端缺省 total=55 | 空串两端 total=55(与缺省一致)
|
||||
仅 start total=3 | 仅 end total=52 | 空结果集 total=0(HTTP 200,不报错)
|
||||
```
|
||||
|
||||
**(4)受影响端点旧参数名拒绝(F1 修复后重跑)**
|
||||
|
||||
| 请求 | 结果 |
|
||||
| --- | --- |
|
||||
| `GET /api/admin/exchanges?created_at_start=2026-09-01T00:00:00+08:00` | 1001 `created_at_start 已废弃,请改用 start_time 与 end_time` |
|
||||
| `GET /api/admin/asset-allocation-records?created_at_start=...` | 1001 `created_at_start 已废弃,请改用 start_time 与 end_time` |
|
||||
| `GET /api/admin/agent-recharges?start_date=...` | 1001 `start_date 已废弃,请改用 start_time 与 end_time` |
|
||||
| `GET /api/admin/expiring-assets?expires_from=...` | 1001 `expires_from 已废弃,请改用 start_time 与 end_time` |
|
||||
| 上述四个端点的旧参数**空值**(`?start_date=`) | HTTP 200 / code 0(按未传处理) |
|
||||
|
||||
**(5)未改造端点保持宽松(HTTP 200 / code 0,total 与缺省一致)**
|
||||
|
||||
```
|
||||
GET /api/admin/devices?created_at_start=2026-01-01 total=6
|
||||
GET /api/admin/devices?created_at_start=2026-01-01T00:00:00 total=6
|
||||
GET /api/admin/shops/1/main-wallet/transactions?start_date=2026-01-01&end_date=2026-12-31 total=10
|
||||
GET /api/admin/audit/events total=2734186
|
||||
```
|
||||
|
||||
**(6)授权记录闭区间(`GET /api/admin/authorizations`,`iccid` 过滤隔离标记行)**
|
||||
|
||||
```
|
||||
start=2026-09-17T10:00:00+08:00 & end=2026-09-17T11:00:00+08:00 → total=2(恰好命中 02:00:00 与 03:00:00 两行)
|
||||
start=end=2026-09-17T10:00:00+08:00 → total=1(闭区间含 start 端)
|
||||
start=end=2026-09-17T16:00:00+08:00 → total=0(区间外)
|
||||
```
|
||||
|
||||
旧 date-only 值被拒:`GET /api/admin/authorizations?start_time=2026-09-17` → 1001。
|
||||
|
||||
**(7)提现记录非法参数不再返回全量**
|
||||
|
||||
```
|
||||
GET /api/admin/commission/withdrawal-requests(缺省) total=5
|
||||
GET /api/admin/commission/withdrawal-requests?start_time=<合法区间> total=2
|
||||
GET /api/admin/commission/withdrawal-requests?start_time=2026-09-17 00:00:00 → 1001(非全量)
|
||||
GET /api/admin/commission/withdrawal-requests?start_time=2026-09-17 → 1001
|
||||
GET /api/admin/shops/{shop_id}/withdrawal-requests?start_time=2026-09-17 00:00:00 → 1001
|
||||
GET /api/admin/shops/{shop_id}/withdrawal-requests(缺省) total=3
|
||||
```
|
||||
|
||||
**(8)佣金明细:列表 vs 导出(同筛选行集一致)**
|
||||
|
||||
筛选 `start_time=2026-09-17T10:00:00+08:00`、`end_time=2026-09-17T14:00:00+08:00`;fixture 含「start−1s / ==start / start+1s / start+2h / ==end / end+1s / 8 小时漂移探针」七行原佣金 + 一行区间内回溯。
|
||||
|
||||
```
|
||||
列表 GET /api/admin/shops/{shop_id}/commission-records total=5
|
||||
命中:02:00:00、02:00:01、04:00:00、06:00:00(原佣金)+ 03:00:00(回溯)
|
||||
排除:01:59:59(start−1s)、06:00:01(end+1s)、08:00:00(8 小时漂移探针)
|
||||
导出 POST /api/admin/export-tasks{scene=commission_record} status=3 total_rows=5
|
||||
```
|
||||
|
||||
导出产物(`task 61`,UTF-8 BOM CSV):
|
||||
|
||||
```
|
||||
记录来源,记录ID,代理店铺名称,关联订单号,资产标识,佣金来源,金额(元),是否可提现,状态,回溯后佣金余额(元),原佣金记录ID,来源退款单号,佣金入账时间,生成时间
|
||||
原佣金,463,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 06:00:00
|
||||
原佣金,462,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 04:00:00
|
||||
回溯明细,9,...,AUG26014ORD005,AUG26014CARD001,成本价差,-10.00,不可提现,回溯,40.00,460,AUG26014REF001,,2026-09-17 03:00:00
|
||||
原佣金,461,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 02:00:01
|
||||
原佣金,460,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 02:00:00
|
||||
```
|
||||
|
||||
结论:**列表与导出在同一筛选下行集完全一致(5 = 5),无 8 小时量级的边界漂移**(漂移探针行在两端行为一致均被排除);负数金额原样输出(`-10.00`)、入账后余额原样输出(`40.00`),表头 14 列与 `internal/exporter/commission_record_scene.go:53-59` 逐字一致。
|
||||
|
||||
**(9)临期资产:列表 vs 导出、表头、加油包**
|
||||
|
||||
fixture:3 个资产(2 个同一最终到期时刻、1 个第 15 天窗口)+ 1 个挂主套餐的加油包。
|
||||
|
||||
```
|
||||
列表 GET /api/admin/expiring-assets total=3
|
||||
AUG26014CARD001 days=4 final=2026-09-21T16:00:00+08:00
|
||||
AUG26014CARD002 days=4 final=2026-09-21T16:00:00+08:00
|
||||
AUG26014CARD003 days=14 final=2026-10-01T16:00:00+08:00
|
||||
闭区间 start=end=2026-09-21T16:00:00+08:00 → total=2(同一时刻的两个资产都返回)
|
||||
区间外 start=end=2026-09-22T16:00:00+08:00 → total=0
|
||||
同瞬时 Z 写法 start=end=2026-09-21T08:00:00Z → total=2
|
||||
导出 POST /api/admin/expiring-assets/export{days_min:0,days_max:15} status=3 total_rows=3
|
||||
```
|
||||
|
||||
导出产物(`task 62`):
|
||||
|
||||
```
|
||||
店铺,业务员,用户组,资产类型,设备类型,设备型号,资产标识,当前套餐,到期时间,剩余天数
|
||||
AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD001,AUG26014-临期套餐,2026-09-21 16:00:00,4
|
||||
AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD002,AUG26014-临期套餐,2026-09-21 16:00:00,4
|
||||
AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD003,AUG26014-临期套餐,2026-10-01 16:00:00,14
|
||||
```
|
||||
|
||||
结论:**表头 10 列与 `111.md` §18.1 逐列同序一致**;**加油包不单独成行**(4 条 `tb_package_usage` 行 → 3 条导出行);导出与列表同筛选下行集一致(3 = 3)。
|
||||
|
||||
**(10)遗留旧格式冻结任务安全失败(7.9)**
|
||||
|
||||
fixture:`tb_export_task` 插入 `status=1`、`scene=order`、`query_json={"filters":{"start_time":"2026-09-01","end_time":"2026-09-02"}}`(date-only 旧格式),经 `export:dispatch` 投递后由本地 worker 执行:
|
||||
|
||||
```
|
||||
status= 4(已失败)
|
||||
error_message= '导出筛选的时间边界非法,任务已终止' # 与 constants.ExportTaskInvalidTimeFilterMessage 逐字一致
|
||||
file_key=None total_rows=0 total_shards=0 failed_shards=0 download_url=None
|
||||
```
|
||||
|
||||
结论:**任务落失败、错误摘要为规定安全文案、未产出全量文件(无 file_key、无分片行)**。
|
||||
|
||||
**(11)达量预警回归(F2 修复后重跑)**
|
||||
|
||||
```
|
||||
列表 GET /api/admin/package-traffic-alerts 缺省 / RFC3339+08:00 / date-only / 空格分隔 → 均 HTTP 200 code=0(宽松契约不变)
|
||||
导出 POST /api/admin/export-tasks{scene=package_traffic_alert, filters={start_time,end_time 为带时区 RFC3339}}
|
||||
status=3 total_rows=0(测试库 tb_package_traffic_alert 当前 0 行)
|
||||
表头=资产类型,资产标识,对应标识符,卡标识,设备类型,设备型号,套餐名称,真流量已用量(MB),真流量额度(MB),比例(%),阈值快照(%),到期时间,剩余天数,触发时间,店铺,业务员,用户组,通知投递结果
|
||||
```
|
||||
|
||||
结论:**表头 18 列与改动前逐字一致**(`internal/exporter/package_traffic_alert_scene.go:50-56` 未变更,`git diff` 仅改时间筛选的执行期解析);记录粒度与触发快照口径未动;该场景无金额列,金额/余额口径回归由佣金明细导出覆盖。
|
||||
|
||||
**(12)创建后归属变化不扩大数据集(7.7 范围冻结)**
|
||||
|
||||
作为 §6.2 第 13 项(代理 HTTP 全链路)之外的执行期补充,本项以「按代理创建语义直接落入 `scope_shop_ids` 的冻结任务 + 真实 worker 执行」验证归属变化对已冻结任务的影响:
|
||||
|
||||
| 阶段 | 任务 | 冻结 scope | 资产位置 | 结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | `AUG26014-SCOPE-1`(id 65) | `[1,1527]` | 在 1527 | `total_rows=3` |
|
||||
| 2 | `AUG26014-SCOPE-1`(id 66,重置重跑) | `[1,1527]` | 已移出至 shop 2 | `total_rows=0` |
|
||||
| 对照 | 超管无范围任务(id 67) | 空(不限制) | 已移出至 shop 2 | `total_rows=3` |
|
||||
|
||||
结论:**执行期只按创建时冻结的 `scope_shop_ids` 取数;资产归属变化后行集缩小而非扩大**;对照组证明归属变更确实生效(超管看到 3 行)。
|
||||
|
||||
**(13)代理账号经 HTTP 全链路(第二批补充;真实 `POST /api/auth/login` 登录,非注入令牌)**
|
||||
|
||||
fixture:可见店铺 `1528`(`parent_id=1`)内 3 个临期资产 + 不可见店铺 `shop 2` 内 2 个临期资产(`AUG26014OUT001/002`);代理账号 `AUG26014AGENT`(`user_type=3`、`shop_id=1`)与本工具写入的 bcrypt 口令**真实登录**取得令牌。
|
||||
|
||||
```
|
||||
AGENT(有店铺) login ok user_type= 3 shop_id= 1
|
||||
AGENT(无店铺) login ok user_type= 3 shop_id= None
|
||||
```
|
||||
|
||||
1)列表只返回可见店铺:
|
||||
|
||||
```
|
||||
GET /api/admin/expiring-assets (代理令牌)http=200 code=0 total=3
|
||||
iot_card AUG26014CARD001 shop_id=1528 days=4
|
||||
iot_card AUG26014CARD002 shop_id=1528 days=4
|
||||
iot_card AUG26014CARD003 shop_id=1528 days=14
|
||||
summary={"card_count":3,"device_count":0,"total_count":3,"window_days":15}
|
||||
GET /api/admin/expiring-assets (超管对照)total=5
|
||||
identifiers=[CARD001, CARD002, OUT001, OUT002, CARD003]
|
||||
```
|
||||
|
||||
即:**代理看不到 `shop 2` 的 2 个范围外资产,超管能看到全部 5 个**(逐个资产标识与期望一致)。
|
||||
|
||||
2)代理创建并执行临期导出(行集 ⊆ 创建时冻结的可见店铺范围):
|
||||
|
||||
```
|
||||
POST /api/admin/expiring-assets/export {format:csv,days_min:0,days_max:15}
|
||||
http=200 code=0 → task_id=68 task_no=EXP-20260917-960000 status=1
|
||||
task 68 → status=3 total_rows=3(与代理列表 total=3 一致)
|
||||
库内冻结行长(dbhub 只读):
|
||||
id=68 scene=expiring_asset creator_user_id=683 creator_user_type=3 creator_shop_id=1
|
||||
scope_shop_ids=[1, 6, 1528] # 代理 1 号店的下级树快照
|
||||
query_json={"filters":{"days_max":15,"days_min":0},"resolved_headers":["店铺","业务员","用户组","资产类型","设备类型","设备型号","资产标识","当前套餐","到期时间","剩余天数"]}
|
||||
导出产物:3 行,资产标识为 CARD001/CARD002/CARD003(不含 OUT001/OUT002)
|
||||
```
|
||||
|
||||
3)空可见范围(代理无店铺)创建被拒:
|
||||
|
||||
```
|
||||
GET /api/admin/expiring-assets (shop_id=NULL 的 fixture 代理)http=200 code=0 total=0
|
||||
POST /api/admin/expiring-assets/export (同上) http=403 code=1005 msg=代理账号缺少店铺信息
|
||||
```
|
||||
|
||||
4)越权与不存在统一不可见(同一代理令牌):
|
||||
|
||||
```
|
||||
GET /api/admin/export-tasks/1 → http=403 code=1005 msg=无权限操作该资源或资源不存在
|
||||
GET /api/admin/export-tasks/999999 → http=403 code=1005 msg=无权限操作该资源或资源不存在
|
||||
一致: True
|
||||
```
|
||||
|
||||
**(14)达量预警列表 vs 导出同筛选行集一致(7.6 缺口补齐)**
|
||||
|
||||
fixture 直接写入 `tb_package_traffic_alert` 3 条快照(2 条 `triggered_at` 在 `[2026-09-01T00:00:00Z, 2026-09-30T23:59:59Z]` 内、1 条在 2026-10-05 窗外),同筛选分别调列表与导出:
|
||||
|
||||
```
|
||||
GET /api/admin/package-traffic-alerts?start_time=2026-09-01T00:00:00Z&end_time=2026-09-30T23:59:59Z total=2
|
||||
POST /api/admin/export-tasks{scene:package_traffic_alert, filters 同上} → task 69 status=3 total_rows=2
|
||||
(库内冻结行 69:scope_shop_ids=[],query_json.filters 已规范化为 UTC RFC3339)
|
||||
导出产物:
|
||||
资产类型,资产标识,对应标识符,卡标识,设备类型,设备型号,套餐名称,真流量已用量(MB),真流量额度(MB),比例(%),阈值快照(%),到期时间,剩余天数,触发时间,店铺,业务员,用户组,通知投递结果
|
||||
物联网卡,AUG26014CARD001,,,,,AUG26014-临期套餐,1500,2000,75.00,80.00,,,2026-09-20 20:00:00,AUG26014-临期验证店,<业务员账号名>,<用户组名>,待投递
|
||||
物联网卡,AUG26014CARD001,,,,,AUG26014-临期套餐,1500,2000,75.00,70.00,,,2026-09-15 08:00:00,AUG26014-临期验证店,<业务员账号名>,<用户组名>,待投递
|
||||
```
|
||||
|
||||
结论:**列表 total=2 == 导出 total_rows=2,窗外那条被两端一致排除**;触发快照列(用量/额度/比例/阈值)原样输出,未被执行期改动影响。
|
||||
|
||||
### 6.3 fixture 清单与清理结果
|
||||
|
||||
第一批(§6.2 第 1–12 项):
|
||||
|
||||
| 表 | 标记方式 | 造行数 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tb_shop` | `shop_name LIKE 'AUG26014%'` | 1 | 临期资产归属店铺(`parent_id=1`,供代理可见链路) |
|
||||
| `tb_iot_card` | `iccid LIKE 'AUG26014%'` | 3 | 临期资产 |
|
||||
| `tb_package_usage` | `package_name LIKE 'AUG26014%'` | 4(3 主套餐 + 1 加油包) | 最终到期推算 / 加油包不单独成行 |
|
||||
| `tb_order` | `order_no LIKE 'AUG26014%'` | 6 | 佣金明细关联订单号与 `(order_id,package_id)` 唯一索引避让 |
|
||||
| `tb_commission_record` | `remark LIKE 'AUG26014%'` | 7 | 佣金闭区间两端 + 1 秒内 + 4 小时内 + 8 小时漂移探针 |
|
||||
| `tb_commission_clawback_record` | `refund_no LIKE 'AUG26014%'` | 1 | 回溯分支负金额口径 |
|
||||
| `tb_enterprise_card_authorization` | `remark LIKE 'AUG26014%'` | 3 | 授权闭区间三态(等于 start / 等于 end / 区间外,整秒) |
|
||||
| `tb_commission_withdrawal_request` | `withdrawal_no LIKE 'AUG26014%'` | 3 | 提现闭区间三态(整秒) |
|
||||
| `tb_export_task` / `tb_export_shard_task` | `task_no LIKE 'AUG26014%'` | 遗留任务 1 + 冻结范围任务 1 | 7.9 遗留安全、范围冻结 |
|
||||
|
||||
第二批(§6.2 第 13–14 项):
|
||||
|
||||
| 表 | 标记方式 | 造行数 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tb_iot_card`(范围外) | `iccid LIKE 'AUG26014OUT%'`(`shop_id=2`) | 2 | 代理可见范围对照(不得出现在代理列表/导出) |
|
||||
| `tb_order`(范围外) | `order_no LIKE 'AUG26014OUTORD%'` | 2 | 同上(订单与套餐使用的外键上下文) |
|
||||
| `tb_package_usage`(范围外) | `package_name LIKE 'AUG26014-范围外套餐'` | 2 | 同上 |
|
||||
| `tb_account` | `username LIKE 'AUG26014%'` | 2 | fixture 代理账号:`AUG26014AGENT`(`shop_id=1`)、`AUG26014AGENTNOSHOP`(`shop_id=NULL`);真实登录用,口令 bcrypt 由工具写入且不回显 |
|
||||
| `tb_package_traffic_alert` | `notification_event_id LIKE 'AUG26014%'` | 3(2 条窗内 + 1 条窗外) | 达量预警列表与导出同筛选行集一致 |
|
||||
|
||||
**清理结果**(`cleanup` 按标记删除 + `cleanup-tasks` 删除本次经 HTTP 创建的无标记导出任务):
|
||||
|
||||
```
|
||||
CLEANUP_DONE
|
||||
COUNT tb_shop=0 COUNT tb_iot_card=0 COUNT tb_package_usage=0
|
||||
COUNT tb_order=0 COUNT tb_account=0 COUNT tb_package_traffic_alert=0
|
||||
COUNT tb_export_task=0(标记任务) COUNT tb_export_shard_task=0(标记分片)
|
||||
DELETED_TASKS=6 ids=58,59,61,62,64,67 # 第一批
|
||||
DELETED_TASKS=2 ids=68,69 # 第二批(代理导出、达量预警导出)
|
||||
```
|
||||
|
||||
清理后库内基线回到验证前状态(dbhub 只读复核):
|
||||
|
||||
```
|
||||
tb_export_task = 52 tb_export_shard_task = 48 id > 52 的导出任务 = 0
|
||||
AUG26014 标记:tb_shop / tb_iot_card / tb_package_usage / tb_order / tb_account /
|
||||
tb_package_traffic_alert / tb_export_task 计数全部 = 0
|
||||
```
|
||||
|
||||
### 6.4 证据与代码版本的对应
|
||||
|
||||
| 证据批次 | 时间 | HEAD / 指纹 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 预演(T1/T2/T3/T7 首轮) | 17:26–17:37 | `d52be16` / 修复前二进制 | 仅作预演记录;本文件引用的有效证据全部为修复后批次 |
|
||||
| 第一批(§6.2 第 1–12 项) | 17:39–17:47 | `d52be16` / `51c619f8…` | T7 实现尚未提交;当时 HEAD 尚不含维护者的路由门禁修复,故代理 HTTP 项为 403/1005 |
|
||||
| 第二批(§6.2 第 13–14 项、§6.1 第 15–17 行) | 18:0x–18:1x | `e8ab1f47`(含 `398a5e42`)/ `37d7d690…` | 仅多出维护者对 `internal/routes/{package_traffic_alert,asset_auto_renewal}.go` 的门禁作用域修复提交;T7 实现内容与第一批一致 |
|
||||
|
||||
### 6.5 未验证项(环境限制)
|
||||
|
||||
1. **「代理可见店铺为空列表」这一极端分支**:未单独构造。`GetSubordinateShopIDs` 恒包含账号自身店铺(`internal/store/postgres/shop_store.go:162-192` 以 `ids := []uint{shopID}` 起始),因此「非零 shop_id 且可见项为空」在正常数据下不可达;已用 `shop_id=NULL` 的 fixture 代理账号覆盖最近的等价分支(创建导出被拒 `403 / 1005 代理账号缺少店铺信息`,见 §6.2 第 13 项第 3 段)。
|
||||
2. **达量预警「真实扫描链路」产生的行级口径**:本批以 fixture 直接写入 `tb_package_traffic_alert` 快照行,验证了列表与导出同筛选行集一致(2=2)与快照列原样输出;未跑真实的每日扫描任务(需有效套餐 + 真流量累计 + 通知链路,代价高且与本 Change 无关)。
|
||||
3. **`tb_export_task` 中「旧格式冻结值」的历史存量**:清理前全库实测 0 条(`count(*) WHERE query_json::text ~ '[0-9]{4}-[0-9]{2}-[0-9]{2}[ T]'` = 0),7.9 因此用自建 fixture 覆盖。
|
||||
4. **迁移 `up/down/up`**:本变更无 Schema 变更(§5),按设计为「不适用」。
|
||||
|
||||
### 6.6 既有缺陷 D1:路由门禁作用域(已定位,已由维护者提交 `398a5e42` 修复)
|
||||
|
||||
**现象(第一批,HEAD 尚为 `d52be16`、不含修复)**:代理账号访问 `/api/admin/expiring-assets` 与新增的 `/api/admin/expiring-assets/export` 一律返回 403 / `1005 无权限操作该资源或资源不存在`,与路由注释「后台和代理共用」及路由描述「企业账号禁止调用」矛盾。原始输出:
|
||||
|
||||
```
|
||||
$ curl -s -H "Authorization: Bearer <agent-token>" 'http://127.0.0.1:3000/api/admin/expiring-assets?page=1&page_size=1'
|
||||
{"code":1005,"data":null,"msg":"无权限操作该资源或资源不存在"}
|
||||
|
||||
$ curl -s -X POST -H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
|
||||
-d '{"format":"csv"}' 'http://127.0.0.1:3000/api/admin/expiring-assets/export'
|
||||
{"code":1005,"data":null,"msg":"无权限操作该资源或资源不存在"}
|
||||
```
|
||||
|
||||
定位过程中的门禁键隔离实验(**注入令牌的 `user_type` 由验证者显式写入 Redis 值决定**,非账号真实类型):同一 `user_id=144` 以 `user_type=2` 注入 → 200,以 `user_type=3` 注入 → 1005;`user_id=148`(真实代理)以 `user_type=3` 注入 → 1005;平台 `user_id=149`(**平台账号,`user_type=2`,非代理**)以 `user_type=2` 注入 → 200。旁证:`user_type=3` 的令牌访问任一不存在的 `/api/admin/*` 路径同样 1005(超管/平台为 `1006 资源未找到`),说明拦截发生在其后的路由之前。
|
||||
|
||||
**根因(已由维护者提交证实)**:`internal/routes/package_traffic_alert.go` 与 `internal/routes/asset_auto_renewal.go` 原以 `router.Group("", gate)` 在管理端根组注册「仅超管/平台」门禁。Fiber 的 `Group(prefix, handlers...)` 会把该处理器注册为 `path=/api/admin` 的 USE 处理器并匹配**整个 `/api/admin` 前缀**,且按注册顺序先于其后注册的路由执行,因此 `internal/routes/admin.go` 中位于 `:107`(package-traffic-alert)与 `:110`(asset-auto-renewal)之后注册的全部后台接口(含 `:125` admin exchanges、`:149` `/assets/*`、`:150` `/expiring-assets` 与新增 `/expiring-assets/export`、`:165` agent-recharges,以及 `:122` admin orders)对代理/企业账号一律 403-1005;早于该点的 `:36/:52/:67/:71/:74/:83/:89/:101` 不受影响——与实测 200/1005 的分界完全吻合。
|
||||
|
||||
**修复(维护者提交 `398a5e42`,2026-09-17 17:49:33,不在本 Change 改动内)**:`git show 398a5e42` 将两处门禁改为挂在各自功能前缀组上(`asset_auto_renewal.go` → `router.Group("/asset-auto-renewal-config", …)`;`package_traffic_alert.go` → `router.Group("/package-traffic-alert-rules", gate)` 与 `router.Group("/package-traffic-alerts", gate)`),并在注释中写明「gate 必须挂在功能路径组上:Fiber 的组中间件按路径前缀生效,挂在空路径组上会落到 /api/admin 前缀」。该修复与本文件定位的机制一致。
|
||||
|
||||
**修复后复验(第二批,HEAD=`e8ab1f47`)**:代理账号经真实登录访问同一端点已可达,列表只返回可见店铺(3 行,不含范围外 2 个资产),导出创建并执行为 3 行,详见 §6.2 第 13 项;本 Change 的代理视角验收因此在本批全部通过。
|
||||
|
||||
**归类**:既有实现缺陷(**非本 Change 引入**),**已由维护者在 HEAD 修复**;本 Change 无需改动,亦未被该缺陷遗留影响。验证的第一批 2 个 FAIL(`§6.1` 第 15 行)为修复前版本的照实记录,已保留。
|
||||
|
||||
|
||||
## 7. 实现期发现(需 director 决策,未在本次改动)
|
||||
|
||||
`tb_export_task`、`tb_commission_record`、`tb_commission_withdrawal_request`、`tb_agent_recharge_record`、`tb_enterprise_card_authorization` 等列的时间列为 PostgreSQL `timestamp without time zone`,其中保存的是应用本地时区(部署容器 `TZ=Asia/Shanghai`)的**挂钟时间**;`tb_order`、`tb_exchange_order`、`tb_asset_allocation_record` 为 `timestamp with time zone`。测试库实测(dbhub 只读查询):
|
||||
|
||||
```sql
|
||||
SELECT o.created_at AS order_created_tz, c.created_at AS commission_created_naive, (c.created_at - o.created_at) AS delta
|
||||
FROM tb_commission_record c JOIN tb_order o ON o.id = c.order_id ORDER BY c.id DESC LIMIT 1;
|
||||
-- 2026-08-25 08:08:15.00218+00 | 2026-08-25 16:08:20.158216 | {"hours": 8, "seconds": 5, ...}
|
||||
```
|
||||
|
||||
即同一笔业务事实在无时区列上的挂钟时间比带时区列的快照早 8 小时。驱动在绑定 `time.Time` 到无时区列时会丢弃时区(仅保留挂钟字段),因此统一按「归一后的 UTC 瞬时」绑定后,无时区列上的有效边界为该瞬时的 UTC 挂钟值。
|
||||
|
||||
本次实现按 spec「解析结果 MUST 归一为 UTC 瞬时」与唯一合规先例(`internal/exporter/package_traffic_alert_scene.go` 的 `startTime.UTC()`)原样绑定,未引入额外的时区换算;列表与导出使用同一解析结果,因此同筛选下行集一致。无时区列上的「用户本地整日区间」与「UTC 挂钟边界」之间的差值属本变更范围外的既有事实。
|
||||
|
||||
director 决定:
|
||||
|
||||
- 该事实**已登记**为本变更产物的已知差异,见 [`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md) 第 6 条「无时区列的时间锚定(本次不改)」。
|
||||
- 本次**不改**:统一按 spec 的「归一为 UTC 瞬时」绑定,列表与导出同向同量、有效边界一致,已由本文件 §6.2 第 8 项(含 8 小时漂移探针)实测确认。
|
||||
- 若产品期望「按真实发生瞬时的上海自然日」筛选,属**另立 Change** 的范围(涉及 8 张无时区列的口径与历史数据)。
|
||||
|
||||
## 8. 相关文件
|
||||
|
||||
- 变更产物:[`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/)
|
||||
- 行为契约:[`../../openspec/specs/export-time-filter/spec.md`](../../openspec/specs/export-time-filter/spec.md)
|
||||
- 上下文证据:[`./context-reset/requirement-evidence.json`](./context-reset/requirement-evidence.json)、[`./context-reset/entry-capability-requirement-matrix.json`](./context-reset/entry-capability-requirement-matrix.json)
|
||||
- 工程门禁:[`../engineering/工程约束.md`](../engineering/工程约束.md)
|
||||
- 同类体例参考:[`./add-priority-polling-queue-verification.md`](./add-priority-polling-queue-verification.md)
|
||||
949
docs/verification/add-operations-reports-verification.md
Normal file
949
docs/verification/add-operations-reports-verification.md
Normal file
File diff suppressed because one or more lines are too long
209
docs/verification/add-priority-polling-queue-verification.md
Normal file
209
docs/verification/add-priority-polling-queue-verification.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# AUG26-016 卡轮询优先队列(add-priority-polling-queue)实施与验证记录
|
||||
|
||||
本文件记录 OpenSpec Change `add-priority-polling-queue` 的实施范围、已实测证据与未验证清单。
|
||||
所有实测均在测试库 `junhong_cmp_test` 与**隔离 Redis DB 15** 上执行;未连接生产、未启动 Worker/API 进程、未调用运营商上游接口。
|
||||
|
||||
## 0. 结论概览
|
||||
|
||||
| 验证项 | 断言计数 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| 9.1 迁移 up/down/up + down 守卫 | 结构断言 4 组 + 探针 6 条 | 通过(含记账 `dirty=true` 的实测结论) |
|
||||
| 9.2–9.7 本机可安全验证部分(A–F + H5 批) | **94 PASS / 0 FAIL** | 通过 |
|
||||
| 接缝执行(认领/跳过/接管/范围校验,非导出接缝经真实 `Handle` 驱动) | **63 PASS / 0 FAIL** | 通过 |
|
||||
| 提示通道(优先键与手动键、FIFO、`RemoveFromAllQueues` 边界、分片队列快照) | **12 PASS / 0 FAIL** | 通过 |
|
||||
| 清理与零残留(PG 六类计数 + Redis 键 + 越界 DB 只读复核) | **20 PASS / 0 FAIL** | 通过 |
|
||||
| 成功路径 `Complete`、真并发、尝试上限第 3 次判定、package handler 接缝、HTTP 层、通道阈值持锁、三类生效触发点生产集成 | — | **未验证**(见第 4 节) |
|
||||
|
||||
## 1. 环境与命令
|
||||
|
||||
- 目标库:`junhong_cmp_test`(`cxd.whcxd.cn:16159`);迁移经 `scripts/migrate.sh`(读取 `.env` 的 `DB_*`)。
|
||||
- 隔离 Redis:`cxd.whcxd.cn:16299` **DB 15**(部署库为 DB 6、本地沙盒为 DB 7,二者对所有写入被显式拒绝)。
|
||||
- 环境装载:`set -a; . ./.env; . ./.env.local; set +a`(`DB_*` 只在 `.env`;Redis 参数只在 `.env.local`)。
|
||||
- 构建:`go build -o /tmp/pf ./cmd/priority-fixture-tmp`。
|
||||
- 只读诊断:dbhub MCP `mcp__postgres_execute_sql_main`(仅 SELECT);Redis 只读用 `redis-cli -n <db> EXISTS|DBSIZE|SCAN`。
|
||||
- 临时工具子命令:`setup|a-store|b-consumer|c-trigger|d-read|e-enqueue|f-audit|g-h5|seam-verify|hint-verify|purge-stale-audits|cleanup|count|insert-fixture|delete-fixture|count-rows|index-probe`。
|
||||
该工具带有安全栏:`DB_NAME` 必须为 `junhong_cmp_test`,所有自建行带 `AUG26016` 标记,清理只按本批 ID 集合或标记删除。
|
||||
- 日志:每场景独立带时间戳文件 `run<N>-<步骤>-<TS>.log`(不覆盖);本文只摘录关键原始行。
|
||||
|
||||
## 2. 已验证清单(含点时间与期望/实际)
|
||||
|
||||
### 2.1 任务 9.1:迁移 up / down / up 与 down 守卫(2026-09-16)
|
||||
|
||||
| 步骤 | 期望 | 实际(原文摘录) |
|
||||
| --- | --- | --- |
|
||||
| 迁移前版本 | 227 | `当前迁移版本: 227` |
|
||||
| `./scripts/migrate.sh up` | 应用到 228,exit=0 | `228/u add_polling_priority_queue (344.53875ms)`、`✓ 迁移操作完成` |
|
||||
| 结构 | 23 列全注释、5 索引(含活动项部分唯一索引)、9 CHECK、0 外键 | `column_count=23`、`commented_columns=23`、`index_count=5`、`c:9`+`p:1`、`fk_count=0` |
|
||||
| 活动项索引谓词 | 仅 `deleted_at IS NULL AND status IN ('pending','processing')` 占键位 | `CREATE UNIQUE INDEX uq_polling_priority_item_active ... WHERE ((deleted_at IS NULL) AND ((status)::text = ANY ((ARRAY['pending','processing'])::text[])))` |
|
||||
| 造 1 条活动 fixture 后 `./scripts/migrate.sh down` | 守卫拒绝回滚且**结构零损伤** | `pq: 存在卡轮询优先队列活动项,拒绝回滚以避免丢失在途加急事实与认领租约状态`、`down_exit=1`;随后 `table_regclass=tb_polling_priority_item`、`row_count=1`、`column_count=23`、`index_count=5` |
|
||||
| 守卫后的记账标记 | —(实测为工具行为) | `{"version":"227","dirty":true}` |
|
||||
| 记账修复 | `force <真实版本>` 只改一行、不动 DDL | `./scripts/migrate.sh force 228` → `{"version":"228","dirty":false}` |
|
||||
| 清 fixture 后 `down` | 表/索引/relation 全部消失,exit=0 | `228/d add_polling_priority_queue (221.332417ms)`;`{"table_regclass":null,"index_count":"0","relation_leftovers":"0"}` |
|
||||
| 再次 `up` | 回到 228 且结构完整 | `{"version":"228","dirty":false,"column_count":"23","index_count":"5","check_count":"9","fk_count":"0"}` |
|
||||
|
||||
**实测结论(`dirty=true` 的口径)**:golang-migrate 在运行某个迁移文件之前先写入目标版本并标 `dirty=true`,成功后写回 `dirty=false`;`down 228` 的目标版本是 227,守卫 `RAISE EXCEPTION` 后库被留在 `227, dirty=true`。
|
||||
这是**迁移工具的记账行为,不是本 Change 的实现缺陷**;恢复方式是 `./scripts/migrate.sh force <当前真实版本>`(本例 228),该命令只修一行记账、不触发 DDL 或数据变更。验证 down 守卫的正确预期应写成「守卫拒绝 + 结构零损伤 + 由维护者 `force` 回当前真实版本并确认 `dirty=false`」。
|
||||
|
||||
### 2.2 部分唯一索引语义与约束探针(2026-09-16)
|
||||
|
||||
```
|
||||
[成功] P1 插入活动行 A(id=1 card_id=6012 task_type=carddata status=pending)
|
||||
[报错] P2 重复活动行(同卡同任务类型) → SQLSTATE=23505 constraint=uq_polling_priority_item_active
|
||||
[成功] P3 软删 A 后插入活动行 B(id=3)→ 软删行不占用活动项键位
|
||||
[报错] P4 插入活动行 C(B 仍活动) → SQLSTATE=23505 constraint=uq_polling_priority_item_active
|
||||
[成功] P5 把 B 转终态(completed/success)后插入活动行 D(id=5)→ 终态行不占用活动项键位
|
||||
[报错] P6 终态与结果一致性(completed 但 result 为空) → SQLSTATE=23514 constraint=ck_polling_priority_item_terminal_result
|
||||
索引与约束探针全部符合预期 (exit=0)
|
||||
```
|
||||
|
||||
### 2.3 A–F 批次(Store / 消费者 / 触发分类 / 读侧 / 人工入队 / 审计)——94 PASS / 0 FAIL
|
||||
|
||||
时间:2026-09-17 09:46:49(+08),日志 `run5-<场景>-20260917T094649.log`(另有一轮同结果运行 `run-e2e-*-20260917T092447.log`)。
|
||||
|
||||
| 场景 | 断言 | 期望 → 实际(摘录) |
|
||||
| --- | --- | --- |
|
||||
| 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 不存在 → 同一码与文案` |
|
||||
| E 人工入队用例 | 14 PASS | `E15 一次入队覆盖全部纳入类型 → created=4 merged=0`;`E16 24h 内再次入队 → created=0 merged=4`;`E17 原因缺失/超长 → CodeInvalidParam`;`E17 拒绝审计 → 5 条 manual_denied`;`E17 被拒绝的请求不产生优先项变更 → 仍 4 行活动项` |
|
||||
| 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_manual_service.go` 直写事实表,不经事件通道)。
|
||||
|
||||
```
|
||||
[PASS] H5-1 经事件通道投递 manual_trigger 返回参数错误 → code=1001 err=优先轮询事件不接受人工入队触发类型 manual_trigger,人工入队请调用人工入队用例
|
||||
[PASS] H5-3 该 event_id 在 tb_outbox_event 新增 0 行 → 期望=0 实际=0
|
||||
[PASS] H5-4 tb_outbox_event 总行数不变(拒绝发生在写入前) → 期望=19453 实际=19453
|
||||
```
|
||||
|
||||
### 2.5 接缝执行(seam-verify)——63 PASS / 0 FAIL
|
||||
|
||||
时间:2026-09-17 10:16:34(+08),隔离 Redis DB 15(打印 `db=15`),**未执行 `FlushDB`**。
|
||||
驱动方式:不启动 Worker/Asynq/HTTP,用 `task.NewPollingBase` + `asynq.NewTask` 直接调用
|
||||
`PollingRealnameHandler` / `PollingCarddataHandler` / `PollingCardStatusHandler` 的真实 `Handle`;
|
||||
`integration` 注入真实仓库(非 nil),使「零上游」由真实计数断言证明。
|
||||
每个任务类型(realname / carddata / card_status)依次断言 (a)–(e):
|
||||
|
||||
| 断言组 | 期望 → 实际(摘录) |
|
||||
| --- | --- |
|
||||
| 计数器真对照 | `写入 1 行 tb_integration_log 后按夹具卡统计 +1 → 期望=1 实际=1`;`对照行已删除:计数回到原值 → 期望=0 实际=0` |
|
||||
| (a) 无活动项 = 基线等价 | `Handle 正常返回 → err=<nil>`;`不产生优先项行 → 0`;`零上游调用 → 0`;`仍按既有路径重入队(分片 ZSET 命中) → key=polling:shard:0:queue:polling:realname member=11696` |
|
||||
| (b) pending = 本次领取 | `claim 审计 +1 → delta=1`;`status=failed claimed_at=2026-09-17 10:16:41+08 attempt=0 reason="流量查询能力未配置"`;`fail+dequeue 各 +1`;`零上游调用 → 0` |
|
||||
| (c) 租约内 = 跳过且零上游 | `状态/认领时间/尝试次数/失败原因全部不变 → processing,claimed_at 前后相同`;`claim=0 fail=0 dequeue=0`;`零上游调用 → 0`;`重新入队(ZSET 命中)` |
|
||||
| (d) 超租约 = 接管 | `claimed_at=2026-09-17 10:16:42+08`(距运行 <30s,已刷新);`claim delta=1`;`零上游调用 → 0` |
|
||||
| (e) 卡不在轮询范围 | `status=failed reason="卡已不在轮询范围内" attempt=0`;`零上游调用 → 0` |
|
||||
|
||||
收尾(同一进程内):
|
||||
|
||||
```
|
||||
[清理] 隔离 Redis 已删除本套件使用的 12 个键(未执行 FlushDB)
|
||||
[清理] 审计事实(按本批 ID 集合精确删除):事件=27,资源行=27
|
||||
[清理] 本功能动作码审计保留条数(非本批写入,未删除):0
|
||||
[清理] 优先项=0;Outbox(prio:)=0;套餐使用=0;绑定=3;卡=6;设备=1
|
||||
[PASS] 清理后计数 … 应为 0(9 条)+ 本批全部自建行已清理干净
|
||||
结果:全部断言通过
|
||||
```
|
||||
|
||||
### 2.6 提示通道(hint-verify)——12 PASS / 0 FAIL
|
||||
|
||||
时间:2026-09-17 10:16:34(+08),隔离 Redis DB 15、未 `FlushDB`、全程前后 `polling:shard:*` 快照 `{}`。
|
||||
|
||||
```
|
||||
[PASS] ① EnqueuePriority 只写优先提示键(LLEN) → 期望=3 实际=3
|
||||
[PASS] ① 手动触发键未被 EnqueuePriority 触碰(LLEN) → 期望=0 实际=0
|
||||
[PASS] ① EnqueueManual 只写手动触发键(LLEN) → 期望=1 实际=1
|
||||
[PASS] ① 优先提示键未被 EnqueueManual 触碰(LLEN) → 期望=3 实际=3
|
||||
[PASS] ② RPush 3 元素后 LPopCount 弹出顺序为先进先出 → 期望=[990016101 990016102 990016103] 实际=[990016101 990016102 990016103]
|
||||
[PASS] ③ 边界登记:RemoveFromAllQueues 不清理优先提示键(LLEN 保持 1)
|
||||
[PASS] ③ 边界登记:RemoveFromAllQueues 不清理手动触发键(LLEN 保持 1)
|
||||
[PASS] ④ 本子命令前后 polling:shard:* 键集合与成员完全一致(diff 为空) → before={} after={}
|
||||
[PASS] 收尾 删除本子命令使用的键数 → 2;两个键 EXISTS=0(未残留)
|
||||
```
|
||||
|
||||
### 2.7 清理与零残留(PG + Redis 只读复核,2026-09-17 10:17 起)
|
||||
|
||||
| 指标 | 运行前 | 运行后 |
|
||||
| --- | --- | --- |
|
||||
| `tb_polling_priority_item` | 0 | **0** |
|
||||
| 标记夹具卡 / 设备(`AUG26016%`) | 0 / 0 | **0 / 0** |
|
||||
| `polling_priority.%` 审计 | 0 | **0** |
|
||||
| `prio:` outbox | 0 | **0** |
|
||||
| 孤儿审计资源(`polling_priority_item` / `iot_card+polling_card`) | 0 / 0 | **0 / 0** |
|
||||
| `schema_migrations` | 228 / dirty=false | **228 / dirty=false** |
|
||||
| Redis DB 15 `polling:*` 键数 | 0 | **0** |
|
||||
| Redis DB 15 本套件 12 键 `EXISTS` | — | **0** |
|
||||
| Redis DB 6 夹具卡专属键(`polling:card:11696`、`traffic:sync:lock:card:11696`) | — | **0 / 0**(无本 Change 痕迹) |
|
||||
|
||||
库内全量孤儿审计资源另有 88 行,分组为 `employee_collection_bill=37`、`employee_collection_application=16`、
|
||||
`employee_collection_application_attempt=16`、`package_traffic_alert=16`、`package_traffic_alert_rule=3`——
|
||||
**全部属于其它能力**,本 Change 两类均为 0。
|
||||
|
||||
### 2.8 一次真实失败与其修复(如实记录)
|
||||
|
||||
首次接缝运行(2026-09-17 10:11:34,`run6-seam-verify-20260917T101134.log`)接缝断言 61/61 通过,
|
||||
但收尾残留检查 2 条 FAIL:`polling_priority.*` 审计残留 27 条(claim 9 / fail 9 / dequeue 9,
|
||||
`created_at` 2026-09-17T02:11:37Z–02:11:49Z)。
|
||||
根因是**验证工具自身的记账缺陷**:场景逐个任务类型收尾会删除优先项行,而审计清理依赖按优先项 ID 反查,删行后即漏删。
|
||||
修复(仅临时工具,未放宽任何断言):运行期采集优先项 ID 并在清理时作为额外 ID 集合传入;另加带时间窗护栏的
|
||||
`purge-stale-audits` 兜底子命令(必须显式给 `AUG26016_PURGE_SINCE`,窗口外的行一律拒绝删除并列出)。
|
||||
修复后重跑 63/63 全绿,27 条残留按兜底路径精确删除(`事件=27,资源行=27,删除后残留=0`)。
|
||||
|
||||
## 3. 工程门禁(收尾复跑,脚手架删除后;2026-09-17 10:26)
|
||||
|
||||
| 命令 | 原始结果 | 退出码 |
|
||||
| --- | --- | --- |
|
||||
| `gofmt -l .` | 仅列出 6 个**历史未格式化**文件:`internal/model/dto/package_dto.go`、`internal/model/order_package_invalidate_task.go`、`internal/model/personal_customer_device.go`、`internal/model/personal_customer_iccid.go`、`internal/model/personal_customer_phone.go`、`internal/query/h5popup/query.go`;本 Change 改动文件(`internal/infrastructure/prioritypolling/event.go`、`internal/task/*`、`internal/polling/*`、`internal/service/*`、`pkg/constants/*`、`migrations/000228_*` 等)**均未出现** → 无新增未格式化文件 | 0 |
|
||||
| `go build ./cmd/api ./cmd/worker` | 无输出(仅 Go 模块缓存 stat 写入诊断,不影响构建) | 0 |
|
||||
| `go vet ./...` | 无诊断输出 | 0 |
|
||||
| `go run cmd/gendocs/main.go`(连续两次) | 两次均输出「成功在以下位置生成 OpenAPI 文档」;`md5(docs/admin-openapi.yaml)` = `696dba47834f93faacba93c745353fdd`(两次一致;该文件被 `.gitignore` 忽略,是本地生成产物) | 0 / 0 |
|
||||
| `openspec validate add-priority-polling-queue --strict` | `Change 'add-priority-polling-queue' is valid` | 0 |
|
||||
| `openspec doctor --json` | `"root": {… "healthy": true, "status": []}`、`"status": []` | 0 |
|
||||
| `./scripts/context-health.sh` | `Context 健康检查通过` | 0 |
|
||||
|
||||
自动化测试按项目决策为 N/A:未创建任何 `*_test.go`(`context-health.sh` 亦校验仓库无 `*_test.go`)。
|
||||
|
||||
## 4. 未验证清单(明确未覆盖,不得据本文推断)
|
||||
|
||||
| 项 | 未验证原因 |
|
||||
| --- | --- |
|
||||
| 优先项成功路径 `Complete`(`processing → completed`) | 需真实上游调用成功;本轮所有执行都停在「能力未配置」失败分支,未制造成功上游响应 |
|
||||
| 真并发下的认领互斥 | 无并发执行环境;本轮以「租约内执行中 → 跳过且零上游」的单线程等价路径覆盖,未做同卡同类型真并发竞态 |
|
||||
| 尝试上限第 3 次判定 | 需稳定失败的真实上游;本轮止于 `FailFinal`(尝试次数不累加),未覆盖 `FailRetryable` 连续 3 次的收敛 |
|
||||
| `package` handler 的接缝 | 其 `Handle` 依赖 `t.ResultWriter().TaskID()`,本地 `asynq.Task` 会 panic,未驱动 |
|
||||
| HTTP 层权限矩阵 | 未启动 API;权限判定仅覆盖服务层与查询层(读侧范围、越权=不存在同一响应),未取得真实状态码/`msg` |
|
||||
| 9.7 通道阈值持锁边界 | 需 `tb_carrier_traffic_threshold_lock` 活动锁与真实停复机评估路径;本轮未造锁、未驱动停复机 |
|
||||
| 三类生效触发点的生产集成 | 新购/续购/加油包/排队顺延的触发分类与事件构造已单测(C 场景),但未经真实订单/支付/激活链路端到端验证 |
|
||||
| `queue_activated` / `addon_activated` 正向落库 `trigger_type` | C 场景只对 `purchase_activated`/`renewal_activated` 做了正向落库;排队顺延与加油包仅验证了事件构造 |
|
||||
| 调度器排空顺序与分片背压 | 需 Worker + 既有部署环境;本文只覆盖提示通道的生产/消费语义 |
|
||||
| 迁移 down 守卫的自动化 | 未引入自动化测试(项目决策 N/A),仅为手工实测记录 |
|
||||
|
||||
## 5. 边界声明(避免误读)
|
||||
|
||||
1. **`tb_integration_log` 全表计数会被在跑的测试部署推高**:观测窗口内该表由 5,905,167 增至 5,905,619(+452),
|
||||
来源是并发运行的既有测试部署(DB 6 心跳/并发/27 卡在跑),**不是本套件产生**;本套件在该表只插入并删除 1 行对照行(复查 = 0)。
|
||||
2. **「零上游调用」的判据是夹具卡维度**:`tb_integration_log WHERE resource_type='iot_card' AND resource_id='<夹具卡ID>'`,
|
||||
每条场景运行前后均为 0(15 条独立断言),并由 `counterControl` 对照证明该计数口径能发现写入。
|
||||
这**不等于**「整库无上游调用」——测试部署的正常上游调用一直在发生。
|
||||
3. **隔离 Redis DB 15 并非本套件独占**:其中仍有他人 7 个键(`auth:refresh:…`、`auth:user:127:tokens`、
|
||||
`auth:token:482e6fdc-…`、`asynq:queues`、`asynq:{export:*}×3`)。因此验证工具**默认不执行 `FlushDB`**,
|
||||
只按清单删除自己使用的键;DB 0/6/7 在所有写入路径上被显式拒绝(DB 6/7 仅做过 `EXISTS`/`DBSIZE`/`SCAN` 只读探测)。
|
||||
4. **成功路径与并发结论不得外推**:本轮所有执行都走「能力未配置」分支,因此「零上游」「状态收敛」的结论仅适用于该分支;
|
||||
成功路径 `Complete`、真并发互斥、尝试上限收敛见第 4 节未验证清单。
|
||||
|
||||
## 6. 收尾处置
|
||||
|
||||
- 验证脚手架 `cmd/priority-fixture-tmp/`(main.go / verify.go / seam.go / hint.go / purge.go)为一次性工具,
|
||||
验证完成后**整目录删除**(`rm -rf cmd/priority-fixture-tmp`,DELETED_EXIT=0)。
|
||||
删除确认:`ls cmd/` 只剩 `api/audit-coverage/audit-retention-simulate/foundation-check/gendocs/migration-finalize/worker`;
|
||||
`find . -name 'priority-fixture-tmp*' -not -path './.git/*'` 无输出(仓库根目录与全仓均无同名二进制);
|
||||
`git status --porcelain | grep -i fixture` 无输出(工作区已不含该目录)。
|
||||
- 测试库 `tb_polling_priority_item` 与本 Change 动作码审计、`prio:` outbox、标记夹具均已清零(见 2.7);
|
||||
本文引用的逐场景日志保存在 `/tmp/aug26-016/`(会话级临时目录,非仓库产物)。
|
||||
- 未提交、未 push。2026-09-17 归档:Change 目录移至 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/`(含 `.openspec.yaml`),delta spec 已同步进主 Specs(新增 `openspec/specs/priority-polling-queue/spec.md`,并在 `openspec/specs/polling-operations/spec.md` 追加「优先轮询项与普通轮询的单次执行互斥」与三条优先队列路由);`tasks.md` 的 9.1–9.7 按用户口径统一勾选,第 4 节未验证清单不因勾选而改变。
|
||||
File diff suppressed because it is too large
Load Diff
5914
docs/verification/context-reset/requirement-evidence.json
Normal file
5914
docs/verification/context-reset/requirement-evidence.json
Normal file
File diff suppressed because one or more lines are too long
597
docs/卡流量计算技术方案-参考老卡管.md
Normal file
597
docs/卡流量计算技术方案-参考老卡管.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# 卡流量计算技术方案(参考老卡管)
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 日期 | 2026-10-20 |
|
||||
| 状态 | 方案,待评审,**代码尚未修改** |
|
||||
| 思路 | **流量怎么算,照搬老卡管;套餐那边一行不动**:只替换"增量从哪来",增量照旧通过 V1 事件交给现有 `DeductDataUsage` 扣减 |
|
||||
| 修订 | 第 2 版(2026-10-20):对照反编译结果与新卡管代码复核后修订——增加切换水位(4.6)、增量改为按月对账(4.5)、已切换卡的通道阈值判定(4.5)、去掉"读数是否有效"标记、更正对老卡管的几处描述;新增第 10 章停复机规则与实名管控;已切换卡的卡表本月用量改由快照覆盖(4.5),切换与退回要求全部停止后再启动(5.2) |
|
||||
| 改动范围 | 流量部分(第 1~9 章):观测链路(算增量)+ 新增卡日快照表 + 切换水位表 + 日流量读取 + 已切换卡的通道阈值判定条件;**套餐表、套餐服务、V1 消费者、激活/重置/改已用量/换货/退款入口、扣减顺序、停复机全部不改**。停复机部分(第 10 章):停机与复机只看套餐和流量(通道开启实名管控时加上实名)、复机不看停机原因、实名默认不管控、暂停轮询的卡不自动停复机;独立开发、独立上线 |
|
||||
| 与旧方案关系 | 替代 `docs/流量事实模型技术方案.md`(第 5 版)的计量部分 |
|
||||
| 老卡管依据 | 反编译 `kyhl-api/WEB-INF/lib` 下 `kyhl-service-1.0.jar`、`kyhl-dal-1.0.jar`、`kyhl-common-1.0.jar`、`kyhl-common-jdbk-1.0.jar` |
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论
|
||||
|
||||
1. **漏洞根源**:新卡管现在的增量 = 本次读数 − 卡上的基线(`tb_iot_card.last_gateway_reading_mb`)。基线是跨套餐、跨用户的卡级状态,一旦出错(被异常高读数抬高、重置日没采到)就永久保留:卡被冻结、读数为 0 时重复计、二次销售时多计。
|
||||
2. **老卡管不依赖基线**:每张卡每天存一条"本月累计"快照,任何一段时间的用量都用快照现算(区间内最大值 − 区间开始前的最大值)。它没有"读数下降"的概念,已经稳定运行多年。
|
||||
3. **本方案**:
|
||||
- 流量计算照搬老卡管:日快照、按各运营商的重置日(`tb_carrier.data_reset_day`)换算成自然月、重置日防旧值、按区间取最大值做差。
|
||||
- 每次观测的增量 = **本月最大快照 − 本月已结算合计**(按月对账),不再用卡表基线。
|
||||
- 卡第一次走新路径时记一条**切换水位**:本次仍按老路径的增量发,之前的部分视为老路径已结算。切换衔接、补数据截断、冻结卡判定都以它为准。
|
||||
- 增量照旧写进 V1 事件 `CardTrafficIncremented`,由现有消费者调用 `DeductDataUsage` 扣减。**套餐分配、加油包顺序、虚流量阈值、停复机、流量详单全部沿用现有代码。**
|
||||
- 已切换的卡,通道阈值按"读数有效且未被防旧值处理"判定,不再受卡表基线影响,冻结卡切换后通道达量停机随即恢复。
|
||||
4. **二次销售**:卡没有生效套餐时,增量照样计算,但**只记为已结算、不发事件**。激活之前被采到的用量不会扣到新套餐上;精度到观测粒度:激活后第一次观测里,"上次观测到激活"这一段仍会算给新套餐。
|
||||
5. **先做卡流量计算,补数据放到后期**:新方案上线后,冻结卡从切换起恢复正常计量;切换前少扣的部分由后期的独立脚本补(5.5)。补数据依据运营商读数(未切换的卡现场查询,已切换的卡取切换水位里切换时的读数),只能补执行时所在运营商周期内的部分,更早的补不回来。
|
||||
|
||||
### 1.1 已定决策(2026-10-20)
|
||||
|
||||
| # | 事项 | 决定 |
|
||||
|---|---|---|
|
||||
| D1 | 流量计算 | 照搬老卡管:日快照、写入时按运营商重置日(`tb_carrier.data_reset_day`,任意 1~28 号)换算成自然月、按区间取最大值做差,没有读数下降判定。重置日不写死,运营商配置改了随之生效 |
|
||||
| D2 | 套餐侧 | 一行不改:增量照旧走 V1 事件与 `DeductDataUsage` |
|
||||
| D3 | 重置日防旧值 | **完全照搬老卡管**(含比较基准):只在重置日当天判断,`R = 1` 比上月最大快照、`R > 1` 比本月 1~R−1 号最大快照,超过 0.9 倍按 0 记;第二天起照常累加,不挂起、不告警。老卡管多年运行没有暴露问题 |
|
||||
| D4 | 卡无生效套餐时的增量 | 只记入 `settled_mb`(已结算),不发事件,用量丢弃 |
|
||||
| D5 | 快照记录范围 | 全部被采集的卡 |
|
||||
| D6 | 上线方式 | 按名单灰度,单位是扣减载体,设备整台切;第一批为 103154 所在设备 15276,核对后扩批再全量。非灰度卡行为与现在一致(4.8) |
|
||||
| D7 | 积压的无套餐回执 | 业务后续手动调整,不在本方案内;处理前不得重放 |
|
||||
| D8 | 顺序 | 先上卡流量计算,补数据放后期(5.5) |
|
||||
| D9 | 补数据:本周期中间重置过套餐的冻结卡 | 不自动补,进清单,附按天数比例的估算值,由人工决定 |
|
||||
| D10 | 增量口径 | 按月对账:`Δ = 本月最大快照 − 本月已结算合计`,不按天对账(4.5) |
|
||||
| D11 | 切换衔接 | 记切换水位:卡第一次走新路径时,本次照发老路径增量,之前的部分记为已结算;不依赖阶段 1 是否覆盖整月(4.6) |
|
||||
| D12 | 已切换卡的通道阈值 | 读数有效且本次读数未被防旧值按 0 处理即判定,不再看老路径是否接受读数(4.5) |
|
||||
| D13 | 套餐在扣减前刚好用完 | 消费者不改,写入已知限制并监控(第 9 节) |
|
||||
| D14~D17 | 停复机规则、实名管控、暂停轮询 | 见 10.3 |
|
||||
|
||||
## 2. 现在的漏洞(已验证,详见旧方案第 2 节)
|
||||
|
||||
| # | 漏洞 | 证据 |
|
||||
|---|---|---|
|
||||
| 1 | **卡被冻结**:读数下降只在重置日当天或前一天才被认作清零,其余日子一律拒绝。基线被抬高后,之后的读数全被拒,套餐不再增长,也不停机 | `internal/domain/cardobservation/traffic.go:58-67`。卡 103154:基线 72535 MB,运营商读数 48808 MB,9-25 之后已用量一直为 0。2026-10-12 统计 15310 张活跃卡中有 3511 张(23%,上限估计)10 月基线没动过 |
|
||||
| 2 | **读数为 0 被当成清零**:重置窗口内读到 0,基线被置 0,下一次真实读数整笔重复计 | `traffic.go:60-61`;电信抽查 124 张中 111 张读数为 0 |
|
||||
| 3 | **无效响应按 0 处理**:`used` 为空、null、无法解析时当成 0,进而触发第 2 条 | `internal/gateway/models.go:55-78` |
|
||||
| 4 | **二次销售多计**:新用户激活时,卡上的基线如果不是激活那一刻的值,差额就扣到了新套餐上 | 资产回收换代见 `docs/资产回收换代技术方案.md` |
|
||||
| 5 | **日流量从来没存成功**:`tb_card_daily_usage` 的表结构和代码不一致,表里 0 行 | 旧方案 P14 |
|
||||
| 6 | **无套餐期间的增量会被扣到下一个套餐上**:老路径不管卡有没有套餐都发 V1 事件;消费者调 `DeductDataUsage` 遇到"没有可用套餐"直接返回错误,任务由 Asynq 重试,重试期间套餐一激活,积压的增量就被扣到新套餐上——这是二次销售多计的另一条路径 | `application/cardobservation/traffic.go:73-76` 不判断有无套餐,有正增量就发事件;`infrastructure/cardobservation/event.go:276-277` 报错即返回;`usage_service.go:110`。**生产核实(2026-10-20)**:回执停在"处理中"(扣减未完成)的有 18,979 条、增量约 11.4 TB;其中 14,838 条对应的卡当前没有生效套餐,**4,141 条(2,404 张卡)对应的卡当前有生效套餐**,这批任务若被重试或人工重放,会扣到现在的套餐上,不得重放。**积压部分由业务后续手动调整,本方案只保证新增的不再产生** |
|
||||
|
||||
## 3. 老卡管怎么算(反编译核实)
|
||||
|
||||
### 3.1 日快照:每卡每天一条,存"本月累计"
|
||||
|
||||
出处:`YidongDayFlowService.saveYidongDayFlow`、`PtLiantongDayFlowService.saveLiantongDayFlow`。
|
||||
|
||||
- 表 `tbl_*_day_flow`:`iccid_mark`、`flow_date`、`used_flow`(自然月累计 MB);普联通另存 `vendor_used_flow`(运营商原始读数)。
|
||||
- 同一天多次查询:用新值覆盖。
|
||||
- 移动:新值比旧值小就跳过(每月 1 号除外)。
|
||||
- 普联通:读数 ≤ 0 不覆盖;当天第一条就是 0 时,沿用昨天的值;其余情况后写覆盖,可以变小。
|
||||
- 电信、广电、广电物联网、5G:与移动、普联通同类(1 号防旧值 + 同一天覆盖),细节略有差异。
|
||||
- 老卡管按 category 分派到各自的表(`CardFlowUtils.getCardFlow`):电信 1、移动 3、联通 4/8/9、5G 108、广电 112/113 等。设备是主卡、副卡各自按区间取值后相加(`CardFlowUtils.getTraffic`)。
|
||||
- 电信采集时按"本月 1 号~今天"向运营商查询(`TelecomUtilNew.queryThisMonthDianxinDayFlow`),拿到的是自然月累计,之后同样写日快照、按快照计费。
|
||||
|
||||
### 3.2 联通:写入时把运营商周期换算成自然月
|
||||
|
||||
联通每月 27 号 0 点清零,运营商读数是"上月 27 号至今"。老卡管在写快照时就换算成自然月累计(老代码把 27 写死在联通的几个类里;本方案改为读 `tb_carrier.data_reset_day`,对任意重置日通用,见 4.3),后面统一按自然月计算:
|
||||
|
||||
| 写入日期 | 存入值 |
|
||||
|---|---|
|
||||
| 27 号~月底 | 运营商读数 + 本月 1~26 号的最大快照 |
|
||||
| 1~26 号 | 运营商读数 − 上月 27 号~月底这段的用量(= 上月最大快照 − 上月 27 号前的最大快照) |
|
||||
|
||||
> 核实:老代码取"上月 1~26 号最大快照"用的是 `findFlowByBeforeDate`,SQL 为 `flow_date >= 1号 and flow_date <= 26号`,包含 26 号,取值正确;27 号起取"本月 1~26 号最大快照"用的是 `getMaxUsedFlow`,SQL 为 `flow_date < 27号`,同样正确。
|
||||
> 老代码的小缺陷:`if ((flow -= subFlow) < 0) subFlow = 0` 改的是 `subFlow` 而不是 `flow`,负值会写进快照,靠查询时 `used_flow > 0` 过滤掉。本方案直接按 0 处理。
|
||||
|
||||
### 3.3 重置日防旧值:只有一条规则
|
||||
|
||||
| 运营商 | 规则 |
|
||||
|---|---|
|
||||
| 移动、电信、广电、广电物联网、5G(1 号清零) | 每月 1 号,读数 > 上月最大快照 × 0.9 → 记为 0 |
|
||||
| 普联通 category 9(27 号清零) | 每月 27 号,读数 > 本月 1~26 号最大快照 × 0.9 → 运营商读数按 0 计(代码写作 `appendFlow > 0 && flow > appendFlow × 1.9`,`flow` 已含 `appendFlow`) |
|
||||
| 联通 category 4、8 | 没有防旧值,只做自然月换算 |
|
||||
|
||||
意思是:重置日读到的值还接近上个周期的总量,就认为运营商还没清零或者读到了旧缓存,当天先不增长。除此之外,老卡管**没有任何读数下降的判定**。
|
||||
|
||||
### 3.4 按区间取值
|
||||
|
||||
出处:`YidongDayFlowMapper.xml` 的 `getQueryTrafficByDate`。
|
||||
|
||||
```
|
||||
区间用量 = max(区间内的快照) − max(本月 1 号 ~ 区间开始前一天的快照)
|
||||
```
|
||||
|
||||
- 只取 `used_flow > 0` 的快照;不能跨月查询;结果 < 0 时按 0。
|
||||
- **取的是最大值,不是最新值**,所以旧缓存返回一个偏小的值,不会让用量变小。
|
||||
|
||||
### 3.5 套餐用量(本方案不照搬这一块)
|
||||
|
||||
老卡管从套餐开始日到今天按月切段累加,再拿总用量和所有套餐的总额度比较,不区分每个套餐用了多少。新卡管有加油包、独立有效期、`by_day` 重置、按套餐退款,这一块**沿用新卡管现有的 `DeductDataUsage`**,不改。
|
||||
|
||||
## 4. 新方案
|
||||
|
||||
### 4.1 原则
|
||||
|
||||
| # | 原则 |
|
||||
|---|---|
|
||||
| R1 | **流量照搬老卡管**:日快照 + 自然月换算 + 重置日防旧值 + 区间取最大值做差 |
|
||||
| R2 | **套餐侧零改动**:增量照旧走 V1 事件和 `DeductDataUsage`;套餐表、套餐入口、分配顺序、停复机都不改(停复机的调整见第 10 章,与流量部分无关) |
|
||||
| R3 | **不再用卡表基线算增量**:增量只由快照得出;卡表三列照旧由老逻辑维护,只作兼容展示 |
|
||||
| R4 | **日期一律按 `Asia/Shanghai`** |
|
||||
|
||||
### 4.2 数据模型
|
||||
|
||||
新增两张表,**不改任何现有表**。迁移从当前最新编号之后开始,成对 up/down。
|
||||
|
||||
**`tb_card_flow_day`(卡日快照,永久保留;仓库现无分区表,一年约 3,200 万行,靠唯一索引足够,本期不分区)**
|
||||
|
||||
| 列 | 类型 | 含义 |
|
||||
|---|---|---|
|
||||
| `id` | bigserial | |
|
||||
| `iot_card_id` | bigint | |
|
||||
| `flow_date` | date | 上海日期 |
|
||||
| `month_used_mb` | numeric(14,3) | 自然月累计(换算后),计算用 |
|
||||
| `vendor_used_mb` | numeric(14,3) | 当天最后一次有效的运营商原始读数,排障用 |
|
||||
| `settled_mb` | numeric(14,3) | 当天已结算的增量合计,默认 0。包括:发出 V1 的增量、无生效套餐时丢弃的增量、切换时记为"老路径已结算"的部分(见 4.5、4.6) |
|
||||
| `stale_guarded` | boolean | 重置日当天是否触发过防旧值(读数按 0 记) |
|
||||
| `obs_count` | int | 当天有效观测次数 |
|
||||
| `created_at`、`updated_at` | timestamptz | |
|
||||
|
||||
唯一键 `(iot_card_id, flow_date)`。每卡每天一行,8.9 万卡约 3200 万行/年。
|
||||
|
||||
**`tb_card_flow_switch`(切换水位,每卡一行)**
|
||||
|
||||
卡第一次走新路径时写入(4.6),之后只读。退回老路径时删除(5.2)。
|
||||
|
||||
| 列 | 类型 | 含义 |
|
||||
|---|---|---|
|
||||
| `id` | bigserial | |
|
||||
| `iot_card_id` | bigint | 唯一 |
|
||||
| `switched_at` | timestamptz | 第一次走新路径的观测时刻 |
|
||||
| `month_used_mb` | numeric(14,3) | 切换时本月最大快照(写入本次读数后) |
|
||||
| `vendor_used_mb` | numeric(14,3) | 切换时的运营商读数 `r` |
|
||||
| `old_baseline_mb` | numeric(14,3) | 切换时卡表 `last_gateway_reading_mb` |
|
||||
| `old_month_usage_mb` | numeric(14,3) | 切换时卡表 `current_month_usage_mb` |
|
||||
| `old_reading_accepted` | boolean | 切换这次观测老路径是否接受读数(冻结卡为 false) |
|
||||
| `old_increment_mb` | numeric(14,3) | 切换这次观测老路径算出的增量(照此发 V1) |
|
||||
| `created_at` | timestamptz | |
|
||||
|
||||
作用:切换衔接(4.6)、补数据截断与冻结卡判定(5.5)。
|
||||
|
||||
### 4.3 写快照(照搬 3.1~3.3,按运营商重置日 `R` 统一)
|
||||
|
||||
`R` 取 `tb_carrier.data_reset_day`。**运营商在每月 `R` 号 0 点(北京时间,`Asia/Shanghai`)清零**:联通为 27 号 0 点(业务确认,见 `docs/engineering/套餐流量计算逻辑核对.md`;与老卡管 `DateUtil.getLtPeriod` 一致),其余为 1 号 0 点。周期边界直接复用现有 `carrierthreshold.PeriodStart`(上海时区重置日 0 点),下文"今天日号""重置日当天"都按北京时间的日期判断。**查询失败、为空或不在 1~28 时,按卡的运营商类型兜底:`tb_iot_card.carrier_type = CUCC`(联通)取 27,其余取 1**,并记日志。不能沿用现有 `CarrierStore.GetDataResetDay` 一律返回 1 的做法(`carrier_store.go:103-104`)——联通卡被错当成 1 号重置会让自然月换算出错、累计值被抬高,按取最大值的规则会多扣且收不回。老路径继续用原函数,不改。
|
||||
|
||||
**注意取值位置**:三个入口现在调用 `ApplyTrafficObservation` 前就用 `GetDataResetDay` 算好 `TrafficObservation.ResetDay` 传进来(`polling_carddata_handler.go:103`、`series_runner.go:561`、`service/iot_card/service.go:1638`),查询失败时已经是 1。新逻辑**不能使用这个字段**,必须在观测事务里按已锁定卡的 `carrier_id` 严格查一次 `data_reset_day`,失败再按上面的运营商类型兜底。
|
||||
|
||||
每次观测(轮询、人工刷新、系列观测三个入口)读到 `r`:
|
||||
|
||||
1. **无效读数和 0 不写**:查询失败、`used` 为空 / null / 无法解析 / `< 0`、或 `r = 0`,一律不写快照、不发增量。(老卡管普联通对 0 是"当天已有行不覆盖、当天第一条沿用昨天的值",移动是写一条 0;本方案直接不写,因为计算只看最大值,效果相同。)Gateway 的 `FlexFloat` 已把空、null、无法解析都转成 0(`internal/gateway/models.go:55-78`),与读数 0 走同一处理,**所以不需要新增"读数是否有效"标记**,Gateway 与三个观测入口都不用改。
|
||||
2. **换算成自然月累计 `m`**:
|
||||
- `R = 1`:`m = r`;
|
||||
- `R > 1`,今天日号 `≥ R`:`m = r + max(本月 1 号~R−1 号的 month_used_mb)`;
|
||||
- `R > 1`,今天日号 `< R`:`m = r − (上月最大 month_used_mb − 上月 1 号~R−1 号的最大 month_used_mb)`,< 0 按 0。
|
||||
3. **重置日防旧值(完全照搬老卡管,比较基准也一致)**:只在重置日当天判断,原始读数照存 `vendor_used_mb`:
|
||||
- `R = 1`:`r > 上月最大 month_used_mb × 0.9` 时按 `r = 0` 计算 `m`(同 `YidongDayFlowService.saveYidongDayFlow`;上月没有快照时比较值为 0,任何正读数当天都按 0 记,次日如实写入,与老卡管一致);
|
||||
- `R > 1`:本月 1 号~R−1 号的最大 `month_used_mb` 大于 0,且 `r` 超过它的 0.9 倍时,按 `r = 0` 计算 `m`(同 `PtLiantongDayFlowService.saveLiantongDayFlow`:`appendFlow > 0 && flow > appendFlow × 1.9`);本月前 R−1 天没有快照时不判断;
|
||||
- 触发时置 `stale_guarded = true`;第二天起不再判断,读数照常写入。
|
||||
4. **同一天合并**:当天已有行时,`month_used_mb = max(旧值, m)`。这一步是改进,不是照搬:老卡管普联通同一天后写覆盖、可以变小,但新方案的增量一旦发出就收不回,所以快照只增不减。
|
||||
5. **写法**:一律用 `INSERT ... ON CONFLICT (iot_card_id, flow_date) DO UPDATE SET month_used_mb = GREATEST(tb_card_flow_day.month_used_mb, EXCLUDED.month_used_mb), ...`,不先查后写。已切换卡在事务 1 内写,卡行已被锁(`traffic.go:40`),天然串行;未切换卡在事务 1 提交后写,没有卡锁,靠这条原子语句防止轮询与人工刷新并发写丢值。
|
||||
|
||||
### 4.4 每天的快照用量(照搬 3.4,用于日流量展示)
|
||||
|
||||
```
|
||||
U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天的 month_used_mb)
|
||||
< 0 按 0;那天是本月第一条快照时减数为 0
|
||||
```
|
||||
|
||||
- 中间有几天没采到,那几天的用量自然并入采到的那天,不会丢。
|
||||
- 每天的日流量就是 `U(卡, 那天)`,直接替代 Redis 日流量缓冲、02:00 落盘和 `tb_card_daily_usage`。
|
||||
- **算增量不用这个公式**,用 4.5 的按月对账。两者按月合计相同,但按天对账遇到乱序观测会重复扣(见 4.5 说明)。
|
||||
|
||||
### 4.5 算增量并发出(替代 `r − 基线`)
|
||||
|
||||
在观测事务(事务 1,现有 `ApplyTraffic` 所在事务)里,对**已切换且已有切换水位的卡**(没有水位的走 4.6):
|
||||
|
||||
1. 卡行已在事务开头加锁(`traffic.go:40`),同卡观测天然串行,不再单独锁快照行;
|
||||
2. 按 4.3 写今天的快照;
|
||||
3. 按月对账:`M = 本月最大 month_used_mb`(写入后),`S = 本月各天 settled_mb 合计`,增量 `Δ = M − S`;
|
||||
4. `Δ > 0` 且**该卡有可扣的生效套餐**(与 `resolveActivePackages` 同口径:卡自身有**生效中(status=1)**的套餐,或卡当前绑定的设备有生效中的套餐;**已用完(2)、待生效(0)不算**;只读查询,不加锁):写一条 V1 事件 `CardTrafficIncremented`,载荷与现在完全相同(只是增量值换了来源),同时今天的 `settled_mb += Δ`;
|
||||
5. `Δ > 0` 但**没有生效套餐**:不发事件,只把今天的 `settled_mb += Δ`。这部分用量不属于任何套餐,丢弃(日流量照常可见);
|
||||
6. `Δ ≤ 0`:不发事件。
|
||||
7. **通道阈值判定**:本次读数有效(`r > 0`)且本次读数未被防旧值按 0 处理时,用 `r` 调用 `EvaluateInTx`,不再看老路径是否接受读数(见下方说明)。
|
||||
|
||||
说明:
|
||||
|
||||
- **为什么按月对账**:若按天对账(`今天用量 − 今天已发出`),一次迟到的观测改写了昨天的快照,昨天会补发一笔,而今天的增量已按旧的"此前最大值"发过,两笔重叠的部分会扣两次(跨零点时轮询与人工刷新并发即可出现)。按月对账只看"本月最大值"和"本月已结算合计",与观测先后无关。
|
||||
- **跨月**:新月份 `S = 0`,`M` 只取新月份的快照,第一条快照的增量就是本月累计。重置日为 1 号的卡,"上月最后一次采到 → 清零"之间的用量拿不到(见第 9 节"旧周期尾部")。
|
||||
- **增量与已结算标记在同一事务里**,和 Outbox 事件一起提交或一起回滚,不会重复发、也不会漏发。
|
||||
- **消费者完全不改**:现有消费者照旧调用 `DeductDataUsage` 扣减(先加油包、后主套餐、超额记最后一个),写流量详单,调用 `EvaluateAndAct` 判断停复机,回执表照旧保证幂等。
|
||||
- **没有生效套餐时不发事件**:现有消费者遇到"没有可用套餐"会报错重试(第 2 节第 6 条),所以不能把这部分增量发出去。只记为已结算:**激活之前被采到的用量不会扣到新套餐上**。精度到观测粒度:激活后第一次观测里,"上次观测到激活"这一段仍会算给新套餐(老卡管按天取值,激活当天的全部用量都算给新套餐)。
|
||||
- 是否有生效套餐只在事务 1 里只读判断一次,不改套餐侧代码;判断出错时按"有套餐"处理并发事件(保持现有行为,不丢量)。事务 1 看到有生效套餐、但消费者扣减前套餐刚好被前一条事件扣完的情况,仍会报错重试,见第 9 节。
|
||||
- 卡表三列(`last_gateway_reading_mb` 等):老 `ApplyTraffic` 照常计算、照常写,只是它算出的增量对已切换的卡不再使用。`last_gateway_reading_mb` 是观测的 CAS 条件,新代码只读不写。
|
||||
- **卡表本月用量**:卡表 `current_month_usage_mb` 被资产详情(`service/asset/service.go:482`)、卡列表(`service/iot_card/service.go:501`)、设备导出(`exporter/device_scene.go:423`)直接读取。冻结卡按老逻辑这一列一直是 0,所以**已切换的卡在事务 1 里用本月最大快照 `M` 覆盖这一列**(写在同一条 `Updates` 里,老逻辑算出的值对已切换卡不用),三处读取都不用改。这一列只用于展示;补数据判定冻结卡时,已切换卡看水位、不看这一列(5.5),不受影响。跨月时老逻辑照常把上月值转入 `last_month_total_mb`,已切换卡转入的就是上月快照的最大值。
|
||||
- **通道阈值**:老路径只在读数被接受时判定(`traffic.go:84`),冻结卡读数一直被拒,所以通道达量停机永远不触发;不改的话,全量切换后依然如此,只能等卡在重置窗口内被采到、基线自行重置。`EvaluateInTx` 只做"达量即写停机锁",没有"读数低就复机",所以放开判定不会误复机;排除被防旧值处理的读数,是为了防止重置日读到上周期旧总量、在新周期误停机;同一天之后读到的真实读数照常判定。此改动只在已切换分支内。
|
||||
- **已切换的卡不再发老增量的 V1**:老 `ApplyTraffic` 算出的 `decision.IncrementMB` 对已切换的卡丢弃(切换那一次除外,见 4.6),现在第 73-76 行的发事件逻辑只在未切换分支执行;每次观测只会发一条 V1,Outbox 量不增加。
|
||||
- 只有已用完或待生效套餐的卡,增量同样丢弃。这和现状一致:现状下这类增量会因"没有可用套餐"扣减失败并积压(第 2 节第 6 条),并不会扣到已用完的套餐上。
|
||||
|
||||
**未切换的卡**:事务 1 一切照旧(老增量、老 V1、老通道阈值条件);快照在事务 1 提交后按 4.3 第 5 步原子写入,失败只告警(见 4.7),用于切换前积累数据和比对;`settled_mb` 不动。
|
||||
|
||||
### 4.6 切换衔接:切换水位
|
||||
|
||||
卡已在新路径名单中、但 `tb_card_flow_switch` 里还没有它的行时,这次观测是它的**第一次切换观测**,在事务 1 内:
|
||||
|
||||
1. 按 4.3 写今天的快照,得到 `M`(本月最大)与 `S`(本月已结算合计,正常为 0);
|
||||
2. 本次增量**照用老路径的结果** `decision.IncrementMB`:`> 0` 且有生效套餐时发 V1;无生效套餐时不发(与 4.5 一致,老路径这时会发);
|
||||
3. 今天的 `settled_mb += max(M − S, 0)`,即把本月到此刻为止全部记为已结算;
|
||||
4. 写入切换水位行(4.2),记下 `M`、`r`、卡表基线、卡表本月计入、老路径是否接受读数、老增量;
|
||||
5. 本次的通道阈值仍按老路径条件判定,从下一次观测起按 4.5 第 7 步;卡表本月用量从本次起按 4.5 用 `M` 覆盖。
|
||||
|
||||
效果:
|
||||
|
||||
- 切换这一次的增量与老路径相同,之后只发切换之后的增长,**不依赖切换前本月是否已有快照**,阶段 1 不需要覆盖整月。
|
||||
- 冻结卡:老路径本次增量为 0,切换前少扣的部分不在这里处理,由 5.5 按水位补。
|
||||
- 退回老路径时删除水位行(5.2);再切回会重新走一次本节,退回期间老路径扣过的部分不会重扣。
|
||||
- 写水位与快照、Outbox 在同一事务,失败随事务 1 回滚。
|
||||
|
||||
### 4.7 失败处理
|
||||
|
||||
| 失败 | 处理 |
|
||||
|---|---|
|
||||
| Gateway 查询失败 / 无效读数 / 读数为 0 | 不写快照、不发增量 |
|
||||
| 运营商重置日查不到或非法 | 按运营商类型兜底:联通(`CUCC`)27 号 0 点,其余 1 号 0 点,北京时间;记日志。老路径照旧 |
|
||||
| 已切换卡:快照、已结算标记或切换水位写入失败 | 随事务 1 回滚,观测重入队(与现在观测失败的处理一致) |
|
||||
| 未切换卡:快照写入失败 | 只告警,不影响观测;总开关可整体停写 |
|
||||
| 某天没采到 | 用量并入下一个有快照的日子 |
|
||||
|
||||
### 4.8 对非灰度卡的影响核对
|
||||
|
||||
**硬约束:非灰度的卡和设备,扣费、停复机、观测结果、页面展示都与现在一致。** 新代码碰到非灰度卡的每一处:
|
||||
|
||||
| # | 接触点 | 设计 | 结论 |
|
||||
|---|---|---|---|
|
||||
| 1 | 写快照(阶段 1 起全部卡都写) | 非灰度卡在观测事务**提交之后**用一条 `INSERT ... ON CONFLICT DO UPDATE` 原子写入(4.3 第 5 步),失败、超时只告警,不让观测失败、不重入队;总开关 `JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED` 关掉即停写,不用发版 | 不影响扣费与观测;数据库多一次写入(现约 2,400 次/小时),上线后观察轮询耗时,异常即关开关 |
|
||||
| 2 | 判断走新路径还是老路径 | 观测事务内用启动时载入内存的卡集合判断,不查库;判断出错一律走老路径 | 不影响 |
|
||||
| 3 | 增量计算 | 非灰度卡仍用"读数 − 基线",仍发 V1 事件,代码不改 | 不影响 |
|
||||
| 4 | 无套餐时不发事件 | 只对灰度卡生效 | 不影响 |
|
||||
| 5 | Gateway 与三个观测入口 | 不改(不新增"读数是否有效"标记,见 4.3 第 1 步) | 不影响 |
|
||||
| 6 | 日流量查询 | 非灰度卡仍读老来源 | 不影响 |
|
||||
| 7 | 数据库迁移 | 只新建表,不改现有表 | 不影响 |
|
||||
| 8 | 设备部分卡切换 | 不允许:设备整台切(5.2) | 不影响 |
|
||||
| 9 | 通道阈值判定条件 | 只在已切换分支改为"读数有效且本次未被防旧值处理";未切换分支仍是"老路径接受读数" | 不影响 |
|
||||
| 10 | 切换水位表 | 只有已切换卡读写 | 不影响 |
|
||||
| 11 | 卡表 `current_month_usage_mb` | 只对已切换卡用快照覆盖;未切换卡仍由老逻辑写 | 不影响 |
|
||||
|
||||
**唯一无法靠设计消除的风险**:`ApplyTraffic` 是所有卡共用的代码,改动出错可能波及全部卡。约束:新逻辑只放在"判定为灰度卡"的分支里,老分支一行不改;评审重点核对这一处;测试环境回归"非灰度卡观测一次"的结果与改动前完全一致。
|
||||
|
||||
## 5. 上线
|
||||
|
||||
### 5.0 上线前提核实(2026-10-20,生产只读抽样)
|
||||
|
||||
新算法依赖"运营商读数在 `data_reset_day` 清零"。对生产卡表做 10% 块抽样,取本月有读数被接受的卡,比较当前读数与本月计入量:
|
||||
|
||||
| 运营商分组 | 抽样卡数 | 读数 ≈ 本月计入 | 读数略大 | 读数远大于本月计入 | 结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| 重置日 1 号(移动、电信、广电共 11 个运营商账号) | 765 | 712(93%) | 28 | 25 | 读数在 1 号清零,与配置一致 |
|
||||
| 重置日 27 号(联通共 10 个运营商账号) | 406 | 134 | 243 | 29 | 读数含 9-27~9-30,略大于自然月计入,符合 27 号清零 |
|
||||
|
||||
- 两组都符合各自配置的重置日。
|
||||
- "读数远大于本月计入"的少数卡(约 4%~7%)需要在灰度期抽查,可能是单卡读数口径异常或基线问题。
|
||||
- 抽样未覆盖的运营商账号(本月抽样中没有被接受读数的卡):1、10、11、12、16、17、20~23、25、27、30、34、37,灰度扩批前补查。
|
||||
- `tb_card_flow_day` 在生产库与测试库均不存在(两边迁移版本均为 240),代码中无引用,新建无冲突。
|
||||
|
||||
### 5.1 阶段
|
||||
|
||||
| 阶段 | 内容 | 对业务影响 |
|
||||
|---|---|---|
|
||||
| 1 写快照 | 建 `tb_card_flow_day`,全部卡在观测事务提交后写快照 | 无,只写不用 |
|
||||
| 2 灰度切换 | 名单内的卡第一次观测按 4.6 写切换水位,之后按 4.5 算增量;其余照旧 | 只影响名单内的卡 |
|
||||
| 3 全量 | 全部卡切换;之后下线 Redis 日流量缓冲与 02:00 落盘 | — |
|
||||
|
||||
阶段 1 至少跑满一个完整自然日再进入阶段 2。切换衔接靠切换水位(4.6),**不要求阶段 1 覆盖整月**。但防旧值和联通换算要用上个周期的快照:重置日为 1 号的卡,最好上月已有快照;联通卡最好上月 1~26 号已有快照,否则阶段 1 第一个月的联通快照会把上月 27 号以后的用量算进本月(只影响日流量展示的分摊,不影响按月对账的增量)。
|
||||
|
||||
### 5.2 切换方式
|
||||
|
||||
- 环境变量 `JUNHONG_TRAFFIC_CALC_MODE`:`off`(默认)/ `gray` / `full`;`JUNHONG_TRAFFIC_CALC_CARDS`:灰度名单,写扣减载体:`iot_card:103154`(单卡套餐的卡)或 `device:15276`(设备,展开为当前绑定的全部卡);`JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED`:快照写入总开关,默认开。改完重启 API 和全部 Worker(见下方配置位置)。
|
||||
- 判定在事务 1 里做,用启动时展开的内存集合,不查库;判定出错一律按老路径。
|
||||
- **切换、扩批、退回都要先停掉 API 和全部 Worker,再一起启动,不逐个重启**。原因:逐个重启时会有新旧进程并存的窗口,同一张卡如果先被新进程写了水位、再被老进程观测(多为人工刷新),老进程发的那笔增量不计入已结算合计,下次新路径观测会再扣一次。窗口只有几分钟,概率很低,但停后再启即可完全避免。
|
||||
- **配置位置**:新增 `pkg/config/config.go` 的 `TrafficCalc` 配置段与 `pkg/config/defaults/config.yaml` 的 `traffic_calc:` 默认值(`mode: off`、名单为空、快照开关开),沿用现有"嵌入默认配置 + `JUNHONG_` 环境变量覆盖"机制。生产上由维护者写入各运行目录的 `.env.prod`:**API(`/opt/junhong_cmp/api/.env.prod`,人工刷新走 API 进程)、调度 Worker(`/opt/junhong_cmp/worker/.env.prod`)和三个执行 Worker(`worker-1/2/3/.env.prod`)五处必须一致**,否则同一张卡在不同进程里会走不同路径。改完重启对应服务。
|
||||
- **灰度单位是扣减载体,设备必须整台切**:名单里的设备,当前绑定的全部卡一起切换;名单里写 `iot_card:X` 但这张卡实际用的是设备套餐(与 `resolveActivePackages` 同口径),启动日志报错并跳过该项,不阻止 worker 启动。否则同一个设备套餐会同时收到新旧两种算法的增量,设备上没切换的卡也会被连带影响。
|
||||
- **灰度期间灰度设备不换绑、不新绑卡**:名单在启动时展开成卡集合,之后新绑到灰度设备上的卡要重启后才会切换,这期间设备处于"部分切换"状态。确需换绑的,换绑后立即重启 API 和全部 Worker 重新展开;或先把该设备移出名单。
|
||||
- **退回**:从名单删除或改回 `off`,**删除这些卡在 `tb_card_flow_switch` 里的水位行**,再重启。老 `ApplyTraffic` 一直在维护卡表基线,退回后老路径接着算(冻结卡会回到冻结状态)。水位行必须删:否则以后再切回时不会重新走 4.6,退回期间老路径扣过的增长会被新路径再扣一次。退回与再切回可以在同一天。
|
||||
- 灰度设备换绑后重启:新绑上的卡第一次走新路径时按 4.6 写水位,不会重复扣;换绑前后这台设备仍短暂处于"部分切换",所以上一条"不换绑"的约束保留。
|
||||
|
||||
### 5.3 冻结卡与历史误差
|
||||
|
||||
- 切换后冻结卡(如 103154)立即恢复正常计量:切换那一次老路径增量为 0(4.6),之后增量只看快照,不看卡表基线;通道阈值判定同时恢复(4.5 第 7 步)。
|
||||
- **切换前少扣的部分放到后期补**,用独立脚本执行,见 5.5。
|
||||
- **上个周期及更早少扣的补不回来**:运营商读数已经清零,没有任何记录;期间套餐已经重置的,那个计费周期也已结束。
|
||||
- **第一批灰度:103154 所在设备 15276**(联通 103154、移动 99426、电信 106882,三张卡本月计入都为 0)。核对无误后再放其他冻结卡、电信读数常为 0 的卡、二次销售的卡、联通卡、带加油包的载体。
|
||||
|
||||
### 5.4 扩批条件
|
||||
|
||||
1. 灰度卡逐日的增量合计 = 按快照手工复算的结果;
|
||||
2. 无误停、无误复;
|
||||
3. 至少跨过一次运营商清零(每个运营商各自的重置日);
|
||||
4. 冻结卡恢复计量,二次销售卡没有带入上一个用户的用量。
|
||||
|
||||
### 5.5 补数据(后期,独立脚本)
|
||||
|
||||
**定位**:卡流量计算上线后再做,不阻塞主方案。只补冻结卡在**执行时所在运营商周期**内少扣的部分;更早周期的补不回来(运营商读数已清零)。
|
||||
|
||||
**生产现状(2026-10-20 只读核实)**:
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `tb_package_usage_daily_record` 是否漏记扣减 | **没有漏记**:生效中和已用完的套餐中,没有一个"已用量 > 最新日记录累计值"(10,718 个一致;1,972 个已用量为 0、是重置后尚未扣减;10,399 个从未扣减) |
|
||||
| 疑似冻结卡(最近两天在采集、本月计入为 0、上月 ≥ 100 MB) | **3,425 张**:设备卡 1,705(联通 1,073、移动 450、电信 182),单卡 1,720(广电 931、电信 339、联通 236、移动 214)。含本月确实没用的卡,是上限 |
|
||||
| 103154 所在设备 15276 | 联通 103154、移动 99426、电信 106882 三张卡**本月计入都是 0**,不只联通卡冻结 |
|
||||
| 设备下混有不同重置日的卡(联通 27 号 + 其他 1 号) | 7,438 台设备、19,466 张卡,是设备套餐的大头 |
|
||||
| 套餐计费起点落在本运营商周期内(多为 `by_day` 每 30 天重置) | 10,919 个载体 |
|
||||
|
||||
后两项说明:按"载体读数之和 − 载体本周期扣减"的算法,大部分载体会因"重置日不同"或"起点在周期内"被排除,所以**改为按卡补冻结卡**。
|
||||
|
||||
**算法(按卡)**:冻结卡在整个本运营商周期里一条读数都没被接受,本周期计入为 0,所以
|
||||
|
||||
```
|
||||
这张卡本周期少扣 = 运营商当前读数 r
|
||||
```
|
||||
|
||||
- 每张卡按自己运营商的重置日确定周期起点,设备下混着不同运营商的卡也能用;不需要按套餐拆分日记录。
|
||||
- **未切换的卡**:`r` 在执行时现场查询 Gateway,限速执行;查询失败、无效、读数为 0 的跳过,进清单。
|
||||
- **已切换的卡**:`r` 取切换水位的 `vendor_used_mb`(切换时的运营商读数),不现场查。切换之后的用量已由新路径计算,用水位截断,不会重复。切换时刻不在执行时所在运营商周期内的,进清单。
|
||||
|
||||
**判定为冻结卡(全部满足才自动补)**:
|
||||
|
||||
| 条件 | 原因 |
|
||||
|---|---|
|
||||
| 卡(或其所在设备)有**生效中(status=1)**的套餐(与 4.5 同口径) | 只有已用完或待生效套餐时,`DeductDataUsage` 找不到可扣套餐会报错,补扣事件会积压(第 2 节第 6 条);这类卡进清单 |
|
||||
| 未切换卡:最近两天在被采集,本月计入(`current_month_usage_mb`)为 0。已切换卡:水位的 `old_reading_accepted = false` 且 `old_month_usage_mb = 0` | 本周期没有任何读数被老路径接受。已切换卡切换后卡表仍由老逻辑维护、本月计入可能一直是 0,所以只能看水位,不能看卡表 |
|
||||
| `r` < 基线(未切换卡用现场读数与卡表 `last_gateway_reading_mb`;已切换卡用水位的 `vendor_used_mb` 与 `old_baseline_mb`) | 基线属于上个周期或被异常值抬高,读数一直被拒 |
|
||||
| 卡所在扣减载体的套餐计费起点(激活或最近一次重置)**早于本运营商周期起点** | 否则 `r` 里有一部分属于已经结束的上个计费周期,不该补到新周期 |
|
||||
| 本周期内没有回收换代、换绑 | 读数和套餐对不上;也排除了二次销售 |
|
||||
|
||||
不满足的进人工清单。其中"计费起点在本周期内"的冻结卡,清单里附按时间比例估算的参考值(`r × 起点至今天数 ÷ 本周期已过天数`),由人工决定,不自动扣。
|
||||
|
||||
**执行(套餐侧不改)**:
|
||||
|
||||
1. **预演(只读)**:输出每张卡的卡号、运营商、扣减载体、`r`、基线、补扣量、补扣后是否超额;汇总补扣总量、预计停机数、清单与原因。
|
||||
2. 确认后正式执行:每张卡写一条一次性的 V1 事件,事件 ID 由卡、运营商周期、批次组成,重复执行同一批次不重复写;由现有消费者调用 `DeductDataUsage` 扣减,超额照常停机,补扣量照常写入 `daily_record`。
|
||||
3. 已切换到新方案的卡,切换之后的用量由新方案计算,补扣只补切换之前那段:补扣量取水位的 `vendor_used_mb`,避免与新方案重复。
|
||||
4. 补扣写审计,注明"冻结卡补扣"与补扣量。
|
||||
5. 现有消费者会把每条 V1 事件的增量累加进当天的 Redis 日流量缓冲,所以**未切换卡的日流量会在补扣当天出现一次突增**(已切换卡的日流量读快照,不受影响)。执行前告知运营,或在预演清单中标出。
|
||||
|
||||
## 6. 改动清单
|
||||
|
||||
| 位置 | 改动 |
|
||||
|---|---|
|
||||
| `migrations/` | 新建 `tb_card_flow_day`、`tb_card_flow_switch` |
|
||||
| 新增 `internal/domain/cardflow/` | 纯函数:自然月换算、重置日防旧值、每天用量、按月对账增量;表驱动单测 |
|
||||
| 新增 `internal/store/postgres/card_flow_day_store.go` | 快照原子写入(`ON CONFLICT DO UPDATE`)、本月最大值与已结算合计查询 |
|
||||
| 新增 `internal/store/postgres/card_flow_switch_store.go` | 切换水位读写 |
|
||||
| `internal/application/cardobservation/traffic.go` | 已切换的卡:增量改为 4.5 的按月对账,第一次切换观测按 4.6 写水位,通道阈值按 4.5 第 7 步判定,卡表 `current_month_usage_mb` 用本月最大快照覆盖,V1 事件载荷不变,按卡的 `carrier_id` 严格查重置日;未切换的卡:老逻辑不变,提交后写快照。快照写入都在 `ApplyTrafficObservation` 里完成,三个观测入口与 Gateway 不改 |
|
||||
| 日流量查询 | 已切换的卡读快照 |
|
||||
| `pkg/config` | 三个环境变量(模式、灰度名单、快照写入总开关) |
|
||||
| 后期:补数据脚本(`cmd/`) | 预演(只读)+ 正式执行(写 V1 事件),见 5.5 |
|
||||
| **套餐表、`internal/service/package/`、V1 消费者、激活/重置/改已用量/换货/退款入口** | **不改** |
|
||||
|
||||
## 7. 测试用例
|
||||
|
||||
**领域层(表驱动)**
|
||||
|
||||
| 场景 | 期望 |
|
||||
|---|---|
|
||||
| 移动同月读数上涨 | 今天用量 = 今天最大快照 − 本月此前最大快照 |
|
||||
| 重置日 `R = 27`:26 号 23:59 读数 5000,27 号 00:05 清零后读数 100(北京时间) | 27 号 `month_used_mb` = 100 + 26 号前最大值;自然月累计连续 |
|
||||
| 重置日 `R = 15`(任意非 1 号) | 换算规则同上,以 15 号为界,结果连续 |
|
||||
| 重置日 `R = 1` | 不换算,`month_used_mb` = 读数 |
|
||||
| 运营商的 `data_reset_day` 被修改 | 从修改后的下一次写入起按新重置日换算 |
|
||||
| `R > 1` 时 1 号~R−1 号换算 | 减去上月 R 号~月底的用量(= 上月最大 − 上月 1~R−1 号最大,含 R−1 号) |
|
||||
| 重置日当天读到上周期旧值(> 90%) | 当天按 0 记,`stale_guarded = true`;之后读到真实低值正常累计 |
|
||||
| 重置日次日起仍读到旧值 | 照常累加(与老卡管一致) |
|
||||
| 重置日当天真实用量超过上周期 90% | 当天按 0 记;次日如实写入,取最大值把这部分带回来,只晚一天,不丢 |
|
||||
| 周期中途读到偏小的旧缓存 | 当天快照取最大值,用量不变小,不发负增量 |
|
||||
| 读数 0 / 无效 | 不写快照,不发增量 |
|
||||
| 运营商重置日查询失败:联通卡 | 按 27 号换算与防旧值,记日志 |
|
||||
| 运营商重置日查询失败:非联通卡 | 按 1 号处理,记日志 |
|
||||
| `R = 1` 重置日当天读数 > 上月最大快照 × 0.9 | 当天按 0 记 |
|
||||
| `R = 27` 重置日当天读数 > 本月 1~26 号最大快照 × 0.9 | 当天按 0 记 |
|
||||
| 中间 3 天没采到 | 3 天的用量并入采到的那天 |
|
||||
| 跨自然月 | 本月第一条快照减数为 0,从本月累计开始算;按月对账时新月份已结算合计为 0 |
|
||||
| 按月对账:本月最大 500、已结算合计 450 | 增量 50 |
|
||||
| 乱序:今天已发出后,一次迟到观测抬高了昨天的快照 | 按月对账的增量 = 本月最大 − 已结算合计,不会把重叠部分扣两次 |
|
||||
|
||||
**应用层**
|
||||
|
||||
| 场景 | 期望 |
|
||||
|---|---|
|
||||
| **卡 103154 类:卡表基线 72535,运营商读数 48808** | 切换后增量按快照正常发出,套餐恢复增长 |
|
||||
| **二次销售:上一个用户本月用到 3000;新用户 15 号 14:00 激活;激活前当天又读到 3020,激活后读到 3050** | 3020 及之前的增量在无套餐时只记入 `settled_mb`、不发事件;新套餐只扣 30 |
|
||||
| 卡只有已用完或待生效的套餐 | 不发事件,增量丢弃 |
|
||||
| 灰度卡观测一次 | 只发一条 V1(新算法增量),不再发老增量的 V1 |
|
||||
| 无套餐期间连续观测,之后激活 | Outbox 里没有该卡积压的 V1 事件;激活后只扣激活之后的增长 |
|
||||
| 同一次观测重复处理 / 同卡并发观测 | 卡行锁串行,按月对账保证每段增长只结算一次 |
|
||||
| 事务 1 回滚 | 快照、`settled_mb`、切换水位、Outbox 一起回滚,不重不漏 |
|
||||
| 第一次切换观测:正常卡,本月已有快照 | 只发老路径这次的增量;写水位;本月已结算合计 = 本月最大 |
|
||||
| 第一次切换观测:正常卡,本月还没有快照 | 同上,不会把整月累计当增量发出 |
|
||||
| 第一次切换观测:冻结卡(老路径拒绝读数) | 不发增量;水位记 `old_reading_accepted = false`、`vendor_used_mb = r`;下一次观测起按快照正常发 |
|
||||
| 退回老路径(删水位)后当天再切回 | 重新按 4.6 写水位,退回期间老路径扣过的部分不重扣 |
|
||||
| 冻结卡切换后读数超过通道阈值 | 写通道停机锁(老路径下不会触发) |
|
||||
| 冻结卡切换后 | 资产详情、卡列表、设备导出的本月用量 = 本月最大快照,不再是 0 |
|
||||
| 重置日当天先触发防旧值、之后读到真实低值 | 真实读数照常做通道阈值判定 |
|
||||
| 重置日当天读到上周期旧总量并触发防旧值 | 不做通道阈值判定,不误停机 |
|
||||
| 未切换的卡并发写快照(轮询与人工刷新同时) | 原子写入,`month_used_mb` 取较大值,不丢 |
|
||||
| 名单写 `device:15276` | 设备当前绑定的 3 张卡全部切换 |
|
||||
| 联通卡观测时运营商表查询失败(入口传进来的 `ResetDay` 已被兜底成 1) | 新逻辑不用入口的值,自行严格查询,失败按 27 号处理 |
|
||||
| 名单写 `iot_card:X`,但该卡实际用设备套餐 | 启动日志报错并跳过该项,worker 正常启动,该卡仍走老路径 |
|
||||
| 非灰度卡观测一次 | 卡表、V1 事件、扣减、停复机结果与改动前完全一致;快照在提交后单独写 |
|
||||
| 关闭快照写入总开关 | 立即停写快照;非灰度卡不受任何影响 |
|
||||
| 主套餐 + 加油包 | 与现有 `DeductDataUsage` 行为完全一致(不改) |
|
||||
| 退回老路径后的下一次观测 | 老路径照常运行 |
|
||||
| 查询失败 | 不写快照、不发增量、不改套餐状态 |
|
||||
|
||||
**补数据脚本(后期)**
|
||||
|
||||
| 场景 | 期望 |
|
||||
|---|---|
|
||||
| 单卡冻结:读数 48808 < 基线 72535,本月计入 0,套餐起点早于周期起点 | 补扣 48808;超额则停机 |
|
||||
| 设备 3 卡(联通、移动、电信)都冻结 | 每张卡按自己的周期起点各补其读数,合计扣到设备套餐 |
|
||||
| 套餐在本周期中间激活(含二次销售) | 不补,进清单 |
|
||||
| 本周期内回收换代 / 换绑 | 不补,进清单 |
|
||||
| 读数查询失败 / 为 0 | 不补,进清单 |
|
||||
| 冻结卡只有已用完的套餐 | 不补,进清单 |
|
||||
| 少扣为负(多扣) | 不退,进清单 |
|
||||
| 同一批次重复执行 | 事件 ID 相同,不重复写 |
|
||||
| 已切换的冻结卡 | 补扣量取水位的 `vendor_used_mb`,不现场查;切换之后的用量不重复补 |
|
||||
| 已切换的正常卡(水位 `old_reading_accepted = true`) | 不补 |
|
||||
| 套餐计费起点在本周期内的冻结卡 | 不自动补,进清单并附按时间比例的估算值 |
|
||||
|
||||
**测试环境验证**:103154 同类卡、三卡共用设备、二次销售卡、重置日为 27 号与 1 号的运营商各跨一次重置日、另构造一个非常规重置日(如 15 号)各一组,逐日核对快照、增量与套餐已用量。
|
||||
|
||||
## 8. 工作量(粗估)
|
||||
|
||||
| # | 任务 | 人天 |
|
||||
|---|---|:-:|
|
||||
| 1 | 迁移与模型 | 0.25 |
|
||||
| 2 | 领域层:自然月换算、防旧值、每天用量、按月对账 + 单测 | 1.5 |
|
||||
| 3 | 快照存储(原子写入)+ 切换水位存储 | 0.75 |
|
||||
| 4 | `ApplyTraffic` 已切换卡:按月对账、切换水位、通道阈值条件 | 1.25 |
|
||||
| 5 | 切换机制(模式、名单、启动展开) | 0.5 |
|
||||
| 6 | 日流量读侧 | 0.5 |
|
||||
| 7 | 集成测试与测试环境验证 | 1.5 |
|
||||
| | 合计 | 约 6.25 |
|
||||
|
||||
与第 1 版相比:去掉"读数是否有效"标记和三个入口的改动,省下的工作量用于切换水位与通道阈值条件,合计不变。
|
||||
|
||||
后期补数据脚本(5.5:预演、条件判断、写 V1 事件、清单)另计约 1.5 人天。
|
||||
|
||||
不含灰度观察期:至少跨过一次运营商清零,约 30 天内。
|
||||
|
||||
## 9. 已知限制
|
||||
|
||||
| 限制 | 说明 |
|
||||
|---|---|
|
||||
| 切换前的误差 | 后期按 5.5 补,只能补执行时所在运营商周期内的部分;更早的补不回来;不满足条件的进人工清单 |
|
||||
| 扣减前套餐刚好用完 | 事务 1 看到有生效套餐并发出事件,但消费者扣减前,同卡的前一条事件已把唯一的套餐扣到"已用完",这条事件扣减时报"没有可用套餐"并重试,之后可能扣到新激活的套餐上(第 2 节第 6 条的同一条路径)。需要同卡在很短时间内有两条事件,概率低。消费者不改(D13),灰度期监控"处理中"回执的新增数,确有发生再评估是否让消费者对已切换卡直接结束 |
|
||||
| 二次销售精度 | 只精确到观测粒度:激活后第一次观测里"上次观测到激活"的用量会算给新套餐。要更精确需要激活时触发一次立即观测(改激活入口,本期不做) |
|
||||
| 切换那一次沿用老路径增量 | 第一次切换观测发的是老路径算出的增量(4.6),如果这一次正好撞上老路径的问题(例如重置窗口读到 0 把基线置 0 后,又读到真实值整笔计入),会照样扣一次。每张卡最多一次 |
|
||||
| 换绑后第一次观测 | 设备换上一张本月没有快照的卡,第一次观测(走 4.6)发出的是老路径的增量,与现状一致:老路径对从未采过的卡基线为 0,会把整张卡的周期读数算给设备套餐 |
|
||||
| 上一代套餐仍为"生效中" | `DeductDataUsage` 只按状态选套餐、不看世代,回收换代后上一代套餐如果仍为生效中,新用户的增量可能扣到它上面。这是套餐侧问题,按 `docs/资产回收换代技术方案.md` 单独修复,本方案不改套餐侧 |
|
||||
| 旧周期尾部 | 旧周期最后一次采集到清零之间的用量拿不到,老卡管同样如此 |
|
||||
| 防旧值 | 完全照搬老卡管:阈值 0.9、只在重置日当天判断。运营商延迟清零超过一天时,次日的旧值会被累加(老卡管同样如此,多年未暴露问题) |
|
||||
| 积压的无套餐回执 | 18,979 条卡在"处理中"的回执(含 4,141 条对应卡当前有生效套餐)由业务后续手动调整,不在本方案内;处理前不得重放 |
|
||||
| 修改运营商重置日 | 月中修改 `data_reset_day` 会让当月换算前后不连续,可能产生一次跳变多扣。修改应安排在运营商实际重置日前后,并在修改后核对受影响卡的当日增量 |
|
||||
| 采集不足 | 轮询吞吐问题(约 2.4k 次/小时,平均每卡约 1.5 天一次)不在本方案范围;采不到只会晚扣,不会丢。但每张卡在自己的重置日(1 号或联通 27 号),最后一次采集到清零之间的用量会丢("旧周期尾部"),采得越稀丢得越多;重置日当天没采到的卡也不会触发防旧值 |
|
||||
| 扣错收不回 | 套餐侧仍是增量扣减,一旦扣错只能人工改已用量。快照可以完整复算每天应扣多少,作为核对依据 |
|
||||
| 电信 | 老卡管的电信在采集时按"本月 1 号~今天"向运营商查询(`TelecomUtilNew.queryThisMonthDianxinDayFlow`),拿到自然月累计后同样写日快照、按快照计费;新卡管 Gateway 目前只返回当前周期累计,本期统一按快照处理,读数为 0 的电信卡仍扣不到。**后续安排 Gateway 支持按日期区间查询**:支持后,电信卡改为按区间查询的结果写快照,算增量的逻辑不变;同时可以用来补旧周期尾部、补更早周期的少扣数据 |
|
||||
|
||||
## 10. 停复机规则与实名管控
|
||||
|
||||
本章与流量计算相互独立,可以分开开发、分开上线;放在同一份方案里一起评审。
|
||||
|
||||
### 10.1 停复机原则(已定)
|
||||
|
||||
| | 条件 | 说明 |
|
||||
|---|---|---|
|
||||
| **停机** | 卡开机,且满足任一:① 没有套餐(套餐到期);② 流量用完;③ 卡所属通道开启了实名管控(`realname_control_enabled = 1`)且卡未实名 | 只有这三条。谁开的机不重要 |
|
||||
| **复机** | 卡停机,且同时满足:① 有生效套餐;② 流量没用完;③ 卡已实名,**或**卡所属通道关闭了实名管控(`realname_control_enabled = 0`) | **不看停机原因**:外部停的、卡管自动停的、管理员手动停的、设备手动停的,满足条件都复机 |
|
||||
| 不动作 | 卡或所在设备暂停轮询;卡持有本周期通道阈值锁(属于"流量用完",锁期内不复机) | 见 10.4 |
|
||||
|
||||
- **外部停机与卡管无关**:外部环境导致的停机,卡管判断该开机就复机,**除非这张卡(或所在设备)已被加入暂停轮询**。卡管不判断是谁停的,只按上面的条件决定开或停;外部反复停卡,卡管就按规则反复复机,不设次数限制。
|
||||
- **实名默认不管控**:通道上开启"实名管控"后,未实名的卡会被停机,停机后实名了才复机;没开启的通道,实名不参与停复机。"实名管控"是通道上**新增的独立开关**(`realname_control_enabled`),只管停复机,**不复用通道现有的实名相关字段**(如实名链接类型 `realname_link_type`,它只决定实名链接怎么生成)。开启后,通道下所有卡都按"是否已实名"判断,**不再区分行业卡,也不再看卡或设备上的实名策略**(实名策略只用于购买流程)。
|
||||
- `stop_reason` 仍照常记录(流量耗尽、无套餐、未实名、手动、运营商侧停机、通道阈值),**只用于展示和审计,不参与复机判断**。
|
||||
|
||||
### 10.2 现状与差异(代码核实)
|
||||
|
||||
| # | 现在的行为 | 与原则的差异 | 代码 |
|
||||
|---|---|---|---|
|
||||
| 1 | 自动复机只处理停机原因为流量耗尽、无套餐、未实名的卡 | 外部停机(记为 `carrier_stopped`)、手动停机(`manual`)、设备保护期停机(`protect_period`)满足条件也不复机,只能人工处理 | `stop_resume_service.go` 的 `isPollingStopReason`、`EvaluateAndAct` 停机分支、`resumeSingleCard`;`iot_card_store.go` 的 `ListByDeviceIDAndPollingStopReasons` |
|
||||
| 2 | 非行业卡、未实名、卡或设备实名策略不是 `none` 时一律停机且不能复机 | 实名默认管控,且与行业卡、资产实名策略耦合;想放开只能逐张改实名策略 | `isRealnameOK`,被 `checkStopReasons`、`shouldResume`、`resumeDeviceCards`、`ManualStartCard` 共用;保护期一致性检查跳过未实名卡(`polling_protect_handler.go:77`) |
|
||||
|
||||
停机这一侧现在已经只看无套餐、流量耗尽、未实名,符合原则;停机和复机里的实名都改成按通道判断。
|
||||
|
||||
### 10.3 已定决策(2026-10-20)
|
||||
|
||||
| # | 事项 | 决定 |
|
||||
|---|---|---|
|
||||
| D14 | 停机条件 | 无套餐、流量用完、通道开启实名管控且卡未实名,三者任一 |
|
||||
| D15 | 复机条件 | 有生效套餐、流量没用完、卡已实名或通道关闭了实名管控,三条同时满足即复机;**不看停机原因**(含外部停机、手动停机、设备手动停机) |
|
||||
| D16 | 实名管控 | 通道(`tb_carrier`)新增独立开关 `realname_control_enabled`,不复用现有实名字段,**默认 0 = 不管控**;设为 1 时通道下所有卡按是否已实名判断(未实名停机、实名后才复机),不区分行业卡、不看资产实名策略。**本期只加通道开关,不迁移、不修改任何卡或设备数据**;卡级、设备级的实名管控以后需要时再加(同样用独立字段,默认继承通道) |
|
||||
| D17 | 暂停轮询的卡 | 卡或所在设备暂停轮询时,卡管不自动停复机,人工停复机照常。风险停机、机卡分离停机、已销户等运营商异常状态**由人工判断并手动暂停轮询**,卡管不自动处理;现有"独立卡风险停机/已销户自动暂停轮询"的逻辑不改 |
|
||||
|
||||
### 10.4 设计
|
||||
|
||||
**统一判断**:新增一个"目标状态"判断,`EvaluateAndAct` 开机分支、停机分支、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 都用它,不再各自判断。
|
||||
|
||||
- 开头先看暂停轮询:卡 `enable_polling = false`,或卡所在设备 `enable_polling = false`(与轮询范围 `ShouldEnqueue` 同口径),本次不动作。
|
||||
- 卡开机:命中 10.1 的停机条件即停机,停机原因按命中的条件记录。设备维度扩散规则不变(无套餐、流量用完扩散到设备;未实名只停该卡)。
|
||||
- 卡停机:满足 10.1 的复机条件即复机,**删除"只处理轮询原因停机"的限制**(`isPollingStopReason`)。持有通道阈值锁时不复机(现有 `channelThresholdBlocked`,不变)。
|
||||
- 设备复机:`ListByDeviceIDAndPollingStopReasons` 改为取设备下全部停机卡,逐张按复机条件判断。
|
||||
- 套餐判断沿用现有口径(`HasValidByCarrier`:生效中或待生效都算有套餐;流量用完沿用 `isTrafficExhausted`),本章不改套餐口径。
|
||||
|
||||
**实名管控(D16)**:
|
||||
|
||||
- 迁移:`tb_carrier` 增加 `realname_control_enabled smallint not null default 0`。
|
||||
- `isRealnameOK`(停机判断、复机判断、设备复机、人工复机共用)改为:卡所属通道(`card.carrier_id`)`realname_control_enabled = 0` → 满足;= 1 → 卡 `real_name_status` 为已实名才满足。**删除行业卡放行和卡/设备实名策略判断**。查询通道失败时本次不动作并记日志(不按任何默认值停机或复机)。
|
||||
- 保护期一致性检查:只在通道开启实名管控且卡未实名时跳过。
|
||||
- 人工复机(`ManualStartCard`)同样经过 `isRealnameOK`,自动一致。
|
||||
- 通道管理接口(`service/carrier` 创建、修改、详情)增加该字段,修改写审计。从 0 改为 1 时,管理端二次确认并提示该通道未实名的开机卡数(下一次评估会被停机);从 1 改为 0 后,该通道因未实名停机的卡会在下一次评估时复机。
|
||||
- 卡和设备上的实名策略(`none` / `before_order` / `after_order`)、首次实名激活套餐,只用于购买与激活流程,不受影响。
|
||||
|
||||
**暂停轮询的卡(D17)**:
|
||||
|
||||
- 运营商异常状态(风险停机、机卡分离停机、已销户)不做自动处理,由人工判断后在管理端暂停轮询(现有接口)。现有独立卡风险停机/已销户自动暂停轮询的逻辑(`ShouldStopPollingForRisk`)保持不变。
|
||||
- 暂停轮询后卡管不自动停复机(10.4 开头的检查),人工处理完再开启轮询,之后按 10.1 正常管理。
|
||||
- 没有被暂停轮询的异常状态卡,卡管照常按 10.1 判断;复机请求被上游拒绝时按现有失败处理。
|
||||
|
||||
### 10.5 上线
|
||||
|
||||
新规则上线后会有一批卡集中动作,用一个总开关控制:
|
||||
|
||||
| 开关 | 默认 | 作用 |
|
||||
|---|---|---|
|
||||
| `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED` | `false` | 开启 10.1 的新规则(复机不看停机原因、实名按通道判断);关闭时与现在完全一致 |
|
||||
| `tb_carrier.realname_control_enabled` | `0` | 按通道配置;只在总开关开启后生效 |
|
||||
|
||||
暂停轮询的卡不自动停复机(D17)也受总开关控制。
|
||||
|
||||
步骤:
|
||||
|
||||
1. 发版,总开关保持关闭。
|
||||
2. **确认哪些通道需要实名管控**,在管理端设为 1。新规则默认不管控实名,没设置的通道,现在因未实名停机的卡在总开关打开后都会复机。
|
||||
3. **打开总开关前预演(只读 SQL)**,统计下一次评估会集中动作的卡:
|
||||
- 会被复机:停机、未暂停轮询、有套餐、流量没用完、(通道开启实名管控时已实名),按停机原因分组(`carrier_stopped`、`manual`、`protect_period`、`not_realname` 等)。
|
||||
- 会被停机:开机、所属通道开启实名管控、未实名的卡(含行业卡,原来行业卡不停)。
|
||||
4. 业务确认后打开总开关,先停掉 API 和全部 Worker 再一起启动(与 5.2 相同);观察停复机审计和上游失败数。
|
||||
|
||||
### 10.6 改动清单
|
||||
|
||||
| 位置 | 改动 |
|
||||
|---|---|
|
||||
| `migrations/` | `tb_carrier` 增加 `realname_control_enabled` |
|
||||
| `internal/model/carrier.go`、`service/carrier`、相关 DTO | 实名管控字段的读写与审计 |
|
||||
| `internal/service/iot_card/stop_resume_service.go` | 目标状态判断(10.4);复机不看停机原因;`isRealnameOK` 改为按通道判断;暂停轮询不动作;总开关 |
|
||||
| `internal/store/postgres/iot_card_store.go` | 设备复机改为取设备下全部停机卡 |
|
||||
| `internal/task/polling_protect_handler.go` | 只在通道开启实名管控且卡未实名时跳过 |
|
||||
| `pkg/config` | 总开关 |
|
||||
| **外部停机仍记 `carrier_stopped`、套餐口径、流量侧、购买与激活流程的实名策略** | **不改** |
|
||||
|
||||
### 10.7 测试用例
|
||||
|
||||
| 场景 | 期望 |
|
||||
|---|---|
|
||||
| 开机卡套餐到期 | 停机,原因 `no_package`(设备卡扩散到设备) |
|
||||
| 开机卡流量用完 | 停机,原因 `traffic_exhausted` |
|
||||
| 外部停机的卡,有套餐、流量没用完 | 自动复机 |
|
||||
| 管理员手动停机的卡,有套餐、流量没用完 | 下一次评估自动复机 |
|
||||
| 设备手动停机,保护期结束后 | 下一次评估按复机条件复机 |
|
||||
| 停机卡没有套餐 | 保持停机;充值激活后复机 |
|
||||
| 停机卡持有通道阈值锁 | 锁期内不复机 |
|
||||
| 通道未开启实名管控(默认),未实名卡有套餐 | 不停机;已因未实名停机的卡复机;人工复机不因未实名被拒 |
|
||||
| 通道开启实名管控,未实名的卡开着机、有套餐 | 停机,原因 `not_realname`,只停这张卡 |
|
||||
| 通道开启实名管控,未实名的卡停着机、有套餐 | 不复机;实名后下一次评估复机;人工复机被拒 |
|
||||
| 通道开启实名管控,未实名的行业卡 | 停机(不再区分行业卡) |
|
||||
| 设备复机时设备下有一张未实名卡(通道开启实名管控) | 跳过这张卡(停机原因记为 `not_realname`),其他卡照常复机 |
|
||||
| 通道开启实名管控,卡已实名、实名策略为 `before_order` | 按有套餐、流量判断,实名策略不参与 |
|
||||
| 查询通道实名管控失败 | 本次不动作,记日志 |
|
||||
| 设备下混有开启与未开启实名管控的通道的卡 | 各卡按自己通道判断实名;套餐与流量按设备维度 |
|
||||
| 暂停轮询的卡(卡或所在设备暂停),充值激活触发评估 | 不停不复;人工停复机照常 |
|
||||
| 总开关关闭 | 与现在完全一致 |
|
||||
443
docs/卡流量采集写入与扣减技术方案.md
Normal file
443
docs/卡流量采集写入与扣减技术方案.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# 卡流量采集写入与扣减技术方案
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 日期 | 2026-10-28 |
|
||||
| 状态 | 技术方案已确认,2026-10-28 按评审意见修订(T18–T25,T6 照搬老系统、按配置的重置日),**代码尚未修改** |
|
||||
| 前置方案 | `docs/卡流量计算技术方案-参考老卡管.md`(第 2 版):日快照、按重置日换算、重置日防旧值、按月对账、切换水位。本方案在它的基础上补齐"读数写入前的过滤""采集留痕""扣减口径""换卡""冲正" |
|
||||
| 对应 Change | `add-card-traffic-queue-and-observed-deduction`(基于已实现的 `rebuild-card-traffic-metering-and-stop-resume`) |
|
||||
| 范围 | 测试环境 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论
|
||||
|
||||
1. **核心问题是多扣和少扣。** 现在的卡表基线算法(`last_gateway_reading_mb` + 重置窗口)一个读数就直接生效:暴涨立即扣、窗口内抖动重复扣、延迟重置整月不扣。灰度路径(日快照 + 本月最大值 − 已结算)解决了重置相关的问题,但没有过滤异常点,而且当天快照取最大值,暴涨会被留下来。
|
||||
2. **本方案:**
|
||||
- 读数先进 **Redis 队列**,队列内读数不下降(允许小容差)才写**最后一个点**,异常点在写入前拦下。
|
||||
- **一次性全量切换**为日快照 + 本月最大值 − 已结算,卡表基线算法、灰度名单、切换水位下线。
|
||||
- 有套餐的卡**每次采集都留记录**,日快照读数为 0 也写;没套餐的卡只在读数 > 0 时写日快照。
|
||||
- 扣减保持**增量扣减**,扣费按**采集时刻**确定套餐,日期按采集时间算。
|
||||
- 扣错了走**人工冲正流水**;初期不做自动对账,由人工排查。
|
||||
|
||||
### 1.1 已定决策(2026-10-28)
|
||||
|
||||
| # | 事项 | 决定 |
|
||||
|---|---|---|
|
||||
| T1 | 无效读数 | 接口失败、限流、缺字段、`used` 为 null / 空 / 解析失败、负数、NaN:不入队、不写快照;`FlexFloat` 要能区分"没值"和 0 |
|
||||
| T2 | 队列存储 | Redis,每卡一个 key,Lua 原子更新 |
|
||||
| T3 | 队列长度 | N = 3,可配置 |
|
||||
| T4 | 写入条件 | 队列满 N 个点,相邻两段都不下降(回退在容差内算持平),写**最后一个点**(即本次采集的点);新点不在队尾时不写 |
|
||||
| T5 | 跨天清空 | 可配置开关 `traffic.queue.clear_on_new_day`,默认打开;打开时新点与队列里的点不在同一天(上海时间),先清空再入队 |
|
||||
| T6 | 重置日 | 照搬老系统(移动 `YidongDayFlowService`、联通 `PtLiantongDayFlowService`),只把重置日换成通道配置的重置日:按配置的重置日换算成自然月累计;重置日当天全天防旧值(读数 > 比较基准 × 0.9 按 0 计;比较基准为 0 时当天任何正读数都按 0 计,不论配置的重置日是几号),不按小时区分,不设多日警惕期;重置日当天没取到有效数据,第二天起照常按新周期计;重置日早上的旧周期读数不记回前一天,月末尾巴丢失 |
|
||||
| T7 | 路径统一 | 测试环境一次性全量切换,不再灰度:所有卡走日快照 + 本月最大值 − 已结算;卡表基线算法、`TrafficCalcPolicy`、切换水位下线;切换时所有卡打"待建起点"标记(见 6.2) |
|
||||
| T8 | 写入范围 | 有套餐:每次采集写采集记录;日快照读数为 0 也写;套餐日用量每天一行,没扣也写 0。没套餐:不写采集记录,读数 > 0 才写日快照 |
|
||||
| T9 | 没套餐期间的用量 | 记为已结算,之后买了套餐**不补扣** |
|
||||
| T10 | 扣减方式 | 增量扣减;按采集时刻生效的套餐扣,套餐到扣费时已用完或到期也照扣 |
|
||||
| T11 | 停复机判断 | 通道阈值只在**写入的点**上判断,被队列拦下的点不参与 |
|
||||
| T12 | 其他来源 | 人工刷新、激活前结算与轮询进同一个队列,规则一致(没有运营商流量推送);人工刷新返回本次读数、是否写入、原因 |
|
||||
| T13 | 当天没有写入 | 不补写日快照,靠告警发现 |
|
||||
| T14 | 换卡 | 换卡完成时给新卡打"待建起点"标记;新卡第一次**通过队列写入**时建起点(本月最大快照全部记为已结算,不扣费),之后正常扣 |
|
||||
| T15 | 扣错 | 人工冲正,走冲正流水 |
|
||||
| T16 | 对账 | 初期不做,人工排查 |
|
||||
| T17 | "写入值 ≥ 上次写入值" | 不单独判断:日快照取最大值、增量 = 最大值 − 已结算,小的值本来就不产生增量 |
|
||||
| T18 | "有套餐"口径 | 采集侧(写入范围、是否发事件、采集记录)与扣费侧用同一个"采集时刻有效套餐"判断,**已用完但仍在有效期内的套餐算有套餐**(见 6.1) |
|
||||
| T19 | 按采集时刻扣费的边界 | 只对当前"生效中"的套餐做状态迁移;停机按当前状态判断;扣费时一个套餐都查不到按成功跳过;采集后套餐流量重置的,扣在重置后的周期(见 8.1) |
|
||||
| T20 | 读数 0 | 读数校验放宽为"有限且 ≥ 0",有没有套餐决定 0 写不写,不再由读数校验拦(见第 6 节) |
|
||||
| T21 | 采集间隔上限 | 留 2 次冗余:开关打开时间隔 ≤ 24h ÷ (N+2),关闭时 ≤ `max_point_age_hours` ÷ (N+2)(见 4.4) |
|
||||
| T22 | 回退容差 | 相邻两点回退 ≤ `traffic.queue.drop_tolerance_mb`(默认 1 MB)算持平;重置等大幅下降仍被拦下 |
|
||||
| T23 | 冲正 | 锁套餐行、按当前值加减;退回后已用量不能小于 0;提交后走停复机判断,不直接复机(见第 11 节) |
|
||||
| T24 | 日期 | 整条链路的日期统一用上海日期(`cardflow.ShanghaiDate`),包括消费者的日流量缓冲 key |
|
||||
| T25 | 套餐日用量 0 行 | 在观测事务里写:当天第一次写入时,为采集时刻的每个有效套餐确保当天一行 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 整体流程
|
||||
|
||||
```
|
||||
运营商接口
|
||||
│
|
||||
├─ ① 解析读数 ──────────── 无效:记采集记录(有套餐的卡),结束
|
||||
│
|
||||
├─ ② Redis 队列(Lua 原子):跨天清空(可配)→ 丢弃过期点 → 入队 → 只留最后 N 个
|
||||
│
|
||||
├─ ③ 写入判断:新点在队尾、队列满 N 个点且不下降(容差内)→ 写最后一个点 ── 不满足:记采集记录(有套餐的卡),结束
|
||||
│
|
||||
├─ ④ 数据库事务(卡级行锁)
|
||||
│ 有"待建起点"标记:写快照 → 本月最大全部记为已结算 → 清标记,结束(第 10 节)
|
||||
│ 换算月累计(重置日防旧值)→ 写日快照(取最大)
|
||||
│ → Δ = 本月最大快照 − 本月已结算 → 记已结算
|
||||
│ → 采集时刻有套餐:确保当天套餐日用量行(T25);Δ > 0 时写流量事件(outbox,带采集时间、采集 ID)
|
||||
│ → 通道阈值判断(只在写入的点上)
|
||||
│ → 写采集记录(有套餐的卡)
|
||||
│
|
||||
└─ ⑤ 异步消费流量事件
|
||||
按采集时刻生效的套餐,按优先级扣减(容量按虚流量阈值)
|
||||
→ 套餐日用量 upsert(日期 = 采集日期)→ 扣费审计 → 套餐用完触发停机
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 读数解析
|
||||
|
||||
| 运营商返回 | 入队 | 写快照 | 采集记录(有套餐的卡) |
|
||||
|---|---|---|---|
|
||||
| 接口失败、超时、非 200 | 否 | 否 | 记,`result = failed` |
|
||||
| 限流 | 否 | 否 | 记,`result = rate_limited` |
|
||||
| 响应缺 ICCID 等必要字段 | 否 | 否 | 记,`result = invalid_payload` |
|
||||
| `used` 为 null、空、无法解析 | 否 | 否 | 记,`result = no_reading`,读数为空 |
|
||||
| 负数、NaN、Inf | 否 | 否 | 记,`result = invalid_reading` |
|
||||
| ≥ 0 的数 | 是 | 看第 5 节 | 记 |
|
||||
|
||||
**改动**:`internal/gateway/models.go:55` 的 `FlexFloat` 目前把 null、空、无法解析都转成 0。改为带"是否有值"的类型(或 `*float64`),流量查询结果据此区分"没值"和真实的 0;其他使用 `FlexFloat` 的字段保持原行为。
|
||||
|
||||
新类型的**零值必须表示"没值"**:响应里完全没有 `used` 字段时,JSON 解析不会调用 `UnmarshalJSON`,字段停在零值。用 `*float64`(nil)或带 `Valid` 标志的结构(`Valid = false`)都满足;不能用零值为 0 的类型。
|
||||
|
||||
---
|
||||
|
||||
## 4. 采集队列(Redis)
|
||||
|
||||
### 4.1 存储
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| key | `traffic:queue:{card_id}` |
|
||||
| 值 | 最近 N 个点,每个点 = 采集时间(Unix 毫秒)+ 运营商原始读数(MB,原样,不换算) |
|
||||
| 过期 | 每次更新后设为第二天 01:00(上海时间);跨天清空关闭时设为 `max_point_age_hours` 之后 |
|
||||
| 内存 | 每卡约 150 字节,100 万张卡约 150 MB |
|
||||
|
||||
队列只是写入前的过滤器,**不是账**:
|
||||
|
||||
- key 丢了(Redis 重启、淘汰)→ 队列从空开始,晚写 N−1 次,不多扣也不少扣。
|
||||
- 入队成功、后面的数据库事务失败 → 队列里多一个点,下次采集重新判断即可。
|
||||
|
||||
Redis 淘汰策略:测试环境已确认为 `noeviction`、未设 `maxmemory`,队列不会被淘汰;内存需随卡数观察。
|
||||
|
||||
### 4.2 Lua 脚本
|
||||
|
||||
入参:`card_id`、新点(采集时间、读数)、`N`、`clear_on_new_day`、`max_point_age_ms`、过期时间。上海日期由 Go 侧算好传入(每个点存日号),Lua 里不做时区换算。
|
||||
|
||||
```
|
||||
points = 读出队列
|
||||
if clear_on_new_day and points 非空 and 上海日期(points 最后一个点) != 上海日期(新点):
|
||||
points = []
|
||||
if max_point_age_ms > 0:
|
||||
丢掉 points 中 新点时间 − 点时间 > max_point_age_ms 的点
|
||||
按采集时间把新点插入 points(同一时间的点去重)
|
||||
只保留最后 N 个
|
||||
写回,设置过期
|
||||
return points, 新点是否在队尾
|
||||
```
|
||||
|
||||
按采集时间插入,保证同一张卡同时有两次采集(轮询 + 人工刷新)时顺序正确;观测事务本身仍然加卡级行锁。
|
||||
|
||||
**乱序**:新点插入后不在队尾(比队列里已有的点更早到达顺序靠后),或者插进去就被裁掉,本次**不写**,`decision = out_of_order`。队尾的点之前已经判断过,再写一次虽然不影响账(GREATEST + 增量公式),但会让这条采集记录看起来写了自己的读数,排查时误导。
|
||||
|
||||
### 4.3 配置项
|
||||
|
||||
放在系统配置里,读法与沉默期总开关 `package.silence.enabled` 相同(`internal/application/packagesilence/settings.go`,带短缓存),改了不用重新发布。
|
||||
|
||||
| key | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `traffic.queue.size` | 3 | 队列长度 N,最小 2 |
|
||||
| `traffic.queue.clear_on_new_day` | true | 跨天是否清空 |
|
||||
| `traffic.queue.max_point_age_hours` | 72 | 队列里超过这个时长的点,入队时丢掉;0 表示不限 |
|
||||
| `traffic.queue.drop_tolerance_mb` | 1 | 相邻两点回退不超过这个值算持平;0 表示不允许回退 |
|
||||
|
||||
### 4.4 跨天清空开关
|
||||
|
||||
| | 打开(默认) | 关闭 |
|
||||
|---|---|---|
|
||||
| 队列里的点 | 只有当天的 | 可以跨天,最长 `max_point_age_hours` |
|
||||
| 每天首次写入 | 当天攒满 N 个点 | 跨天后的新点进来就能判断 |
|
||||
| 前一天最后一段用量 | 算进第二天第一次写入 | 跨天的点能直接写 |
|
||||
| 采集间隔上限 | **间隔 ≤ 24h ÷ (N+2)**(N=3 时为 4.8 小时) | 间隔 ≤ `max_point_age_hours` ÷ (N+2)(默认为 14.4 小时) |
|
||||
|
||||
关闭时的重置日:队列里存的是原始读数,重置时读数大幅下降、斜率为负,自然被拦下,新周期攒满 N 个不下降的点后才写,不会把新旧周期混着写。
|
||||
|
||||
`max_point_age_hours` 的作用:卡停用几天后重新采集时,不用几天前的旧点来确认当前读数是否稳定。
|
||||
|
||||
**采集间隔上限**:理论最低要求是窗口内采到 N 次(开关打开时 24h ÷ N),但这样一次失败或限流,当天就写不进去,T13 告警会常态触发。上限按 N+2 次算,留 2 次冗余。
|
||||
|
||||
间隔还决定每天第一次写入的时间:开关打开时,当天第一次写入在第一次采集之后 (N−1) × 间隔(N=3、间隔 4.8 小时,约为当天第一次采集后 9.6 小时)。这之前的用量(含前一天最后一段)都在这次写入时一起扣,停机也在这时才判断。需要更及时的扣费和停机时,应缩短间隔,而不是只满足上限。
|
||||
|
||||
**采集间隔校验**:只校验**启用中**的轮询配置,停用的不管。保存(启用状态)或启用轮询配置时,流量采集间隔超过当前开关下的上限直接报错;`traffic.queue.size` 或开关修改时,同样校验现有启用中的轮询配置,不满足的拒绝保存。测试库启用中的只有 id 29(间隔 60 秒,满足上限);id 25(86400 秒)是停用的,不拦,以后要启用时再按上限调小。
|
||||
|
||||
---
|
||||
|
||||
## 5. 写入判断
|
||||
|
||||
同时满足以下条件,写**最后一个点**(即本次采集的点):
|
||||
|
||||
1. 新点在队尾(4.2 乱序);
|
||||
2. 队列满 N 个点;
|
||||
3. 相邻两点都不下降:后一个读数 ≥ 前一个读数 − `drop_tolerance_mb`。
|
||||
|
||||
- 只比较先后读数,与采集间隔是否均匀无关。
|
||||
- 持平也写:有套餐但没用流量的卡能写入 0 用量。
|
||||
- **容差**:日快照取当天最大值、增量 = 最大值 − 已结算,小幅回退的点写进去也不产生增量,所以容差不会造成多扣;它只是避免读数在几 KB 内来回跳时整窗不写。重置时的大幅下降远超容差,照样被拦下。
|
||||
|
||||
N=3、容差 1 MB 的例子:
|
||||
|
||||
| 队列 | 结果 |
|
||||
|---|---|
|
||||
| 100, 150, 200 | 写 200 |
|
||||
| 100, 100, 100 | 写 100 |
|
||||
| 100, 150, 120 | 不写 |
|
||||
| 100, 99999, 120 | 不写(中间点暴涨被拦下) |
|
||||
| 100, 110, 99999 | 写 99999(最后一个点暴涨挡不住,见第 14 节) |
|
||||
| 100.002, 100.001, 100.003 | 回退 0.001 在容差内,写 100.003 |
|
||||
| 3010, 20, 25(重置) | 不写 |
|
||||
| 20, 25, 30(新周期) | 写 30 |
|
||||
|
||||
不写时,原因记进采集记录(第 7.1 节)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 写入:日快照与结算
|
||||
|
||||
沿用灰度路径(`internal/application/cardobservation/traffic_flow.go`),改动见下。
|
||||
|
||||
**读数校验放宽**:`cardflow.ValidReading` 现在要求读数 > 0,`writeSnapshot` 与 `writeUnswitchedSnapshot` 都以它为前提,读数为 0 的点写不进快照,与 T8 冲突。改为"有限且 ≥ 0"(与第 3 节的入队条件一致);没套餐的卡读数为 0 不写快照,由 6.1 的写入范围判断,不再由读数校验拦。读数 0 在重置日不触发防旧值(`isStale` 对 ≤ 0 直接返回否),换算结果不变。
|
||||
|
||||
1. **换算**:`cardflow.Convert`,按卡所属通道的重置日把**被写入那个点**的读数换算成自然月累计;重置日当天读数超过比较基准 × 0.9 按 0 计(防旧值)。日期取被写入那个点的采集日期。
|
||||
2. **写日快照** `tb_card_flow_day`:upsert,`month_used_mb` 取当天最大(`GREATEST`)。
|
||||
3. **结算**:`Δ = 本月最大快照 − 本月已结算`,Δ > 0 记入当天 `settled_mb`。
|
||||
4. **事件**:采集时刻卡有套餐(6.1.1)且 Δ > 0,同事务写流量事件(outbox),事件带采集时间和采集 ID。
|
||||
5. **停复机**:通道阈值只在这里判断(被队列拦下的点不判断)。
|
||||
|
||||
### 6.1 写入范围
|
||||
|
||||
| | 有套餐 | 没套餐 |
|
||||
|---|---|---|
|
||||
| 被写入的点读数为 0 | 写日快照 | 不写 |
|
||||
| 被写入的点读数 > 0 | 写日快照 | 写日快照 |
|
||||
| 套餐日用量 | 每个有效套餐(6.1.1)每天一行,没扣也写一行 0 | — |
|
||||
| Δ | 发事件扣费 | 只记已结算(T9) |
|
||||
|
||||
**套餐日用量 0 行在观测事务里写**(T25):扣减消费者只在 Δ > 0 时才运行,没用流量的卡走不到那里。所以在观测事务里,当天第一次写入时,为采集时刻的每个有效套餐 `INSERT ... ON CONFLICT (package_usage_id, date) DO NOTHING` 一行 0;之后扣减按 8.2 累加。由 `usage_service` 提供方法,观测事务调用,不在 cardobservation 里直接写套餐表。
|
||||
|
||||
#### 6.1.1 "有套餐"口径(T18)
|
||||
|
||||
采集侧(写入范围、是否发事件、是否写采集记录)与扣费侧(8.1 查套餐)用**同一个函数**判断"采集时刻有效套餐":
|
||||
|
||||
| 条件 | 说明 |
|
||||
|---|---|
|
||||
| 状态 | `status IN (生效中, 已用完, 已过期)`;待生效、已失效不算 |
|
||||
| 已生效 | `activated_at ≤ observed_at`;`activated_at` 为空的历史数据按已生效处理(与现有口径一致) |
|
||||
| 未到期 | `expires_at` 为空,或 `expires_at > observed_at` |
|
||||
| 未退款 | `refund_id IS NULL` |
|
||||
| 载体 | 卡自己的套餐;卡自己一个都没有时,看卡当前绑定设备的套餐 |
|
||||
|
||||
- **已用完但仍在有效期内的套餐算有套餐**:套餐用完到真正停机之间的用量照常发事件,扣费时进最后一个套餐的超出部分(T10)。现在 `countActivePackages`(`traffic_calc_policy.go:148`)只认"生效中"、不看 `expires_at`,这段用量被记为已结算、不发事件,`recordOverflowToDepletedPackage` 实际收不到流量,需要改。
|
||||
- 已过期状态之所以纳入,是因为状态可能在采集之后才变为已过期;是否在采集时刻有效由 `expires_at` 判断。
|
||||
- 待实名激活、沉默期等待中的套餐处于待生效状态,不算有套餐,期间用量按 T9 记为已结算。
|
||||
|
||||
### 6.2 路径统一
|
||||
|
||||
测试环境**一次性全量切换**,不再灰度(T7):
|
||||
|
||||
- 删掉 `TrafficCalcPolicy`(灰度名单)、卡表基线算法的扣费作用、第一次切换逻辑(`firstSwitch`)和切换水位(`tb_card_flow_switch` 不再读写)。
|
||||
- **切换时所有卡打"待建起点"标记**(随迁移执行,与换卡同一个标记,第 10 节)。原因:切换前扣费走的是卡表基线算法,日快照上没有本月已结算(测试库 `tb_card_flow_day` 为 0 行);不打标记,切换后第一次写入的 Δ 会是运营商读数对应的整月用量,而这些用量老路径已经扣过。打了标记,每张卡切换后第一次写入只建起点、不扣费。
|
||||
- 切换过程中老代码还在跑也安全:老代码不认标记,照常按老路径扣;新代码第一次写入只建起点,不会重复扣。
|
||||
- 代价:老路径最后一次扣费到新路径建起点之间的用量不扣(约 N−1 个采集周期),测试环境可接受。
|
||||
- 卡表上的 `current_month_usage_mb` 等展示字段由日快照覆盖(与现灰度路径一致);`last_gateway_reading_mb` 不再使用。
|
||||
|
||||
---
|
||||
|
||||
## 7. 留痕
|
||||
|
||||
### 7.1 采集记录(新表)
|
||||
|
||||
只记**有套餐的卡**,每次采集一条,包括接口失败和读数为 0。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `id`、`iot_card_id`、`observed_at` | |
|
||||
| `observation_id` | 采集 ID,与流量事件、扣费审计、对接日志串联 |
|
||||
| `source`、`scene` | 轮询、人工刷新、激活前结算 |
|
||||
| `result` | 读数是否有效:`ok` / `failed` / `rate_limited` / `invalid_payload` / `no_reading` / `invalid_reading` |
|
||||
| `vendor_used_mb` | 运营商原始读数,可为空 |
|
||||
| `queue_points` | 入队后的队列(jsonb:采集时间 + 读数) |
|
||||
| `decision` | 读数有效之后怎么判断的,`result ≠ ok` 时为空:`written` / `baseline`(建起点)/ `queue_not_full` / `slope_negative` / `out_of_order` / `settled_existing`(激活前结算没写入,用已有本月最大快照结算) |
|
||||
| `month_used_mb`、`stale_guarded` | 写入时才有:换算后月累计、是否触发防旧值 |
|
||||
| `delta_mb`、`event_id` | 写入时才有:本次 Δ、流量事件 ID |
|
||||
| `created_at` | |
|
||||
|
||||
- 按 `observed_at` 按月分区,保留 90 天,过期直接删分区(不用 `DELETE ... LIMIT`)。整月都过期才能删,所以同时存在 4 个分区(约 120 天的数据)。
|
||||
- 数据量:每天行数 ≈ 有套餐的卡数 × 每天采集次数。例:10 万张卡、间隔 4.8 小时(每天 5 次),每天 50 万行,4 个分区约 6000 万行。
|
||||
- 索引:`(iot_card_id, observed_at DESC)`、`observation_id`。
|
||||
|
||||
### 7.2 排查路径
|
||||
|
||||
```
|
||||
扣费审计(package_usage.deduct_traffic:扣了哪个套餐、多少、前后值)
|
||||
→ parent_event_id → 流量事件(卡、增量、采集时间、采集 ID)
|
||||
→ observation_id → 采集记录(当时的原始读数、队列、判断结果)
|
||||
→ 卡 + 日期 → 日快照(当天最大值、已结算)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 扣减
|
||||
|
||||
扣减逻辑沿用 `internal/service/package/usage_service.go` 的 `DeductDataUsage`:小数部分攒在 Redis;加油包优先,再按 `priority`、`id`;每个套餐容量 = 阈值 − 已用(启用虚流量时阈值为虚总量,否则为真总量);最后一个套餐承接超出部分;全部用完触发停机。
|
||||
|
||||
改动两点:
|
||||
|
||||
### 8.1 按采集时刻确定套餐
|
||||
|
||||
- 流量事件已带 `observed_at`,消费者把它传给扣减。
|
||||
- 查套餐由"当前 `status = 生效中`"改为"**采集时刻处于生效期内**":已激活、`activated_at ≤ observed_at`、未到期(`expires_at > observed_at`)、未退款、未作废;设备回退口径不变。
|
||||
- 查询条件与 6.1.1 共用同一个函数,采集侧和扣费侧不会出现"采集说有、扣费说没有"(采集后退款、作废的情况除外,见下)。
|
||||
- 采集时刻生效、但扣费时已用完或已到期的套餐**照扣**(T10)。
|
||||
- 排序、容量、超出承接规则不变。
|
||||
|
||||
**状态迁移只作用于当前"生效中"的套餐**(T19)。现有循环(`usage_service.go:124-166`)在余量 ≤ 0 或扣到阈值时把套餐改成"已用完"。查询范围扩大后,集合里会有已过期、已用完的套餐,按现有写法会把"已过期"改回"已用完"。改为:
|
||||
|
||||
| 套餐当前状态 | 累加 `data_usage_mb`、写日用量 | 改状态 |
|
||||
|---|---|---|
|
||||
| 生效中 | 是 | 扣到阈值改为已用完(同现在) |
|
||||
| 已用完 | 是(只会作为最后一个套餐承接超出) | 否 |
|
||||
| 已过期 | 是 | 否 |
|
||||
|
||||
- 余量 ≤ 0 且不是最后一个套餐的,跳过;只有当前是"生效中"的才顺带改为已用完。
|
||||
- 停机判断 `checkAndTriggerSuspension` 不变,仍按**当前**状态判断是否还有可用套餐。
|
||||
|
||||
**扣费时一个套餐都查不到**:采集时刻有套餐(所以发了事件),扣费前该套餐被退款或作废。现在 `DeductDataUsage` 返回"没有可用套餐"错误,消费者会一直重试。改为:记告警日志和扣费审计(标明跳过原因),事件按成功处理,不重试;该笔用量需要时走人工冲正。
|
||||
|
||||
**采集之后套餐流量被重置**:日/月重置任务(`reset_service.go`)把 `data_usage_mb` 清零。采集时刻早于套餐 `last_reset_at`、扣费在重置之后的,**扣在重置后的周期**,不跳过(跳过就是少扣)。审计里记录 `observed_at` 与 `last_reset_at`,排查时能看出这笔是跨重置扣的。列入第 14 节已知限制。
|
||||
|
||||
### 8.2 套餐日用量
|
||||
|
||||
- `updateDailyRecord` 由"先查后插"改为 upsert(`(package_usage_id, date)` 唯一),`daily_usage_mb` 累加、`cumulative_usage_mb` 覆盖。
|
||||
- `date` 取采集日期(`cardflow.ShanghaiDate(observed_at)`),不取扣费时间。
|
||||
- 消费者的日流量缓冲 key(`event.go` 里 `RedisCardDailyTrafficKey(..., event.ObservedAt.Format(...))`)同样改为上海日期;现在直接 `Format`,时间值是 UTC 时上海 0~8 点会落到前一天(T24)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 其他读数来源
|
||||
|
||||
| 来源 | 处理 |
|
||||
|---|---|
|
||||
| 轮询 | 第 2~6 节 |
|
||||
| 人工刷新 | 同一个队列、同一套规则;接口返回本次读数、是否写入、不写原因 |
|
||||
| 激活前结算 | 读数同样过队列;本次没写入时,用卡上已有的本月最大快照做结算(把它全部记为已结算),不新写快照,`decision = settled_existing` |
|
||||
|
||||
激活前结算没写入时,"最近一次写入到激活"之间的用量会算给新套餐,精度到写入粒度(第 14 节)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 换卡
|
||||
|
||||
套餐使用记录随换卡整条迁到新卡(`internal/service/exchange/migration.go:103`),已用量跟着走,所以不需要老系统那套"旧卡流量补算"(`tbl_new_card_add_flow`)。需要处理的是新卡的起点:
|
||||
|
||||
| 问题 | 例子 | 处理 |
|
||||
|---|---|---|
|
||||
| 新卡换卡前已有读数 | 出库前测试用了 50 MB,库存期间没轮询 | 新卡**建起点**(下文),这 50 MB 不扣 |
|
||||
| 旧卡最后一段 | 最后一次采集 10:00,换卡完成 10:30 | 不处理:旧卡之后的采集按没套餐处理,记为已结算(与 T9 一致) |
|
||||
|
||||
**建起点**:
|
||||
|
||||
1. 换卡事务内,在新卡上打"待建起点"标记(`tb_iot_card` 新增标记字段),不在换卡流程里调用运营商接口。
|
||||
2. 新卡的读数照常过队列。**第一次满足写入条件**时,看到标记:写日快照,把新卡本月最大快照全部记为已结算,不发事件,清掉标记,写采集记录(`decision = baseline`)。
|
||||
3. 之后按正常规则处理。
|
||||
|
||||
建起点也过队列,是为了不让一个异常读数当起点:第一个读数是 99999 时如果直接当起点,本月最大值一直是 99999,之后整月增量为 0、不扣。
|
||||
|
||||
N=3、间隔 1 小时的例子:
|
||||
|
||||
| 时间 | 事件 | 读数 | 队列 | 处理 |
|
||||
|---|---|---|---|---|
|
||||
| 10-20 10:00 | 换卡完成 | — | — | 打标记 |
|
||||
| 10-20 11:00 | 采集 | 52 | 52 | 不满,不写 |
|
||||
| 10-20 12:00 | 采集 | 53 | 52, 53 | 不满,不写 |
|
||||
| 10-20 13:00 | 采集 | 55 | 52, 53, 55 | 写 55,建起点:55 记为已结算,不扣,清标记 |
|
||||
| 10-20 14:00 | 采集 | 58 | 53, 55, 58 | 写 58,扣 3 |
|
||||
|
||||
代价:换卡完成到建起点之间的用量不扣(比直接用第一个读数多 N−1 个采集周期),与"旧卡最后一段不补扣"口径一致。
|
||||
|
||||
设备换货时,设备套餐迁到新设备,新设备绑定的每张卡同样打标记。
|
||||
|
||||
---
|
||||
|
||||
## 11. 人工冲正
|
||||
|
||||
新增冲正流水表,冲正只通过它进行,不直接改套餐字段。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `id`、`package_usage_id`、`iot_card_id` / `device_id` | |
|
||||
| `delta_mb` | 正数补扣,负数退回 |
|
||||
| `before_usage_mb`、`after_usage_mb` | 套餐已用量前后值 |
|
||||
| `reason` | 必填 |
|
||||
| `ref_observation_id`、`ref_event_id` | 关联的采集记录、流量事件,可选 |
|
||||
| `operator_id`、`created_at` | |
|
||||
|
||||
处理:
|
||||
|
||||
1. 事务内先 `SELECT ... FOR UPDATE` 锁套餐行(与扣减同一把锁),按**锁住后的当前值** + `delta_mb` 计算新值,`before_usage_mb` 取锁住后的值。避免与扣减并发时互相覆盖。
|
||||
2. 退回后已用量小于 0 直接报错,不截成 0。
|
||||
3. 同一事务内写流水、更新套餐 `data_usage_mb`,按阈值重新计算状态(已用完 ↔ 生效中);已过期、已失效的套餐只改已用量,不改状态(与 8.1 一致)。
|
||||
4. 写审计(新动作码,例如 `package_usage.adjust_traffic`)。
|
||||
5. 提交后调用现有停复机判断(`evaluator.EvaluateAndAct`),**不直接复机或停机**:卡可能同时因为实名、沉默期、人工等原因停着,冲正只改变了"套餐用完"这一个条件,由停复机判断综合决定。
|
||||
6. 卡上的日快照与已结算不改,冲正只作用在套餐上。
|
||||
|
||||
---
|
||||
|
||||
## 12. 监控与告警
|
||||
|
||||
| 指标 | 告警条件 |
|
||||
|---|---|
|
||||
| 有套餐的卡一整天没写入 | 开启轮询的卡,当天采集记录里没有 `decision = written`(T13) |
|
||||
| 队列拦截率 | 某张卡当天 `slope_negative` 占比过高,或全局拦截率突增 |
|
||||
| 待建起点 | 开启轮询的卡,"待建起点"标记超过 1 天未清除(一直没写入);不轮询的卡不告警 |
|
||||
| 乱序 | `out_of_order` 数量突增(排查并发采集或时钟问题) |
|
||||
| 防旧值命中 | 重置日命中数,便于核对重置日配置 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 改动清单
|
||||
|
||||
| 位置 | 改动 |
|
||||
|---|---|
|
||||
| `internal/gateway/models.go` | 流量读数区分"没值"和 0,零值表示没值 |
|
||||
| `internal/task/polling_carddata_handler.go` | 无效读数分类;调用队列;写采集记录 |
|
||||
| 新增队列组件(Redis + Lua)、配置项注册 | 第 4 节;返回新点是否在队尾;写入判断(不下降 + 容差、写最后一个点) |
|
||||
| `internal/application/cardobservation/traffic.go`、`traffic_flow.go` | 按 6.1 的写入范围;确保套餐日用量 0 行;通道阈值只在写入点判断;删除未切换分支、`firstSwitch` 与切换水位 |
|
||||
| `internal/domain/cardobservation/traffic.go` | 基线算法下线 |
|
||||
| `internal/application/cardobservation/traffic_calc_policy.go` | `hasActivePackageForCard` 改为调用 6.1.1 的共用判断(含已用完、按 `expires_at`、排除退款);删除灰度名单 |
|
||||
| `internal/domain/cardflow/rules.go` | `ValidReading` 放宽为"有限且 ≥ 0";`isStale` 去掉 `ThisMonthBeforeResetMax > 0` 条件,比较基准为 0 时不论配置的重置日是几号都照常防旧值;调用方注释同步 |
|
||||
| `internal/infrastructure/cardobservation/event.go` | 把 `observed_at` 传给扣减;日流量缓冲 key 用上海日期 |
|
||||
| `internal/service/package/usage_service.go` | 按采集时刻查套餐(与采集侧共用判断);状态迁移只作用于当前生效中的套餐;查不到套餐按成功跳过;`updateDailyRecord` upsert,日期按采集日期(上海);提供"确保当天日用量行"方法供观测事务调用 |
|
||||
| 人工刷新、激活前结算入口 | 过队列;人工刷新返回写入结果 |
|
||||
| `internal/service/exchange` | 换卡完成时给新卡打"待建起点"标记 |
|
||||
| 观测写入 | 有"待建起点"标记时,第一次满足写入条件时建起点并清标记 |
|
||||
| 轮询配置保存/启用、队列配置保存 | 采集间隔上限校验(24h 或 `max_point_age_hours` ÷ (N+2)),只校验启用中的配置 |
|
||||
| 新表 + 迁移 | 采集记录(按月分区)、冲正流水;`tb_iot_card` 待建起点标记,迁移时**所有卡置为待建起点**(6.2);`(package_usage_id, date)` 唯一索引 |
|
||||
| 后台接口 | 采集记录查询、冲正(锁套餐行、不允许负数、提交后走停复机判断) |
|
||||
|
||||
---
|
||||
|
||||
## 14. 已知限制
|
||||
|
||||
| 限制 | 说明 |
|
||||
|---|---|
|
||||
| 最后一个点暴涨 | 队列挡不住,靠人工冲正 |
|
||||
| 运营商连续返回错误且递增 | 队列挡不住,靠人工冲正 |
|
||||
| 运营商整体抬高、事后下调 | 队列挡不住;初期靠人工,后期可接运营商 T+1 数据对账 |
|
||||
| 月末尾巴 | 最后一次**写入**的旧周期读数到运营商归零之间的用量丢失,与老系统一致 |
|
||||
| 防旧值比较基准为 0 | 没有快照可比(新卡、之前没写入)时,配置的重置日当天任何正读数都按 0 计,次日如实写入;只会晚一天扣,不会多扣 |
|
||||
| 运营商跨天才归零 | 防旧值只管重置日当天(T6,不设警惕期);运营商晚一天才清零、重置日当天没取到数后第二天拿到旧数、清零后又返回缓存旧数,这些读数都会被当成新周期用量,造成多扣 |
|
||||
| 写入粒度 | 激活前结算、换卡、没套餐 → 有套餐的切换点,精度都到"最近一次写入" |
|
||||
| 晚写 | 队列需要攒满 N 个点,读数回退超过容差时会一直不写,扣费与停机相应延后 |
|
||||
| 切换和换卡的起点 | 切换、换卡后到建起点之间的用量不扣(约 N−1 个采集周期) |
|
||||
| 跨套餐流量重置 | 采集在套餐重置前、扣费在重置后的,扣在重置后的周期 |
|
||||
| 采集后退款、作废 | 扣费时查不到套餐,跳过不扣,需要时人工冲正 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 后续
|
||||
|
||||
- **对账**:接入运营商 T+1 日用量或月账单后,定期从日快照(或运营商数据)重算,与套餐已用量比对;多扣自动退回、少扣告警,调整同样走冲正流水。
|
||||
- **月末尾巴**:重置日把旧周期读数的增量补上(旧周期读数 ≥ 上次旧周期读数时,差额计入本月),可选。
|
||||
1127
docs/流量事实模型技术方案.md
Normal file
1127
docs/流量事实模型技术方案.md
Normal file
File diff suppressed because it is too large
Load Diff
150
docs/测试环境导入生产数据验证流量计量技术方案.md
Normal file
150
docs/测试环境导入生产数据验证流量计量技术方案.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 测试环境导入生产数据验证流量计量技术方案
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 日期 | 2026-10-29 |
|
||||
| 状态 | 方案已确认(2026-10-29),代码未修改 |
|
||||
| 范围 | 测试环境 |
|
||||
| 关联 | `docs/卡流量采集写入与扣减技术方案.md`、Change `add-card-traffic-queue-and-observed-deduction`、`docs/测试环境生产卡只读模式清理顺序.md` |
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
生产(迁移版本 240,仍是卡表基线算法)大量卡"卡住":卡表基线 `last_gateway_reading_mb` 高于运营商读数,读数被当成下降拒绝,套餐长期扣 0。2026-10-29 核对:
|
||||
|
||||
- 生产有生效套餐、开启轮询、本月用量为 0、基线大于 0 的卡:1632 张(独立卡 1233 张)。
|
||||
- 其中 916 张在 flowcollector 10-26 起的读数里持续上涨、一直未扣,合计约 1635 GB。
|
||||
- 单卡导入测试环境已验证新算法可解冻并按增量扣费(测试卡 12244、12245),但单卡无法说明全量差距。
|
||||
|
||||
目标:把生产的卡、设备与套餐数据导入测试环境,用新计量逻辑(采集队列、日快照、建起点、按采集时刻扣减)跑真实读数,算出全量差距;**全过程不得对真实卡、设备发起任何写操作(停复机、限速、实名认证、设备指令)**。
|
||||
|
||||
不做:订单、钱包、分佣、退款、审批、个人客户、通知等业务流程的验证;生产发布与回滚。
|
||||
|
||||
## 2. 数据规模(2026-10-29)
|
||||
|
||||
| 项 | 生产 | 测试 |
|
||||
|---|---|---|
|
||||
| 卡 | 98,567(开启轮询 90,073,独立卡 59,900) | 33 |
|
||||
| 设备 | 20,144(有效绑定 38,667) | 6 |
|
||||
| 生效中套餐 | 22,426(其中设备套餐 10,799) | — |
|
||||
| 待生效 / 生效中 / 已用完套餐 | 29,175 | 128(全部状态) |
|
||||
| 使用中的运营商通道 | 34 | 13 |
|
||||
| 迁移版本 | 240 | 268 |
|
||||
|
||||
## 3. 方案总览
|
||||
|
||||
```
|
||||
① 建只读表 + 开关上线(测试环境)
|
||||
② 生产数据 → 只读表(全部 ICCID 与设备 IMEI)
|
||||
③ 确认部署生效、只读表已写入
|
||||
④ 导入卡、设备、绑定、套餐(测试库),全部卡打"待建起点"
|
||||
⑤ 轮询按新逻辑采集 → 采集记录、日快照、套餐扣减
|
||||
⑥ 差距报表:新逻辑应扣 vs 生产已扣
|
||||
⑦ 验证结束后按清理文档撤除
|
||||
```
|
||||
|
||||
必须先 ①②③ 再 ④:导入的卡在测试环境一开始没有套餐或状态不一致,会立即触发停复机判断,此时只读保护必须已生效。
|
||||
|
||||
## 4. 只读表与开关
|
||||
|
||||
### 4.1 表 `tb_gateway_readonly_card`
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `id` | 主键 |
|
||||
| `identifier` | 网关请求里的卡号 `cardNo`:卡为 ICCID,设备指令为设备 IMEI;大写;唯一索引 |
|
||||
| `identifier_type` | `iccid` / `imei` |
|
||||
| `source` | 来源,如 `prod_import_20261029` |
|
||||
| `remark` | 备注 |
|
||||
| `created_at` | 写入时间 |
|
||||
|
||||
- 新的成对迁移建表;不写入数据(数据是测试环境专用,由导入脚本写入)。
|
||||
- 不使用数据库外键。
|
||||
|
||||
### 4.2 开关 `JUNHONG_GATEWAY_READONLY_ENABLED`
|
||||
|
||||
只读表里的卡与设备**只允许查询类调用**,其余一律不允许:
|
||||
|
||||
| 允许 | 接口 |
|
||||
|---|---|
|
||||
| 查卡状态 | `/flow-card/status` |
|
||||
| 查实名 | `/flow-card/realName` |
|
||||
| 查流量 | `/flow-card/flow` |
|
||||
| 查设备信息 | `/device/info` |
|
||||
| 查设备槽位 | `/device/slot-info` |
|
||||
| 同步设备状态 | `/device/sync-info` |
|
||||
|
||||
- `true`:网关客户端每次调用前,接口不在上表时,按请求卡号(`cardNo`)查表,命中则本地拦截、不发请求,返回明确失败(不是结果未知),记日志 `gateway.readonly_card.blocked`。上表接口不查表、直接放行。
|
||||
- 被拦截的包括但不限于:停机 `/flow-card/cardStop`、复机 `/flow-card/cardStart`、限速 `/flow-card/speedLimit`、实名认证链接 `/flow-card/RealNameVerification`,以及设备切卡、恢复出厂、重启、WiFi 配置、模式切换。
|
||||
- 查表失败(含超时、表不存在)按拦截处理:宁可调用失败,不放过真卡。
|
||||
- 不做内存缓存:非查询类调用本身不频繁,按唯一索引查表约 1 ms;表一写入立即生效,没有刷新空窗。
|
||||
- 未配置或为 `false`:不查表。生产不配置。
|
||||
- 替换现有 `JUNHONG_GATEWAY_READONLY_ICCIDS`;名单内现有两张卡(`89861590172420410612`、`89861590172420420169`)迁入表中。
|
||||
|
||||
### 4.3 拦截覆盖面
|
||||
|
||||
所有网关调用都经过 `internal/gateway/client.go` 的 `doRequest` / `doRequestWithoutPayloadLog`,在这里判断即可覆盖所有入口:自动停复机(轮询、流量扣减、通道阈值、套餐激活与到期、订单、退款)、人工停复机按钮、限速档位、实名认证链接、设备各类接口等。业务代码不做任何改动。
|
||||
|
||||
## 5. 数据导入
|
||||
|
||||
### 5.1 范围(2026-10-29 已确认:全量)
|
||||
|
||||
| 数据 | 导入内容 |
|
||||
|---|---|
|
||||
| 运营商通道 | 不导入;按 `carrier_code` 映射到测试库已有通道,测试库缺的通道按生产配置补建(含重置日) |
|
||||
| 卡 | 生产全部卡(98,567):ICCID、ICCID19/20、MSISDN、虚拟号、通道、卡类别、实名状态、网络状态、实名策略、轮询开关;店铺统一挂测试代理 9「测试采购部」 |
|
||||
| 资产标识 | 每张卡的 ICCID、虚拟号写入 `tb_asset_identifier` |
|
||||
| 设备与绑定 | 生产全部设备(20,144):设备号、IMEI、SN、槽位与有效绑定关系;店铺统一挂测试代理 9「测试采购部」 |
|
||||
| 套餐使用记录 | 卡套餐与设备套餐中待生效、生效中、已用完的记录(约 29,175 条),保留生产的开始(生效)时间、结束(到期)时间、真总量、虚总量、重置周期与下次重置时间、优先级与主套餐 / 加油包关系;**只保存真实已用量**(`data_usage_mb`,导入时刻的生产当前值;待生效套餐本来为 0)。虚流量已用量不存储,由真实已用量 × 展示倍率 `display_gain_ratio_snapshot` 现算(上限为真总量);虚总量、展示倍率、是否启用虚流量是套餐条款快照(停机线 = 启用虚流量时的虚总量,否则真总量),照原样复制;订单号用 `PROD-<原订单号>` 标记,订单 ID 用不存在的占位值;`package_id` 指向一个测试占位套餐 |
|
||||
| 不导入 | 订单、钱包、分佣、退款、个人客户、审批、历史日用量与审计 |
|
||||
|
||||
### 5.2 方式
|
||||
|
||||
用导入脚本直接写测试库(后台导卡接口一次只能导卡,不能导设备与套餐,也不适合 10 万级数据):
|
||||
|
||||
1. 从生产只读导出(dbhub / 只读连接),生成 CSV。
|
||||
2. 写只读表:全部 ICCID 与设备 IMEI。
|
||||
3. 确认开关已部署(以 Gitea Actions 部署成功为准)且只读表行数与导出一致。
|
||||
4. 按通道映射写卡、资产标识、设备、绑定、套餐;同 ICCID / IMEI 已存在则跳过并记录。
|
||||
5. 全部导入的卡置"待建起点"(`traffic_baseline_pending = true`)。
|
||||
6. 触发轮询重新加载(重启 Worker 或调用轮询初始化),使导入的卡进入调度。
|
||||
|
||||
导入脚本放在 `cmd/zz_tmp_*`,不提交;只允许连接 `junhong_cmp_test`。
|
||||
|
||||
### 5.3 建起点
|
||||
|
||||
每张卡在测试环境第一次通过队列写入时只建起点:把当时运营商读数对应的本月累计全部记为已结算,不扣费。之后按增量扣到导入的套餐上。因此测试环境算出的是"导入之后"的应扣,导入之前的差距由第 7 节用 flowcollector 读数补充。
|
||||
|
||||
## 6. 轮询
|
||||
|
||||
- **完全按测试环境现有的轮询配置与代码逻辑运行**,不为导入的卡、设备单独配置,也不改业务流程:查卡状态、查实名、查流量、查套餐照常执行;流量照常进采集队列、写日快照、建起点、按增量扣套餐;套餐照常激活、到期、重置;停复机判断照常执行并记录。
|
||||
- 唯一不同:导入资产的网关调用只放行查询类(查卡状态、查实名、查流量、查设备信息、查设备槽位、同步设备状态),停机、复机等其他调用在网关出口拦截(第 4 节)。被拦截的停复机记为失败并按退避重试,本地卡状态不变;这些失败记录是预期的,也正好说明"按新逻辑该停/该复"的卡有哪些。
|
||||
- 查卡状态、查实名放行后,测试环境的网络状态、实名状态跟随真实情况。
|
||||
- 轮询频率不作为本方案的约束。
|
||||
|
||||
## 7. 差距报表
|
||||
|
||||
按套餐(设备套餐按设备合并)输出:
|
||||
|
||||
| 列 | 来源 |
|
||||
|---|---|
|
||||
| 卡 / 设备、套餐、生效与到期时间、真总量、虚总量 | 导入数据 |
|
||||
| 导入时已用量 | 导入时刻生产套餐已用量(两边共同起点) |
|
||||
| 测试环境新增扣减 | 测试环境套餐已用量 − 导入时已用量(建起点之后按新逻辑扣) |
|
||||
| 导入前差距上限 | 本月运营商读数(重置日按通道配置换算)对生效后的上限,来自 flowcollector |
|
||||
| 生产同期新增扣减 | 生产套餐已用量(同一时间点只读查询)− 导入时已用量 |
|
||||
| 差额、是否已到停机线 | 计算 |
|
||||
|
||||
汇总:卡住卡数、总差额(GB)、按新逻辑已到停机线但生产未停的套餐数。
|
||||
|
||||
## 8. 清理
|
||||
|
||||
按 `docs/测试环境生产卡只读模式清理顺序.md` 执行:先处理测试环境里的生产卡 → 关开关并重新部署 → 新迁移删表 → 可选清理代码。导入的卡、设备、套餐与采集数据在第一步删除。
|
||||
|
||||
## 9. 已确认事项
|
||||
|
||||
已确认(2026-10-29):
|
||||
|
||||
1. 导入全量:卡、设备、绑定,以及卡套餐与设备套餐(含开始、结束时间)。
|
||||
2. 套餐只保存真实已用量(导入时刻生产当前值),虚流量已用量不存储、由真实已用量 × 展示倍率现算。差距报表因此按"导入后测试环境新增扣减 vs 生产同期新增扣减"对比,两边起点一致。
|
||||
3. 卡和设备全部挂测试代理 9「测试采购部」。之前单独导入的两张卡(测试卡 12244、12245,目前未挂店铺)一并挂过去。
|
||||
4. 生产卡的 MSISDN、虚拟号等按原值导入,不脱敏。
|
||||
47
docs/测试环境生产卡只读模式清理顺序.md
Normal file
47
docs/测试环境生产卡只读模式清理顺序.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 测试环境生产卡只读模式清理顺序
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 日期 | 2026-10-29 |
|
||||
| 范围 | 测试环境 |
|
||||
| 对应机制 | 网关只读卡表 `tb_gateway_readonly_card` + 环境变量 `JUNHONG_GATEWAY_READONLY_ENABLED` |
|
||||
|
||||
## 背景
|
||||
|
||||
为验证卡流量采集与扣减,把生产上"卡住"的卡导入测试环境。这些是真实用户的卡,测试环境只能查询,不能停复机。
|
||||
|
||||
- 只读卡表 `tb_gateway_readonly_card`:存放导入卡的 ICCID 与导入设备的 IMEI(`identifier` 唯一索引)。
|
||||
- 环境变量 `JUNHONG_GATEWAY_READONLY_ENABLED=true` 时,网关每次写操作(停机、复机、限速、实名认证、设备指令)前按请求卡号查表:在表里就本地拦截、不发请求;查流量、卡状态、实名这三类查询不查表、直接放行。
|
||||
- 查表失败按拦截处理(宁可停复机失败,不放过真卡)。
|
||||
- 生产不配置该环境变量,代码不查表。
|
||||
|
||||
## 清理顺序(必须按顺序执行)
|
||||
|
||||
### 1. 先处理测试环境里的生产卡(保护仍在,安全)
|
||||
|
||||
- 把表里这些卡在测试环境的 `enable_polling` 设为 `false`,或删除卡及其测试套餐、采集记录、日快照。
|
||||
- 确认这些卡已不在轮询队列中,也没有待执行的停复机指令。
|
||||
|
||||
> 顺序错误的后果:保护撤掉后这些卡仍在测试环境,一旦触发无套餐、套餐用完、套餐到期等停复机,会**真实停掉用户的卡**。
|
||||
|
||||
### 2. 关闭开关并重新部署
|
||||
|
||||
- 部署脚本去掉 `JUNHONG_GATEWAY_READONLY_ENABLED`(或设为 `false`),重新部署 API 与 Worker。
|
||||
- 以 Gitea Actions 部署成功为准确认生效(不要用数据库连接建立时间判断,连接池每 5 分钟会重建连接)。
|
||||
|
||||
> 顺序错误的后果:开关开着就删表,查表报"表不存在"按失败处理,测试环境**所有卡**的写操作都会被拦截,直到关闭开关。
|
||||
|
||||
### 3. 删表
|
||||
|
||||
- 新建一对迁移:up 执行 `DROP TABLE tb_gateway_readonly_card`,down 重建该表。
|
||||
- 不修改原建表迁移。
|
||||
|
||||
### 4. 清理代码(可选)
|
||||
|
||||
- 删除写操作前的查表逻辑与开关配置。开关关闭时这段代码不生效,不删也不影响。
|
||||
|
||||
## 其他说明
|
||||
|
||||
- 生产:不配置开关,不查表。若该版本发布到生产,建表迁移会建出空表,不影响功能,后续删表迁移随之执行即可。
|
||||
- 数据量:几万行(ICCID + 备注),删表瞬间完成,无锁表风险。
|
||||
- 留痕:拦截记录在日志、停复机记录与审计中,与该表无关,删表不影响已有记录。
|
||||
2
go.mod
2
go.mod
@@ -48,6 +48,7 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/golang/snappy v0.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
@@ -61,6 +62,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/nsqio/go-nsq v1.1.0 // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -106,6 +106,8 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -175,6 +177,8 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/nsqio/go-nsq v1.1.0 h1:PQg+xxiUjA7V+TLdXw7nVrJ5Jbl3sN86EhGCQj4+FYE=
|
||||
github.com/nsqio/go-nsq v1.1.0/go.mod h1:vKq36oyeVXgsS5Q8YEO7WghqidAVXQlcFxzQbQTuDEY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -42,6 +43,7 @@ type ChangeAudit struct {
|
||||
PersonalOpenIDs []PersonalCustomerOpenIDChange
|
||||
PersonalDevices []PersonalCustomerDeviceChange
|
||||
PersonalICCIDs []PersonalCustomerICCIDChange
|
||||
PhoneAssociations []PhoneAssetAssociationChange
|
||||
Role *model.Role
|
||||
Roles []RoleChange
|
||||
Permissions []PermissionChange
|
||||
@@ -59,6 +61,24 @@ type PersonalCustomerPhoneChange struct {
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// PhoneAssetAssociationChange 保存手机号—资产关联资源的前后变化。
|
||||
// 手机号一律传入脱敏值:关联用例不得把完整手机号写入审计(ENG-LOG-001)。
|
||||
// 关联指向的资产以资产类型与资产 ID 声明,由 Writer 组装为参考资源。
|
||||
type PhoneAssetAssociationChange struct {
|
||||
AssociationID uint
|
||||
PhoneMasked string
|
||||
AssetType string
|
||||
AssetID uint
|
||||
AssetDisplayName string
|
||||
Status int
|
||||
Source string
|
||||
InvalidatedAt *time.Time
|
||||
InvalidationMethod string
|
||||
InvalidationReason string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// PersonalCustomerOpenIDChange 保存个人客户微信主体资源变化。
|
||||
type PersonalCustomerOpenIDChange struct {
|
||||
OpenID *model.PersonalCustomerOpenID
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
@@ -17,21 +18,30 @@ import (
|
||||
|
||||
// ApprovalDecisionHandler 将渠道无关审批终态应用到员工线下代充值业务。
|
||||
type ApprovalDecisionHandler struct {
|
||||
db *gorm.DB
|
||||
posting *walletapp.PostingService
|
||||
audit RechargeAuditWriter
|
||||
db *gorm.DB
|
||||
posting *walletapp.PostingService
|
||||
audit RechargeAuditWriter
|
||||
billCreation *employeecollectionapp.BillCreationService
|
||||
}
|
||||
|
||||
// NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。
|
||||
func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService, audit RechargeAuditWriter) *ApprovalDecisionHandler {
|
||||
return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit}
|
||||
func NewApprovalDecisionHandler(
|
||||
db *gorm.DB,
|
||||
posting *walletapp.PostingService,
|
||||
audit RechargeAuditWriter,
|
||||
billCreation *employeecollectionapp.BillCreationService,
|
||||
) *ApprovalDecisionHandler {
|
||||
return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit, billCreation: billCreation}
|
||||
}
|
||||
|
||||
// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。
|
||||
// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态(含审批提交失败)不修改钱包。
|
||||
func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
|
||||
if h == nil || h.db == nil || h.posting == nil || h.audit == nil {
|
||||
return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置")
|
||||
}
|
||||
if h.billCreation == nil {
|
||||
return errors.New(errors.CodeInternalError, "员工代收款建账能力未配置")
|
||||
}
|
||||
if event.BusinessType != constants.ApprovalBusinessTypeOfflineRecharge || event.BusinessID == 0 || event.InstanceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "员工线下代充值审批终态参数无效")
|
||||
}
|
||||
@@ -60,6 +70,12 @@ func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.
|
||||
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusClosed, "企业微信审批已删除")
|
||||
case constants.ApprovalDecisionRevokedAfterApproved:
|
||||
return nil
|
||||
case constants.ApprovalDecisionSubmissionFailed:
|
||||
reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID)
|
||||
if err != nil || !failed {
|
||||
return err
|
||||
}
|
||||
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusSubmissionFailed, reason)
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的线下代充值审批终态")
|
||||
}
|
||||
@@ -99,6 +115,10 @@ func (h *ApprovalDecisionHandler) applyApproved(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 员工代收款建账:锚点为“平台账号发起的线下充值入账成功”,按来源唯一键 recharge:{id} 幂等。
|
||||
if _, err := h.billCreation.CreateFromRechargeInTx(ctx, tx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.Status == constants.RechargeStatusCompleted && posting.AlreadyApplied {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -81,7 +82,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
|
||||
command.MerchantIdentity = strings.TrimSpace(command.MerchantIdentity)
|
||||
command.ThirdPartyTradeNo = strings.TrimSpace(command.ThirdPartyTradeNo)
|
||||
if command.PaymentNo == "" || command.ConfigID == 0 || command.PaidAt.IsZero() {
|
||||
if command.PaymentNo == "" || command.PaidAt.IsZero() {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
|
||||
}
|
||||
|
||||
@@ -91,6 +92,9 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payment.MerchantID == nil && command.ConfigID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
|
||||
}
|
||||
alreadyConfirmed, err := domain.ValidatePaymentConfirmation(toDomainConfirmationFacts(payment, recharge, command))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -114,6 +118,10 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
if paymentUpdate.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "代理充值支付单状态已变化")
|
||||
}
|
||||
|
||||
if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, paidAt); err != nil {
|
||||
return err
|
||||
}
|
||||
rechargeUpdate := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
|
||||
Where("id = ? AND status IN ?", recharge.ID, []int{constants.RechargeStatusPending, constants.RechargeStatusClosed}).
|
||||
Updates(map[string]any{"status": constants.RechargeStatusPaid, "payment_transaction_id": command.ThirdPartyTradeNo, "paid_at": paidAt})
|
||||
@@ -127,7 +135,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
EventID: "agent-recharge:" + strconv.FormatUint(uint64(recharge.ID), 10) + ":payment-confirmed",
|
||||
RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo, PaymentID: payment.ID, PaymentNo: payment.PaymentNo,
|
||||
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount,
|
||||
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
|
||||
PaymentMethod: payment.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
|
||||
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
ParentEventID: command.ParentEventID,
|
||||
}
|
||||
@@ -186,7 +194,9 @@ func toDomainConfirmationFacts(payment *model.Payment, recharge *model.AgentRech
|
||||
OrderType: payment.OrderType, ExpectedOrderType: model.PaymentOrderTypeAgentRecharge,
|
||||
PaymentMethod: payment.PaymentMethod, RechargePaymentMethod: recharge.PaymentMethod, RechargePaymentChannel: rechargeChannel,
|
||||
PaymentConfigID: paymentConfigID, RechargePaymentConfigID: rechargeConfigID, ConfirmedConfigID: command.ConfigID,
|
||||
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
|
||||
FrozenMerchant: payment.MerchantID != nil, FrozenMerchantPaymentMethod: payment.MerchantPaymentMethodSnapshot,
|
||||
FrozenMerchantProviderType: payment.MerchantProviderTypeSnapshot,
|
||||
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
|
||||
PaymentAmount: payment.Amount, RechargeAmount: recharge.Amount, ConfirmedAmount: command.Amount,
|
||||
PaymentOrderID: payment.OrderID, RechargeID: recharge.ID, PaymentState: domain.PaymentState(payment.Status),
|
||||
RechargeStatus: recharge.Status, StoredTradeNo: payment.ThirdPartyTradeNo, ConfirmedTradeNo: command.ThirdPartyTradeNo,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
@@ -23,7 +24,12 @@ type CreateOfflineCommand struct {
|
||||
RechargeNo string
|
||||
Amount int64
|
||||
PaymentVoucherKeys []string
|
||||
Remark string
|
||||
OtherVoucherKeys []string
|
||||
// OfflinePaymentMethodID 是提交人选择的线下收款方式字典项 ID。
|
||||
OfflinePaymentMethodID uint
|
||||
// ExternalTransactionNo 是人工确认后的交易流水号,独立于在线渠道第三方交易号。
|
||||
ExternalTransactionNo string
|
||||
Remark string
|
||||
}
|
||||
|
||||
// CreateOfflineResult 返回已原子保存的业务申请和初始审批状态。
|
||||
@@ -46,6 +52,106 @@ func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port, audit Rec
|
||||
return &OfflineCreationService{db: db, approval: approval, audit: audit}
|
||||
}
|
||||
|
||||
// TriggerHistorical 为历史待审批线下代充值补发一次企业微信审批。
|
||||
func (s *OfflineCreationService) TriggerHistorical(ctx context.Context, recordID uint) (*CreateOfflineResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || recordID == 0 {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
|
||||
}
|
||||
|
||||
var record model.AgentRechargeRecord
|
||||
if err := s.db.WithContext(ctx).First(&record, recordID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "充值记录不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值申请失败")
|
||||
}
|
||||
if record.PaymentMethod != constants.RechargeMethodOffline || record.Status != constants.RechargeStatusPending || record.ApprovalInstanceID != nil {
|
||||
return nil, errors.New(errors.CodeConflict, "充值申请状态不允许补发审批")
|
||||
}
|
||||
account, shop, wallet, err := s.loadHistoricalFacts(ctx, &record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, SubmitterAccountID: record.UserID,
|
||||
CorrelationID: record.RechargeNo,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
command := CreateOfflineCommand{
|
||||
SubmitterAccountID: record.UserID, SubmitterUserType: account.UserType, ShopID: record.ShopID,
|
||||
RechargeNo: record.RechargeNo, Amount: record.Amount,
|
||||
PaymentVoucherKeys: []string(record.PaymentVoucherKey), Remark: record.Remark,
|
||||
OtherVoucherKeys: []string(record.OtherVoucherKeys),
|
||||
}
|
||||
if record.ExternalTransactionNo != nil {
|
||||
command.ExternalTransactionNo = *record.ExternalTransactionNo
|
||||
}
|
||||
// 补发审批使用历史记录已冻结的收款方式快照,不回查当前字典,避免历史材料被字典变更改写。
|
||||
var frozenCode, frozenName string
|
||||
if record.OfflinePaymentMethodCode != nil {
|
||||
frozenCode = *record.OfflinePaymentMethodCode
|
||||
}
|
||||
if record.OfflinePaymentMethodName != nil {
|
||||
frozenName = *record.OfflinePaymentMethodName
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, frozenCode, frozenName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var approvalStatus int
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current model.AgentRechargeRecord
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, recordID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "充值记录不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定历史线下代充值申请失败")
|
||||
}
|
||||
if current.PaymentMethod != constants.RechargeMethodOffline || current.Status != constants.RechargeStatusPending || current.ApprovalInstanceID != nil {
|
||||
return errors.New(errors.CodeConflict, "充值申请状态不允许补发审批")
|
||||
}
|
||||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeOfflineRecharge,
|
||||
BusinessID: current.ID, SubmitterAccountID: current.UserID,
|
||||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||||
CorrelationID: current.RechargeNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
|
||||
Where("id = ? AND payment_method = ? AND status = ? AND approval_instance_id IS NULL", current.ID, constants.RechargeMethodOffline, constants.RechargeStatusPending).
|
||||
Update("approval_instance_id", reference.InstanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联线下代充值审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "线下代充值审批实例关联已变化")
|
||||
}
|
||||
current.ApprovalInstanceID = &reference.InstanceID
|
||||
record = current
|
||||
approvalStatus = reference.Status
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败")
|
||||
}
|
||||
return s.audit.WriteAgentRecharge(ctx, tx, RechargeAudit{
|
||||
ActionCode: constants.AuditActionAgentRechargeCreated, Summary: "补发员工线下代充值审批",
|
||||
Record: ¤t, Approval: &instance, Wallet: wallet,
|
||||
AfterData: map[string]any{"status": current.Status, "approval_instance_id": current.ApprovalInstanceID},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CreateOfflineResult{
|
||||
Record: &record, ShopName: shop.ShopName, SubmitterName: account.Username, ApprovalStatus: approvalStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。
|
||||
func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
|
||||
@@ -65,20 +171,31 @@ func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOffl
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paymentChannel := constants.RechargeMethodOffline
|
||||
record := &model.AgentRechargeRecord{
|
||||
UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID,
|
||||
RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount,
|
||||
PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel,
|
||||
PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark),
|
||||
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
externalTransactionNo := strings.TrimSpace(command.ExternalTransactionNo)
|
||||
var record *model.AgentRechargeRecord
|
||||
var approvalStatus int
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
paymentMethod, err := loadEnabledOfflinePaymentMethod(ctx, tx, command.OfflinePaymentMethodID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, paymentMethod.Code, paymentMethod.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record = &model.AgentRechargeRecord{
|
||||
UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID,
|
||||
RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount,
|
||||
PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel,
|
||||
PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark),
|
||||
ExternalTransactionNo: &externalTransactionNo,
|
||||
OfflinePaymentMethodID: &paymentMethod.ID,
|
||||
OfflinePaymentMethodCode: &paymentMethod.Code,
|
||||
OfflinePaymentMethodName: &paymentMethod.Name,
|
||||
OtherVoucherKeys: model.StringJSONBArray(command.OtherVoucherKeys),
|
||||
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败")
|
||||
}
|
||||
@@ -129,17 +246,103 @@ func validateCreateOfflineCommand(command CreateOfflineCommand) error {
|
||||
if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount {
|
||||
return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
|
||||
}
|
||||
if len(command.PaymentVoucherKeys) == 0 || len(command.PaymentVoucherKeys) > 5 {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值必须上传 1 至 5 个支付凭证")
|
||||
if command.OfflinePaymentMethodID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
|
||||
}
|
||||
for _, key := range command.PaymentVoucherKeys {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空")
|
||||
}
|
||||
if err := validateRechargeTransactionNo(command.ExternalTransactionNo); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateVoucherKeys(command.PaymentVoucherKeys, 1, constants.AgentRechargePaymentVoucherMaxCount, "线下充值必须上传 1 至 5 个支付凭证"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateVoucherKeys(command.OtherVoucherKeys, 0, constants.AgentRechargeOtherVoucherMaxCount, "线下充值其他凭证最多 5 个")
|
||||
}
|
||||
|
||||
// validateRechargeTransactionNo 校验交易流水号必填且不超过长度上限;不参与去重与幂等判定。
|
||||
func validateRechargeTransactionNo(value string) error {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值必须填写交易流水号")
|
||||
}
|
||||
if len([]rune(trimmed)) > constants.AgentRechargeExternalTransactionNoMaxLength {
|
||||
return errors.New(errors.CodeInvalidParam, "交易流水号长度超出限制")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateVoucherKeys 校验凭证对象键数量与内容,minCount 为 0 时允许为空。
|
||||
func validateVoucherKeys(keys []string, minCount, maxCount int, message string) error {
|
||||
if len(keys) < minCount || len(keys) > maxCount {
|
||||
return errors.New(errors.CodeInvalidParam, message)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
trimmed := strings.TrimSpace(key)
|
||||
if trimmed == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能为空")
|
||||
}
|
||||
if len([]rune(trimmed)) > constants.AgentRechargeVoucherKeyMaxLength {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键长度超出限制")
|
||||
}
|
||||
if _, exists := seen[trimmed]; exists {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能重复")
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadEnabledOfflinePaymentMethod 读取启用的线下收款方式字典项;不存在或已停用一律拒绝。
|
||||
// 仅校验存在性与启停,不做编码或名称的二次改写,快照以字典当前值为准。
|
||||
func loadEnabledOfflinePaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
|
||||
if id == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
|
||||
}
|
||||
var paymentMethod model.EmployeeCollectionPaymentMethod
|
||||
if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
|
||||
}
|
||||
if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
|
||||
}
|
||||
return &paymentMethod, nil
|
||||
}
|
||||
|
||||
func (s *OfflineCreationService) loadHistoricalFacts(
|
||||
ctx context.Context, record *model.AgentRechargeRecord,
|
||||
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
|
||||
if record == nil || record.UserID == 0 || record.ShopID == 0 || record.AgentWalletID == 0 {
|
||||
return nil, nil, nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var account model.Account
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", record.UserID, constants.StatusEnabled).First(&account).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, nil, errors.New(errors.CodeForbidden, "原创建账号不可用")
|
||||
}
|
||||
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值创建人失败")
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", record.ShopID).First(&shop).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
|
||||
}
|
||||
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值目标店铺失败")
|
||||
}
|
||||
var wallet model.AgentWallet
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("id = ? AND shop_id = ? AND wallet_type = ? AND status = ?", record.AgentWalletID, record.ShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).
|
||||
First(&wallet).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, nil, errors.New(errors.CodeWalletNotFound, "原充值主钱包不存在或不可用")
|
||||
}
|
||||
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值主钱包失败")
|
||||
}
|
||||
return &account, &shop, &wallet, nil
|
||||
}
|
||||
|
||||
func (s *OfflineCreationService) loadCreationFacts(
|
||||
ctx context.Context,
|
||||
command CreateOfflineCommand,
|
||||
@@ -170,7 +373,7 @@ func (s *OfflineCreationService) loadCreationFacts(
|
||||
return &account, &shop, &wallet, nil
|
||||
}
|
||||
|
||||
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) {
|
||||
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName, paymentMethodCode, paymentMethodName string) ([]byte, []byte, error) {
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||||
"account_id": command.SubmitterAccountID, "account_name": submitterName,
|
||||
"user_type": command.SubmitterUserType,
|
||||
@@ -179,15 +382,19 @@ func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopN
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值提交人快照失败")
|
||||
}
|
||||
requestSnapshot, err := sonic.Marshal(map[string]any{
|
||||
constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo),
|
||||
constants.ApprovalFieldShopID: command.ShopID,
|
||||
constants.ApprovalFieldShopName: shopName,
|
||||
constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100),
|
||||
constants.ApprovalFieldAmountCent: command.Amount,
|
||||
constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys,
|
||||
constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark),
|
||||
constants.ApprovalFieldSubmitterID: command.SubmitterAccountID,
|
||||
constants.ApprovalFieldSubmitterName: submitterName,
|
||||
constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo),
|
||||
constants.ApprovalFieldShopID: command.ShopID,
|
||||
constants.ApprovalFieldShopName: shopName,
|
||||
constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100),
|
||||
constants.ApprovalFieldAmountCent: command.Amount,
|
||||
constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys,
|
||||
constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark),
|
||||
constants.ApprovalFieldSubmitterID: command.SubmitterAccountID,
|
||||
constants.ApprovalFieldSubmitterName: submitterName,
|
||||
constants.ApprovalFieldOfflinePaymentMethod: paymentMethodName,
|
||||
constants.ApprovalFieldOfflinePaymentMethodCode: paymentMethodCode,
|
||||
constants.ApprovalFieldExternalTransactionNo: strings.TrimSpace(command.ExternalTransactionNo),
|
||||
constants.ApprovalFieldOtherVoucherKey: command.OtherVoucherKeys,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值审批业务快照失败")
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -46,20 +47,28 @@ type AvailablePaymentMethodsResult struct {
|
||||
|
||||
// OnlineCreationService 创建代理在线扫码充值单。
|
||||
type OnlineCreationService struct {
|
||||
db *gorm.DB
|
||||
wechat OnlinePaymentPort
|
||||
alipay OnlinePaymentPort
|
||||
audit PaymentAuditWriter
|
||||
db *gorm.DB
|
||||
runtime *merchantpayment.RuntimeLoader
|
||||
wechat OnlinePaymentPort
|
||||
alipay OnlinePaymentPort
|
||||
fuiou OnlinePaymentPort
|
||||
audit PaymentAuditWriter
|
||||
policy *OnlinePaymentMethodPolicy
|
||||
}
|
||||
|
||||
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入两个渠道 Adapter。
|
||||
func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
|
||||
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, audit: audit}
|
||||
// SetPaymentMethodPolicy 注入代理在线自充允许范围策略。
|
||||
func (s *OnlineCreationService) SetPaymentMethodPolicy(policy *OnlinePaymentMethodPolicy) {
|
||||
s.policy = policy
|
||||
}
|
||||
|
||||
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入运行时路由和三个渠道 Adapter。
|
||||
func NewOnlineCreationService(db *gorm.DB, runtime *merchantpayment.RuntimeLoader, wechat, alipay, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
|
||||
return &OnlineCreationService{db: db, runtime: runtime, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit}
|
||||
}
|
||||
|
||||
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
|
||||
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
|
||||
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.audit == nil {
|
||||
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil || s.policy == nil {
|
||||
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
|
||||
}
|
||||
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
|
||||
@@ -78,14 +87,19 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeInternalError, err, "生成在线充值请求指纹失败")
|
||||
}
|
||||
// 幂等回放先于允许范围门禁:同一 request_id 的重试属于既有单,不是新单,
|
||||
// 不因允许范围变更被拒绝;允许范围只拦截会真正新建充值单与支付单的路径。
|
||||
if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found {
|
||||
return replay, err
|
||||
}
|
||||
account, shop, wallet, config, adapter, err := s.loadCreationFacts(ctx, command)
|
||||
if err := s.policy.IsAllowed(ctx, command.PaymentMethod); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config)
|
||||
result, config, adapter, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet)
|
||||
if err != nil {
|
||||
if replay, found, replayErr := s.loadReplay(ctx, command, fingerprint); replayErr != nil || found {
|
||||
return replay, replayErr
|
||||
@@ -98,6 +112,11 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
|
||||
ExpireAt: *result.Payment.ExpireAt, PayerClientIP: command.PayerClientIP, Config: config,
|
||||
})
|
||||
if err != nil {
|
||||
if merchantpayment.ShouldDisableMerchant(err) {
|
||||
if disableErr := merchantpayment.DisableMerchantAfterExplicitFailure(ctx, s.db, result.Payment, "payment_precreate_failed", time.Now()); disableErr != nil {
|
||||
return nil, disableErr
|
||||
}
|
||||
}
|
||||
if !isUnknownPaymentResult(err) {
|
||||
if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, closeErr, "支付链接生成失败且关闭本地订单失败")
|
||||
@@ -124,26 +143,56 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AvailablePaymentMethods 按固定顺序返回配置完整的在线支付方式。
|
||||
// AvailablePaymentMethods 按允许范围与可用商户池交集返回在线支付方式。
|
||||
func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) {
|
||||
result := AvailablePaymentMethodsResult{
|
||||
Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount,
|
||||
}
|
||||
if userType != constants.UserTypeAgent {
|
||||
return result, apperrors.New(apperrors.CodeForbidden, "仅代理账号可以查询在线支付方式")
|
||||
if s == nil || s.db == nil || s.runtime == nil {
|
||||
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
|
||||
}
|
||||
var config model.WechatConfig
|
||||
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return result, nil
|
||||
if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform {
|
||||
return result, apperrors.New(apperrors.CodeForbidden, "仅代理或平台账号可以查询在线支付方式")
|
||||
}
|
||||
if s.policy == nil {
|
||||
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值允许范围策略未配置")
|
||||
}
|
||||
allowed, err := s.policy.AllowedMethods(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, method := range allowed {
|
||||
var merchants []model.PaymentMerchant
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&model.PaymentMerchant{}).
|
||||
Joins("JOIN tb_payment_merchant_pool_member AS member ON member.merchant_id = tb_payment_merchant.id AND member.deleted_at IS NULL").
|
||||
Joins("JOIN tb_payment_merchant_pool AS pool ON pool.id = member.pool_id AND pool.deleted_at IS NULL").
|
||||
Where("pool.payment_method = ? AND pool.status = ? AND tb_payment_merchant.payment_method = ? AND tb_payment_merchant.status = ?", method, model.PaymentMerchantStatusEnabled, method, model.PaymentMerchantStatusEnabled).
|
||||
Order("member.sort_order ASC").Find(&merchants).Error
|
||||
if err != nil {
|
||||
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询代理在线支付商户池失败")
|
||||
}
|
||||
for index := range merchants {
|
||||
// 仅微信直连(v3/v2)商户需要全局授权配置中的 AppID;富友商户不依赖该配置。
|
||||
var authorization *model.WechatAuthorization
|
||||
merchant := &merchants[index]
|
||||
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
|
||||
var authErr error
|
||||
authorization, authErr = s.runtime.LoadAuthorization(ctx)
|
||||
if authErr != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
config, configErr := merchantpayment.MerchantConfig(merchant, authorization)
|
||||
if configErr != nil {
|
||||
continue
|
||||
}
|
||||
adapter := s.adapter(method, config)
|
||||
if adapter != nil && adapter.Available(config) {
|
||||
result.Methods = append(result.Methods, method)
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
|
||||
}
|
||||
if s.wechat.Available(&config) {
|
||||
result.Methods = append(result.Methods, constants.RechargeMethodWechat)
|
||||
}
|
||||
if s.alipay.Available(&config) {
|
||||
result.Methods = append(result.Methods, constants.RechargeMethodAlipay)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -151,40 +200,29 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use
|
||||
func (s *OnlineCreationService) loadCreationFacts(
|
||||
ctx context.Context,
|
||||
command CreateOnlineCommand,
|
||||
) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, error) {
|
||||
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
|
||||
var account model.Account
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND user_type = ? AND status = ?", command.AccountID, constants.UserTypeAgent, constants.StatusEnabled).First(&account).Error; err != nil || account.ShopID == nil || *account.ShopID != command.CurrentShopID {
|
||||
if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
|
||||
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
|
||||
}
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
|
||||
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.CurrentShopID, constants.StatusEnabled).First(&shop).Error; err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
|
||||
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
|
||||
}
|
||||
var wallet model.AgentWallet
|
||||
if err := s.db.WithContext(ctx).Where("shop_id = ? AND wallet_type = ? AND status = ?", command.CurrentShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).First(&wallet).Error; err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
|
||||
return nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
|
||||
}
|
||||
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
|
||||
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
|
||||
}
|
||||
var config model.WechatConfig
|
||||
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
|
||||
}
|
||||
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
|
||||
}
|
||||
adapter := s.adapter(command.PaymentMethod)
|
||||
if adapter == nil || !adapter.Available(&config) {
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
|
||||
}
|
||||
return &account, &shop, &wallet, &config, adapter, nil
|
||||
return &account, &shop, &wallet, nil
|
||||
}
|
||||
|
||||
func (s *OnlineCreationService) createLocalFacts(
|
||||
@@ -194,36 +232,58 @@ func (s *OnlineCreationService) createLocalFacts(
|
||||
account *model.Account,
|
||||
shop *model.Shop,
|
||||
wallet *model.AgentWallet,
|
||||
config *model.WechatConfig,
|
||||
) (*CreateOnlineResult, error) {
|
||||
) (*CreateOnlineResult, *model.WechatConfig, OnlinePaymentPort, error) {
|
||||
rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
paymentNo, err := newBusinessNo("PAY", fmt.Sprintf("%d", time.Now().UnixMilli()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expireMinutes := config.AliPayExpireMinutes
|
||||
if expireMinutes <= 0 {
|
||||
expireMinutes = model.DefaultAliPayExpireMinutes
|
||||
}
|
||||
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
|
||||
channel, requestID := command.PaymentMethod, command.RequestID
|
||||
record := &model.AgentRechargeRecord{
|
||||
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
|
||||
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
|
||||
PaymentConfigID: &config.ID, Status: constants.RechargeStatusPending,
|
||||
RequestID: &requestID, RequestFingerprint: &fingerprint,
|
||||
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
payment := &model.Payment{
|
||||
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
|
||||
PaymentMethod: command.PaymentMethod, MerchantIdentity: paymentMerchantIdentity(command.PaymentMethod, config),
|
||||
Amount: command.Amount, Status: model.PaymentRecordStatusPending,
|
||||
PaymentConfigID: &config.ID, ExpireAt: &expireAt,
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var record *model.AgentRechargeRecord
|
||||
var payment *model.Payment
|
||||
var config *model.WechatConfig
|
||||
var adapter OnlinePaymentPort
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
route, err := s.runtime.SelectForNewPaymentWithTx(ctx, tx, command.PaymentMethod, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 仅微信直连(v3/v2)商户需要全局授权配置中的 AppID;富友商户不依赖该配置。
|
||||
var authorization *model.WechatAuthorization
|
||||
if route.Merchant.ProviderType == model.ProviderTypeWechat || route.Merchant.ProviderType == model.ProviderTypeWechatV2 {
|
||||
authorization, err = s.runtime.LoadAuthorization(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
config, err = merchantpayment.MerchantConfig(route.Merchant, authorization)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adapter = s.adapter(command.PaymentMethod, config)
|
||||
if adapter == nil || !adapter.Available(config) {
|
||||
return apperrors.New(apperrors.CodeNoPaymentConfig)
|
||||
}
|
||||
expireMinutes := config.AliPayExpireMinutes
|
||||
if expireMinutes <= 0 {
|
||||
expireMinutes = model.DefaultAliPayExpireMinutes
|
||||
}
|
||||
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
|
||||
channel, requestID := paymentChannel(command.PaymentMethod, config), command.RequestID
|
||||
record = &model.AgentRechargeRecord{
|
||||
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
|
||||
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
|
||||
Status: constants.RechargeStatusPending, RequestID: &requestID, RequestFingerprint: &fingerprint,
|
||||
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
payment = &model.Payment{
|
||||
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
|
||||
PaymentMethod: command.PaymentMethod, Amount: command.Amount,
|
||||
Status: model.PaymentRecordStatusPending, ExpireAt: &expireAt,
|
||||
}
|
||||
merchantpayment.FreezeRoute(payment, route)
|
||||
if err := tx.Create(record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -231,15 +291,22 @@ func (s *OnlineCreationService) createLocalFacts(
|
||||
if err := tx.Create(payment).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
|
||||
if err := s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
|
||||
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建代理充值支付记录",
|
||||
Payment: payment, Recharge: record, AfterData: map[string]any{"status": payment.Status},
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return merchantpayment.RecordRoutingAttempt(ctx, tx, payment)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
|
||||
var appErr *apperrors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
|
||||
}
|
||||
return &CreateOnlineResult{Recharge: record, Payment: payment}, nil
|
||||
return &CreateOnlineResult{Recharge: record, Payment: payment}, config, adapter, nil
|
||||
}
|
||||
|
||||
func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string {
|
||||
@@ -247,6 +314,9 @@ func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) s
|
||||
return ""
|
||||
}
|
||||
if paymentMethod == constants.RechargeMethodWechat {
|
||||
if config.ProviderType == model.ProviderTypeFuiou {
|
||||
return config.FyMchntCd
|
||||
}
|
||||
return config.WxMchID
|
||||
}
|
||||
if paymentMethod == constants.RechargeMethodAlipay {
|
||||
@@ -255,6 +325,14 @@ func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) s
|
||||
return ""
|
||||
}
|
||||
|
||||
// paymentChannel 返回实际支付渠道:富友配置下微信业务方式落库为 fuiou,其余与业务方式一致。
|
||||
func paymentChannel(paymentMethod string, config *model.WechatConfig) string {
|
||||
if paymentMethod == constants.RechargeMethodWechat && config != nil && config.ProviderType == model.ProviderTypeFuiou {
|
||||
return model.ProviderTypeFuiou
|
||||
}
|
||||
return paymentMethod
|
||||
}
|
||||
|
||||
func (s *OnlineCreationService) loadReplay(
|
||||
ctx context.Context,
|
||||
command CreateOnlineCommand,
|
||||
@@ -316,8 +394,11 @@ func (s *OnlineCreationService) closeFailedCreation(ctx context.Context, result
|
||||
})
|
||||
}
|
||||
|
||||
func (s *OnlineCreationService) adapter(paymentMethod string) OnlinePaymentPort {
|
||||
func (s *OnlineCreationService) adapter(paymentMethod string, config *model.WechatConfig) OnlinePaymentPort {
|
||||
if paymentMethod == constants.RechargeMethodWechat {
|
||||
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
|
||||
return s.fuiou
|
||||
}
|
||||
return s.wechat
|
||||
}
|
||||
if paymentMethod == constants.RechargeMethodAlipay {
|
||||
|
||||
@@ -34,6 +34,8 @@ const (
|
||||
OnlinePaymentStateClosed = "closed"
|
||||
// OnlinePaymentStateUnknown 表示渠道状态暂时无法确定。
|
||||
OnlinePaymentStateUnknown = "unknown"
|
||||
// OnlinePaymentStateNotFound 表示渠道明确没有这笔订单;恢复任务在支付单过期后按关闭处理。
|
||||
OnlinePaymentStateNotFound = "not_found"
|
||||
)
|
||||
|
||||
// OnlinePaymentRequest 描述生成支付链接与主动查单所需的最小事实。
|
||||
|
||||
58
internal/application/agentrecharge/online_payment_policy.go
Normal file
58
internal/application/agentrecharge/online_payment_policy.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package agentrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// OnlinePaymentMethodConfigReader 提供代理在线自充允许范围的严格读取能力。
|
||||
type OnlinePaymentMethodConfigReader interface {
|
||||
GetStrict(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
// OnlinePaymentMethodPolicy 将受控配置值映射为对外可见的线上支付方式集合。
|
||||
type OnlinePaymentMethodPolicy struct {
|
||||
reader OnlinePaymentMethodConfigReader
|
||||
}
|
||||
|
||||
// NewOnlinePaymentMethodPolicy 创建代理在线自充允许范围策略。
|
||||
func NewOnlinePaymentMethodPolicy(reader OnlinePaymentMethodConfigReader) *OnlinePaymentMethodPolicy {
|
||||
return &OnlinePaymentMethodPolicy{reader: reader}
|
||||
}
|
||||
|
||||
// AllowedMethods 严格读取允许范围;配置缺失使用注册默认值,非法值失败关闭。
|
||||
func (p *OnlinePaymentMethodPolicy) AllowedMethods(ctx context.Context) ([]string, error) {
|
||||
if p == nil || p.reader == nil {
|
||||
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值允许范围未配置")
|
||||
}
|
||||
value, err := p.reader.GetStrict(ctx, constants.SystemConfigAgentSelfRechargeAllowedMethods)
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "读取代理在线充值允许范围失败")
|
||||
}
|
||||
switch value {
|
||||
case constants.AgentSelfRechargeAllowedWechatOnly:
|
||||
return []string{constants.RechargeMethodWechat}, nil
|
||||
case constants.AgentSelfRechargeAllowedAlipayOnly:
|
||||
return []string{constants.RechargeMethodAlipay}, nil
|
||||
case constants.AgentSelfRechargeAllowedBoth:
|
||||
return []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay}, nil
|
||||
default:
|
||||
return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "代理在线充值允许范围值非法")
|
||||
}
|
||||
}
|
||||
|
||||
// IsAllowed 判断业务支付方式是否在当前受控允许范围内。
|
||||
func (p *OnlinePaymentMethodPolicy) IsAllowed(ctx context.Context, method string) error {
|
||||
methods, err := p.AllowedMethods(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, allowed := range methods {
|
||||
if allowed == method {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return apperrors.New(apperrors.CodeNoPaymentConfig, "当前支付方式不在代理在线充值允许范围内")
|
||||
}
|
||||
109
internal/application/agentrecharge/payment_voucher_ocr.go
Normal file
109
internal/application/agentrecharge/payment_voucher_ocr.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package agentrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
||||
)
|
||||
|
||||
// PaymentVoucherObjectStore 提供付款凭证附件的元数据与内容读取能力。
|
||||
type PaymentVoucherObjectStore interface {
|
||||
Stat(ctx context.Context, key string) (*storage.ObjectMetadata, error)
|
||||
Download(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// PaymentVoucherRecognizer 是付款凭证识别的外部能力接缝,只暴露支付单号。
|
||||
type PaymentVoucherRecognizer interface {
|
||||
ExtractPaymentVoucherOrderNumber(ctx context.Context, imageBase64 string) (string, error)
|
||||
}
|
||||
|
||||
// PaymentVoucherRecognitionResult 是识别结果中本系统消费的唯一字段。
|
||||
type PaymentVoucherRecognitionResult struct {
|
||||
// ExternalTransactionNo 是识别出的支付单号,仅作交易流水号表单预填值。
|
||||
ExternalTransactionNo string
|
||||
}
|
||||
|
||||
// PaymentVoucherOCRService 按附件对象键识别付款凭证,只返回交易流水号预填值。
|
||||
// 识别不创建申请、不写入任何资金事实字段;其余识别字段一律不返回、不落库。
|
||||
//
|
||||
// ENG-AUDIT-001 事实决定:识别调用不产生状态变更、不涉及资金与权限,因此
|
||||
// 不写 Audit Event、Domain Ledger、Integration Log 与 Outbox;调用记录由 Access Log 与
|
||||
// Gateway 客户端的路径级日志承载,识别载荷与原始结果不进入任何一类事实。
|
||||
type PaymentVoucherOCRService struct {
|
||||
objects PaymentVoucherObjectStore
|
||||
recognizer PaymentVoucherRecognizer
|
||||
}
|
||||
|
||||
// NewPaymentVoucherOCRService 创建付款凭证识别用例。
|
||||
func NewPaymentVoucherOCRService(objects PaymentVoucherObjectStore, recognizer PaymentVoucherRecognizer) *PaymentVoucherOCRService {
|
||||
return &PaymentVoucherOCRService{objects: objects, recognizer: recognizer}
|
||||
}
|
||||
|
||||
// Recognize 校验附件为图片后调用识别能力,只返回交易流水号预填值。
|
||||
// 非图片、对象不存在、内容为空或识别失败都返回明确失败,不阻断人工填写。
|
||||
func (s *PaymentVoucherOCRService) Recognize(ctx context.Context, objectKey string) (*PaymentVoucherRecognitionResult, error) {
|
||||
if s == nil || s.objects == nil || s.recognizer == nil {
|
||||
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "付款凭证识别能力未配置")
|
||||
}
|
||||
key := strings.TrimSpace(objectKey)
|
||||
if key == "" || len([]rune(key)) > constants.AgentRechargeVoucherKeyMaxLength {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象键无效")
|
||||
}
|
||||
metadata, err := s.objects.Stat(ctx, key)
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "付款凭证对象不存在或不可读")
|
||||
}
|
||||
if metadata == nil || metadata.Size <= 0 {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象内容为空")
|
||||
}
|
||||
if metadata.Size > constants.AgentRechargeVoucherMaxBytes {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证图片超过允许大小")
|
||||
}
|
||||
reader, err := s.objects.Download(ctx, key)
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "读取付款凭证对象失败")
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
content, err := io.ReadAll(io.LimitReader(reader, constants.AgentRechargeVoucherMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "读取付款凭证内容失败")
|
||||
}
|
||||
if int64(len(content)) > constants.AgentRechargeVoucherMaxBytes {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证图片超过允许大小")
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象内容为空")
|
||||
}
|
||||
if !isPaymentVoucherImage(metadata.ContentType, content) {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证必须是图片文件")
|
||||
}
|
||||
// base64 编码只存在于本次调用内存中,禁止写入日志、审计或错误信息。
|
||||
orderNumber, err := s.recognizer.ExtractPaymentVoucherOrderNumber(ctx, base64.StdEncoding.EncodeToString(content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(orderNumber) == "" {
|
||||
return nil, apperrors.New(apperrors.CodeGatewayInvalidResp, "未从付款凭证中识别出交易流水号")
|
||||
}
|
||||
return &PaymentVoucherRecognitionResult{ExternalTransactionNo: strings.TrimSpace(orderNumber)}, nil
|
||||
}
|
||||
|
||||
// isPaymentVoucherImage 校验对象声明的类型为图片,并用内容嗅探拦截被改名的非图片文件。
|
||||
// 嗅探结果为空或 application/octet-stream 表示未知容器(如 webp),交由识别服务判定;
|
||||
// 明确识别为其他类型的(PDF、压缩包、文本等)直接拒绝。
|
||||
func isPaymentVoucherImage(declaredContentType string, content []byte) bool {
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(declaredContentType)), "image/") {
|
||||
return false
|
||||
}
|
||||
sniffed := strings.ToLower(strings.TrimSpace(http.DetectContentType(content)))
|
||||
if sniffed == "" || sniffed == "application/octet-stream" || strings.HasPrefix(sniffed, "image/") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -14,21 +15,23 @@ import (
|
||||
// RecoverOnlinePaymentService 批量收敛长期缺少支付链接或待支付的代理在线充值。
|
||||
type RecoverOnlinePaymentService struct {
|
||||
db *gorm.DB
|
||||
runtime *merchantpayment.RuntimeLoader
|
||||
wechat OnlinePaymentPort
|
||||
alipay OnlinePaymentPort
|
||||
fuiou OnlinePaymentPort
|
||||
confirm *ConfirmOnlinePaymentService
|
||||
audit PaymentAuditWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
|
||||
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
|
||||
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, audit: audit, now: time.Now}
|
||||
func NewRecoverOnlinePaymentService(db *gorm.DB, runtime *merchantpayment.RuntimeLoader, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
|
||||
return &RecoverOnlinePaymentService{db: db, runtime: runtime, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now}
|
||||
}
|
||||
|
||||
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
|
||||
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
|
||||
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil || s.audit == nil {
|
||||
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
|
||||
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
@@ -51,7 +54,13 @@ func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, er
|
||||
for index := range payments {
|
||||
payment := &payments[index]
|
||||
recharge := recharges[payment.OrderID]
|
||||
config := recoveryConfig(payment, configs)
|
||||
config, configErr := s.recoveryConfig(ctx, payment, configs)
|
||||
if configErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = configErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if recharge == nil || config == nil {
|
||||
if firstErr == nil {
|
||||
firstErr = errors.New(errors.CodeConflict, "待恢复支付单缺少充值单或创建配置")
|
||||
@@ -70,7 +79,7 @@ func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, er
|
||||
}
|
||||
|
||||
func (s *RecoverOnlinePaymentService) recoverOne(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord, config *model.WechatConfig, now time.Time) error {
|
||||
adapter := s.adapter(payment.PaymentMethod)
|
||||
adapter := s.adapter(payment.PaymentMethod, config)
|
||||
if adapter == nil {
|
||||
return errors.New(errors.CodeNoPaymentConfig, "代理充值创建时支付配置不可用")
|
||||
}
|
||||
@@ -130,6 +139,12 @@ func (s *RecoverOnlinePaymentService) queryPayment(ctx context.Context, adapter
|
||||
return err
|
||||
case OnlinePaymentStateClosed:
|
||||
return s.closePending(ctx, payment, recharge)
|
||||
case OnlinePaymentStateNotFound:
|
||||
// 渠道没有这笔订单:过期后已不可能再支付,按关闭处理;未过期时可能尚未同步到渠道,继续等待。
|
||||
if payment.ExpireAt != nil && time.Now().After(*payment.ExpireAt) {
|
||||
return s.closePending(ctx, payment, recharge)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -140,7 +155,7 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
|
||||
configIDs := make([]uint, 0, len(payments))
|
||||
for index := range payments {
|
||||
rechargeIDs = append(rechargeIDs, payments[index].OrderID)
|
||||
if payments[index].PaymentConfigID != nil {
|
||||
if payments[index].MerchantID == nil && payments[index].PaymentConfigID != nil {
|
||||
configIDs = append(configIDs, *payments[index].PaymentConfigID)
|
||||
}
|
||||
}
|
||||
@@ -150,8 +165,10 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
|
||||
}
|
||||
var configRows []model.WechatConfig
|
||||
if len(configIDs) > 0 {
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值创建配置失败")
|
||||
// merchant_id 为空仅为留存期内历史支付;独立 Change 删除旧路径前,
|
||||
// 必须按其 payment_config_id 读取,包括已软删除的原始配置。
|
||||
if err := s.db.WithContext(ctx).Unscoped().Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值历史配置失败")
|
||||
}
|
||||
}
|
||||
recharges := make(map[uint]*model.AgentRechargeRecord, len(rechargeRows))
|
||||
@@ -165,11 +182,27 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
|
||||
return recharges, configs, nil
|
||||
}
|
||||
|
||||
func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig) *model.WechatConfig {
|
||||
if payment.PaymentConfigID == nil {
|
||||
return nil
|
||||
// recoveryConfig 对冻结商户支付单按当前凭证版本加载;历史支付单保持 payment_config_id 路径。
|
||||
func (s *RecoverOnlinePaymentService) recoveryConfig(ctx context.Context, payment *model.Payment, configs map[uint]*model.WechatConfig) (*model.WechatConfig, error) {
|
||||
if payment.MerchantID != nil {
|
||||
merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 仅微信直连(v3/v2)商户需要全局授权配置中的 AppID;富友商户不依赖该配置。
|
||||
var authorization *model.WechatAuthorization
|
||||
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
|
||||
authorization, err = s.runtime.LoadAuthorization(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return merchantpayment.MerchantConfig(merchant, authorization)
|
||||
}
|
||||
return configs[*payment.PaymentConfigID]
|
||||
if payment.PaymentConfigID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return configs[*payment.PaymentConfigID], nil
|
||||
}
|
||||
|
||||
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord) error {
|
||||
@@ -200,8 +233,11 @@ func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment
|
||||
})
|
||||
}
|
||||
|
||||
func (s *RecoverOnlinePaymentService) adapter(paymentMethod string) OnlinePaymentPort {
|
||||
func (s *RecoverOnlinePaymentService) adapter(paymentMethod string, config *model.WechatConfig) OnlinePaymentPort {
|
||||
if paymentMethod == constants.RechargeMethodWechat {
|
||||
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
|
||||
return s.fuiou
|
||||
}
|
||||
return s.wechat
|
||||
}
|
||||
if paymentMethod == constants.RechargeMethodAlipay {
|
||||
|
||||
@@ -3,11 +3,14 @@ package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 审批恢复适配器方法见文件末尾。
|
||||
|
||||
// PrepareRequest 是业务写入前执行审批渠道可用性检查的请求。
|
||||
type PrepareRequest struct {
|
||||
BusinessType string
|
||||
@@ -79,3 +82,35 @@ type SubmissionRequestedEvent struct {
|
||||
type SubmissionEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event SubmissionRequestedEvent) error
|
||||
}
|
||||
|
||||
type RecoveryPort interface {
|
||||
EnqueueSubmittedSync(ctx context.Context, tx *gorm.DB, instanceID uint) error
|
||||
EnqueueUnknownConfirm(ctx context.Context, tx *gorm.DB, instanceID uint) error
|
||||
RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, instanceID uint) (bool, error)
|
||||
}
|
||||
|
||||
// RecoveryAdapter 将恢复动作委托给基础设施实现。
|
||||
type RecoveryAdapter struct {
|
||||
SyncFunc func(context.Context, *gorm.DB, uint) error
|
||||
ConfirmFunc func(context.Context, *gorm.DB, uint) error
|
||||
RecoverFunc func(context.Context, *gorm.DB, uint) (bool, error)
|
||||
}
|
||||
|
||||
func (a RecoveryAdapter) SyncSubmitted(ctx context.Context, tx *gorm.DB, id uint) error {
|
||||
if a.SyncFunc == nil {
|
||||
return stderrors.New("审批原实例同步接缝未配置")
|
||||
}
|
||||
return a.SyncFunc(ctx, tx, id)
|
||||
}
|
||||
func (a RecoveryAdapter) ConfirmUnknown(ctx context.Context, tx *gorm.DB, id uint) error {
|
||||
if a.ConfirmFunc == nil {
|
||||
return stderrors.New("审批结果确认接缝未配置")
|
||||
}
|
||||
return a.ConfirmFunc(ctx, tx, id)
|
||||
}
|
||||
func (a RecoveryAdapter) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, id uint) (bool, error) {
|
||||
if a.RecoverFunc == nil {
|
||||
return false, stderrors.New("审批提交事件恢复接缝未配置")
|
||||
}
|
||||
return a.RecoverFunc(ctx, tx, id)
|
||||
}
|
||||
|
||||
44
internal/application/approval/submission_failure.go
Normal file
44
internal/application/approval/submission_failure.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ConfirmSubmissionFailed 在业务消费事务内锁定审批实例并确认其仍为“提交失败”,返回可对外展示的失败原因。
|
||||
// 实例已不是提交失败(例如被恢复重放)时 ok 为 false,业务消费者应不改变业务单。
|
||||
// 原因只返回固定白名单文案,禁止透传企微原文或内部错误。
|
||||
func ConfirmSubmissionFailed(ctx context.Context, tx *gorm.DB, instanceID uint) (string, bool, error) {
|
||||
var row struct {
|
||||
Status int `gorm:"column:status"`
|
||||
LastError string `gorm:"column:last_error"`
|
||||
}
|
||||
result := tx.WithContext(ctx).Raw(
|
||||
`SELECT ai.status, COALESCE(wc.last_error, '') AS last_error
|
||||
FROM tb_approval_instance ai
|
||||
LEFT JOIN tb_wecom_approval_context wc ON wc.approval_instance_id = ai.id
|
||||
WHERE ai.id = ? FOR UPDATE OF ai`, instanceID).Scan(&row)
|
||||
if result.Error != nil {
|
||||
return "", false, errors.Wrap(errors.CodeDatabaseError, result.Error, "锁定提交失败审批实例失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return "", false, errors.New(errors.CodeNotFound, "审批实例不存在")
|
||||
}
|
||||
if row.Status != constants.ApprovalStatusSubmissionFailed {
|
||||
return "", false, nil
|
||||
}
|
||||
return SubmissionFailureReason(row.LastError), true, nil
|
||||
}
|
||||
|
||||
// SubmissionFailureReason 把企微提交失败原文转换为稳定白名单文案。
|
||||
func SubmissionFailureReason(lastError string) string {
|
||||
if strings.TrimSpace(lastError) == "" {
|
||||
return constants.ApprovalSubmissionFailedStatusName
|
||||
}
|
||||
return constants.ApprovalSubmissionFailedReason
|
||||
}
|
||||
226
internal/application/assetautorenewal/config.go
Normal file
226
internal/application/assetautorenewal/config.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"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/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// ConfigView 是自动续费配置的读取视图。
|
||||
type ConfigView struct {
|
||||
Enabled int `json:"enabled"`
|
||||
Scope string `json:"scope"`
|
||||
PackageIDs []uint `json:"package_ids"`
|
||||
DaysBeforeExpiry int `json:"days_before_expiry"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
Updater uint `json:"updater"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ConfigRequest 是保存自动续费配置的请求。
|
||||
type ConfigRequest struct {
|
||||
Enabled int `json:"enabled"`
|
||||
Scope string `json:"scope"`
|
||||
PackageIDs []uint `json:"package_ids"`
|
||||
DaysBeforeExpiry int `json:"days_before_expiry"`
|
||||
}
|
||||
|
||||
// GetConfig 读取唯一的自动续费配置;仅超级管理员与平台账号可见。
|
||||
func (s *Service) GetConfig(ctx context.Context) (*ConfigView, error) {
|
||||
if _, err := requirePlatformOperator(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config, err := s.configStore.Get(ctx)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "自动续费配置不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败")
|
||||
}
|
||||
return toConfigView(config), nil
|
||||
}
|
||||
|
||||
// SaveConfig 保存自动续费配置:单行事务锁串行化、事务内自增配置版本,并与审计同事务写入。
|
||||
//
|
||||
// 保存只影响后续扫描:已产生的尝试记录保留触发时的配置版本快照,不重算。
|
||||
func (s *Service) SaveConfig(ctx context.Context, request ConfigRequest) (*ConfigView, error) {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
packageIDs, err := normalizeConfigRequest(&request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(packageIDs) > 0 {
|
||||
if err := s.validateSellableMainPackages(ctx, packageIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "自动续费配置审计接缝未配置")
|
||||
}
|
||||
saved := &model.AssetAutoRenewalConfig{}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
current, lockErr := s.configStore.LockInTx(ctx, tx)
|
||||
if lockErr != nil {
|
||||
if lockErr == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "自动续费配置不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定自动续费配置失败")
|
||||
}
|
||||
before := configSnapshot(current)
|
||||
saved.Enabled = request.Enabled
|
||||
saved.Scope = request.Scope
|
||||
saved.PackageIDs = model.UintJSONBArray(packageIDs)
|
||||
saved.DaysBeforeExpiry = request.DaysBeforeExpiry
|
||||
saved.ConfigVersion = current.ConfigVersion + 1
|
||||
saved.Creator = current.Creator
|
||||
saved.Updater = operatorID
|
||||
if saveErr := s.configStore.SaveInTx(ctx, tx, saved, operatorID); saveErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, saveErr, "保存自动续费配置失败")
|
||||
}
|
||||
if auditErr := s.auditWriter.WriteAssetAutoRenewalConfigChange(ctx, tx, audit.AssetAutoRenewalConfigAudit{
|
||||
OperatorID: operatorID,
|
||||
OperationType: constants.AuditOperationAssetAutoRenewalConfigUpdate,
|
||||
Description: "保存资产钱包自动续费配置",
|
||||
BeforeData: before,
|
||||
AfterData: configSnapshot(saved),
|
||||
RequestID: derefString(middleware.GetRequestIDFromContext(ctx)),
|
||||
CorrelationID: derefString(middleware.GetRequestIDFromContext(ctx)),
|
||||
}); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := &ConfigView{
|
||||
Enabled: saved.Enabled, Scope: saved.Scope, PackageIDs: packageIDs,
|
||||
DaysBeforeExpiry: saved.DaysBeforeExpiry, ConfigVersion: saved.ConfigVersion,
|
||||
Updater: saved.Updater, UpdatedAt: s.now(),
|
||||
}
|
||||
s.logger.Info("资产钱包自动续费配置已保存",
|
||||
zap.Int("enabled", view.Enabled), zap.String("scope", view.Scope),
|
||||
zap.Int("days_before_expiry", view.DaysBeforeExpiry), zap.Int64("config_version", view.ConfigVersion))
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// derefString 安全解引用可空字符串,供审计上下文可选字段复用。
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
// requirePlatformOperator 复核调用者仅限超级管理员与平台账号,并返回其账号 ID。
|
||||
//
|
||||
// 路由组已做粗粒度门禁,这里在业务边界再复核一次账号类型(ENG-AUTHZ-001):
|
||||
// 代理、企业与个人客户一律按「无权限或不存在」统一拒绝,不形成可枚举差异。
|
||||
func requirePlatformOperator(ctx context.Context) (uint, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// normalizeConfigRequest 归一化并校验保存请求,返回去重升序的指定套餐集合。
|
||||
func normalizeConfigRequest(request *ConfigRequest) ([]uint, error) {
|
||||
if request.Enabled != constants.AssetAutoRenewalConfigEnabledOff &&
|
||||
request.Enabled != constants.AssetAutoRenewalConfigEnabledOn {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自动续费总开关取值非法")
|
||||
}
|
||||
if request.Scope != constants.AssetAutoRenewalScopeAll && request.Scope != constants.AssetAutoRenewalScopeSpecified {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自动续费适用范围取值非法")
|
||||
}
|
||||
if request.DaysBeforeExpiry < constants.AssetAutoRenewalMinDaysBeforeExpiry ||
|
||||
request.DaysBeforeExpiry > constants.AssetAutoRenewalMaxDaysBeforeExpiry {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自动续费到期前天数必须在 1 至 90 之间")
|
||||
}
|
||||
seen := make(map[uint]struct{}, len(request.PackageIDs))
|
||||
packageIDs := make([]uint, 0, len(request.PackageIDs))
|
||||
for _, packageID := range request.PackageIDs {
|
||||
if packageID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自动续费指定套餐包含无效 ID")
|
||||
}
|
||||
if _, exists := seen[packageID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[packageID] = struct{}{}
|
||||
packageIDs = append(packageIDs, packageID)
|
||||
}
|
||||
sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] })
|
||||
if request.Scope == constants.AssetAutoRenewalScopeSpecified && len(packageIDs) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "指定范围必须至少选择一个主套餐")
|
||||
}
|
||||
if request.Scope == constants.AssetAutoRenewalScopeAll {
|
||||
packageIDs = nil
|
||||
}
|
||||
return packageIDs, nil
|
||||
}
|
||||
|
||||
// validateSellableMainPackages 校验指定集合只能选择当前可售主套餐。
|
||||
//
|
||||
// 可售口径与购买校验的平台分支一致:套餐为正式套餐、全局启用且上架。
|
||||
// 运行时不因后来下架而拒绝(交由续费豁免判定),因此下架只在此处拦截配置保存。
|
||||
func (s *Service) validateSellableMainPackages(ctx context.Context, packageIDs []uint) error {
|
||||
packages, err := s.loadPackagesByIDs(ctx, packageIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, packageID := range packageIDs {
|
||||
pkg, exists := packages[packageID]
|
||||
if !exists {
|
||||
return errors.New(errors.CodeInvalidParam, "指定套餐不存在")
|
||||
}
|
||||
if pkg.PackageType != constants.PackageTypeFormal {
|
||||
return errors.New(errors.CodeInvalidParam, "指定范围只能选择主套餐")
|
||||
}
|
||||
if pkg.Status != constants.StatusEnabled {
|
||||
return errors.New(errors.CodeInvalidParam, "指定套餐已禁用")
|
||||
}
|
||||
if pkg.ShelfStatus != constants.ShelfStatusOn {
|
||||
return errors.New(errors.CodeInvalidParam, "指定套餐已下架")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// configSnapshot 生成配置前后值快照,字段口径固定,便于审计比对。
|
||||
func configSnapshot(config *model.AssetAutoRenewalConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"enabled": config.Enabled,
|
||||
"scope": config.Scope,
|
||||
"package_ids": []uint(config.PackageIDs),
|
||||
"days_before_expiry": config.DaysBeforeExpiry,
|
||||
"config_version": config.ConfigVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func toConfigView(config *model.AssetAutoRenewalConfig) *ConfigView {
|
||||
return &ConfigView{
|
||||
Enabled: config.Enabled,
|
||||
Scope: config.Scope,
|
||||
PackageIDs: []uint(config.PackageIDs),
|
||||
DaysBeforeExpiry: config.DaysBeforeExpiry,
|
||||
ConfigVersion: config.ConfigVersion,
|
||||
Updater: config.Updater,
|
||||
UpdatedAt: config.UpdatedAt,
|
||||
}
|
||||
}
|
||||
222
internal/application/assetautorenewal/event.go
Normal file
222
internal/application/assetautorenewal/event.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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/outboxid"
|
||||
)
|
||||
|
||||
// assetAutoRenewalResumePayloadVersion 是自动续费复机事件的载荷版本。
|
||||
const assetAutoRenewalResumePayloadVersion = 1
|
||||
|
||||
// resumePayload 是自动续费复机事件的载荷,只携带尝试与资产标识,消费者按尝试 ID 认领执行权。
|
||||
type resumePayload struct {
|
||||
AttemptID uint `json:"attempt_id"`
|
||||
AssetType string `json:"asset_type"`
|
||||
AssetID uint `json:"asset_id"`
|
||||
}
|
||||
|
||||
// AppendResumeRequested 在续费事务内幂等写入复机事件。
|
||||
//
|
||||
// 事件 ID 由尝试记录 ID 派生:续费成功与复机状态同事务写入,重复投递不会创建第二个事件
|
||||
// (ENG-OUTBOX-001)。调用方必须已确认可复机条件成立,本函数不做条件判定。
|
||||
func AppendResumeRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, attempt *model.AssetAutoRenewalAttempt) error {
|
||||
if repository == nil {
|
||||
return gorm.ErrInvalidDB
|
||||
}
|
||||
if attempt == nil || attempt.ID == 0 {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
value := strconv.FormatUint(uint64(attempt.ID), 10)
|
||||
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: outboxid.Stable(constants.OutboxEventTypeAssetAutoRenewalResumeRequested+":", value),
|
||||
EventType: constants.OutboxEventTypeAssetAutoRenewalResumeRequested,
|
||||
PayloadVersion: assetAutoRenewalResumePayloadVersion,
|
||||
AggregateType: "asset_auto_renewal_attempt",
|
||||
AggregateID: value,
|
||||
ResourceType: attempt.AssetType,
|
||||
ResourceID: strconv.FormatUint(uint64(attempt.AssetID), 10),
|
||||
BusinessKey: constants.OutboxEventTypeAssetAutoRenewalResumeRequested + ":" + value,
|
||||
Payload: resumePayload{
|
||||
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ResumeConsumer 把自动续费复机事件转成一次复机动作。
|
||||
type ResumeConsumer struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewResumeConsumer 创建自动续费复机事件消费者。
|
||||
func NewResumeConsumer(service *Service) *ResumeConsumer {
|
||||
return &ResumeConsumer{service: service}
|
||||
}
|
||||
|
||||
// Consume 按尝试记录认领执行权后执行复机;重复投递由认领字段兜住,不会产生第二次外部调用。
|
||||
func (c *ResumeConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.service == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行能力未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeAssetAutoRenewalResumeRequested {
|
||||
return outbox.Permanent(gorm.ErrInvalidData)
|
||||
}
|
||||
if envelope.PayloadVersion != assetAutoRenewalResumePayloadVersion {
|
||||
return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷版本不受支持"))
|
||||
}
|
||||
var payload resumePayload
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return outbox.Permanent(errors.Wrap(errors.CodeInvalidParam, err, "自动续费复机事件载荷格式错误"))
|
||||
}
|
||||
if payload.AttemptID == 0 {
|
||||
return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷不完整"))
|
||||
}
|
||||
// 消费者不经过计划任务入口,必须自带操作者与来源,否则失败审计会因审计上下文缺失被拒(fail-closed)。
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.OutboxEventTypeAssetAutoRenewalResumeRequested,
|
||||
ActorName: "资产钱包自动续费复机结果消费者", Source: constants.AuditSourceWorker,
|
||||
CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID,
|
||||
})
|
||||
return c.service.ExecuteResume(ctx, payload.AttemptID)
|
||||
}
|
||||
|
||||
// ExecuteResume 认领并执行一次自动续费复机,回写尝试记录的复机状态、外部交互号与失败原因。
|
||||
//
|
||||
// 「回写复机终态 + 投递失败通知 + 失败审计」在同一个短事务内闭合:任一失败整体回滚,
|
||||
// 记录退回「已投递且已提交」,由恢复扫描按只读查询继续收敛,因此通知不会因一次写入抖动而永久丢失。
|
||||
// 复机失败或结果未知时绝不回滚续费事实:订单、套餐生效与钱包扣款保持已提交状态。
|
||||
func (s *Service) ExecuteResume(ctx context.Context, attemptID uint) error {
|
||||
if s.resume == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
|
||||
}
|
||||
attempt, err := s.attemptStore.Load(ctx, attemptID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费尝试记录失败")
|
||||
}
|
||||
if attempt.ResumeStatus != constants.AssetAutoRenewalResumeStatusRequested {
|
||||
// 已收敛或未投递复机:重复投递与非复机尝试都按幂等结束。
|
||||
return nil
|
||||
}
|
||||
claimed, err := s.attemptStore.ClaimResumeSubmission(ctx, attemptID, s.now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
// 认领已被占用:可能是并发重复投递,也可能是上次「已调用但未回写」的进程中断。
|
||||
// 两种情况都不得再次调用运营商,留给恢复扫描按只读查询收敛。
|
||||
s.logger.Info("自动续费复机已被并发执行,跳过重复调用", zap.Uint("attempt_id", attemptID))
|
||||
return nil
|
||||
}
|
||||
outcome, resumeErr := s.resume.ResumeAssetForAutoRenewal(ctx, attempt.AssetType, attempt.AssetID)
|
||||
status := constants.AssetAutoRenewalResumeStatusUnknown
|
||||
reason := outcome.SafeReason
|
||||
switch {
|
||||
case !outcome.Applied:
|
||||
// 判定在执行时已不成立:按跳过记录,不通知,也不改写任何续费事实。
|
||||
status = constants.AssetAutoRenewalResumeStatusSkipped
|
||||
reason = ""
|
||||
case outcome.Result == constants.AuditResultSuccess:
|
||||
status = constants.AssetAutoRenewalResumeStatusSucceeded
|
||||
reason = ""
|
||||
case outcome.Result == constants.AuditResultFailed:
|
||||
status = constants.AssetAutoRenewalResumeStatusFailed
|
||||
reason = resumeFailureDetail(outcome.SafeReason)
|
||||
default:
|
||||
status = constants.AssetAutoRenewalResumeStatusUnknown
|
||||
reason = resumeFailureDetail(outcome.SafeReason)
|
||||
}
|
||||
if err := s.finalizeResumeOutcome(ctx, attempt, status, reason, outcome.IntegrationID); err != nil {
|
||||
return err
|
||||
}
|
||||
if resumeErr != nil {
|
||||
s.logger.Warn("自动续费复机执行未确认完成",
|
||||
zap.Uint("attempt_id", attemptID), zap.String("result", outcome.Result), zap.Error(resumeErr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// finalizeResumeOutcome 在同一短事务内回写复机终态;确认失败时同事务投递通知并写失败审计。
|
||||
func (s *Service) finalizeResumeOutcome(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, status int, reason, integrationID string) error {
|
||||
expected := []int{constants.AssetAutoRenewalResumeStatusRequested}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
updated, err := s.attemptStore.MarkResumeOutcomeInTx(ctx, tx, attempt.ID, expected, status, integrationID, reason)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
// 已被并发收敛:不重复投递通知与审计。
|
||||
s.logger.Info("自动续费复机结果已被并发收敛,跳过通知与审计", zap.Uint("attempt_id", attempt.ID))
|
||||
return nil
|
||||
}
|
||||
if status != constants.AssetAutoRenewalResumeStatusFailed {
|
||||
return nil
|
||||
}
|
||||
if err := s.appendFailureNotifications(ctx, tx, failureNotification{
|
||||
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
|
||||
Identifier: s.assetIdentifier(ctx, attempt.AssetType, attempt.AssetID),
|
||||
ShopID: attempt.ShopID, CustomerID: attempt.CustomerID,
|
||||
TriggerDate: attempt.TriggerDate, Reason: constants.AssetAutoRenewalFailureResumeFailed,
|
||||
PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendResumeFailureAudit(ctx, tx, attempt, reason)
|
||||
})
|
||||
}
|
||||
|
||||
// appendResumeFailureAudit 在复机失败终态事务内写统一审计,主资源为本次尝试记录。
|
||||
func (s *Service) appendResumeFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason string) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
|
||||
}
|
||||
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费复机失败",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed,
|
||||
ErrorSummary: reason,
|
||||
CorrelationID: attemptID,
|
||||
Metadata: map[string]any{
|
||||
"asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
|
||||
"failure_kind": constants.AssetAutoRenewalFailureResumeFailed,
|
||||
},
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
|
||||
DisplayName: "自动续费尝试 " + attemptID,
|
||||
Relation: constants.AuditResourceRelationPrimary,
|
||||
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
|
||||
"resume_status": constants.AssetAutoRenewalResumeStatusFailed,
|
||||
"failure_reason": constants.AssetAutoRenewalFailureResumeFailed,
|
||||
},
|
||||
BeforeData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusRequested},
|
||||
AfterData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusFailed},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
// resumeFailureDetail 组装可安全展示的复机失败原因,不写渠道报文原文。
|
||||
func resumeFailureDetail(safeReason string) string {
|
||||
if safeReason == "" {
|
||||
return "复机结果确认为失败"
|
||||
}
|
||||
return safeReason
|
||||
}
|
||||
136
internal/application/assetautorenewal/notify.go
Normal file
136
internal/application/assetautorenewal/notify.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
||||
)
|
||||
|
||||
// failureNotification 是一条失败通知事件所需冻结的事实。
|
||||
type failureNotification struct {
|
||||
AttemptID uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
Identifier string
|
||||
ShopID *uint
|
||||
CustomerID uint
|
||||
TriggerDate time.Time
|
||||
Reason string
|
||||
PackageName string
|
||||
FinalExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// appendFailureNotifications 在调用方事务内为当前个人客户与资产所属店铺各写一条幂等通知事件。
|
||||
//
|
||||
// 每日至多一条由上锁的两个条件推出:同一资产同一自然日至多一次尝试,且幂等键内嵌资产类型与资产 ID、
|
||||
// 上海自然日、原因类型与接收人。资产所属店铺当时无有效业务员时不阻断:店铺接收人由既有店铺解析
|
||||
// 在投递期完成,解析为空列表即正常结束,不影响续费事实与尝试记录;资产无店铺归属时只创建客户通知。
|
||||
// 非通知原因(如占位中断收敛)一律不投递,未登记原因按 fail-closed 处理。
|
||||
func (s *Service) appendFailureNotifications(ctx context.Context, tx *gorm.DB, request failureNotification) error {
|
||||
if s.outbox == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "自动续费通知 Outbox 未配置")
|
||||
}
|
||||
if !constants.IsAssetAutoRenewalNotifiableFailureReason(request.Reason) {
|
||||
s.logger.Warn("自动续费失败原因不属于通知口径,已跳过通知投递",
|
||||
zap.Uint("attempt_id", request.AttemptID), zap.String("reason", request.Reason))
|
||||
return nil
|
||||
}
|
||||
templateData := map[string]string{
|
||||
"asset_identifier": request.Identifier,
|
||||
"package_name": request.PackageName,
|
||||
"failure_reason": constants.GetAssetAutoRenewalFailureReasonName(request.Reason),
|
||||
"expiry_date": formatShanghaiDate(request.FinalExpiresAt, s.now()),
|
||||
}
|
||||
assetIDText := strconv.FormatUint(uint64(request.AssetID), 10)
|
||||
// 资源引用按资产类型选择既有可跳转目标:卡用 iot_card 详情、设备用 device 详情
|
||||
// (两者都在 internal/query/notification/target.go 的目标定义里,idTarget + 可用性复核),
|
||||
// 使店铺/业务员点开通知能进入对应资产详情,而不是落到无目标类型。
|
||||
refType := assetRefType(request.AssetType)
|
||||
expiresAt := request.FinalExpiresAt
|
||||
if expiresAt == nil {
|
||||
fallback := s.now().UTC()
|
||||
expiresAt = &fallback
|
||||
}
|
||||
if request.CustomerID > 0 {
|
||||
eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "c", request.CustomerID)
|
||||
_, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification,
|
||||
PayloadVersion: constants.NotificationPayloadVersionV1,
|
||||
AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10),
|
||||
ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID,
|
||||
Payload: notificationapp.PersonalCustomerDirectPayload{
|
||||
RecipientID: request.CustomerID, NotificationType: constants.NotificationTypeAssetAutoRenewalFailed,
|
||||
TemplateData: templateData, RefType: refType, RefID: assetIDText,
|
||||
ExpiresAt: expiresAt,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费客户通知事件失败")
|
||||
}
|
||||
}
|
||||
if request.ShopID == nil || *request.ShopID == 0 {
|
||||
return nil
|
||||
}
|
||||
eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "shop", *request.ShopID)
|
||||
_, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification,
|
||||
PayloadVersion: constants.NotificationPayloadVersionV1,
|
||||
AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10),
|
||||
ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID,
|
||||
Payload: notificationapp.AdminDynamicPayload{
|
||||
TargetKind: constants.NotificationTargetKindShop, TargetID: *request.ShopID,
|
||||
NotificationType: constants.NotificationTypeAssetAutoRenewalFailed,
|
||||
TemplateData: templateData, RefType: refType, RefID: assetIDText,
|
||||
ExpiresAt: expiresAt,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费店铺通知事件失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assetRefType 把资产类型映射为可跳转的通知引用类型(卡片详情 / 设备详情)。
|
||||
func assetRefType(assetType string) string {
|
||||
if assetType == constants.AssetWalletResourceTypeDevice {
|
||||
return constants.NotificationRefTypeDevice
|
||||
}
|
||||
return constants.NotificationRefTypeIotCard
|
||||
}
|
||||
|
||||
// failureEventID 构造失败通知的稳定幂等键。
|
||||
//
|
||||
// 键内嵌资产类型与资产 ID、上海自然日、原因类型与接收人;复机失败沿用该次尝试的日期键,
|
||||
// 因此同一尝试只通知一次且不跨日新增。超长时由 outboxid.Stable 追加稳定摘要,仍保持唯一。
|
||||
func failureEventID(assetType string, assetID uint, triggerDate time.Time, reason, recipientKind string, recipientID uint) string {
|
||||
dateKey := triggerDate.In(shanghaiLocation).Format("20060102")
|
||||
return outboxid.Stable("aar:", fmt.Sprintf("%s:%d:%s:%s:%s:%d",
|
||||
assetCode(assetType), assetID, dateKey, reason, recipientKind, recipientID))
|
||||
}
|
||||
|
||||
// assetCode 把资产类型压缩为单字母代码,只为把幂等键长度压进 Outbox 预算。
|
||||
func assetCode(assetType string) string {
|
||||
if assetType == constants.AssetWalletResourceTypeDevice {
|
||||
return "d"
|
||||
}
|
||||
return "c"
|
||||
}
|
||||
|
||||
// formatShanghaiDate 把业务到期时间格式化为上海自然日文本,供通知模板与展示期使用。
|
||||
func formatShanghaiDate(value *time.Time, fallback time.Time) string {
|
||||
target := fallback
|
||||
if value != nil {
|
||||
target = *value
|
||||
}
|
||||
return target.In(shanghaiLocation).Format("2006-01-02")
|
||||
}
|
||||
108
internal/application/assetautorenewal/recovery.go
Normal file
108
internal/application/assetautorenewal/recovery.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RecoveryResult 是一次复机结果恢复扫描的可观察结果。
|
||||
//
|
||||
// Scanned 为扫到的未收敛尝试数;Confirmed 为本次回填为已确认结果的尝试数;
|
||||
// Pending 为结果仍未确认、等待下次扫描的尝试数;Anomaly 为超过查询窗口仍不可确认、
|
||||
// 本次标记转人工的尝试数。
|
||||
type RecoveryResult struct {
|
||||
Scanned int
|
||||
Confirmed int
|
||||
Pending int
|
||||
Anomaly int
|
||||
}
|
||||
|
||||
// RecoverResumeResults 扫描未收敛的复机子结果:只查询运营商状态回填,绝不重复发起复机调用。
|
||||
//
|
||||
// 收敛口径与既有停复机恢复一致(internal/application/carrierthreshold/cycle.go:246-296):
|
||||
// - 查询确认已复机 → 回填成功;
|
||||
// - 「已知但未复机」或不可判定 → 仍算未确认,等到下一次扫描;
|
||||
// - 自提交起超过查询窗口仍不可确认 → 标记异常并退出自动扫描转人工核对。
|
||||
//
|
||||
// 恢复扫描**不**据此判定「复机失败」:续购后新主套餐多为待生效,卡在此期间本就可能仍处于停机,
|
||||
// 把「未复机」当失败会发出误报通知。复机失败只由消费者在网关明确返回失败时确认(「仅确认失败才通知」)。
|
||||
// 单条失败不中断整批,但会作为首个错误返回,交既有任务重试。
|
||||
func (s *Service) RecoverResumeResults(ctx context.Context) (RecoveryResult, error) {
|
||||
result := RecoveryResult{}
|
||||
if s.resume == nil {
|
||||
return result, errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
|
||||
}
|
||||
now := s.now()
|
||||
attempts, err := s.attemptStore.ScanUnresolvedResumes(ctx, now, constants.AssetAutoRenewalRecoveryBatchSize)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Scanned = len(attempts)
|
||||
var firstErr error
|
||||
for index := range attempts {
|
||||
if err := s.recoverResumeResult(ctx, &attempts[index], now, &result); err != nil {
|
||||
s.logger.Warn("自动续费复机结果恢复单条失败",
|
||||
zap.Uint("attempt_id", attempts[index].ID), zap.Error(err))
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
s.logger.Info("自动续费复机结果恢复扫描完成",
|
||||
zap.Int("scanned", result.Scanned), zap.Int("confirmed", result.Confirmed),
|
||||
zap.Int("pending", result.Pending), zap.Int("anomaly", result.Anomaly))
|
||||
return result, firstErr
|
||||
}
|
||||
|
||||
// recoverResumeResult 处理单条未收敛的复机子结果。
|
||||
func (s *Service) recoverResumeResult(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, now time.Time, result *RecoveryResult) error {
|
||||
online, known, integrationID, err := s.resume.QueryAutoRenewalResumeState(ctx, attempt.AssetType, attempt.AssetID)
|
||||
if err != nil || !known || !online {
|
||||
// 查询失败、状态不可判定、或已知仍未复机:一律按「仍未确认」处理,
|
||||
// 绝不误判为失败终态,也绝不据此发出失败通知。
|
||||
result.Pending++
|
||||
if !expiredResumeQueryWindow(attempt.ResumeSubmittedAt, now) {
|
||||
return nil
|
||||
}
|
||||
marked, markErr := s.attemptStore.MarkResumeAnomaly(ctx, attempt.ID,
|
||||
"复机结果超过确认窗口仍不可查,请人工核对")
|
||||
if markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
if marked {
|
||||
result.Anomaly++
|
||||
s.logger.Warn("自动续费复机结果超期不可确认,已标记异常转人工",
|
||||
zap.Uint("attempt_id", attempt.ID), zap.String("asset_type", attempt.AssetType),
|
||||
zap.Uint("asset_id", attempt.AssetID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
expected := []int{
|
||||
constants.AssetAutoRenewalResumeStatusRequested,
|
||||
constants.AssetAutoRenewalResumeStatusUnknown,
|
||||
}
|
||||
marked, markErr := s.attemptStore.MarkResumeOutcome(ctx, attempt.ID, expected,
|
||||
constants.AssetAutoRenewalResumeStatusSucceeded, integrationID, "")
|
||||
if markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
if marked {
|
||||
result.Confirmed++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expiredResumeQueryWindow 判断复机子任务自提交起是否已超过自动查询窗口。
|
||||
// 未提交(提交认领时刻为空)表示尚未发起复机,不算超期。
|
||||
func expiredResumeQueryWindow(submittedAt *time.Time, now time.Time) bool {
|
||||
if submittedAt == nil {
|
||||
return false
|
||||
}
|
||||
return now.Sub(*submittedAt) >= constants.AssetAutoRenewalResumeQueryWindow
|
||||
}
|
||||
817
internal/application/assetautorenewal/renew.go
Normal file
817
internal/application/assetautorenewal/renew.go
Normal file
@@ -0,0 +1,817 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
||||
)
|
||||
|
||||
// renewalHalt 是执行事务内重读资格事实后必须终止本次尝试的可安全记录原因。
|
||||
//
|
||||
// 它作为事务闭包的返回错误使已取得行锁的续费事务整体回滚(此时尚未写入任何资金与订单事实),
|
||||
// 再由调用方在独立短事务中落终态、投递通知与写审计。
|
||||
// FailureReason 与 SkipReason 恰有一个非空:非空失败原因触发通知,跳过原因不触发通知。
|
||||
type renewalHalt struct {
|
||||
FailureReason string
|
||||
SkipReason string
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Error 实现 error,使事务闭包能把终止信号回传给调用方。
|
||||
func (h *renewalHalt) Error() string {
|
||||
if h.SkipReason != "" {
|
||||
return "自动续费跳过:" + constants.GetAssetAutoRenewalSkipReasonName(h.SkipReason)
|
||||
}
|
||||
return "自动续费未执行:" + constants.GetAssetAutoRenewalFailureReasonName(h.FailureReason)
|
||||
}
|
||||
|
||||
// renewalFacts 是一次续费执行成功后用于运行日志的关键事实。
|
||||
type renewalFacts struct {
|
||||
RenewPrice int64
|
||||
OrderID uint
|
||||
OrderNo string
|
||||
}
|
||||
|
||||
// executeRenewal 在单个事务内闭合一次续购:先锁资产钱包行、后锁资产载体行,锁后重读全部资格事实,
|
||||
// 再扣可用余额、建订单与明细、写已支付支付记录、写钱包流水、激活套餐、写佣金与观测 Outbox、
|
||||
// 更新尝试记录为成功并写成功审计。任一步失败整体回滚,不存在部分成功状态。
|
||||
//
|
||||
// windowDays 是本次扫描使用的配置窗口,必须传入实际配置值:窗口是触发条件而不是资格不变式,
|
||||
// 用常量上限会让「人工已把最终到期推远」被误判为失败。
|
||||
//
|
||||
// 返回 halt 表示重读后应落失败或跳过终态(事务已回滚且未写入任何事实);返回 err 表示事务失败。
|
||||
func (s *Service) executeRenewal(ctx context.Context, candidate assetquery.Candidate, attempt *model.AssetAutoRenewalAttempt, windowDays int) (*renewalFacts, *renewalHalt, error) {
|
||||
if s.outbox == nil {
|
||||
return nil, nil, errors.New(errors.CodeInvalidStatus, "自动续费 Outbox 未配置")
|
||||
}
|
||||
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, candidate.AssetType, candidate.AssetID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "资产钱包不存在,无法以可用余额续购",
|
||||
}, nil
|
||||
}
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "读取资产钱包失败")
|
||||
}
|
||||
facts := &renewalFacts{}
|
||||
var halt *renewalHalt
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 锁序固定为「先资产钱包行、后资产载体行」,与人工路径的「先冻结钱包、后激活套餐」一致,
|
||||
// 避免与人工事务的锁序反转形成死锁。
|
||||
lockedWallet, lockErr := s.assetWalletStore.LockByIDWithTx(ctx, tx, wallet.ID)
|
||||
if lockErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定资产钱包失败")
|
||||
}
|
||||
if carrierErr := s.lockCarrier(ctx, tx, candidate.AssetType, candidate.AssetID); carrierErr != nil {
|
||||
return carrierErr
|
||||
}
|
||||
// 锁后重读:最终到期、当前主套餐、待生效主套餐、可售续费价与可用余额都以重读结果为准。
|
||||
execution, execErr := s.rereadUnderLock(ctx, tx, candidate, lockedWallet, windowDays)
|
||||
if execErr != nil {
|
||||
var halted *renewalHalt
|
||||
if asRenewalHalt(execErr, &halted) {
|
||||
halt = halted
|
||||
return execErr
|
||||
}
|
||||
return execErr
|
||||
}
|
||||
if err := s.writeRenewalFacts(ctx, tx, execution, attempt, facts); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if halt != nil {
|
||||
return nil, halt, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
return facts, nil, nil
|
||||
}
|
||||
|
||||
// executionPlan 是锁后重读得到的执行输入。
|
||||
type executionPlan struct {
|
||||
candidate assetquery.Candidate
|
||||
asset *assetSnapshot
|
||||
wallet *model.AssetWallet
|
||||
pkg *model.Package
|
||||
sellerShop *uint
|
||||
price int64
|
||||
costPrice int64
|
||||
}
|
||||
|
||||
// assetSnapshot 是执行事务内锁定的资产事实。
|
||||
type assetSnapshot struct {
|
||||
assetType string
|
||||
assetID uint
|
||||
identifier string
|
||||
shopID *uint
|
||||
seriesID *uint
|
||||
generation int
|
||||
}
|
||||
|
||||
// rereadUnderLock 在行锁内重读全部资格事实,并给出可执行或必须终止的判断。
|
||||
//
|
||||
// 判定顺序体现「资格不变式先于触发条件」:
|
||||
// 1. 先判资格不变式——已存在待生效主套餐即「人工已完成续购 / 不叠加周期」,无论最终到期被推到多远
|
||||
// 都 MUST 跳过(规格 Requirement 8),绝不退化为「不可续费」失败与错误通知;
|
||||
// 2. 再判窗口与推算(触发条件)——不在窗口或推算不再明确,才是「当前条件不允许自动续购」。
|
||||
//
|
||||
// 之后依次判在途人工订单、钱包状态、可售续费价与可用余额。
|
||||
func (s *Service) rereadUnderLock(ctx context.Context, tx *gorm.DB, candidate assetquery.Candidate, wallet *model.AssetWallet, windowDays int) (*executionPlan, error) {
|
||||
asset, err := s.lockAndSnapshotAsset(ctx, tx, candidate.AssetType, candidate.AssetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inTxQuery := s.candidates.WithDB(tx)
|
||||
// 第 1 步:资格不变式(先于窗口判定)。
|
||||
state, err := inTxQuery.MainUsageStateOf(ctx, candidate.AssetType, candidate.AssetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state.HasPendingMainPackage {
|
||||
return nil, &renewalHalt{
|
||||
SkipReason: constants.AssetAutoRenewalSkipManualRenewed,
|
||||
Detail: "锁后重读发现该资产已存在待生效主套餐",
|
||||
}
|
||||
}
|
||||
if state.CurrentPackageID == 0 {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "锁后重读未找到当前主套餐商品",
|
||||
}
|
||||
}
|
||||
// 第 2 步:触发条件(窗口与推算口径),窗口取本次扫描的配置值。
|
||||
current, err := inTxQuery.Candidate(ctx, candidate.AssetType, candidate.AssetID, windowDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current == nil {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "锁后重读最终到期已不在触发窗口或推算结果不再明确",
|
||||
}
|
||||
}
|
||||
inFlight, err := inTxQuery.OpenManualMainPackageOrder(ctx, wallet.ID, candidate.AssetType, candidate.AssetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inFlight {
|
||||
return nil, &renewalHalt{
|
||||
SkipReason: constants.AssetAutoRenewalSkipManualOrderPending,
|
||||
Detail: "锁后重读发现该资产存在未关闭的个人资产钱包主套餐订单",
|
||||
}
|
||||
}
|
||||
if wallet.Status != constants.AssetWalletStatusNormal {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "资产钱包当前不可用于扣款",
|
||||
}
|
||||
}
|
||||
price, pkg, sellerShop, costPrice, err := s.resolveExecutablePrice(ctx, candidate.AssetType, candidate.AssetID, current.CurrentPackageID)
|
||||
if err != nil {
|
||||
var halted *renewalHalt
|
||||
if asRenewalHalt(err, &halted) {
|
||||
return nil, halted
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if wallet.GetAvailableBalance() < price {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureInsufficientBalance,
|
||||
Detail: "资产钱包可用余额小于执行时当前可售续费价",
|
||||
}
|
||||
}
|
||||
return &executionPlan{
|
||||
candidate: *current, asset: asset, wallet: wallet, pkg: pkg,
|
||||
sellerShop: sellerShop, price: price, costPrice: costPrice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveExecutablePrice 复用应用层购买校验与价格策略取得可续费判定与执行时续费价。
|
||||
//
|
||||
// 校验入口是个人卡/设备购买校验(含续费豁免下架与生效零售价、成本价比较),
|
||||
// 绝不依赖 handler 层续费价实现;任何校验失败都归一为「不可续费」并保留可安全记录的说明。
|
||||
func (s *Service) resolveExecutablePrice(ctx context.Context, assetType string, assetID, renewPackageID uint) (int64, *model.Package, *uint, int64, error) {
|
||||
if s.purchaseValidation == nil {
|
||||
return 0, nil, nil, 0, errors.New(errors.CodeServiceUnavailable, "购买校验能力未配置")
|
||||
}
|
||||
packageIDs := []uint{renewPackageID}
|
||||
var result *purchase_validation.PurchaseValidationResult
|
||||
var err error
|
||||
switch assetType {
|
||||
case constants.AssetWalletResourceTypeIotCard:
|
||||
result, err = s.purchaseValidation.ValidatePersonalCardPurchase(ctx, assetID, packageIDs)
|
||||
case constants.AssetWalletResourceTypeDevice:
|
||||
result, err = s.purchaseValidation.ValidatePersonalDevicePurchase(ctx, assetID, packageIDs)
|
||||
default:
|
||||
return 0, nil, nil, 0, errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, nil, nil, 0, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "当前条件不允许自动续购:" + purchaseValidationReason(err),
|
||||
}
|
||||
}
|
||||
if len(result.Packages) == 0 {
|
||||
return 0, nil, nil, 0, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "当前条件不允许自动续购:未解析到可售续费套餐",
|
||||
}
|
||||
}
|
||||
var sellerShop uint
|
||||
if result.Card != nil && result.Card.ShopID != nil {
|
||||
sellerShop = *result.Card.ShopID
|
||||
}
|
||||
if result.Device != nil && result.Device.ShopID != nil {
|
||||
sellerShop = *result.Device.ShopID
|
||||
}
|
||||
costPrice := int64(0)
|
||||
if sellerShop > 0 {
|
||||
resolved, costErr := s.purchaseValidation.GetCostPrice(ctx, result.Packages[0], sellerShop)
|
||||
if costErr != nil {
|
||||
return 0, nil, nil, 0, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "当前条件不允许自动续购:渠道成本价不可读",
|
||||
}
|
||||
}
|
||||
costPrice = resolved
|
||||
}
|
||||
if result.TotalPrice <= 0 {
|
||||
return 0, nil, nil, 0, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "当前条件不允许自动续购:生效续费价异常",
|
||||
}
|
||||
}
|
||||
var sellerShopPtr *uint
|
||||
if sellerShop > 0 {
|
||||
sellerShopPtr = &sellerShop
|
||||
}
|
||||
return result.TotalPrice, result.Packages[0], sellerShopPtr, costPrice, nil
|
||||
}
|
||||
|
||||
// writeRenewalFacts 在同一事务内闭合扣款、订单、支付、钱包流水、套餐生效、可靠事件、尝试记录与审计。
|
||||
func (s *Service) writeRenewalFacts(ctx context.Context, tx *gorm.DB, plan *executionPlan, attempt *model.AssetAutoRenewalAttempt, facts *renewalFacts) error {
|
||||
now := s.now()
|
||||
wallet := plan.wallet
|
||||
if err := s.assetWalletStore.DeductBalanceWithTx(ctx, tx, wallet.ID, plan.price, wallet.Version); err != nil {
|
||||
return errors.Wrap(errors.CodeConflict, err, "资产钱包扣款失败")
|
||||
}
|
||||
order, item, err := s.buildRenewalOrder(ctx, tx, plan, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单失败")
|
||||
}
|
||||
item.OrderID = order.ID
|
||||
if err := tx.WithContext(ctx).Create(item).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单明细失败")
|
||||
}
|
||||
payment := &model.Payment{
|
||||
PaymentNo: order.OrderNo,
|
||||
OrderID: order.ID,
|
||||
OrderType: model.PaymentOrderTypePackage,
|
||||
PaymentMethod: model.PaymentByWallet,
|
||||
Amount: plan.price,
|
||||
Status: model.PaymentRecordStatusPaid,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(payment).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费支付记录失败")
|
||||
}
|
||||
referenceType := constants.ReferenceTypeOrder
|
||||
walletTransaction := &model.AssetWalletTransaction{
|
||||
AssetWalletID: wallet.ID,
|
||||
ResourceType: wallet.ResourceType,
|
||||
ResourceID: wallet.ResourceID,
|
||||
UserID: plan.candidate.CustomerID,
|
||||
TransactionType: constants.AssetTransactionTypeDeduct,
|
||||
Amount: -plan.price,
|
||||
BalanceBefore: wallet.Balance,
|
||||
BalanceAfter: wallet.Balance - plan.price,
|
||||
Status: constants.TransactionStatusSuccess,
|
||||
ReferenceType: &referenceType,
|
||||
ReferenceNo: &order.OrderNo,
|
||||
Creator: plan.candidate.CustomerID,
|
||||
ShopIDTag: wallet.ShopIDTag,
|
||||
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
if err := s.walletTransactionStore.CreateWithTx(ctx, tx, walletTransaction); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费钱包流水失败")
|
||||
}
|
||||
usage, err := s.activateMainPackage(ctx, tx, order, plan, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.silence != nil {
|
||||
if err := s.silence.ApplyOnUsageCreatedInTx(ctx, tx, usage, plan.pkg, now, constants.SilenceTriggerAutoPurchase); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.silence.RecordForOrderInTx(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := commissiondelivery.AppendCommissionCalculate(ctx, tx, s.outbox, order.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.observationEvents == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "自动续费观测 Outbox 未配置")
|
||||
}
|
||||
observationID := "asset-auto-renewal:" + strconv.FormatUint(uint64(attempt.ID), 10)
|
||||
if err := s.observationEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
|
||||
EventID: outboxEventID(observationID), Scene: constants.CardObservationScenePackageChanged,
|
||||
ResourceType: observationResourceType(plan.asset.assetType), ResourceID: plan.asset.assetID,
|
||||
SyncTypes: []string{
|
||||
constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic,
|
||||
constants.CardObservationSyncTypeNetwork,
|
||||
},
|
||||
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now.UTC(),
|
||||
RequestID: observationID, CorrelationID: observationID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]any{
|
||||
"status": constants.AssetAutoRenewalAttemptStatusSucceeded,
|
||||
"failure_reason": "",
|
||||
"failure_detail": "",
|
||||
"skip_reason": "",
|
||||
"final_expires_at": plan.candidate.FinalExpiresAt,
|
||||
"current_usage_id": plan.candidate.CurrentUsageID,
|
||||
"current_package_id": plan.candidate.CurrentPackageID,
|
||||
"renew_package_id": plan.pkg.ID,
|
||||
"renew_price": plan.price,
|
||||
"wallet_id": wallet.ID,
|
||||
"wallet_transaction_id": walletTransaction.ID,
|
||||
"deduct_amount": plan.price,
|
||||
"balance_before": wallet.Balance,
|
||||
"balance_after": wallet.Balance - plan.price,
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
}
|
||||
// current_usage_id / current_package_id 保留**触发时**解析到的当前主套餐快照(规格 Requirement 7
|
||||
// 要求「触发时解析」),不覆盖为本次新生成的套餐使用记录;新记录通过 order_id / order_no 追溯,
|
||||
// 「续购后处于待生效」也可由 usage_after_success 断言直接观察。
|
||||
resumeReady, resumeReason, err := s.evaluateResumeGate(ctx, plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resumeReady {
|
||||
updates["resume_status"] = constants.AssetAutoRenewalResumeStatusRequested
|
||||
updates["resume_failure_reason"] = ""
|
||||
} else {
|
||||
updates["resume_status"] = constants.AssetAutoRenewalResumeStatusSkipped
|
||||
updates["resume_failure_reason"] = resumeReason
|
||||
}
|
||||
updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, updates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
return errors.Wrap(errors.CodeConflict, gorm.ErrInvalidData, "续费尝试已非处理中,拒绝重复成功")
|
||||
}
|
||||
facts.RenewPrice = plan.price
|
||||
facts.OrderID = order.ID
|
||||
facts.OrderNo = order.OrderNo
|
||||
if resumeReady {
|
||||
attempt.ResumeStatus = constants.AssetAutoRenewalResumeStatusRequested
|
||||
if err := AppendResumeRequested(ctx, tx, s.outbox, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.appendRenewalAudit(ctx, tx, plan, attempt, order, payment, wallet, walletTransaction, usage, updates)
|
||||
}
|
||||
|
||||
// evaluateResumeGate 在同一事务内按可复机判定给出复机去向。
|
||||
//
|
||||
// 判定只做数据库读取、不持有任何外部 I/O,因此可以安全地留在资金事务闭包内(ENG-TX-001);
|
||||
// 它也不对资产钱包行或载体行加锁,因此不会与已持有的行锁形成等待。
|
||||
func (s *Service) evaluateResumeGate(ctx context.Context, plan *executionPlan) (bool, string, error) {
|
||||
if s.resume == nil {
|
||||
return false, "", errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
|
||||
}
|
||||
ready, reason, err := s.resume.AutoRenewalResumeReady(ctx, plan.asset.assetType, plan.asset.assetID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
return ready, reason, nil
|
||||
}
|
||||
|
||||
// buildRenewalOrder 组装续购订单与唯一明细:买家恒为当前个人客户,金额为执行时当前可售续费价。
|
||||
func (s *Service) buildRenewalOrder(ctx context.Context, tx *gorm.DB, plan *executionPlan, now time.Time) (*model.Order, *model.OrderItem, error) {
|
||||
orderType := model.OrderTypeSingleCard
|
||||
var iotCardID, deviceID *uint
|
||||
if plan.asset.assetType == constants.AssetWalletResourceTypeDevice {
|
||||
orderType = model.OrderTypeDevice
|
||||
deviceID = &plan.asset.assetID
|
||||
} else {
|
||||
iotCardID = &plan.asset.assetID
|
||||
}
|
||||
generation := plan.asset.generation
|
||||
if generation <= 0 {
|
||||
generation = 1
|
||||
}
|
||||
paidAmount := plan.price
|
||||
operatorAccountID, operatorAccountName := s.personalCustomerOperatorSnapshot(ctx, plan.candidate.CustomerID)
|
||||
order := &model.Order{
|
||||
BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID},
|
||||
OrderNo: s.orderStore.GenerateOrderNo(), OrderType: orderType,
|
||||
BuyerType: model.BuyerTypePersonal, BuyerID: plan.candidate.CustomerID,
|
||||
IotCardID: iotCardID, DeviceID: deviceID, AssetIdentifier: plan.asset.identifier,
|
||||
TotalAmount: plan.price, PaymentMethod: model.PaymentMethodWallet,
|
||||
PaymentStatus: model.PaymentStatusPaid, PaidAt: &now,
|
||||
CommissionStatus: model.CommissionStatusPending, CommissionConfigVersion: 0,
|
||||
Source: constants.OrderSourceClient, Generation: generation, ActualPaidAmount: &paidAmount,
|
||||
OperatorAccountID: operatorAccountID, OperatorAccountType: model.OperatorAccountTypePersonalCustomer,
|
||||
OperatorAccountName: operatorAccountName, SellerShopID: plan.sellerShop,
|
||||
SeriesID: plan.asset.seriesID, SellerCostPrice: plan.costPrice,
|
||||
}
|
||||
item := &model.OrderItem{
|
||||
BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID},
|
||||
PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, Quantity: 1,
|
||||
UnitPrice: plan.price, Amount: plan.price,
|
||||
PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift,
|
||||
}
|
||||
return order, item, nil
|
||||
}
|
||||
|
||||
// personalCustomerOperatorSnapshot 读取个人客户昵称作为订单操作者名称快照。
|
||||
func (s *Service) personalCustomerOperatorSnapshot(ctx context.Context, customerID uint) (*uint, string) {
|
||||
if customerID == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
customer, err := s.personalCustomerStore.GetByID(ctx, customerID)
|
||||
if err != nil {
|
||||
return &customerID, ""
|
||||
}
|
||||
return &customerID, customer.Nickname
|
||||
}
|
||||
|
||||
// activateMainPackage 在同一事务内激活续购的主套餐:按既有排队规则决定待生效或立即生效。
|
||||
//
|
||||
// 资格前置保证本次执行前不存在待生效主套餐,因此续购最多领先一个周期;
|
||||
// 只有当当前主套餐在执行前刚好过期时新记录才立即生效,此时按既有规则追加套餐生效优先轮询请求。
|
||||
func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, plan *executionPlan, now time.Time) (*model.PackageUsage, error) {
|
||||
terms, err := packagepkg.ResolveTermsFromTx(ctx, tx, plan.pkg, order.SellerShopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasCurrentMain, err := packagepkg.HasCurrentMainPackageForQueue(tx.WithContext(ctx), plan.asset.assetType, plan.asset.assetID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var status, priority int
|
||||
var activatedAt, expiresAt time.Time
|
||||
var nextResetAt *time.Time
|
||||
pendingRealnameActivation := false
|
||||
if terms.ExpiryBase == constants.PackageExpiryBaseFromActivation {
|
||||
realnamed, realnameErr := s.isCarrierRealnamed(ctx, tx, plan.asset.assetType, plan.asset.assetID)
|
||||
if realnameErr != nil {
|
||||
return nil, realnameErr
|
||||
}
|
||||
pendingRealnameActivation = !realnamed
|
||||
}
|
||||
if hasCurrentMain {
|
||||
status = constants.PackageUsageStatusPending
|
||||
var maxPriority int
|
||||
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Where(carrierColumn(plan.asset.assetType)+" = ?", plan.asset.assetID).
|
||||
Select("COALESCE(MAX(priority), 0)").Scan(&maxPriority).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐排队优先级失败")
|
||||
}
|
||||
priority = maxPriority + 1
|
||||
} else {
|
||||
priority = 1
|
||||
if pendingRealnameActivation {
|
||||
status = constants.PackageUsageStatusPending
|
||||
} else {
|
||||
status = constants.PackageUsageStatusActive
|
||||
activatedAt = now
|
||||
expiresAt = packagepkg.CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
|
||||
nextResetAt = packagepkg.CalculateNextResetTime(plan.pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
|
||||
}
|
||||
}
|
||||
virtualTotalMB, displayGainRatio, enableVirtualData := model.BuildPackageUsageSnapshotValues(plan.pkg)
|
||||
retailAmount := order.TotalAmount
|
||||
usage := &model.PackageUsage{
|
||||
BaseModel: model.BaseModel{Creator: order.Creator, Updater: order.Creator},
|
||||
OrderID: order.ID, OrderNo: order.OrderNo,
|
||||
PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, UsageType: order.OrderType,
|
||||
DataLimitMB: plan.pkg.RealDataMB,
|
||||
VirtualTotalMBSnapshot: virtualTotalMB, DisplayGainRatioSnapshot: displayGainRatio,
|
||||
EnableVirtualDataSnapshot: enableVirtualData, Status: status, Priority: priority,
|
||||
DataResetCycle: plan.pkg.DataResetCycle, PendingRealnameActivation: pendingRealnameActivation,
|
||||
Generation: order.Generation, PaidAmount: &order.SellerCostPrice, RetailAmount: &retailAmount,
|
||||
PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift,
|
||||
}
|
||||
terms.Apply(usage)
|
||||
if plan.asset.assetType == constants.AssetWalletResourceTypeIotCard {
|
||||
usage.IotCardID = plan.asset.assetID
|
||||
} else {
|
||||
usage.DeviceID = plan.asset.assetID
|
||||
}
|
||||
if status == constants.PackageUsageStatusActive {
|
||||
usage.ActivatedAt = &activatedAt
|
||||
usage.ExpiresAt = &expiresAt
|
||||
usage.NextResetAt = nextResetAt
|
||||
}
|
||||
if err := tx.WithContext(ctx).Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "写入续费套餐使用记录失败")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(usage).Updates(map[string]any{
|
||||
"status": usage.Status, "pending_realname_activation": usage.PendingRealnameActivation,
|
||||
}).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "写回续费套餐使用记录状态失败")
|
||||
}
|
||||
if status != constants.PackageUsageStatusActive {
|
||||
return usage, nil
|
||||
}
|
||||
triggerType, err := packagepkg.ResolveActivationTriggerType(ctx, tx, plan.asset.assetType, plan.asset.assetID, usage.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := packagepkg.AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage,
|
||||
plan.asset.assetType, plan.asset.assetID, triggerType, activatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// isCarrierRealnamed 判断载体是否已满足实名激活条件,口径与既有自动购包一致。
|
||||
func (s *Service) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (bool, error) {
|
||||
switch assetType {
|
||||
case constants.AssetWalletResourceTypeIotCard:
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).Select("real_name_status").First(&card, assetID).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "读取卡实名状态失败")
|
||||
}
|
||||
return card.RealNameStatus == constants.RealNameStatusVerified, nil
|
||||
case constants.AssetWalletResourceTypeDevice:
|
||||
var count int64
|
||||
subQuery := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
|
||||
Select("iot_card_id").Where("device_id = ? AND bind_status = ?", assetID, constants.BindStatusBound)
|
||||
if err := tx.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "统计设备实名卡失败")
|
||||
}
|
||||
return count > 0, nil
|
||||
default:
|
||||
return false, errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||||
}
|
||||
}
|
||||
|
||||
// lockCarrier 在事务内按资产类型对载体行加行锁,作为与人工路径共享的序列化点。
|
||||
func (s *Service) lockCarrier(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
|
||||
switch assetType {
|
||||
case constants.AssetWalletResourceTypeIotCard:
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&card, assetID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败")
|
||||
}
|
||||
return nil
|
||||
case constants.AssetWalletResourceTypeDevice:
|
||||
var device model.Device
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, assetID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||||
}
|
||||
}
|
||||
|
||||
// lockAndSnapshotAsset 在已有行锁的事务内读取资产快照。
|
||||
func (s *Service) lockAndSnapshotAsset(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (*assetSnapshot, error) {
|
||||
switch assetType {
|
||||
case constants.AssetWalletResourceTypeIotCard:
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费卡事实失败")
|
||||
}
|
||||
if !card.IsStandalone {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "该卡已绑定设备,独立卡维度不执行自动续费",
|
||||
}
|
||||
}
|
||||
return &assetSnapshot{
|
||||
assetType: constants.AssetWalletResourceTypeIotCard, assetID: card.ID,
|
||||
identifier: card.ICCID, shopID: card.ShopID, seriesID: card.SeriesID, generation: card.Generation,
|
||||
}, nil
|
||||
case constants.AssetWalletResourceTypeDevice:
|
||||
var device model.Device
|
||||
if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费设备事实失败")
|
||||
}
|
||||
identifier := device.VirtualNo
|
||||
if identifier == "" {
|
||||
identifier = device.IMEI
|
||||
}
|
||||
return &assetSnapshot{
|
||||
assetType: constants.AssetWalletResourceTypeDevice, assetID: device.ID,
|
||||
identifier: identifier, shopID: device.ShopID, seriesID: device.SeriesID, generation: device.Generation,
|
||||
}, nil
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||||
}
|
||||
}
|
||||
|
||||
// carrierColumn 返回套餐使用记录上的资产外键列名。
|
||||
func carrierColumn(assetType string) string {
|
||||
if assetType == constants.AssetWalletResourceTypeDevice {
|
||||
return "device_id"
|
||||
}
|
||||
return "iot_card_id"
|
||||
}
|
||||
|
||||
// observationResourceType 把资产类型映射为观测序列的资源类型。
|
||||
func observationResourceType(assetType string) string {
|
||||
if assetType == constants.AssetWalletResourceTypeDevice {
|
||||
return constants.CardObservationResourceTypeDevice
|
||||
}
|
||||
return constants.CardObservationResourceTypeCard
|
||||
}
|
||||
|
||||
// asRenewalHalt 从错误中取出终止信号,非终止信号返回 false。
|
||||
func asRenewalHalt(err error, target **renewalHalt) bool {
|
||||
halt, ok := err.(*renewalHalt)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
*target = halt
|
||||
return true
|
||||
}
|
||||
|
||||
// appendRenewalAudit 在续费事务内写成功审计,资源覆盖尝试记录、订单、钱包、流水与套餐使用记录。
|
||||
func (s *Service) appendRenewalAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
plan *executionPlan,
|
||||
attempt *model.AssetAutoRenewalAttempt,
|
||||
order *model.Order,
|
||||
payment *model.Payment,
|
||||
wallet *model.AssetWallet,
|
||||
walletTransaction *model.AssetWalletTransaction,
|
||||
usage *model.PackageUsage,
|
||||
updates map[string]any,
|
||||
) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
|
||||
}
|
||||
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
|
||||
resources := []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
|
||||
DisplayName: "自动续费尝试 " + attemptID,
|
||||
Relation: constants.AuditResourceRelationPrimary,
|
||||
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
|
||||
"status": constants.AssetAutoRenewalAttemptStatusSucceeded,
|
||||
"renew_package_id": plan.pkg.ID, "renew_price": plan.price,
|
||||
"wallet_id": wallet.ID, "wallet_transaction_id": walletTransaction.ID,
|
||||
"deduct_amount": plan.price, "balance_before": wallet.Balance,
|
||||
"balance_after": wallet.Balance - plan.price,
|
||||
"order_id": order.ID, "order_no": order.OrderNo,
|
||||
"resume_status": updates["resume_status"],
|
||||
},
|
||||
BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing},
|
||||
AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusSucceeded, "failure_reason": ""},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}}
|
||||
orderResource := audit.OrderResource(order, constants.AuditResourceRelationAffected, constants.AuditResourceRoleAssetAutoRenewalOrder)
|
||||
resources = append(resources, orderResource)
|
||||
walletID := strconv.FormatUint(uint64(wallet.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWallet, ID: &walletID, Key: walletID, DisplayName: "资产钱包 " + walletID,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAutoRenewalWallet,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID,
|
||||
},
|
||||
BeforeData: map[string]any{"balance": walletTransaction.BalanceBefore},
|
||||
AfterData: map[string]any{"balance": walletTransaction.BalanceAfter},
|
||||
})
|
||||
walletTxID := strconv.FormatUint(uint64(walletTransaction.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWalletTransaction, ID: &walletTxID, Key: walletTxID,
|
||||
DisplayName: "资产钱包流水 " + walletTxID,
|
||||
Relation: constants.AuditResourceRelationAffected,
|
||||
Role: constants.AuditResourceRoleAssetAutoRenewalWalletTransaction,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": walletTransaction.ID, "asset_wallet_id": walletTransaction.AssetWalletID,
|
||||
"resource_type": walletTransaction.ResourceType, "resource_id": walletTransaction.ResourceID,
|
||||
"transaction_type": walletTransaction.TransactionType,
|
||||
"reference_no": walletTransaction.ReferenceNo, "status": walletTransaction.Status,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"amount": walletTransaction.Amount, "balance_before": walletTransaction.BalanceBefore,
|
||||
"balance_after": walletTransaction.BalanceAfter,
|
||||
},
|
||||
})
|
||||
resources = append(resources, audit.PaymentResource(payment, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPayment, nil, nil))
|
||||
if usage != nil {
|
||||
resources = append(resources, audit.PackageUsageResource(usage,
|
||||
constants.AuditResourceRelationAffected, constants.AuditResourceRolePackageUsageTarget, nil, nil))
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionAssetAutoRenewalRenewed, Summary: "资产钱包自动续费完成",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: order.OrderNo,
|
||||
Metadata: map[string]any{
|
||||
"asset_type": plan.asset.assetType, "asset_id": plan.asset.assetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
|
||||
},
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
// appendFailureAudit 在独立短事务内写失败审计;跳过终态不写审计,由尝试记录本身承载(Domain Ledger)。
|
||||
func (s *Service) appendFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
|
||||
}
|
||||
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费失败",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed,
|
||||
ErrorCode: strconv.Itoa(reasonCode(reason)), ErrorSummary: detail,
|
||||
CorrelationID: attemptID,
|
||||
Metadata: map[string]any{
|
||||
"asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), "failure_reason": reason,
|
||||
},
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
|
||||
DisplayName: "自动续费尝试 " + attemptID,
|
||||
Relation: constants.AuditResourceRelationPrimary,
|
||||
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
|
||||
"status": constants.AssetAutoRenewalAttemptStatusFailed,
|
||||
"failure_reason": reason,
|
||||
},
|
||||
BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing},
|
||||
AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusFailed, "failure_reason": reason},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
// reasonCode 把失败归类映射为审计错误码位,便于按原因检索失败事件。
|
||||
func reasonCode(reason string) int {
|
||||
switch reason {
|
||||
case constants.AssetAutoRenewalFailureInsufficientBalance:
|
||||
return 1
|
||||
case constants.AssetAutoRenewalFailureNotRenewable:
|
||||
return 2
|
||||
case constants.AssetAutoRenewalFailureOrderFailed:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// purchaseValidationReason 从购买校验错误中提取可安全记录的说明,不写底层错误细节。
|
||||
func purchaseValidationReason(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
message := err.Error()
|
||||
switch {
|
||||
case strings.Contains(message, "套餐已禁用"):
|
||||
return "套餐商品被禁用"
|
||||
case strings.Contains(message, "套餐已下架"):
|
||||
return "当前渠道下架且不满足续费豁免"
|
||||
case strings.Contains(message, "价格配置异常"):
|
||||
return "生效零售价低于成本价"
|
||||
case strings.Contains(message, "可购买范围"), strings.Contains(message, "未关联套餐系列"),
|
||||
strings.Contains(message, "绑定设备"):
|
||||
return "不在可购买范围或资产未关联套餐系列"
|
||||
case strings.Contains(message, "赠送套餐"):
|
||||
return "赠送套餐不参与自动续购"
|
||||
default:
|
||||
return "当前条件不允许自动续购"
|
||||
}
|
||||
}
|
||||
|
||||
// outboxEventID 把观测事件标识裁剪进 Outbox 的事件 ID 长度预算。
|
||||
func outboxEventID(value string) string {
|
||||
return outboxid.Stable("card-observation:", value)
|
||||
}
|
||||
245
internal/application/assetautorenewal/scan.go
Normal file
245
internal/application/assetautorenewal/scan.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ScanResult 汇总一次每日扫描的可观察结果。
|
||||
type ScanResult struct {
|
||||
Converged int64
|
||||
Candidates int
|
||||
Attempted int
|
||||
Succeeded int
|
||||
Failed int
|
||||
Skipped int
|
||||
Duplicated int
|
||||
}
|
||||
|
||||
// RunDailyScan 执行一次每日自动续费扫描:先收敛历史非终态尝试,再按资产扫描候选并逐项执行。
|
||||
//
|
||||
// 只有扫描级失败(配置读取、候选读取、数据库不可用)返回错误交既有任务重试;单个资产执行失败
|
||||
// 在用例内捕获并落终态后继续处理其余资产。总开关关闭时不创建任何新尝试与订单,既有记录保留。
|
||||
func (s *Service) RunDailyScan(ctx context.Context) (ScanResult, error) {
|
||||
result := ScanResult{}
|
||||
if s.db == nil {
|
||||
return result, errors.New(errors.CodeServiceUnavailable, "自动续费用例未配置")
|
||||
}
|
||||
converged, err := s.attemptStore.ConvergeUnfinished(ctx, s.today())
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Converged = converged
|
||||
if converged > 0 {
|
||||
s.logger.Info("自动续费历史非终态尝试已收敛", zap.Int64("converged", converged))
|
||||
}
|
||||
config, err := s.configStore.Get(ctx)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return result, errors.New(errors.CodeNotFound, "自动续费配置不存在")
|
||||
}
|
||||
return result, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败")
|
||||
}
|
||||
if config.Enabled != 1 {
|
||||
s.logger.Info("自动续费总开关关闭,本次扫描不创建尝试与订单")
|
||||
return result, nil
|
||||
}
|
||||
if config.Scope != constants.AssetAutoRenewalScopeAll && config.Scope != constants.AssetAutoRenewalScopeSpecified {
|
||||
return result, errors.New(errors.CodeInvalidStatus, "自动续费配置范围取值非法")
|
||||
}
|
||||
candidates, err := s.candidates.Candidates(ctx, config.DaysBeforeExpiry)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Candidates = len(candidates)
|
||||
scope := newScopeMatcher(config)
|
||||
for _, candidate := range candidates {
|
||||
if !scope.matches(candidate.CurrentPackageID) {
|
||||
continue
|
||||
}
|
||||
outcome, processErr := s.processCandidate(ctx, candidate, config)
|
||||
if processErr != nil {
|
||||
// 单资产失败已落终态,继续处理其余资产;扫描任务本身不因该资产失败而失败。
|
||||
s.logger.Error("自动续费单资产执行失败,继续处理其余资产",
|
||||
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID),
|
||||
zap.Error(processErr))
|
||||
result.Failed++
|
||||
continue
|
||||
}
|
||||
switch outcome {
|
||||
case candidateSucceeded:
|
||||
result.Attempted++
|
||||
result.Succeeded++
|
||||
case candidateFailed:
|
||||
result.Attempted++
|
||||
result.Failed++
|
||||
case candidateSkipped:
|
||||
result.Attempted++
|
||||
result.Skipped++
|
||||
default:
|
||||
result.Duplicated++
|
||||
}
|
||||
}
|
||||
s.logger.Info("自动续费每日扫描完成",
|
||||
zap.Int64("converged", result.Converged), zap.Int("candidates", result.Candidates),
|
||||
zap.Int("succeeded", result.Succeeded), zap.Int("failed", result.Failed),
|
||||
zap.Int("skipped", result.Skipped), zap.Int("duplicated", result.Duplicated))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// scanOutcome 是一次候选处理的终态归属,用于汇总扫描结果。
|
||||
type scanOutcome int
|
||||
|
||||
const (
|
||||
candidateDuplicated scanOutcome = iota
|
||||
candidateSucceeded
|
||||
candidateFailed
|
||||
candidateSkipped
|
||||
)
|
||||
|
||||
// scopeMatcher 表达配置的适用范围:全部主套餐,或指定主套餐集合。
|
||||
//
|
||||
// 运行时只按当前主套餐商品是否在集合内判定,不因后来下架而拒绝——下架交给续费豁免判定。
|
||||
type scopeMatcher struct {
|
||||
all bool
|
||||
packageIDs map[uint]struct{}
|
||||
}
|
||||
|
||||
func newScopeMatcher(config *model.AssetAutoRenewalConfig) scopeMatcher {
|
||||
if config.Scope == constants.AssetAutoRenewalScopeAll {
|
||||
return scopeMatcher{all: true}
|
||||
}
|
||||
ids := make(map[uint]struct{}, len(config.PackageIDs))
|
||||
for _, packageID := range config.PackageIDs {
|
||||
ids[packageID] = struct{}{}
|
||||
}
|
||||
return scopeMatcher{packageIDs: ids}
|
||||
}
|
||||
|
||||
func (m scopeMatcher) matches(packageID uint) bool {
|
||||
if m.all {
|
||||
return true
|
||||
}
|
||||
_, exists := m.packageIDs[packageID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// processCandidate 处理单个候选:占位写入、执行、落终态与通知。
|
||||
//
|
||||
// 占位冲突表示该资产当日已尝试,直接跳过且不重复扣款;执行阶段的失败与跳过各以独立短事务落终态。
|
||||
func (s *Service) processCandidate(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig) (scanOutcome, error) {
|
||||
triggerDate := s.today()
|
||||
attempt, err := s.buildAttempt(ctx, candidate, config, triggerDate)
|
||||
if err != nil {
|
||||
return candidateFailed, err
|
||||
}
|
||||
created, err := s.attemptStore.CreatePlaceholder(ctx, attempt)
|
||||
if err != nil {
|
||||
return candidateFailed, err
|
||||
}
|
||||
if !created {
|
||||
s.logger.Info("该资产当日已存在自动续费尝试,跳过",
|
||||
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID))
|
||||
return candidateDuplicated, nil
|
||||
}
|
||||
facts, halt, err := s.executeRenewal(ctx, candidate, attempt, config.DaysBeforeExpiry)
|
||||
if halt != nil {
|
||||
if halt.SkipReason != "" {
|
||||
return candidateSkipped, s.finalizeSkip(ctx, attempt, halt)
|
||||
}
|
||||
return candidateFailed, s.finalizeFailure(ctx, attempt, halt.FailureReason, halt.Detail)
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("自动续费事务失败并已整体回滚",
|
||||
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID), zap.Error(err))
|
||||
return candidateFailed, s.finalizeFailure(ctx, attempt, constants.AssetAutoRenewalFailureOrderFailed,
|
||||
"续购事务执行失败并已整体回滚,未产生订单、扣款与套餐事实")
|
||||
}
|
||||
s.logger.Info("自动续费续购成功",
|
||||
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID),
|
||||
zap.Uint("order_id", facts.OrderID), zap.String("order_no", facts.OrderNo),
|
||||
zap.Int64("renew_price", facts.RenewPrice))
|
||||
return candidateSucceeded, nil
|
||||
}
|
||||
|
||||
// buildAttempt 组装占位尝试记录,冻结触发时的客户、店铺、配置窗口与套餐快照。
|
||||
func (s *Service) buildAttempt(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig, triggerDate time.Time) (*model.AssetAutoRenewalAttempt, error) {
|
||||
sequence, err := s.attemptStore.CountByAsset(ctx, candidate.AssetType, candidate.AssetID, triggerDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finalExpiresAt := candidate.FinalExpiresAt
|
||||
attempt := &model.AssetAutoRenewalAttempt{
|
||||
AssetType: candidate.AssetType, AssetID: candidate.AssetID, TriggerDate: triggerDate,
|
||||
Status: constants.AssetAutoRenewalAttemptStatusProcessing,
|
||||
CustomerID: candidate.CustomerID,
|
||||
ShopID: candidate.ShopID,
|
||||
ConfigVersion: config.ConfigVersion, WindowDays: config.DaysBeforeExpiry,
|
||||
FinalExpiresAt: &finalExpiresAt,
|
||||
CurrentUsageID: candidate.CurrentUsageID,
|
||||
CurrentPackageID: candidate.CurrentPackageID,
|
||||
RenewPackageID: candidate.CurrentPackageID,
|
||||
OperatorType: constants.AssetAutoRenewalOperatorTypeSystemTask,
|
||||
OperatorID: constants.TaskTypeAssetAutoRenewalScan,
|
||||
AttemptSeq: int(sequence) + 1,
|
||||
}
|
||||
return attempt, nil
|
||||
}
|
||||
|
||||
// finalizeSkip 以独立短事务落跳过终态:不扣款、不建订单、不发送通知。
|
||||
func (s *Service) finalizeSkip(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, halt *renewalHalt) error {
|
||||
_, err := s.attemptStore.Finalize(ctx, attempt.ID, map[string]any{
|
||||
"status": constants.AssetAutoRenewalAttemptStatusSkipped,
|
||||
"skip_reason": halt.SkipReason,
|
||||
"failure_reason": "",
|
||||
"failure_detail": halt.Detail,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("自动续费跳过该资产",
|
||||
zap.String("asset_type", attempt.AssetType), zap.Uint("asset_id", attempt.AssetID),
|
||||
zap.String("skip_reason", halt.SkipReason))
|
||||
return nil
|
||||
}
|
||||
|
||||
// finalizeFailure 以独立短事务落失败终态,并在同一事务内投递通知与写失败审计。
|
||||
//
|
||||
// 该短事务与已回滚的续费事务不共用连接或事务(ENG-TX-001 例外),条件更新依据尝试记录仍非终态;
|
||||
// 已被并发收敛时不再投递通知与审计。中断收敛(interrupted)不属于通知口径,不会被通知。
|
||||
func (s *Service) finalizeFailure(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, map[string]any{
|
||||
"status": constants.AssetAutoRenewalAttemptStatusFailed,
|
||||
"failure_reason": reason,
|
||||
"failure_detail": detail,
|
||||
"skip_reason": "",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
s.logger.Info("自动续费尝试已被并发收敛,跳过通知与审计",
|
||||
zap.Uint("attempt_id", attempt.ID))
|
||||
return nil
|
||||
}
|
||||
if notifyErr := s.appendFailureNotifications(ctx, tx, failureNotification{
|
||||
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
|
||||
Identifier: s.assetIdentifier(ctx, attempt.AssetType, attempt.AssetID),
|
||||
ShopID: attempt.ShopID, CustomerID: attempt.CustomerID,
|
||||
TriggerDate: attempt.TriggerDate, Reason: reason,
|
||||
PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt,
|
||||
}); notifyErr != nil {
|
||||
return notifyErr
|
||||
}
|
||||
return s.appendFailureAudit(ctx, tx, attempt, reason, detail)
|
||||
})
|
||||
}
|
||||
190
internal/application/assetautorenewal/service.go
Normal file
190
internal/application/assetautorenewal/service.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Package assetautorenewal 编排资产钱包自动续费:受控配置维护、每日扫描与尝试、续费事务闭合、
|
||||
// 失败通知与复机可靠投递。
|
||||
//
|
||||
// 本包不调用任何支付渠道或运营商接口:续购价格与可售判定复用应用层购买校验与价格策略,
|
||||
// 复机执行通过 ResumeCommander 端口复用既有停复机单一事实源,通知与复机都通过公共 Outbox
|
||||
// 在业务事务内写出事件(ENG-OUTBOX-001)。资金、订单、套餐与成功审计在同一事务内闭合(ENG-TX-001)。
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
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/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||
"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"
|
||||
)
|
||||
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// ResumeOutcome 是一次复机调用可安全记录的结果摘要。
|
||||
//
|
||||
// Applied 为 false 表示本次未满足可复机条件、没有发起任何复机调用;Result 取值
|
||||
// constants.AuditResultSuccess / Failed / Unknown。
|
||||
type ResumeOutcome struct {
|
||||
Applied bool
|
||||
IntegrationID string
|
||||
Result string
|
||||
SafeReason string
|
||||
}
|
||||
|
||||
// ResumeCommander 是自动续费成功后复机的执行边界。
|
||||
//
|
||||
// 实现必须复用既有停复机单一事实源(重试、Integration Log、统一审计、观测序列)与既有
|
||||
// 套餐/流量/实名/风险判定;本包绝不复制这些规则,也绝不直接调用运营商接口。
|
||||
type ResumeCommander interface {
|
||||
// AutoRenewalResumeReady 判断该资产当前是否满足自动复机条件;不满足时返回可安全记录的原因。
|
||||
AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error)
|
||||
// ResumeAssetForAutoRenewal 执行复机并返回结果分类。
|
||||
ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (ResumeOutcome, error)
|
||||
// QueryAutoRenewalResumeState 只查询运营商状态回填复机结果,绝不重复发起复机。
|
||||
QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (online bool, known bool, integrationID string, err error)
|
||||
}
|
||||
|
||||
// Dependencies 汇总自动续费用例的装配依赖。
|
||||
//
|
||||
// DB 与 Redis 用于构造本用例独占的资产钱包、流水、订单、套餐与资产 Store;
|
||||
// 其余依赖是配置、价格、候选、复机与可靠事件的能力边界。
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Redis *redis.Client
|
||||
Logger *zap.Logger
|
||||
Outbox *outbox.Repository
|
||||
AuditWriter *audit.Writer
|
||||
PurchaseValidation *purchase_validation.Service
|
||||
Candidates *assetquery.Query
|
||||
Resume ResumeCommander
|
||||
ObservationEvents cardObservationApp.SeriesEventWriter
|
||||
PriorityEvents priorityapp.PriorityEventWriter
|
||||
}
|
||||
|
||||
// Service 执行资产钱包自动续费的配置维护、每日扫描、续费事务与终止态收敛。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
configStore *postgres.AssetAutoRenewalConfigStore
|
||||
attemptStore *postgres.AssetAutoRenewalAttemptStore
|
||||
assetWalletStore *postgres.AssetWalletStore
|
||||
walletTransactionStore *postgres.AssetWalletTransactionStore
|
||||
orderStore *postgres.OrderStore
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
packageStore *postgres.PackageStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
personalCustomerStore *postgres.PersonalCustomerStore
|
||||
candidates *assetquery.Query
|
||||
purchaseValidation *purchase_validation.Service
|
||||
outbox *outbox.Repository
|
||||
auditWriter *audit.Writer
|
||||
resume ResumeCommander
|
||||
observationEvents cardObservationApp.SeriesEventWriter
|
||||
priorityEvents priorityapp.PriorityEventWriter
|
||||
logger *zap.Logger
|
||||
now func() time.Time
|
||||
silence SilenceRecorder
|
||||
}
|
||||
|
||||
// SilenceRecorder 在续购创建套餐记录的同一事务内设置沉默期与首次购买套餐时间(无人参与路径只用默认值)。
|
||||
type SilenceRecorder interface {
|
||||
ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error
|
||||
RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error
|
||||
}
|
||||
|
||||
// SetSilenceRecorder 注入沉默期与首次购买套餐时间写入接缝;未注入时不记录。
|
||||
func (s *Service) SetSilenceRecorder(recorder SilenceRecorder) {
|
||||
s.silence = recorder
|
||||
}
|
||||
|
||||
// NewService 创建资产钱包自动续费用例。
|
||||
func NewService(deps Dependencies) *Service {
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &Service{
|
||||
db: deps.DB,
|
||||
configStore: postgres.NewAssetAutoRenewalConfigStore(deps.DB),
|
||||
attemptStore: postgres.NewAssetAutoRenewalAttemptStore(deps.DB),
|
||||
assetWalletStore: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
walletTransactionStore: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
|
||||
orderStore: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||
packageUsageStore: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
|
||||
packageStore: postgres.NewPackageStore(deps.DB),
|
||||
iotCardStore: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
deviceStore: postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||
personalCustomerStore: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
candidates: deps.Candidates,
|
||||
purchaseValidation: deps.PurchaseValidation,
|
||||
outbox: deps.Outbox,
|
||||
auditWriter: deps.AuditWriter,
|
||||
resume: deps.Resume,
|
||||
observationEvents: deps.ObservationEvents,
|
||||
priorityEvents: deps.PriorityEvents,
|
||||
logger: logger,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// today 返回当前上海自然日,作为触发日期与每日唯一键的统一口径。
|
||||
func (s *Service) today() time.Time {
|
||||
return assetquery.Today(s.now())
|
||||
}
|
||||
|
||||
// assetIdentifier 读取资产对外的可读标识,取不到时回退为资产 ID 文本。
|
||||
// 通知模板要求标识非空,因此绝不返回空串。
|
||||
func (s *Service) assetIdentifier(ctx context.Context, assetType string, assetID uint) string {
|
||||
switch assetType {
|
||||
case constants.AssetWalletResourceTypeIotCard:
|
||||
if card, err := s.iotCardStore.GetByID(ctx, assetID); err == nil && card.ICCID != "" {
|
||||
return card.ICCID
|
||||
}
|
||||
case constants.AssetWalletResourceTypeDevice:
|
||||
if device, err := s.deviceStore.GetByID(ctx, assetID); err == nil {
|
||||
if device.VirtualNo != "" {
|
||||
return device.VirtualNo
|
||||
}
|
||||
if device.IMEI != "" {
|
||||
return device.IMEI
|
||||
}
|
||||
}
|
||||
}
|
||||
return strconv.FormatUint(uint64(assetID), 10)
|
||||
}
|
||||
|
||||
// packageName 读取套餐商品名称,取不到时回退为套餐 ID 文本。
|
||||
func (s *Service) packageName(ctx context.Context, packageID uint) string {
|
||||
if packageID == 0 {
|
||||
return "未知套餐"
|
||||
}
|
||||
if pkg, err := s.packageStore.GetByID(ctx, packageID); err == nil && pkg.PackageName != "" {
|
||||
return pkg.PackageName
|
||||
}
|
||||
return strconv.FormatUint(uint64(packageID), 10)
|
||||
}
|
||||
|
||||
// loadPackagesByIDs 批量读取套餐商品,用于一次性取价与快照。
|
||||
func (s *Service) loadPackagesByIDs(ctx context.Context, packageIDs []uint) (map[uint]*model.Package, error) {
|
||||
result := make(map[uint]*model.Package, len(packageIDs))
|
||||
if len(packageIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var packages []*model.Package
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", packageIDs).Find(&packages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费套餐商品失败")
|
||||
}
|
||||
for _, pkg := range packages {
|
||||
result[pkg.ID] = pkg
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -57,26 +57,9 @@ func (s *Service) ArchiveIntegrationDate(ctx context.Context, archiveDate time.T
|
||||
return s.archiveIntegrationDate(ctx, archiveDate, false)
|
||||
}
|
||||
|
||||
// FinalizePreviousIntegrationMonth 复核并终结上一个完整自然月的 Integration Log 归档。
|
||||
func (s *Service) FinalizePreviousIntegrationMonth(ctx context.Context) error {
|
||||
now := time.Now().In(s.location)
|
||||
return s.FinalizeIntegrationMonth(ctx, now.AddDate(0, -1, 0))
|
||||
}
|
||||
|
||||
// FinalizeIntegrationMonth 逐日复核指定完整自然月,并为变化内容创建最终 revision。
|
||||
func (s *Service) FinalizeIntegrationMonth(ctx context.Context, month time.Time) error {
|
||||
monthStart := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
|
||||
currentMonth := time.Now().In(s.location)
|
||||
currentMonthStart := time.Date(currentMonth.Year(), currentMonth.Month(), 1, 0, 0, 0, 0, s.location)
|
||||
if !monthStart.Before(currentMonthStart) {
|
||||
return fmt.Errorf("只能终结已经结束的 Integration Log 完整自然月")
|
||||
}
|
||||
for date := monthStart; date.Before(monthStart.AddDate(0, 1, 0)); date = date.AddDate(0, 0, 1) {
|
||||
if err := s.archiveIntegrationDate(ctx, date, true); err != nil {
|
||||
return fmt.Errorf("终结 %s Integration Log 归档失败: %w", date.Format(time.DateOnly), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// FinalizeIntegrationDate 为指定已结束自然日形成 Integration Log 最终归档版本。
|
||||
func (s *Service) FinalizeIntegrationDate(ctx context.Context, archiveDate time.Time) error {
|
||||
return s.archiveIntegrationDate(ctx, archiveDate, true)
|
||||
}
|
||||
|
||||
func (s *Service) archiveIntegrationDate(ctx context.Context, archiveDate time.Time, final bool) error {
|
||||
@@ -94,22 +77,20 @@ func (s *Service) archiveIntegrationDate(ctx context.Context, archiveDate time.T
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if final && run.Status == constants.ArchiveStatusSuccess && run.IsFinal && run.CleanedAt != nil {
|
||||
terminalCount, countErr := s.integrationTerminalCount(ctx, start, end)
|
||||
if countErr != nil {
|
||||
return countErr
|
||||
}
|
||||
if terminalCount == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file, err := s.buildIntegrationArchiveFile(ctx, start, end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(file.path)
|
||||
if final {
|
||||
pending, pendingErr := s.integrationPendingCount(ctx, start, end)
|
||||
if pendingErr != nil {
|
||||
return pendingErr
|
||||
}
|
||||
if pending > 0 {
|
||||
_ = s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).
|
||||
Updates(map[string]any{"is_final": false, "error_summary": "存在 pending Integration Log,无法形成最终归档", "updated_at": time.Now()}).Error
|
||||
return fmt.Errorf("仍有 %d 条 pending Integration Log,无法形成最终归档", pending)
|
||||
}
|
||||
}
|
||||
if run.Status == constants.ArchiveStatusSuccess && run.RecordCount == file.recordCount && run.SHA256 == file.sha256 {
|
||||
valid, validateErr := s.validateIntegrationRun(ctx, run)
|
||||
if validateErr == nil && valid && (!final || run.IsFinal) {
|
||||
@@ -165,7 +146,8 @@ func (s *Service) acquireIntegrationRun(ctx context.Context, run *model.LogArchi
|
||||
Where("id = ? AND (status <> ? OR updated_at < ?)", run.ID, constants.ArchiveStatusRunning, now.Add(-3*time.Hour)).
|
||||
Updates(map[string]any{
|
||||
"status": constants.ArchiveStatusRunning, "revision": revision, "is_final": false,
|
||||
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "", "completed_at": nil, "updated_at": now,
|
||||
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "", "completed_at": nil,
|
||||
"cleanup_started_at": nil, "cleaned_at": nil, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, fmt.Errorf("锁定 Integration Log 归档任务失败: %w", result.Error)
|
||||
@@ -320,16 +302,6 @@ func (s *Service) integrationRecordCount(ctx context.Context, start, end time.Ti
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *Service) integrationPendingCount(ctx context.Context, start, end time.Time) (int64, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
|
||||
Where("created_at >= ? AND created_at < ? AND result = ?", start, end, constants.IntegrationResultPending).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, fmt.Errorf("统计 pending Integration Log 失败: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func integrationObjectKeys(date time.Time, revision int) (string, string) {
|
||||
prefix := fmt.Sprintf("audit-archive/v1/%04d/%02d/%02d", date.Year(), date.Month(), date.Day())
|
||||
name := fmt.Sprintf("integration-logs-%s-r%d", date.Format(time.DateOnly), revision)
|
||||
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -19,25 +17,18 @@ import (
|
||||
|
||||
const maxManifestBytes = 1024 * 1024
|
||||
|
||||
// RetentionAudit 描述月度物理清理的统一审计事实。
|
||||
// RetentionAudit 保留系统审计 Writer 的输入兼容类型。
|
||||
type RetentionAudit struct {
|
||||
EventID string
|
||||
Month string
|
||||
Summary string
|
||||
Result string
|
||||
ErrorSummary string
|
||||
RangeStart time.Time
|
||||
RangeEnd time.Time
|
||||
EventCount int64
|
||||
ResourceCount int64
|
||||
IntegrationCount int64
|
||||
ManifestKeys []string
|
||||
DurationMS int64
|
||||
EventID, Month, Summary, Result, ErrorSummary string
|
||||
RangeStart, RangeEnd time.Time
|
||||
EventCount, ResourceCount, IntegrationCount int64
|
||||
ManifestKeys []string
|
||||
DurationMS int64
|
||||
}
|
||||
|
||||
// RetentionResult 是月度留存清理的结构化执行结果。
|
||||
// RetentionResult 是单日留存处理结果。
|
||||
type RetentionResult struct {
|
||||
Month string
|
||||
ArchiveDate string
|
||||
EventCount int64
|
||||
ResourceCount int64
|
||||
IntegrationCount int64
|
||||
@@ -46,191 +37,150 @@ type RetentionResult struct {
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
type retentionRuns struct {
|
||||
audit []*model.LogArchiveRun
|
||||
integration []*model.LogArchiveRun
|
||||
// RetentionBlockedError 描述阻断日期推进的安全上下文。
|
||||
type RetentionBlockedError struct {
|
||||
ArchiveDate string
|
||||
Source string
|
||||
Err error
|
||||
}
|
||||
|
||||
// CleanupPreviousMonth 校验并物理清理上一个完整自然月的在线审计日志。
|
||||
func (s *Service) CleanupPreviousMonth(ctx context.Context) (RetentionResult, error) {
|
||||
now := time.Now().In(s.location)
|
||||
return s.CleanupMonth(ctx, now.AddDate(0, -1, 0))
|
||||
func (e *RetentionBlockedError) Error() string {
|
||||
return e.ArchiveDate + " " + e.Source + ": " + e.Err.Error()
|
||||
}
|
||||
func (e *RetentionBlockedError) Unwrap() error { return e.Err }
|
||||
|
||||
// ValidatePreviousMonth 只读校验上一个完整自然月的归档与清理门禁。
|
||||
func (s *Service) ValidatePreviousMonth(ctx context.Context) (RetentionResult, error) {
|
||||
now := time.Now().In(s.location)
|
||||
return s.ValidateMonth(ctx, now.AddDate(0, -1, 0))
|
||||
}
|
||||
|
||||
// ValidateMonth 只读校验指定完整自然月,不写清理断点且不删除在线数据。
|
||||
func (s *Service) ValidateMonth(ctx context.Context, month time.Time) (result RetentionResult, err error) {
|
||||
// RetainPendingDays 每次最多处理一个最早待处理的上海自然日,避免单任务跨历史日期长时间占用数据库。
|
||||
func (s *Service) RetainPendingDays(ctx context.Context, cleanup bool) ([]RetentionResult, error) {
|
||||
if s.db == nil || s.store == nil {
|
||||
return result, fmt.Errorf("日志留存演练数据库或对象存储未配置")
|
||||
return nil, fmt.Errorf("日志日留存数据库或对象存储未配置")
|
||||
}
|
||||
start, end, err := s.retentionMonthRange(month)
|
||||
start, end, err := s.pendingRetentionRange(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
return nil, err
|
||||
}
|
||||
if !start.Before(end) {
|
||||
return nil, nil
|
||||
}
|
||||
result, err := s.retainDate(ctx, start, cleanup)
|
||||
if err != nil {
|
||||
return nil, &RetentionBlockedError{ArchiveDate: start.Format(time.DateOnly), Source: constants.AuditArchiveSource, Err: err}
|
||||
}
|
||||
return []RetentionResult{result}, nil
|
||||
}
|
||||
|
||||
// RetainDate 校验并按需清理指定已结束自然日,供受控演练使用。
|
||||
func (s *Service) RetainDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
|
||||
return s.retainDate(ctx, date, cleanup)
|
||||
}
|
||||
|
||||
func (s *Service) pendingRetentionRange(ctx context.Context) (time.Time, time.Time, error) {
|
||||
today := time.Now().In(s.location)
|
||||
end := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location)
|
||||
var earliest *time.Time
|
||||
for _, item := range []struct{ table, column, condition string }{
|
||||
{"tb_audit_event", "created_at", ""},
|
||||
{"tb_integration_log", "created_at", "result <> 'pending'"},
|
||||
} {
|
||||
query := s.db.WithContext(ctx).Table(item.table).Select("MIN(" + item.column + ")")
|
||||
if item.condition != "" {
|
||||
query = query.Where(item.condition)
|
||||
}
|
||||
var value *time.Time
|
||||
if err := query.Scan(&value).Error; err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("查询日留存起点失败: %w", err)
|
||||
}
|
||||
if value != nil && (earliest == nil || value.Before(*earliest)) {
|
||||
local := value.In(s.location)
|
||||
earliest = &local
|
||||
}
|
||||
}
|
||||
if earliest == nil {
|
||||
return end, end, nil
|
||||
}
|
||||
start := time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, s.location)
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func (s *Service) retainDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
|
||||
startedAt := time.Now()
|
||||
result.Month = start.Format("2006-01")
|
||||
runs, err := s.loadRetentionRuns(ctx, start, end)
|
||||
auditResult, err := s.retainAuditDate(ctx, date, cleanup)
|
||||
if err != nil {
|
||||
return result, err
|
||||
return RetentionResult{}, err
|
||||
}
|
||||
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
|
||||
return result, err
|
||||
integrationResult, err := s.retainIntegrationDate(ctx, date, cleanup)
|
||||
if err != nil {
|
||||
return RetentionResult{}, err
|
||||
}
|
||||
summarizeRetentionRuns(runs, &result)
|
||||
result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), EventCount: auditResult.EventCount, ResourceCount: auditResult.ResourceCount, IntegrationCount: integrationResult.IntegrationCount, ManifestKeys: append(auditResult.ManifestKeys, integrationResult.ManifestKeys...)}
|
||||
result.EstimatedBatches = estimatedRetentionBatches(result)
|
||||
result.Duration = time.Since(startedAt)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CleanupMonth 校验归档硬门禁后按固定顺序物理清理指定完整自然月。
|
||||
func (s *Service) CleanupMonth(ctx context.Context, month time.Time) (result RetentionResult, cleanupErr error) {
|
||||
if s.db == nil || s.store == nil || s.audit == nil {
|
||||
return result, fmt.Errorf("日志留存清理数据库、对象存储或审计 Writer 未配置")
|
||||
}
|
||||
start, end, err := s.retentionMonthRange(month)
|
||||
func (s *Service) retainAuditDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
|
||||
run, err := s.retentionRun(ctx, date, constants.AuditArchiveSource)
|
||||
if err != nil {
|
||||
return result, err
|
||||
return RetentionResult{}, err
|
||||
}
|
||||
startedAt := time.Now()
|
||||
result.Month = start.Format("2006-01")
|
||||
cleanupErr = s.executeRetention(ctx, start, end, &result)
|
||||
result.Duration = time.Since(startedAt)
|
||||
if auditErr := s.recordRetentionAudit(ctx, start, end, result, cleanupErr); auditErr != nil {
|
||||
if cleanupErr != nil {
|
||||
return result, fmt.Errorf("%w;记录留存清理失败审计失败: %v", cleanupErr, auditErr)
|
||||
if run == nil || run.Status != constants.ArchiveStatusSuccess {
|
||||
if err := s.ArchiveDate(ctx, date); err != nil {
|
||||
return RetentionResult{}, fmt.Errorf("Audit 归档失败: %w", err)
|
||||
}
|
||||
run, err = s.retentionRun(ctx, date, constants.AuditArchiveSource)
|
||||
if err != nil {
|
||||
return RetentionResult{}, err
|
||||
}
|
||||
return result, fmt.Errorf("记录留存清理成功审计失败: %w", auditErr)
|
||||
}
|
||||
return result, cleanupErr
|
||||
if err := s.validateAuditRetentionDay(ctx, date, run); err != nil {
|
||||
return RetentionResult{}, fmt.Errorf("Audit 完整性校验失败: %w", err)
|
||||
}
|
||||
result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), EventCount: run.EventCount, ResourceCount: run.ResourceCount, ManifestKeys: []string{run.ManifestKey}}
|
||||
if cleanup {
|
||||
if err := s.cleanupAuditDate(ctx, date, run); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) retentionMonthRange(month time.Time) (time.Time, time.Time, error) {
|
||||
start := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
|
||||
end := start.AddDate(0, 1, 0)
|
||||
now := time.Now().In(s.location)
|
||||
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, s.location)
|
||||
if !end.Before(currentMonth) && !end.Equal(currentMonth) {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("只能清理已经结束的完整自然月")
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func (s *Service) executeRetention(ctx context.Context, start, end time.Time, result *RetentionResult) error {
|
||||
started, err := s.retentionCleanupStarted(ctx, start, end)
|
||||
func (s *Service) retainIntegrationDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
|
||||
run, err := s.retentionRun(ctx, date, constants.IntegrationArchiveSource)
|
||||
if err != nil {
|
||||
return err
|
||||
return RetentionResult{}, err
|
||||
}
|
||||
if !started {
|
||||
lastDay := end.AddDate(0, 0, -1)
|
||||
if err := s.ArchiveDate(ctx, lastDay); err != nil {
|
||||
return fmt.Errorf("完成上月最后一天 Audit 归档失败: %w", err)
|
||||
}
|
||||
if err := s.ArchiveIntegrationDate(ctx, lastDay); err != nil {
|
||||
return fmt.Errorf("完成上月最后一天 Integration Log 归档失败: %w", err)
|
||||
}
|
||||
if err := s.FinalizeIntegrationMonth(ctx, start); err != nil {
|
||||
return err
|
||||
if run == nil || run.Status != constants.ArchiveStatusSuccess {
|
||||
if err := s.ArchiveIntegrationDate(ctx, date); err != nil {
|
||||
return RetentionResult{}, fmt.Errorf("Integration Log 归档失败: %w", err)
|
||||
}
|
||||
}
|
||||
runs, err := s.loadRetentionRuns(ctx, start, end)
|
||||
if err := s.FinalizeIntegrationDate(ctx, date); err != nil {
|
||||
return RetentionResult{}, fmt.Errorf("Integration Log 最终归档失败: %w", err)
|
||||
}
|
||||
run, err = s.retentionRun(ctx, date, constants.IntegrationArchiveSource)
|
||||
if err != nil {
|
||||
return err
|
||||
return RetentionResult{}, err
|
||||
}
|
||||
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
|
||||
return err
|
||||
if err := s.validateIntegrationRetentionDay(ctx, date, run); err != nil {
|
||||
return RetentionResult{}, fmt.Errorf("Integration Log 完整性校验失败: %w", err)
|
||||
}
|
||||
summarizeRetentionRuns(runs, result)
|
||||
if err := s.cleanupAuditMonth(ctx, start, end, runs.audit); err != nil {
|
||||
return err
|
||||
result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), IntegrationCount: run.RecordCount, ManifestKeys: []string{run.ManifestKey}}
|
||||
if cleanup {
|
||||
if err := s.cleanupIntegrationDate(ctx, date, run); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
return s.cleanupIntegrationMonth(ctx, start, end, runs.integration)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) retentionCleanupStarted(ctx context.Context, start, end time.Time) (bool, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
|
||||
Where("archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
|
||||
start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("读取月度清理断点失败: %w", err)
|
||||
func (s *Service) retentionRun(ctx context.Context, date time.Time, source string) (*model.LogArchiveRun, error) {
|
||||
var runs []model.LogArchiveRun
|
||||
if err := s.db.WithContext(ctx).Where("source = ? AND archive_date = ? AND instance_id = ?", source, date.Format(time.DateOnly), s.instanceID).Find(&runs).Error; err != nil {
|
||||
return nil, fmt.Errorf("读取日归档账本失败: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadRetentionRuns(ctx context.Context, start, end time.Time) (retentionRuns, error) {
|
||||
var rows []model.LogArchiveRun
|
||||
err := s.db.WithContext(ctx).Where(
|
||||
"source IN ? AND archive_date >= ? AND archive_date < ? AND instance_id = ?",
|
||||
[]string{constants.AuditArchiveSource, constants.IntegrationArchiveSource}, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID,
|
||||
).Order("archive_date ASC, source ASC").Find(&rows).Error
|
||||
if err != nil {
|
||||
return retentionRuns{}, fmt.Errorf("读取月度归档账本失败: %w", err)
|
||||
if len(runs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
days := int(end.Sub(start).Hours() / 24)
|
||||
if len(rows) != days*2 {
|
||||
return retentionRuns{}, fmt.Errorf("月度归档账本缺日:期望 %d 条,实际 %d 条", days*2, len(rows))
|
||||
}
|
||||
runs := retentionRuns{audit: make([]*model.LogArchiveRun, 0, days), integration: make([]*model.LogArchiveRun, 0, days)}
|
||||
for index := range rows {
|
||||
run := &rows[index]
|
||||
switch run.Source {
|
||||
case constants.AuditArchiveSource:
|
||||
runs.audit = append(runs.audit, run)
|
||||
case constants.IntegrationArchiveSource:
|
||||
runs.integration = append(runs.integration, run)
|
||||
}
|
||||
}
|
||||
if len(runs.audit) != days || len(runs.integration) != days {
|
||||
return retentionRuns{}, fmt.Errorf("月度 Audit 或 Integration 归档账本不完整")
|
||||
}
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateRetentionRuns(ctx context.Context, start, end time.Time, runs retentionRuns) error {
|
||||
if err := validateCleanupLedgerState(runs.audit); err != nil {
|
||||
return fmt.Errorf("Audit 清理断点非法: %w", err)
|
||||
}
|
||||
if err := validateCleanupLedgerState(runs.integration); err != nil {
|
||||
return fmt.Errorf("Integration 清理断点非法: %w", err)
|
||||
}
|
||||
for index := range runs.audit {
|
||||
date := start.AddDate(0, 0, index)
|
||||
if err := s.validateAuditRetentionDay(ctx, date, runs.audit[index]); err != nil {
|
||||
return fmt.Errorf("%s Audit 清理门禁失败: %w", date.Format(time.DateOnly), err)
|
||||
}
|
||||
if err := s.validateIntegrationRetentionDay(ctx, date, runs.integration[index]); err != nil {
|
||||
return fmt.Errorf("%s Integration 清理门禁失败: %w", date.Format(time.DateOnly), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCleanupLedgerState(runs []*model.LogArchiveRun) error {
|
||||
started, cleaned := 0, 0
|
||||
for _, run := range runs {
|
||||
if run.CleanupStartedAt != nil {
|
||||
started++
|
||||
}
|
||||
if run.CleanedAt != nil {
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
if started != 0 && started != len(runs) {
|
||||
return fmt.Errorf("清理开始断点不是整月原子状态")
|
||||
}
|
||||
if cleaned != 0 && cleaned != len(runs) {
|
||||
return fmt.Errorf("清理完成断点不是整月原子状态")
|
||||
}
|
||||
if cleaned > 0 && started == 0 {
|
||||
return fmt.Errorf("清理完成但缺少开始断点")
|
||||
}
|
||||
return nil
|
||||
return &runs[0], nil
|
||||
}
|
||||
|
||||
func (s *Service) validateAuditRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
|
||||
@@ -258,25 +208,25 @@ func (s *Service) validateIntegrationRetentionDay(ctx context.Context, date time
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if run.CleanedAt != nil {
|
||||
if count != 0 {
|
||||
return fmt.Errorf("已标记清理完成但数据库仍有 %d 条记录", count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if run.CleanupStartedAt != nil {
|
||||
if count > run.RecordCount {
|
||||
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
file, err := s.buildIntegrationArchiveFile(ctx, run.RangeStart, run.RangeEnd)
|
||||
terminalCount, err := s.integrationTerminalCount(ctx, run.RangeStart, run.RangeEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(file.path)
|
||||
if file.recordCount != run.RecordCount || file.sha256 != run.SHA256 {
|
||||
return fmt.Errorf("数据库当前 Integration 内容与最终 revision 不一致")
|
||||
if run.CleanedAt != nil && terminalCount != 0 {
|
||||
return fmt.Errorf("已标记清理完成但数据库仍有 %d 条终态记录", terminalCount)
|
||||
}
|
||||
if run.CleanupStartedAt != nil && count > run.RecordCount {
|
||||
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
|
||||
}
|
||||
if terminalCount > 0 && run.CleanupStartedAt == nil {
|
||||
file, err := s.buildIntegrationArchiveFile(ctx, run.RangeStart, run.RangeEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(file.path)
|
||||
if file.recordCount != run.RecordCount || file.sha256 != run.SHA256 {
|
||||
return fmt.Errorf("数据库当前 Integration 内容与最终 revision 不一致")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -285,8 +235,7 @@ func validateRunBase(run *model.LogArchiveRun, date time.Time, schema string, fi
|
||||
if run.Status != constants.ArchiveStatusSuccess || run.SchemaVersion != schema {
|
||||
return fmt.Errorf("归档状态或 schema version 不符合清理要求")
|
||||
}
|
||||
if run.ArchiveDate.Format(time.DateOnly) != date.Format(time.DateOnly) ||
|
||||
!run.RangeStart.Equal(date) || !run.RangeEnd.Equal(date.AddDate(0, 0, 1)) {
|
||||
if run.ArchiveDate.Format(time.DateOnly) != date.Format(time.DateOnly) || !run.RangeStart.Equal(date) || !run.RangeEnd.Equal(date.AddDate(0, 0, 1)) {
|
||||
return fmt.Errorf("归档日期或半开时间范围不一致")
|
||||
}
|
||||
if final && !run.IsFinal {
|
||||
@@ -297,21 +246,14 @@ func validateRunBase(run *model.LogArchiveRun, date time.Time, schema string, fi
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRemainingCounts(run *model.LogArchiveRun, events, resources int64) error {
|
||||
if run.CleanedAt != nil {
|
||||
if events != 0 || resources != 0 {
|
||||
return fmt.Errorf("已标记清理完成但数据库仍有事件或资源")
|
||||
}
|
||||
return nil
|
||||
if run.CleanedAt != nil && (events != 0 || resources != 0) {
|
||||
return fmt.Errorf("已标记清理完成但数据库仍有事件或资源")
|
||||
}
|
||||
if run.CleanupStartedAt != nil {
|
||||
if events > run.EventCount || resources > run.ResourceCount {
|
||||
return fmt.Errorf("续跑窗口数量超过已归档数量")
|
||||
}
|
||||
return nil
|
||||
if run.CleanupStartedAt != nil && (events > run.EventCount || resources > run.ResourceCount) {
|
||||
return fmt.Errorf("续跑窗口数量超过已归档数量")
|
||||
}
|
||||
if events != run.EventCount || resources != run.ResourceCount {
|
||||
if run.CleanupStartedAt == nil && (events != run.EventCount || resources != run.ResourceCount) {
|
||||
return fmt.Errorf("数据库事件或资源数量与 manifest 不一致")
|
||||
}
|
||||
return nil
|
||||
@@ -322,43 +264,27 @@ func (s *Service) validateAuditManifest(ctx context.Context, run *model.LogArchi
|
||||
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess ||
|
||||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
|
||||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
|
||||
manifest.EventCount != run.EventCount || manifest.ResourceCount != run.ResourceCount ||
|
||||
manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey ||
|
||||
manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
|
||||
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone || manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) || manifest.EventCount != run.EventCount || manifest.ResourceCount != run.ResourceCount || manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
|
||||
return fmt.Errorf("Audit manifest 与 ledger 不一致")
|
||||
}
|
||||
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
|
||||
"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
|
||||
}); err != nil {
|
||||
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision)}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.verifyRetentionObject(ctx, run, false)
|
||||
}
|
||||
|
||||
func (s *Service) validateIntegrationManifest(ctx context.Context, run *model.LogArchiveRun) error {
|
||||
var manifest integrationArchiveManifest
|
||||
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || !manifest.Final ||
|
||||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
|
||||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
|
||||
manifest.RecordCount != run.RecordCount || manifest.CompressedBytes != run.CompressedBytes ||
|
||||
manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
|
||||
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || !manifest.Final || manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone || manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) || manifest.RecordCount != run.RecordCount || manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
|
||||
return fmt.Errorf("Integration manifest 与最终 ledger 不一致")
|
||||
}
|
||||
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
|
||||
"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256,
|
||||
"revision": strconv.Itoa(run.Revision), "final": "true",
|
||||
}); err != nil {
|
||||
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision), "final": "true"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.verifyRetentionObject(ctx, run, true)
|
||||
}
|
||||
|
||||
func (s *Service) readManifest(ctx context.Context, key string, target any) error {
|
||||
object, err := s.store.Stat(ctx, key)
|
||||
if err != nil {
|
||||
@@ -384,13 +310,8 @@ func (s *Service) readManifest(ctx context.Context, key string, target any) erro
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) verifyRetentionObject(ctx context.Context, run *model.LogArchiveRun, final bool) error {
|
||||
metadata := map[string]string{
|
||||
"schema-version": run.SchemaVersion, "source": run.Source,
|
||||
"archive-date": run.RangeStart.Format(time.DateOnly), "timezone": constants.AuditArchiveTimezone,
|
||||
"sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
|
||||
}
|
||||
metadata := map[string]string{"schema-version": run.SchemaVersion, "source": run.Source, "archive-date": run.RangeStart.Format(time.DateOnly), "timezone": constants.AuditArchiveTimezone, "sha256": run.SHA256, "revision": strconv.Itoa(run.Revision)}
|
||||
if run.Source == constants.AuditArchiveSource {
|
||||
metadata["event-count"] = strconv.FormatInt(run.EventCount, 10)
|
||||
metadata["resource-count"] = strconv.FormatInt(run.ResourceCount, 10)
|
||||
@@ -419,53 +340,39 @@ func (s *Service) verifyRetentionObject(ctx context.Context, run *model.LogArchi
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func summarizeRetentionRuns(runs retentionRuns, result *RetentionResult) {
|
||||
result.ManifestKeys = make([]string, 0, len(runs.audit)+len(runs.integration))
|
||||
for _, run := range runs.audit {
|
||||
result.EventCount += run.EventCount
|
||||
result.ResourceCount += run.ResourceCount
|
||||
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
|
||||
}
|
||||
for _, run := range runs.integration {
|
||||
result.IntegrationCount += run.RecordCount
|
||||
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func estimatedRetentionBatches(result RetentionResult) int64 {
|
||||
batchSize := int64(constants.AuditRetentionDeleteBatchSize)
|
||||
return (result.EventCount+batchSize-1)/batchSize +
|
||||
(result.ResourceCount+batchSize-1)/batchSize +
|
||||
(result.IntegrationCount+batchSize-1)/batchSize
|
||||
batch := int64(constants.AuditRetentionDeleteBatchSize)
|
||||
return (result.EventCount+batch-1)/batch + (result.ResourceCount+batch-1)/batch + (result.IntegrationCount+batch-1)/batch
|
||||
}
|
||||
|
||||
func (s *Service) cleanupAuditMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
|
||||
if allRunsCleaned(runs) {
|
||||
func (s *Service) cleanupAuditDate(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
|
||||
if run.CleanedAt != nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.markCleanupStarted(ctx, constants.AuditArchiveSource, start, end); err != nil {
|
||||
if err := s.markCleanupStarted(ctx, constants.AuditArchiveSource, date); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.deleteAuditResources(ctx, start, end); err != nil {
|
||||
if err := s.deleteAuditResources(ctx, date, date.AddDate(0, 0, 1)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.deleteAuditEvents(ctx, start, end); err != nil {
|
||||
if err := s.deleteAuditEvents(ctx, date, date.AddDate(0, 0, 1)); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.markCleaned(ctx, constants.AuditArchiveSource, start, end)
|
||||
return s.markCleaned(ctx, constants.AuditArchiveSource, date)
|
||||
}
|
||||
|
||||
func (s *Service) cleanupIntegrationMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
|
||||
if allRunsCleaned(runs) {
|
||||
func (s *Service) cleanupIntegrationDate(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
|
||||
terminalCount, err := s.integrationTerminalCount(ctx, date, date.AddDate(0, 0, 1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if run.CleanedAt != nil && terminalCount == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.markCleanupStarted(ctx, constants.IntegrationArchiveSource, start, end); err != nil {
|
||||
if err := s.markCleanupStarted(ctx, constants.IntegrationArchiveSource, date); err != nil {
|
||||
return err
|
||||
}
|
||||
end := date.AddDate(0, 0, 1)
|
||||
for {
|
||||
subquery := s.db.Model(&model.IntegrationLog{}).Select("id").
|
||||
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
|
||||
subquery := s.db.Model(&model.IntegrationLog{}).Select("id").Where("created_at >= ? AND created_at < ? AND result <> ?", date, end, constants.IntegrationResultPending).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
|
||||
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.IntegrationLog{})
|
||||
if deleted.Error != nil {
|
||||
return fmt.Errorf("分批物理删除 Integration Log 失败: %w", deleted.Error)
|
||||
@@ -474,15 +381,21 @@ func (s *Service) cleanupIntegrationMonth(ctx context.Context, start, end time.T
|
||||
break
|
||||
}
|
||||
}
|
||||
return s.markCleaned(ctx, constants.IntegrationArchiveSource, start, end)
|
||||
return s.markCleaned(ctx, constants.IntegrationArchiveSource, date)
|
||||
}
|
||||
func (s *Service) integrationTerminalCount(ctx context.Context, start, end time.Time) (int64, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
|
||||
Where("created_at >= ? AND created_at < ? AND result <> ?", start, end, constants.IntegrationResultPending).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, fmt.Errorf("统计终态 Integration Log 失败: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *Service) deleteAuditResources(ctx context.Context, start, end time.Time) error {
|
||||
for {
|
||||
subquery := s.db.Model(&model.AuditEventResource{}).Select("tb_audit_event_resource.id").
|
||||
Joins("JOIN tb_audit_event ON tb_audit_event.id = tb_audit_event_resource.audit_event_id").
|
||||
Where("tb_audit_event.created_at >= ? AND tb_audit_event.created_at < ?", start, end).
|
||||
Order("tb_audit_event_resource.id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
|
||||
subquery := s.db.Model(&model.AuditEventResource{}).Select("tb_audit_event_resource.id").Joins("JOIN tb_audit_event ON tb_audit_event.id = tb_audit_event_resource.audit_event_id").Where("tb_audit_event.created_at >= ? AND tb_audit_event.created_at < ?", start, end).Order("tb_audit_event_resource.id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
|
||||
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEventResource{})
|
||||
if deleted.Error != nil {
|
||||
return fmt.Errorf("分批物理删除 Audit Event Resource 失败: %w", deleted.Error)
|
||||
@@ -492,11 +405,9 @@ func (s *Service) deleteAuditResources(ctx context.Context, start, end time.Time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) deleteAuditEvents(ctx context.Context, start, end time.Time) error {
|
||||
for {
|
||||
subquery := s.db.Model(&model.AuditEvent{}).Select("id").
|
||||
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
|
||||
subquery := s.db.Model(&model.AuditEvent{}).Select("id").Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
|
||||
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEvent{})
|
||||
if deleted.Error != nil {
|
||||
return fmt.Errorf("分批物理删除 Audit Event 失败: %w", deleted.Error)
|
||||
@@ -506,75 +417,19 @@ func (s *Service) deleteAuditEvents(ctx context.Context, start, end time.Time) e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) markCleanupStarted(ctx context.Context, source string, start, end time.Time) error {
|
||||
func (s *Service) markCleanupStarted(ctx context.Context, source string, date time.Time) error {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
|
||||
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NULL",
|
||||
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
|
||||
Updates(map[string]any{"cleanup_started_at": now, "updated_at": now})
|
||||
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("source = ? AND archive_date = ? AND instance_id = ? AND cleanup_started_at IS NULL", source, date.Format(time.DateOnly), s.instanceID).Updates(map[string]any{"cleanup_started_at": now, "updated_at": now})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("记录月度清理开始断点失败: %w", result.Error)
|
||||
}
|
||||
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleanup_started_at IS NOT NULL", "开始")
|
||||
}
|
||||
|
||||
func (s *Service) markCleaned(ctx context.Context, source string, start, end time.Time) error {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
|
||||
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
|
||||
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
|
||||
Updates(map[string]any{"cleaned_at": now, "updated_at": now})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("记录月度清理完成断点失败: %w", result.Error)
|
||||
}
|
||||
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleaned_at IS NOT NULL", "完成")
|
||||
}
|
||||
|
||||
func (s *Service) validateCleanupMarkerCount(ctx context.Context, source string, start, end time.Time, marker, label string) error {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
|
||||
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND "+marker,
|
||||
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("复核月度清理%s断点失败: %w", label, err)
|
||||
}
|
||||
expected := int64(end.Sub(start).Hours() / 24)
|
||||
if count != expected {
|
||||
return fmt.Errorf("月度清理%s断点不完整:期望 %d 条,实际 %d 条", label, expected, count)
|
||||
return fmt.Errorf("记录日清理开始断点失败: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func allRunsCleaned(runs []*model.LogArchiveRun) bool {
|
||||
return len(runs) > 0 && runs[0].CleanedAt != nil
|
||||
}
|
||||
|
||||
func (s *Service) recordRetentionAudit(ctx context.Context, start, end time.Time, result RetentionResult, cleanupErr error) error {
|
||||
audit := RetentionAudit{
|
||||
Month: result.Month, RangeStart: start, RangeEnd: end,
|
||||
EventCount: result.EventCount, ResourceCount: result.ResourceCount,
|
||||
IntegrationCount: result.IntegrationCount, ManifestKeys: result.ManifestKeys,
|
||||
DurationMS: result.Duration.Milliseconds(), Result: constants.AuditResultSuccess,
|
||||
Summary: "完成已归档在线日志月度物理清理",
|
||||
EventID: "evt_retention_" + strings.ReplaceAll(result.Month, "-", "_"),
|
||||
func (s *Service) markCleaned(ctx context.Context, source string, date time.Time) error {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("source = ? AND archive_date = ? AND instance_id = ? AND cleanup_started_at IS NOT NULL", source, date.Format(time.DateOnly), s.instanceID).Updates(map[string]any{"cleaned_at": now, "updated_at": now})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("记录日清理完成断点失败: %w", result.Error)
|
||||
}
|
||||
if cleanupErr != nil {
|
||||
audit.EventID = ""
|
||||
audit.Result = constants.AuditResultFailed
|
||||
audit.Summary = "已归档在线日志月度物理清理失败"
|
||||
audit.ErrorSummary = truncateRetentionError(cleanupErr)
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.audit.WriteRetentionCleanup(ctx, tx, audit)
|
||||
})
|
||||
}
|
||||
|
||||
func truncateRetentionError(err error) string {
|
||||
value := []rune(err.Error())
|
||||
if len(value) > 500 {
|
||||
value = value[:500]
|
||||
}
|
||||
return string(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
530
internal/application/businessusergroup/service.go
Normal file
530
internal/application/businessusergroup/service.go
Normal file
@@ -0,0 +1,530 @@
|
||||
// Package businessusergroup 收口业务用户组、成员归属与店铺负责人批量交接的写用例。
|
||||
// 组只描述平台用户的业务分类,不改变后台角色、登录、权限或数据范围;
|
||||
// 店铺所属组始终由当前负责人实时推导,因此本包不写任何店铺组字段。
|
||||
package businessusergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// Service 业务用户组与成员归属的简单写事务脚本。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
groupStore *postgres.BusinessUserGroupStore
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// New 创建业务用户组事务脚本。
|
||||
func New(db *gorm.DB, groupStore *postgres.BusinessUserGroupStore, auditWriters ...*audit.Writer) *Service {
|
||||
service := &Service{db: db, groupStore: groupStore}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// Create 创建业务用户组并在同一事务写入审计。
|
||||
func (s *Service) Create(ctx context.Context, request *dto.CreateBusinessUserGroupRequest) (*dto.BusinessUserGroupResponse, error) {
|
||||
operatorID, err := s.requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code := strings.TrimSpace(request.Code)
|
||||
name := strings.TrimSpace(request.Name)
|
||||
if code == "" || name == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务用户组编码与名称不能为空")
|
||||
}
|
||||
if !constants.IsValidBusinessLine(request.BusinessLine) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务线取值非法")
|
||||
}
|
||||
group := &model.BusinessUserGroup{
|
||||
Code: code, Name: name, BusinessLine: request.BusinessLine,
|
||||
SortOrder: sortValue(request.Sort), Status: statusValue(request.Enabled),
|
||||
Remark: request.Remark, BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
|
||||
}
|
||||
var response *dto.BusinessUserGroupResponse
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.groupStore.WithTx(tx)
|
||||
exists, err := store.ExistsCode(ctx, code, 0)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验业务用户组编码失败")
|
||||
}
|
||||
if exists {
|
||||
return errors.New(errors.CodeInvalidParam, "业务用户组编码已存在")
|
||||
}
|
||||
if err := store.Create(ctx, group); err != nil {
|
||||
return mapCodeConflict(err)
|
||||
}
|
||||
if err := s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupCreated, "创建业务用户组", group, operatorID, nil, groupSnapshot(group)); err != nil {
|
||||
return err
|
||||
}
|
||||
response = toGroupResponse(group)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Update 更新业务用户组名称、业务线、排序、启停与备注;稳定编码永不允许修改。
|
||||
func (s *Service) Update(ctx context.Context, groupID uint, request *dto.UpdateBusinessUserGroupRequest) (*dto.BusinessUserGroupResponse, error) {
|
||||
operatorID, err := s.requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if groupID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if request.BusinessLine != nil && !constants.IsValidBusinessLine(*request.BusinessLine) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务线取值非法")
|
||||
}
|
||||
if request.Name != nil && strings.TrimSpace(*request.Name) == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务用户组名称不能为空")
|
||||
}
|
||||
var response *dto.BusinessUserGroupResponse
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.groupStore.WithTx(tx)
|
||||
group, err := store.LockByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return groupLookupError(err)
|
||||
}
|
||||
before := groupSnapshot(group)
|
||||
if request.Name != nil {
|
||||
group.Name = strings.TrimSpace(*request.Name)
|
||||
}
|
||||
if request.BusinessLine != nil {
|
||||
group.BusinessLine = *request.BusinessLine
|
||||
}
|
||||
if request.Sort != nil {
|
||||
group.SortOrder = *request.Sort
|
||||
}
|
||||
// 停用保留成员关系:已有成员继续显示已停用,只是不得新增成员或作为批量目标。
|
||||
if request.Enabled != nil {
|
||||
group.Status = statusValue(request.Enabled)
|
||||
}
|
||||
if request.Remark != nil {
|
||||
group.Remark = *request.Remark
|
||||
}
|
||||
if err := store.Update(ctx, group, operatorID); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新业务用户组失败")
|
||||
}
|
||||
after := groupSnapshot(group)
|
||||
// 启停是独立状态事实,与资料变更分开记录,保证审计动作可被独立检索。
|
||||
if before["status"] != after["status"] {
|
||||
action, summary := constants.AuditActionBusinessUserGroupEnabled, "启用业务用户组"
|
||||
if group.Status != constants.StatusEnabled {
|
||||
action, summary = constants.AuditActionBusinessUserGroupDisabled, "停用业务用户组"
|
||||
}
|
||||
if err := s.appendGroupAudit(ctx, tx, action, summary, group, operatorID,
|
||||
map[string]any{"status": before["status"]}, map[string]any{"status": after["status"]}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if groupProfileChanged(before, after) {
|
||||
if err := s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupUpdated, "更新业务用户组", group, operatorID, before, after); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
response = toGroupResponse(group)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Delete 删除无成员的业务用户组;有成员时只能停用或先移走成员。
|
||||
func (s *Service) Delete(ctx context.Context, groupID uint) error {
|
||||
operatorID, err := s.requireOperator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if groupID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.groupStore.WithTx(tx)
|
||||
group, err := store.LockByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return groupLookupError(err)
|
||||
}
|
||||
count, err := store.CountMembers(ctx, group.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "统计业务用户组成员失败")
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "用户组仍有成员,只能停用或先移走成员")
|
||||
}
|
||||
before := groupSnapshot(group)
|
||||
if err := store.Delete(ctx, group.ID, operatorID); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "删除业务用户组失败")
|
||||
}
|
||||
return s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupDeleted, "删除业务用户组", group, operatorID, before, nil)
|
||||
})
|
||||
}
|
||||
|
||||
// AddMembers 增量增加多个启用且未删除的平台用户到指定启用组。
|
||||
func (s *Service) AddMembers(ctx context.Context, groupID uint, request *dto.AddBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMemberMutationResult, error) {
|
||||
operatorID, err := s.requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountIDs, err := normalizeAccountIDs(request.AccountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if groupID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result := &dto.BusinessUserGroupMemberMutationResult{GroupID: groupID, AccountIDs: accountIDs, RequestedCount: len(accountIDs)}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.groupStore.WithTx(tx)
|
||||
group, err := store.LockByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return groupLookupError(err)
|
||||
}
|
||||
if group.Status != constants.StatusEnabled {
|
||||
return errors.New(errors.CodeInvalidStatus, "目标用户组已停用,不能作为成员归属目标")
|
||||
}
|
||||
if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
|
||||
}
|
||||
if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := store.MembersByAccountIDs(ctx, accountIDs)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败")
|
||||
}
|
||||
changed := make([]uint, 0, len(accountIDs))
|
||||
for _, accountID := range accountIDs {
|
||||
member, exists := before[accountID]
|
||||
switch {
|
||||
case !exists:
|
||||
result.AddedCount++
|
||||
result.AddedAccountIDs = append(result.AddedAccountIDs, accountID)
|
||||
changed = append(changed, accountID)
|
||||
case member.BusinessUserGroupID != groupID:
|
||||
result.MovedCount++
|
||||
result.MovedAccountIDs = append(result.MovedAccountIDs, accountID)
|
||||
changed = append(changed, accountID)
|
||||
default:
|
||||
result.UnchangedCount++
|
||||
result.UnchangedAccountIDs = append(result.UnchangedAccountIDs, accountID)
|
||||
}
|
||||
}
|
||||
if err := store.ReplaceMemberGroup(ctx, accountIDs, group.ID, operatorID); err != nil {
|
||||
return mapMemberWriteError(err)
|
||||
}
|
||||
return s.appendMemberAudits(ctx, tx, group, changed, before, operatorID, false)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RemoveMembers 按目标组作用域增量移除成员;目标组启用或停用均可操作。
|
||||
func (s *Service) RemoveMembers(ctx context.Context, groupID uint, request *dto.RemoveBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMemberMutationResult, error) {
|
||||
operatorID, err := s.requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountIDs, err := normalizeAccountIDs(request.AccountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if groupID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result := &dto.BusinessUserGroupMemberMutationResult{GroupID: groupID, AccountIDs: accountIDs, RequestedCount: len(accountIDs)}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.groupStore.WithTx(tx)
|
||||
group, err := store.LockByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return groupLookupError(err)
|
||||
}
|
||||
if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
|
||||
}
|
||||
before, err := store.MembersByAccountIDs(ctx, accountIDs)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败")
|
||||
}
|
||||
for _, accountID := range accountIDs {
|
||||
member, exists := before[accountID]
|
||||
if !exists || member.BusinessUserGroupID != group.ID {
|
||||
return errors.New(errors.CodeConflict, "存在账号已不属于目标用户组,整批未修改")
|
||||
}
|
||||
}
|
||||
affected, err := store.RemoveMembersByGroup(ctx, group.ID, accountIDs, operatorID)
|
||||
if err != nil {
|
||||
return mapMemberWriteError(err)
|
||||
}
|
||||
if affected != int64(len(accountIDs)) {
|
||||
return errors.New(errors.CodeConflict, "目标用户组成员已被并发修改,整批未修改")
|
||||
}
|
||||
result.RemovedCount = len(accountIDs)
|
||||
result.RemovedAccountIDs = append(result.RemovedAccountIDs, accountIDs...)
|
||||
return s.appendMemberAudits(ctx, tx, group, accountIDs, before, operatorID, true)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// mapMemberWriteError 把成员关系写入失败收敛为稳定业务错误。
|
||||
// 并发为同一账号新增成员关系时唯一索引是最终裁决,不能把约束冲突暴露成 500。
|
||||
func mapMemberWriteError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if postgres.IsAccountMemberConflict(err) {
|
||||
return errors.New(errors.CodeConflict, "平台用户分组归属已被并发修改,请重试")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新平台用户分组失败")
|
||||
}
|
||||
|
||||
// requireOperator 校验调用者具备平台维护入口身份,并返回其账号 ID。
|
||||
func (s *Service) requireOperator(ctx context.Context) (uint, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// normalizeAccountIDs 去重并保持首次出现顺序,空集合视为非法参数。
|
||||
func normalizeAccountIDs(values []uint) ([]uint, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "账号列表不能为空")
|
||||
}
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
result := make([]uint, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "账号ID非法")
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ensureEnabledPlatformAccounts 校验全部账号都是当前启用的平台用户,任一不满足即整批失败。
|
||||
// 账号有效性统一走共享谓词,避免各入口对「平台 + 启用 + 未软删」出现口径分叉。
|
||||
func ensureEnabledPlatformAccounts(ctx context.Context, tx *gorm.DB, accountIDs []uint) error {
|
||||
var accounts []model.Account
|
||||
if err := tx.WithContext(ctx).Model(&model.Account{}).
|
||||
Where("id IN ?", accountIDs).Find(&accounts).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验平台用户失败")
|
||||
}
|
||||
valid := 0
|
||||
for _, account := range accounts {
|
||||
if constants.IsAvailablePlatformBusinessOwner(account.UserType, account.Status, account.DeletedAt.Valid) {
|
||||
valid++
|
||||
}
|
||||
}
|
||||
if valid != len(accountIDs) {
|
||||
return errors.New(errors.CodeInvalidParam, "存在无效或非启用的平台用户账号,整批未修改")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupLookupError(err error) error {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "业务用户组不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败")
|
||||
}
|
||||
|
||||
// mapCodeConflict 把稳定编码唯一索引冲突映射为稳定业务错误,并发创建以唯一索引为最终裁决。
|
||||
func mapCodeConflict(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "uk_business_user_group_code") {
|
||||
return errors.New(errors.CodeInvalidParam, "业务用户组编码已存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建业务用户组失败")
|
||||
}
|
||||
|
||||
func sortValue(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func statusValue(enabled *bool) int {
|
||||
if enabled == nil || *enabled {
|
||||
return constants.StatusEnabled
|
||||
}
|
||||
return constants.StatusDisabled
|
||||
}
|
||||
|
||||
func toGroupResponse(group *model.BusinessUserGroup) *dto.BusinessUserGroupResponse {
|
||||
return &dto.BusinessUserGroupResponse{
|
||||
ID: group.ID, Code: group.Code, Name: group.Name,
|
||||
BusinessLine: group.BusinessLine, BusinessLineName: constants.GetBusinessLineName(group.BusinessLine),
|
||||
Sort: group.SortOrder, Enabled: group.Status == constants.StatusEnabled, Remark: group.Remark,
|
||||
CreatedAt: group.CreatedAt.Format(time.RFC3339), UpdatedAt: group.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// groupSnapshot 生成业务用户组的前后值快照,不含任何凭证或敏感信息。
|
||||
func groupSnapshot(group *model.BusinessUserGroup) map[string]any {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": group.ID, "code": group.Code, "name": group.Name,
|
||||
"business_line": group.BusinessLine, "sort_order": group.SortOrder, "status": group.Status,
|
||||
"remark": group.Remark,
|
||||
}
|
||||
}
|
||||
|
||||
// groupProfileChanged 判断除启停外的可维护字段是否发生变化;编码不可修改,不参与比较。
|
||||
func groupProfileChanged(before, after map[string]any) bool {
|
||||
for _, field := range []string{"name", "business_line", "sort_order", "remark"} {
|
||||
if before[field] != after[field] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// businessUserGroupKey 返回业务用户组审计资源的稳定 Key。
|
||||
func businessUserGroupKey(group *model.BusinessUserGroup) string {
|
||||
if group == nil {
|
||||
return ""
|
||||
}
|
||||
if group.Code != "" {
|
||||
return group.Code
|
||||
}
|
||||
return strconv.FormatUint(uint64(group.ID), 10)
|
||||
}
|
||||
|
||||
// businessUserGroupIdentity 返回业务用户组审计身份快照,字段必须落在注册表白名单内。
|
||||
func businessUserGroupIdentity(group *model.BusinessUserGroup) map[string]any {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": group.ID, "code": group.Code, "name": group.Name,
|
||||
"business_line": group.BusinessLine, "status": group.Status,
|
||||
}
|
||||
}
|
||||
|
||||
// appendGroupAudit 在业务事务内追加业务用户组事件。
|
||||
func (s *Service) appendGroupAudit(ctx context.Context, tx *gorm.DB, action, summary string, group *model.BusinessUserGroup, operatorID uint, before, after map[string]any) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置")
|
||||
}
|
||||
var resourceID *string
|
||||
if group.ID != 0 {
|
||||
value := strconv.FormatUint(uint64(group.ID), 10)
|
||||
resourceID = &value
|
||||
}
|
||||
_, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
|
||||
ActionCode: action, Summary: summary, Result: constants.AuditResultSuccess,
|
||||
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceBusinessUserGroup, ID: resourceID,
|
||||
Key: businessUserGroupKey(group), DisplayName: group.Name,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBusinessUserGroupTarget,
|
||||
IdentitySnapshot: businessUserGroupIdentity(group), BeforeData: before, AfterData: after,
|
||||
}},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// appendMemberAudits 在业务事务内为每个实际变化账号追加一条成员归属事件。
|
||||
// 账号是实际被替换归属的资源;目标组作为引用资源,移除时 after 为空。
|
||||
func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *model.BusinessUserGroup, accountIDs []uint, before map[uint]model.BusinessUserGroupMember, operatorID uint, removing bool) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置")
|
||||
}
|
||||
var accounts []model.Account
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", accountIDs).Find(&accounts).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询成员审计账号失败")
|
||||
}
|
||||
accountByID := make(map[uint]model.Account, len(accounts))
|
||||
for _, account := range accounts {
|
||||
accountByID[account.ID] = account
|
||||
}
|
||||
summary := "增量移除平台用户业务用户组成员"
|
||||
afterGroupID := any(nil)
|
||||
if !removing {
|
||||
summary = "增量维护平台用户业务用户组成员"
|
||||
if group != nil {
|
||||
afterGroupID = group.ID
|
||||
}
|
||||
}
|
||||
if removing && group == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "移除成员审计缺少原用户组")
|
||||
}
|
||||
for _, accountID := range accountIDs {
|
||||
account, exists := accountByID[accountID]
|
||||
if !exists {
|
||||
return errors.New(errors.CodeDatabaseError, "成员审计账号不存在")
|
||||
}
|
||||
beforeGroupID := any(nil)
|
||||
if member, ok := before[accountID]; ok {
|
||||
beforeGroupID = member.BusinessUserGroupID
|
||||
}
|
||||
resource := audit.AccountResource(&account, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleAccountTarget)
|
||||
resource.BeforeData = map[string]any{"business_user_group_id": beforeGroupID}
|
||||
resource.AfterData = map[string]any{"business_user_group_id": afterGroupID}
|
||||
resources := []audit.ResourceInput{resource}
|
||||
if group != nil {
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceBusinessUserGroup, ID: optionalID(group.ID),
|
||||
Key: businessUserGroupKey(group), DisplayName: group.Name,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleBusinessUserGroupTarget,
|
||||
IdentitySnapshot: businessUserGroupIdentity(group), SortOrder: 1,
|
||||
})
|
||||
}
|
||||
if _, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionBusinessUserGroupMembersUpdated, Summary: summary,
|
||||
Result: constants.AuditResultSuccess,
|
||||
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: resources,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionalID(id uint) *string {
|
||||
if id == 0 {
|
||||
return nil
|
||||
}
|
||||
value := strconv.FormatUint(uint64(id), 10)
|
||||
return &value
|
||||
}
|
||||
@@ -7,21 +7,24 @@ import (
|
||||
"time"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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/outboxid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// RealnameChangedEvent 是卡实名状态变化的可靠领域事实。
|
||||
type RealnameChangedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
CardID uint `json:"card_id"`
|
||||
BeforeStatus int `json:"before_status"`
|
||||
AfterStatus int `json:"after_status"`
|
||||
EventID string `json:"event_id"`
|
||||
CardID uint `json:"card_id"`
|
||||
BeforeStatus int `json:"before_status"`
|
||||
AfterStatus int `json:"after_status"`
|
||||
// FirstVerified 表示需要触发实名激活:首次实名,或二次实名完成(此时 first_realname_at 不变)。
|
||||
FirstVerified bool `json:"first_verified"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
Source string `json:"source"`
|
||||
@@ -42,6 +45,12 @@ type CacheInvalidator interface {
|
||||
Invalidate(ctx context.Context, cardID uint)
|
||||
}
|
||||
|
||||
// ChannelThresholdEvaluator 在卡流量事务内判定运营商通道流量阈值。
|
||||
// 实现必须在调用方事务内写周期锁与停机事件,使锁事实与流量事实同事务提交。
|
||||
type ChannelThresholdEvaluator interface {
|
||||
EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation carrierthresholddomain.Evaluation) error
|
||||
}
|
||||
|
||||
// StateAudit 描述一次需要与卡事实关联保存的状态操作。
|
||||
type StateAudit struct {
|
||||
ActionCode string
|
||||
@@ -64,6 +73,55 @@ type Service struct {
|
||||
eventWriter EventWriter
|
||||
cache CacheInvalidator
|
||||
auditWriter StateAuditWriter
|
||||
// channelThreshold 为可选的通道阈值判定能力;未注入时流量观测不做阈值判定。
|
||||
channelThreshold ChannelThresholdEvaluator
|
||||
// trafficQueue 是卡流量采集队列;未注入时流量观测报错。
|
||||
trafficQueue TrafficQueuePort
|
||||
// trafficQueueSettings 返回采集队列配置;未注入时使用代码默认值。
|
||||
trafficQueueSettings TrafficQueueSettingsSource
|
||||
// logger 记录采集记录写入失败等不影响观测结果的告警;未注入时不输出。
|
||||
logger *zap.Logger
|
||||
// commandGrace 返回停复机成功后的状态保护窗口;未注入时不保护。
|
||||
commandGrace func(ctx context.Context) time.Duration
|
||||
// collectionSink 把采集记录发布到批量写入消息;未注入或发布失败时同步写库。
|
||||
collectionSink CollectionSink
|
||||
}
|
||||
|
||||
// CollectionSink 发布一条采集记录(成功表或失败表二选一),由批量消费者写库。
|
||||
type CollectionSink interface {
|
||||
PublishCollection(ctx context.Context, success *model.CardTrafficCollection, failure *model.CardTrafficCollectionFailure) error
|
||||
}
|
||||
|
||||
// SetCollectionSink 注入采集记录发布器(Worker 启用 card.collection 批量写入时使用)。
|
||||
func (s *Service) SetCollectionSink(sink CollectionSink) {
|
||||
s.collectionSink = sink
|
||||
}
|
||||
|
||||
// SetCommandGrace 注入停复机成功后的状态保护窗口(受控系统配置 polling.stop_resume.status_grace_seconds)。
|
||||
func (s *Service) SetCommandGrace(grace func(ctx context.Context) time.Duration) {
|
||||
s.commandGrace = grace
|
||||
}
|
||||
|
||||
func (s *Service) currentCommandGrace(ctx context.Context) time.Duration {
|
||||
if s == nil || s.commandGrace == nil {
|
||||
return 0
|
||||
}
|
||||
return s.commandGrace(ctx)
|
||||
}
|
||||
|
||||
// SetTrafficQueue 注入卡流量采集队列、队列配置与告警日志。
|
||||
func (s *Service) SetTrafficQueue(queue TrafficQueuePort, settings TrafficQueueSettingsSource, logger *zap.Logger) {
|
||||
s.trafficQueue = queue
|
||||
s.trafficQueueSettings = settings
|
||||
s.logger = logger
|
||||
}
|
||||
|
||||
// warnLogger 返回可用的告警日志器。
|
||||
func (s *Service) warnLogger() *zap.Logger {
|
||||
if s.logger == nil {
|
||||
return zap.NewNop()
|
||||
}
|
||||
return s.logger
|
||||
}
|
||||
|
||||
// NewService 创建卡实名观测应用服务。
|
||||
@@ -71,6 +129,11 @@ func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *S
|
||||
return &Service{db: db, eventWriter: eventWriter, cache: cache}
|
||||
}
|
||||
|
||||
// SetChannelThresholdEvaluator 注入运营商通道流量阈值达量判定能力。
|
||||
func (s *Service) SetChannelThresholdEvaluator(evaluator ChannelThresholdEvaluator) {
|
||||
s.channelThreshold = evaluator
|
||||
}
|
||||
|
||||
// SetStateAuditWriter 注入卡状态统一审计 Writer。
|
||||
func (s *Service) SetStateAuditWriter(writer StateAuditWriter) {
|
||||
s.auditWriter = writer
|
||||
@@ -94,6 +157,7 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
|
||||
}
|
||||
var decision domain.RealnameDecision
|
||||
var auditedCard *model.IotCard
|
||||
stale := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var card model.IotCard
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
|
||||
@@ -103,6 +167,36 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败")
|
||||
}
|
||||
auditedCard = &card
|
||||
if staleObservation(card.LastRealNameCheckAt, observation.Metadata.ObservedAt) {
|
||||
// 异步入库可能乱序:早于已应用观测的实名结果不改卡事实,只记录。
|
||||
s.warnLogger().Info("实名观测早于卡上最近一次已应用的检查,忽略",
|
||||
zap.Uint("card_id", card.ID), zap.String("observation_id", observation.Metadata.ObservationID),
|
||||
zap.Time("observed_at", observation.Metadata.ObservedAt), zap.Timep("last_checked_at", card.LastRealNameCheckAt),
|
||||
zap.String("metric", "card_observation.realname_stale"))
|
||||
stale = true
|
||||
return nil
|
||||
}
|
||||
realnameRecord, realnameSnapshot, loadErr := loadRealnameTime(ctx, tx, card.ID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
resetGate := domain.EvaluateRealnameResetGate(realnameSnapshot, observation)
|
||||
if resetGate == domain.RealnameResetGateBlocked {
|
||||
// 等待二次实名期间读到的仍是旧实名(上游实名时间不晚于清除前时间,或没有上游时间):不改实名状态,只记检查时间。
|
||||
decision = domain.RealnameDecision{BeforeStatus: card.RealNameStatus, AfterStatus: card.RealNameStatus,
|
||||
ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt}
|
||||
checked := map[string]any{"last_real_name_check_at": observation.Metadata.ObservedAt}
|
||||
if observation.Metadata.Source != constants.CardObservationSourceManualOverride {
|
||||
checked["last_sync_time"] = observation.Metadata.ObservedAt
|
||||
}
|
||||
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(checked).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡实名检查时间失败")
|
||||
}
|
||||
s.warnLogger().Info("等待二次实名的卡读到旧实名,不覆盖",
|
||||
zap.Uint("card_id", card.ID), zap.String("observation_id", observation.Metadata.ObservationID),
|
||||
zap.Timep("upstream_realname_at", observation.UpstreamRealnameAt), zap.Timep("baseline_at", realnameSnapshot.ResetBaselineAt))
|
||||
return nil
|
||||
}
|
||||
nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{
|
||||
CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt,
|
||||
ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt,
|
||||
@@ -135,11 +229,16 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "卡实名状态已被其他请求更新")
|
||||
}
|
||||
if observation.Verified && decision.AfterStatus == constants.RealNameStatusVerified {
|
||||
if err := applyRealnameTime(ctx, tx, &card, realnameRecord, observation, resetGate); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if decision.StatusChanged {
|
||||
eventID := realnameChangedEventID(card.ID, observation.Metadata.ObservationID)
|
||||
if err := s.eventWriter.AppendRealname(ctx, tx, RealnameChangedEvent{
|
||||
EventID: eventID, CardID: card.ID, BeforeStatus: card.RealNameStatus, AfterStatus: decision.AfterStatus,
|
||||
FirstVerified: decision.FirstVerified, ObservedAt: observation.Metadata.ObservedAt,
|
||||
FirstVerified: decision.FirstVerified || resetGate == domain.RealnameResetGateCompleted, ObservedAt: observation.Metadata.ObservedAt,
|
||||
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
|
||||
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
|
||||
}); err != nil {
|
||||
@@ -225,6 +324,9 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
|
||||
}
|
||||
return domain.RealnameDecision{}, err
|
||||
}
|
||||
if stale {
|
||||
return domain.RealnameDecision{}, nil
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.Invalidate(ctx, observation.CardID)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause"
|
||||
"go.uber.org/zap"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -35,6 +37,7 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
|
||||
}
|
||||
var decision domain.NetworkDecision
|
||||
var auditedCard *model.IotCard
|
||||
stale := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var card model.IotCard
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
|
||||
@@ -44,13 +47,28 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡网络事实失败")
|
||||
}
|
||||
auditedCard = &card
|
||||
if staleObservation(card.LastCardStatusCheckAt, observation.Metadata.ObservedAt) {
|
||||
// 异步入库可能乱序:早于已应用观测的网络状态不改卡事实,只记录。
|
||||
s.warnLogger().Info("网络状态观测早于卡上最近一次已应用的检查,忽略",
|
||||
zap.Uint("card_id", card.ID), zap.String("observation_id", observation.Metadata.ObservationID),
|
||||
zap.Time("observed_at", observation.Metadata.ObservedAt), zap.Timep("last_checked_at", card.LastCardStatusCheckAt),
|
||||
zap.String("metric", "card_observation.network_stale"))
|
||||
stale = true
|
||||
return nil
|
||||
}
|
||||
nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{
|
||||
CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason,
|
||||
IsStandalone: card.IsStandalone, EnablePolling: card.EnablePolling,
|
||||
EnablePolling: card.EnablePolling, StoppedAt: card.StoppedAt, ResumedAt: card.ResumedAt,
|
||||
CommandGrace: s.currentCommandGrace(ctx),
|
||||
}, observation)
|
||||
if decisionErr != nil {
|
||||
return decisionErr
|
||||
}
|
||||
if nextDecision.StatusSuppressed {
|
||||
s.warnLogger().Info("停复机成功后保护窗口内观测到相反状态,判定为上游状态滞后,不回写网络状态",
|
||||
zap.Uint("card_id", card.ID), zap.Int("network_status", card.NetworkStatus),
|
||||
zap.String("gateway_status", observation.GatewayStatus), zap.String("metric", "card_observation.network_suppressed"))
|
||||
}
|
||||
decision = nextDecision
|
||||
updates := map[string]any{
|
||||
"last_card_status_check_at": observation.Metadata.ObservedAt,
|
||||
@@ -78,6 +96,12 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新")
|
||||
}
|
||||
if decision.StopPolling && card.EnablePolling {
|
||||
// 风险停机或已销户自动关闭轮询:同事务写不轮询清单(只关该卡自身,不级联设备)。
|
||||
if err := pollingpause.Record(ctx, tx, pollingpause.CardChange(&card, false), constants.PollingToggleSourceRiskAuto); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if decision.StatusChanged {
|
||||
eventID := outboxid.Stable("card-network:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":changed")
|
||||
if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{
|
||||
@@ -162,8 +186,16 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
|
||||
}
|
||||
return domain.NetworkDecision{}, err
|
||||
}
|
||||
if stale {
|
||||
return domain.NetworkDecision{}, nil
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.Invalidate(ctx, observation.CardID)
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// staleObservation 判断观测时间是否早于卡上最近一次已应用的同类检查时间(异步入库乱序保护)。
|
||||
func staleObservation(lastChecked *time.Time, observedAt time.Time) bool {
|
||||
return lastChecked != nil && !observedAt.IsZero() && observedAt.Before(*lastChecked)
|
||||
}
|
||||
|
||||
177
internal/application/cardobservation/realname_reset.go
Normal file
177
internal/application/cardobservation/realname_reset.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// RealnameResetCommand 描述对一张卡的二次实名清除或撤销。
|
||||
type RealnameResetCommand struct {
|
||||
CardID uint
|
||||
BatchID uint
|
||||
OperatorID uint
|
||||
}
|
||||
|
||||
// 二次实名不可执行的原因(业务可预期,按行记录,不当作系统错误)。
|
||||
var (
|
||||
ErrRealnameResetNotVerified = errors.New(errors.CodeInvalidStatus, "卡未实名,无需二次实名")
|
||||
ErrRealnameResetNoUpstream = errors.New(errors.CodeInvalidStatus, "上游未返回实名时间,不支持二次实名")
|
||||
ErrRealnameResetAlreadyReset = errors.New(errors.CodeInvalidStatus, "卡已在等待二次实名")
|
||||
ErrRealnameResetNotPending = errors.New(errors.CodeInvalidStatus, "卡不在等待二次实名,不能撤销")
|
||||
)
|
||||
|
||||
// ResetRealname 二次实名清除:卡置为未实名、当前实名时间置空,记下清除前的上游实名时间作为新旧判定基准。
|
||||
// 只支持上游返回过 archiveTime 的卡;已激活套餐不回退,first_realname_at 不修改。
|
||||
// 状态变化按现有流程写实名变化事件与审计,下游停复机评估与普通未实名卡一致。
|
||||
func (s *Service) ResetRealname(ctx context.Context, command RealnameResetCommand) error {
|
||||
return s.changeRealnameForReset(ctx, command, false)
|
||||
}
|
||||
|
||||
// RevertRealnameReset 撤销等待中的二次实名:卡恢复已实名,当前实名时间恢复为清除前时间。
|
||||
func (s *Service) RevertRealnameReset(ctx context.Context, command RealnameResetCommand) error {
|
||||
return s.changeRealnameForReset(ctx, command, true)
|
||||
}
|
||||
|
||||
func (s *Service) changeRealnameForReset(ctx context.Context, command RealnameResetCommand, revert bool) error {
|
||||
if s == nil || s.db == nil || s.eventWriter == nil || s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡实名观测能力未完整配置")
|
||||
}
|
||||
if command.CardID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "卡 ID 无效")
|
||||
}
|
||||
now := time.Now()
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var card model.IotCard
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", command.CardID).First(&card).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "IoT卡不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败")
|
||||
}
|
||||
record, snapshot, err := loadRealnameTime(ctx, tx, card.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if revert {
|
||||
return s.revertResetInTx(ctx, tx, &card, record, snapshot, command, now)
|
||||
}
|
||||
return s.resetInTx(ctx, tx, &card, record, snapshot, command, now)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.Invalidate(ctx, command.CardID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) resetInTx(ctx context.Context, tx *gorm.DB, card *model.IotCard, record *model.IotCardRealname,
|
||||
snapshot domain.RealnameTimeSnapshot, command RealnameResetCommand, now time.Time) error {
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return ErrRealnameResetNotVerified
|
||||
}
|
||||
if record == nil || record.UpstreamRealnameAt == nil {
|
||||
return ErrRealnameResetNoUpstream
|
||||
}
|
||||
if snapshot.ResetStatus == constants.RealnameResetStatusPending {
|
||||
return ErrRealnameResetAlreadyReset
|
||||
}
|
||||
if err := s.updateCardRealnameStatusInTx(ctx, tx, card, constants.RealNameStatusNotVerified, now, "reset", command.BatchID); err != nil {
|
||||
return err
|
||||
}
|
||||
batchID := command.BatchID
|
||||
if err := tx.WithContext(ctx).Model(&model.IotCardRealname{}).Where("id = ?", record.ID).Updates(map[string]any{
|
||||
"realname_at": nil, "reset_status": constants.RealnameResetStatusPending, "reset_at": now,
|
||||
"reset_baseline_at": *record.UpstreamRealnameAt, "reset_batch_id": batchID, "updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入二次实名等待状态失败")
|
||||
}
|
||||
return writeResetHistory(ctx, tx, card, record.RealnameAt, nil, "", constants.RealnameChangeReset, command, now)
|
||||
}
|
||||
|
||||
func (s *Service) revertResetInTx(ctx context.Context, tx *gorm.DB, card *model.IotCard, record *model.IotCardRealname,
|
||||
snapshot domain.RealnameTimeSnapshot, command RealnameResetCommand, now time.Time) error {
|
||||
if record == nil || snapshot.ResetStatus != constants.RealnameResetStatusPending {
|
||||
return ErrRealnameResetNotPending
|
||||
}
|
||||
if command.BatchID != 0 && (record.ResetBatchID == nil || *record.ResetBatchID != command.BatchID) {
|
||||
return ErrRealnameResetNotPending
|
||||
}
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
if err := s.updateCardRealnameStatusInTx(ctx, tx, card, constants.RealNameStatusVerified, now, "revert", command.BatchID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
restored := record.ResetBaselineAt
|
||||
if err := tx.WithContext(ctx).Model(&model.IotCardRealname{}).Where("id = ?", record.ID).Updates(map[string]any{
|
||||
"realname_at": restored, "realname_at_source": constants.RealnameAtSourceUpstream,
|
||||
"reset_status": constants.RealnameResetStatusNone, "reset_at": nil, "reset_baseline_at": nil, "reset_batch_id": nil,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "撤销二次实名等待状态失败")
|
||||
}
|
||||
return writeResetHistory(ctx, tx, card, nil, restored, constants.RealnameAtSourceUpstream, constants.RealnameChangeResetReverted, command, now)
|
||||
}
|
||||
|
||||
// updateCardRealnameStatusInTx 按实名观测的同一口径更新卡实名状态,并写实名变化事件与人工审计。
|
||||
func (s *Service) updateCardRealnameStatusInTx(ctx context.Context, tx *gorm.DB, card *model.IotCard, after int, now time.Time, kind string, batchID uint) error {
|
||||
verified := after == constants.RealNameStatusVerified
|
||||
result := tx.WithContext(ctx).Model(&model.IotCard{}).Where("id = ? AND real_name_status = ?", card.ID, card.RealNameStatus).Updates(map[string]any{
|
||||
"real_name_status": after,
|
||||
"realname_reversal_count": 0,
|
||||
"realname_reversal_started_at": nil,
|
||||
"activation_status": gorm.Expr(`CASE WHEN network_status = ? AND (card_category = ? OR ?) THEN 1 ELSE 0 END`, constants.NetworkStatusOnline, constants.CardCategoryIndustry, verified),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡实名状态失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "卡实名状态已被其他请求更新")
|
||||
}
|
||||
observationID := "realname-" + kind + ":" + strconv.FormatUint(uint64(batchID), 10) + ":" + strconv.FormatInt(now.UnixNano(), 10)
|
||||
eventID := outboxid.Stable("card-realname:", strconv.FormatUint(uint64(card.ID), 10)+":"+observationID+":changed")
|
||||
if err := s.eventWriter.AppendRealname(ctx, tx, RealnameChangedEvent{
|
||||
EventID: eventID, CardID: card.ID, BeforeStatus: card.RealNameStatus, AfterStatus: after,
|
||||
ObservedAt: now, Source: constants.CardObservationSourceManualOverride, Scene: "realname_" + kind,
|
||||
CorrelationID: observationID,
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名 Outbox 事件失败")
|
||||
}
|
||||
summary := "二次实名清除:卡实名状态置为未实名"
|
||||
if verified {
|
||||
summary = "二次实名撤销:卡恢复已实名"
|
||||
}
|
||||
return s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
|
||||
ActionCode: constants.AuditActionIotCardRealnameStatusUpdated, Summary: summary, Card: card,
|
||||
BeforeData: map[string]any{"real_name_status": card.RealNameStatus},
|
||||
AfterData: map[string]any{"real_name_status": after, "realname_reset_batch_id": batchID},
|
||||
})
|
||||
}
|
||||
|
||||
func writeResetHistory(ctx context.Context, tx *gorm.DB, card *model.IotCard, before, after *time.Time, afterSource, changeType string,
|
||||
command RealnameResetCommand, now time.Time) error {
|
||||
var batchID *uint
|
||||
if command.BatchID != 0 {
|
||||
value := command.BatchID
|
||||
batchID = &value
|
||||
}
|
||||
history := model.IotCardRealnameHistory{
|
||||
CardID: card.ID, ICCID: card.ICCID, BeforeAt: before, AfterAt: after, AfterSource: afterSource,
|
||||
ChangeType: changeType, ObservationSource: constants.CardObservationSourceManualOverride,
|
||||
ResetBatchID: batchID, OperatorID: command.OperatorID, ObservedAt: now, CreatedAt: now,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&history).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名时间历史失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
96
internal/application/cardobservation/realname_time.go
Normal file
96
internal/application/cardobservation/realname_time.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// loadRealnameTime 在调用方事务中读取卡当前实名记录;调用方已锁定卡行,同一张卡的写入因此串行。
|
||||
func loadRealnameTime(ctx context.Context, tx *gorm.DB, cardID uint) (*model.IotCardRealname, domain.RealnameTimeSnapshot, error) {
|
||||
var record model.IotCardRealname
|
||||
err := tx.WithContext(ctx).Where("card_id = ?", cardID).Take(&record).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, domain.RealnameTimeSnapshot{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, domain.RealnameTimeSnapshot{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡当前实名记录失败")
|
||||
}
|
||||
return &record, realnameTimeSnapshot(&record), nil
|
||||
}
|
||||
|
||||
func realnameTimeSnapshot(record *model.IotCardRealname) domain.RealnameTimeSnapshot {
|
||||
if record == nil {
|
||||
return domain.RealnameTimeSnapshot{}
|
||||
}
|
||||
return domain.RealnameTimeSnapshot{
|
||||
Exists: true, RealnameAt: record.RealnameAt, RealnameAtSource: record.RealnameAtSource,
|
||||
UpstreamRealnameAt: record.UpstreamRealnameAt, FirstObservedAt: record.FirstObservedAt,
|
||||
ResetStatus: record.ResetStatus, ResetBaselineAt: record.ResetBaselineAt,
|
||||
}
|
||||
}
|
||||
|
||||
// applyRealnameTime 在卡已被采纳为已实名后更新当前实名记录;只有实名时间变化才写历史。
|
||||
func applyRealnameTime(ctx context.Context, tx *gorm.DB, card *model.IotCard, record *model.IotCardRealname,
|
||||
observation domain.RealnameObservation, gate domain.RealnameResetGate) error {
|
||||
decision := domain.DecideRealnameTime(realnameTimeSnapshot(record), observation, gate)
|
||||
if !decision.Changed {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
var resetBatchID *uint
|
||||
if record != nil {
|
||||
resetBatchID = record.ResetBatchID
|
||||
}
|
||||
if record == nil {
|
||||
created := model.IotCardRealname{
|
||||
CardID: card.ID, RealnameAt: decision.RealnameAt, RealnameAtSource: decision.Source,
|
||||
UpstreamRealnameAt: decision.UpstreamRealnameAt, FirstObservedAt: decision.FirstObservedAt,
|
||||
ResetStatus: constants.RealnameResetStatusNone, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&created).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建卡当前实名记录失败")
|
||||
}
|
||||
} else {
|
||||
updates := map[string]any{
|
||||
"realname_at": decision.RealnameAt, "realname_at_source": decision.Source,
|
||||
"upstream_realname_at": decision.UpstreamRealnameAt, "first_observed_at": decision.FirstObservedAt,
|
||||
"updated_at": now,
|
||||
}
|
||||
if decision.ClearReset {
|
||||
updates["reset_status"] = constants.RealnameResetStatusNone
|
||||
updates["reset_at"] = nil
|
||||
updates["reset_baseline_at"] = nil
|
||||
updates["reset_batch_id"] = nil
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&model.IotCardRealname{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡当前实名记录失败")
|
||||
}
|
||||
}
|
||||
if decision.ClearReset && resetBatchID != nil {
|
||||
if err := tx.WithContext(ctx).Model(&model.IotCardRealnameResetBatch{}).Where("id = ?", *resetBatchID).
|
||||
Updates(map[string]any{"completed_count": gorm.Expr("completed_count + 1"), "updated_at": now}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新二次实名批次完成数失败")
|
||||
}
|
||||
}
|
||||
if !decision.WriteHistory {
|
||||
return nil
|
||||
}
|
||||
history := model.IotCardRealnameHistory{
|
||||
CardID: card.ID, ICCID: card.ICCID, BeforeAt: decision.BeforeAt, AfterAt: decision.RealnameAt,
|
||||
AfterSource: decision.Source, ChangeType: decision.ChangeType, ObservationSource: observation.Metadata.Source,
|
||||
ObservedAt: observation.Metadata.ObservedAt, CreatedAt: now,
|
||||
}
|
||||
if decision.ClearReset {
|
||||
history.ResetBatchID = resetBatchID
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&history).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名时间历史失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -5,11 +5,15 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"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/outboxid"
|
||||
@@ -17,24 +21,56 @@ import (
|
||||
|
||||
// TrafficIncrementedEvent 是卡流量正增量的可靠领域事实。
|
||||
type TrafficIncrementedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
CardID uint `json:"card_id"`
|
||||
IncrementMB float64 `json:"increment_mb"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
Source string `json:"source"`
|
||||
Scene string `json:"scene"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
EventID string `json:"event_id"`
|
||||
CardID uint `json:"card_id"`
|
||||
IncrementMB float64 `json:"increment_mb"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
// ObservationID 是采集 ID,扣费审计据此追溯到采集记录。
|
||||
ObservationID string `json:"observation_id,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Scene string `json:"scene"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// ApplyTrafficObservation 串行应用流量读数,并在正增量时同事务写 Outbox。
|
||||
// TrafficQueuePort 是卡流量采集队列:原子入队并返回入队后的队列与新点是否在队尾。
|
||||
type TrafficQueuePort interface {
|
||||
Push(ctx context.Context, cardID uint, point cardflow.QueuePoint, settings TrafficQueueSettings) ([]cardflow.QueuePoint, bool, error)
|
||||
}
|
||||
|
||||
// TrafficQueueSettingsSource 返回当前采集队列配置。
|
||||
type TrafficQueueSettingsSource interface {
|
||||
Current(ctx context.Context) TrafficQueueSettings
|
||||
}
|
||||
|
||||
// ApplyTrafficObservation 应用一次流量采集:读数先进采集队列,满足写入条件才写日快照、按月对账、
|
||||
// 在采集时刻有套餐时同事务写流量事件;有套餐的卡每次采集写一条采集记录。
|
||||
func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domain.TrafficObservation) (domain.TrafficDecision, error) {
|
||||
if s == nil || s.db == nil || s.eventWriter == nil {
|
||||
if s == nil || s.db == nil || s.eventWriter == nil || s.trafficQueue == nil {
|
||||
return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置")
|
||||
}
|
||||
var decision domain.TrafficDecision
|
||||
var auditedCard *model.IotCard
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := domain.ValidateTrafficObservation(observation); err != nil {
|
||||
return domain.TrafficDecision{}, err
|
||||
}
|
||||
record := newCollectionRecord(observation)
|
||||
decision := domain.TrafficDecision{Result: record.Result}
|
||||
if record.Result != constants.TrafficCollectionResultOK {
|
||||
s.writeCollectionRecord(ctx, record, observation.Metadata.ObservedAt)
|
||||
return decision, nil
|
||||
}
|
||||
reading := observation.GatewayReadingMB
|
||||
decision.ReadingMB = &reading
|
||||
settings := s.queueSettings()
|
||||
points, tail, err := s.trafficQueue.Push(ctx, observation.CardID,
|
||||
cardflow.QueuePoint{ObservedAt: observation.Metadata.ObservedAt, ReadingMB: reading}, settings)
|
||||
if err != nil {
|
||||
return decision, err
|
||||
}
|
||||
record.setQueue(points)
|
||||
judge := cardflow.JudgeQueue(points, tail, settings.Size, settings.DropToleranceMB)
|
||||
record.Decision = judge
|
||||
var outcome *flowOutcome
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var card model.IotCard
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -42,102 +78,182 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败")
|
||||
}
|
||||
auditedCard = &card
|
||||
nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{
|
||||
CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB,
|
||||
CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB,
|
||||
LastGatewayReadingMB: card.LastGatewayReadingMB,
|
||||
}, observation)
|
||||
if decisionErr != nil {
|
||||
return decisionErr
|
||||
decision.CurrentMonthUsageMB, decision.LastGatewayReadingMB = card.CurrentMonthUsageMB, card.LastGatewayReadingMB
|
||||
packages, err := postgres.FindEffectivePackagesAt(ctx, tx, card.ID, observation.Metadata.ObservedAt, false)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡采集时刻有效套餐失败")
|
||||
}
|
||||
decision = nextDecision
|
||||
updates := map[string]any{
|
||||
"last_data_check_at": observation.Metadata.ObservedAt, "last_sync_time": observation.Metadata.ObservedAt,
|
||||
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
|
||||
"current_month_usage_mb": decision.CurrentMonthUsageMB, "data_usage_mb": decision.DataUsageMB,
|
||||
}
|
||||
if decision.ReadingAccepted {
|
||||
updates["last_gateway_reading_mb"] = decision.LastGatewayReadingMB
|
||||
}
|
||||
result := tx.Model(&model.IotCard{}).
|
||||
Where("id = ? AND last_gateway_reading_mb = ?", card.ID, card.LastGatewayReadingMB).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量事实失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新")
|
||||
}
|
||||
if decision.IncrementMB > 0 {
|
||||
eventID := outboxid.Stable("card-traffic:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":incremented")
|
||||
if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
|
||||
EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB,
|
||||
ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source,
|
||||
Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID,
|
||||
CorrelationID: observation.Metadata.CorrelationID,
|
||||
}); err != nil {
|
||||
return err
|
||||
record.HasPackage = len(packages.Packages) > 0
|
||||
record.CarrierID = card.CarrierID
|
||||
if judge != cardflow.QueueWrite {
|
||||
if observation.Metadata.Scene == constants.CardObservationScenePreActivationSettlement {
|
||||
// 激活前结算没写入:用已有本月最大快照结算,不新写快照。
|
||||
if err := s.settleExisting(ctx, tx, &card, observation.Metadata.ObservedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
record.Decision = constants.TrafficDecisionSettledExisting
|
||||
}
|
||||
return touchTrafficCheck(tx, card.ID, observation.Metadata.ObservedAt)
|
||||
}
|
||||
stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB
|
||||
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
|
||||
}
|
||||
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
|
||||
ActionCode: actionCode,
|
||||
Summary: "人工刷新 IoT 卡流量",
|
||||
Card: &card,
|
||||
BeforeData: map[string]any{
|
||||
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
|
||||
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
|
||||
"last_gateway_reading_mb": card.LastGatewayReadingMB,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
|
||||
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
|
||||
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if workerObservationAudited(ctx) && stateChanged {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
|
||||
}
|
||||
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
|
||||
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
|
||||
Summary: "Worker 同步 IoT 卡流量事实", Card: &card,
|
||||
IntegrationID: observation.Metadata.ObservationID,
|
||||
BeforeData: map[string]any{
|
||||
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
|
||||
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
|
||||
"last_gateway_reading_mb": card.LastGatewayReadingMB,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
|
||||
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
|
||||
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := s.applyWrittenPoint(ctx, tx, &card, observation, packages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outcome = next
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
|
||||
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
|
||||
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
|
||||
Summary: "Worker 同步 IoT 卡流量事实失败", Card: auditedCard,
|
||||
IntegrationID: observation.Metadata.ObservationID,
|
||||
}, err)
|
||||
}
|
||||
s.writeTrafficFailureAudit(ctx, observation, err)
|
||||
return domain.TrafficDecision{}, err
|
||||
}
|
||||
if outcome != nil {
|
||||
record.applyOutcome(outcome)
|
||||
decision.ReadingAccepted = outcome.Written
|
||||
decision.IncrementMB = outcome.EmitMB
|
||||
decision.CrossMonth = outcome.CrossMonth
|
||||
decision.CurrentMonthUsageMB = outcome.MonthMaxMB
|
||||
}
|
||||
decision.Decision = record.Decision
|
||||
s.writeCollectionRecord(ctx, record, observation.Metadata.ObservedAt)
|
||||
if s.cache != nil {
|
||||
s.cache.Invalidate(ctx, observation.CardID)
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// applyWrittenPoint 在观测事务内处理一个通过队列判断的写入点。
|
||||
func (s *Service) applyWrittenPoint(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, packages postgres.EffectivePackages) (*flowOutcome, error) {
|
||||
hasPackage := len(packages.Packages) > 0
|
||||
reading := observation.GatewayReadingMB
|
||||
observedAt := observation.Metadata.ObservedAt
|
||||
outcome := &flowOutcome{MonthMaxMB: card.CurrentMonthUsageMB, Decision: constants.TrafficDecisionWritten}
|
||||
if reading == 0 && !hasPackage {
|
||||
// 没套餐的卡读数为 0 不写快照。
|
||||
return outcome, touchTrafficCheck(tx, card.ID, observedAt)
|
||||
}
|
||||
flow, err := s.writeFlow(ctx, tx, card, reading, observedAt, hasPackage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outcome.Written, outcome.MonthMaxMB, outcome.StaleGuarded = true, flow.monthMax, flow.conversion.StaleGuarded
|
||||
outcome.MonthUsedMB, outcome.DeltaMB = flow.conversion.MonthUsedMB, flow.delta
|
||||
if card.TrafficBaselinePending {
|
||||
outcome.Decision = constants.TrafficDecisionBaseline
|
||||
} else if hasPackage && flow.delta > 0 {
|
||||
outcome.EmitMB = flow.delta
|
||||
}
|
||||
if hasPackage {
|
||||
if err := ensureDailyUsageRows(ctx, tx, packages.Packages, flow.flowDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
monthStart := cardflow.MonthStart(flow.flowDate)
|
||||
outcome.CrossMonth = card.CurrentMonthStartDate == nil || card.CurrentMonthStartDate.Before(monthStart)
|
||||
updates := map[string]any{
|
||||
"last_data_check_at": observedAt, "last_sync_time": observedAt,
|
||||
"current_month_usage_mb": flow.monthMax, "current_month_start_date": monthStart,
|
||||
"data_usage_mb": card.DataUsageMB + int64(flow.delta), "traffic_baseline_pending": false,
|
||||
}
|
||||
if outcome.CrossMonth {
|
||||
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
|
||||
}
|
||||
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(updates).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新卡流量事实失败")
|
||||
}
|
||||
if outcome.EmitMB > 0 {
|
||||
outcome.EventID = outboxid.Stable("card-traffic:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":incremented")
|
||||
if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
|
||||
EventID: outcome.EventID, CardID: card.ID, IncrementMB: outcome.EmitMB,
|
||||
ObservedAt: observedAt, ObservationID: observation.Metadata.ObservationID,
|
||||
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
|
||||
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 通道阈值只在写入点判断:读数大于 0 且未被防旧值按 0 处理;激活前结算不触发停复机评估。
|
||||
if reading > 0 && !outcome.StaleGuarded && s.channelThreshold != nil &&
|
||||
observation.Metadata.Scene != constants.CardObservationScenePreActivationSettlement {
|
||||
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
|
||||
CardID: card.ID, CarrierID: card.CarrierID, ReadingMB: reading, ObservedAt: observedAt,
|
||||
// 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。
|
||||
Source: observation.Metadata.Source,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.writeTrafficStateAudit(ctx, tx, card, observation, outcome); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// touchTrafficCheck 只刷新卡的最近流量检查时间(本次没有写入快照)。
|
||||
func touchTrafficCheck(tx *gorm.DB, cardID uint, observedAt time.Time) error {
|
||||
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).
|
||||
Updates(map[string]any{"last_data_check_at": observedAt, "last_sync_time": observedAt}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡流量检查时间失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeTrafficStateAudit 在本次写入改变了卡流量事实时写统一审计(人工刷新或 Worker 观测)。
|
||||
func (s *Service) writeTrafficStateAudit(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, outcome *flowOutcome) error {
|
||||
if outcome.DeltaMB <= 0 && !outcome.CrossMonth {
|
||||
return nil
|
||||
}
|
||||
before := map[string]any{
|
||||
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
|
||||
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
|
||||
}
|
||||
after := map[string]any{
|
||||
"data_usage_mb": card.DataUsageMB + int64(outcome.DeltaMB), "current_month_usage_mb": outcome.MonthMaxMB,
|
||||
"increment_mb": outcome.EmitMB, "delta_mb": outcome.DeltaMB, "decision": outcome.Decision,
|
||||
"observation_id": observation.Metadata.ObservationID,
|
||||
}
|
||||
input := StateAudit{Card: card, BeforeData: before, AfterData: after}
|
||||
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && audited {
|
||||
input.ActionCode, input.Summary = actionCode, "人工刷新 IoT 卡流量"
|
||||
} else if workerObservationAudited(ctx) {
|
||||
input.ActionCode, input.Summary = constants.AuditActionIotCardWorkerTrafficSynced, "Worker 同步 IoT 卡流量事实"
|
||||
input.IntegrationID = observation.Metadata.ObservationID
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
|
||||
}
|
||||
return s.auditWriter.WriteCardStateAudit(ctx, tx, input)
|
||||
}
|
||||
|
||||
// writeTrafficFailureAudit 记录 Worker 观测事务失败的审计。
|
||||
func (s *Service) writeTrafficFailureAudit(ctx context.Context, observation domain.TrafficObservation, err error) {
|
||||
if !workerObservationAudited(ctx) || s.auditWriter == nil {
|
||||
return
|
||||
}
|
||||
// 观测失败常因上下文已超时,失败审计改用独立短超时上下文,避免连带报错。
|
||||
auditCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
ctx = auditCtx
|
||||
var card model.IotCard
|
||||
if loadErr := s.db.WithContext(ctx).Where("id = ?", observation.CardID).First(&card).Error; loadErr != nil {
|
||||
return
|
||||
}
|
||||
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
|
||||
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
|
||||
Summary: "Worker 同步 IoT 卡流量事实失败", Card: &card,
|
||||
IntegrationID: observation.Metadata.ObservationID,
|
||||
}, err)
|
||||
}
|
||||
|
||||
func (s *Service) queueSettings() TrafficQueueSettings {
|
||||
if s.trafficQueueSettings == nil {
|
||||
return DefaultTrafficQueueSettings()
|
||||
}
|
||||
return s.trafficQueueSettings.Current(context.Background())
|
||||
}
|
||||
|
||||
// logTrafficWarn 记录不影响观测结果的告警。
|
||||
func (s *Service) logTrafficWarn(message string, fields ...zap.Field) {
|
||||
s.warnLogger().Warn(message, fields...)
|
||||
}
|
||||
|
||||
167
internal/application/cardobservation/traffic_collection.go
Normal file
167
internal/application/cardobservation/traffic_collection.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
|
||||
cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
"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"
|
||||
)
|
||||
|
||||
// rawResponseMaxBytes 是原始响应体保存上限,超过时截断并标记。
|
||||
const rawResponseMaxBytes = 4096
|
||||
|
||||
// collectionRecord 是一次采集的留痕内容;HasPackage 在观测事务内判断(只对成功读数有意义)。
|
||||
type collectionRecord struct {
|
||||
model.CardTrafficCollection
|
||||
HasPackage bool
|
||||
failure *domain.CollectionFailure
|
||||
iccid string
|
||||
}
|
||||
|
||||
func newCollectionRecord(observation domain.TrafficObservation) *collectionRecord {
|
||||
record := &collectionRecord{CardTrafficCollection: model.CardTrafficCollection{
|
||||
IotCardID: observation.CardID, ObservedAt: observation.Metadata.ObservedAt,
|
||||
ObservationID: observation.Metadata.ObservationID, Source: observation.Metadata.Source,
|
||||
Scene: observation.Metadata.Scene, Result: domain.ResolveReadingResult(observation),
|
||||
CollectionRaw: buildCollectionRaw(observation.Raw), CarrierID: observation.CarrierID,
|
||||
}, failure: observation.Failure, iccid: observation.ICCID}
|
||||
if record.Result == constants.TrafficCollectionResultOK {
|
||||
reading := observation.GatewayReadingMB
|
||||
record.VendorUsedMB = &reading
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func (r *collectionRecord) setQueue(points []cardflow.QueuePoint) {
|
||||
if encoded, err := json.Marshal(points); err == nil {
|
||||
r.QueuePoints = encoded
|
||||
}
|
||||
}
|
||||
|
||||
func (r *collectionRecord) applyOutcome(outcome *flowOutcome) {
|
||||
r.Decision = outcome.Decision
|
||||
if !outcome.Written {
|
||||
return
|
||||
}
|
||||
monthUsed, delta, stale := outcome.MonthUsedMB, outcome.DeltaMB, outcome.StaleGuarded
|
||||
r.MonthUsedMB, r.DeltaMB, r.StaleGuarded, r.EventID = &monthUsed, &delta, &stale, outcome.EventID
|
||||
}
|
||||
|
||||
// writeCollectionRecord 在观测事务之外写采集记录:成功读数只为采集时刻有套餐的卡写成功表;
|
||||
// 失败读数不判断有无套餐,写失败表。注入发布器时发布到批量写入消息,由消费者攒批写库并失败重投;
|
||||
// 未注入或发布失败时同步写库。
|
||||
func (s *Service) writeCollectionRecord(ctx context.Context, record *collectionRecord, _ time.Time) {
|
||||
if record.Result != constants.TrafficCollectionResultOK {
|
||||
s.writeCollectionFailure(ctx, record)
|
||||
return
|
||||
}
|
||||
if !record.HasPackage {
|
||||
return
|
||||
}
|
||||
row := &record.CardTrafficCollection
|
||||
if s.collectionSink != nil {
|
||||
err := s.collectionSink.PublishCollection(ctx, row, nil)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
s.logTrafficWarn("发布卡流量采集记录消息失败,改为同步写库", zap.Uint("card_id", record.IotCardID),
|
||||
zap.String("observation_id", record.ObservationID), zap.Error(err))
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), constants.PollingIngestSyncCollectionTimeout)
|
||||
defer cancel()
|
||||
if err := postgres.NewCardTrafficCollectionStore(s.db).Create(writeCtx, row); err != nil {
|
||||
s.logTrafficWarn("写卡流量采集记录失败", zap.Uint("card_id", record.IotCardID),
|
||||
zap.String("observation_id", record.ObservationID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) writeCollectionFailure(ctx context.Context, record *collectionRecord) {
|
||||
row := &model.CardTrafficCollectionFailure{
|
||||
IotCardID: record.IotCardID, ObservedAt: record.ObservedAt, ObservationID: record.ObservationID,
|
||||
Source: record.Source, Scene: record.Scene, Result: record.Result, CollectionRaw: record.CollectionRaw,
|
||||
ICCID: record.iccid, CarrierID: record.CarrierID,
|
||||
}
|
||||
if failure := record.failure; failure != nil {
|
||||
row.ErrorCategory, row.ProviderCode = failure.ErrorCategory, failure.ProviderCode
|
||||
row.ProviderMessage, row.DurationMS = truncateText(failure.ProviderMessage, 500), failure.DurationMS
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), constants.PollingIngestSyncCollectionTimeout)
|
||||
defer cancel()
|
||||
if row.ICCID == "" || row.CarrierID == 0 {
|
||||
var card model.IotCard
|
||||
if err := s.db.WithContext(writeCtx).Unscoped().Select("iccid", "carrier_id").Where("id = ?", record.IotCardID).Take(&card).Error; err == nil {
|
||||
row.ICCID, row.CarrierID = card.ICCID, card.CarrierID
|
||||
}
|
||||
}
|
||||
if s.collectionSink != nil {
|
||||
err := s.collectionSink.PublishCollection(ctx, nil, row)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
s.logTrafficWarn("发布卡流量采集失败记录消息失败,改为同步写库", zap.Uint("card_id", record.IotCardID),
|
||||
zap.String("observation_id", record.ObservationID), zap.Error(err))
|
||||
}
|
||||
if err := postgres.NewCardTrafficCollectionFailureStore(s.db).Create(writeCtx, row); err != nil {
|
||||
s.logTrafficWarn("写卡流量采集失败记录失败", zap.Uint("card_id", record.IotCardID),
|
||||
zap.String("observation_id", record.ObservationID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// buildCollectionRaw 把网关原始数据转为留存字段:响应可解析为 JSON 且不超过 4KB 时存 JSON,否则存文本并按 4KB 截断。
|
||||
func buildCollectionRaw(raw *domain.GatewayRaw) model.CollectionRaw {
|
||||
if raw == nil {
|
||||
return model.CollectionRaw{}
|
||||
}
|
||||
out := model.CollectionRaw{
|
||||
GatewayCode: raw.GatewayCode, GatewayMsg: truncateText(raw.GatewayMsg, 500),
|
||||
GatewayTraceID: truncateText(raw.TraceID, 128), GatewayAttempts: raw.Attempts,
|
||||
}
|
||||
if raw.HTTPStatus > 0 {
|
||||
status := raw.HTTPStatus
|
||||
out.HTTPStatus = &status
|
||||
}
|
||||
if len(raw.Request) > 0 && json.Valid(raw.Request) {
|
||||
out.RawRequest = datatypes.JSON(raw.Request)
|
||||
}
|
||||
switch {
|
||||
case len(raw.Response) == 0:
|
||||
case len(raw.Response) > rawResponseMaxBytes:
|
||||
out.RawResponseText, out.RawTruncated = validUTF8Prefix(raw.Response, rawResponseMaxBytes), true
|
||||
case json.Valid(raw.Response):
|
||||
out.RawResponse = datatypes.JSON(raw.Response)
|
||||
default:
|
||||
out.RawResponseText = validUTF8Prefix(raw.Response, rawResponseMaxBytes)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validUTF8Prefix 返回不超过 limit 字节且不截断多字节字符的前缀,并替换非法 UTF-8(PostgreSQL text 不接受)。
|
||||
func validUTF8Prefix(data []byte, limit int) string {
|
||||
if len(data) > limit {
|
||||
data = data[:limit]
|
||||
for len(data) > 0 && !utf8.Valid(data) {
|
||||
data = data[:len(data)-1]
|
||||
}
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return string([]rune(string(data)))
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func truncateText(value string, limit int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
118
internal/application/cardobservation/traffic_flow.go
Normal file
118
internal/application/cardobservation/traffic_flow.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// flowOutcome 是一个写入点的日快照计量结果。
|
||||
type flowOutcome struct {
|
||||
// Written 为 true 表示本次写入了日快照(没套餐且读数为 0 时为 false)。
|
||||
Written bool
|
||||
// Decision 是写入判断:已写入或建起点。
|
||||
Decision string
|
||||
// EmitMB 是本次写入流量事件的增量,0 表示不写事件。
|
||||
EmitMB float64
|
||||
// DeltaMB 是本次按月对账计入已结算的增量(含没套餐、建起点时只记已结算的部分)。
|
||||
DeltaMB float64
|
||||
// MonthUsedMB 是本次读数换算后的本月累计;MonthMaxMB 是写入后的本月最大快照。
|
||||
MonthUsedMB float64
|
||||
MonthMaxMB float64
|
||||
// StaleGuarded 为 true 表示本次读数在重置日触发防旧值、按 0 计。
|
||||
StaleGuarded bool
|
||||
// CrossMonth 为 true 表示卡表本月用量跨入新的自然月。
|
||||
CrossMonth bool
|
||||
// EventID 是本次写入的流量事件 ID。
|
||||
EventID string
|
||||
}
|
||||
|
||||
// flowWrite 是一次快照写入与按月对账的结果。
|
||||
type flowWrite struct {
|
||||
flowDate time.Time
|
||||
conversion cardflow.Conversion
|
||||
monthMax float64
|
||||
delta float64
|
||||
}
|
||||
|
||||
// writeFlow 按卡所属通道的重置日换算并原子写入当天快照,再按月对账把增量计入当天已结算。
|
||||
func (s *Service) writeFlow(ctx context.Context, tx *gorm.DB, card *model.IotCard, readingMB float64, observedAt time.Time, hasPackage bool) (flowWrite, error) {
|
||||
resetDay := s.strictResetDay(ctx, tx, card)
|
||||
flowDate := cardflow.ShanghaiDate(observedAt)
|
||||
days := postgres.NewCardFlowDayStore(tx)
|
||||
monthCtx, err := days.MonthContext(ctx, card.ID, flowDate, resetDay)
|
||||
if err != nil {
|
||||
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡日流量快照失败")
|
||||
}
|
||||
conversion := cardflow.Convert(readingMB, flowDate.Day(), resetDay, monthCtx)
|
||||
if err := days.Upsert(ctx, postgres.CardFlowSnapshotWrite{
|
||||
CardID: card.ID, FlowDate: flowDate, MonthUsedMB: conversion.MonthUsedMB,
|
||||
VendorUsedMB: readingMB, StaleGuarded: conversion.StaleGuarded,
|
||||
}); err != nil {
|
||||
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "写入卡日流量快照失败")
|
||||
}
|
||||
monthMax, settled, err := days.MonthTotals(ctx, card.ID, flowDate)
|
||||
if err != nil {
|
||||
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败")
|
||||
}
|
||||
delta := cardflow.MonthlyDelta(monthMax, settled)
|
||||
if err := days.AddSettled(ctx, card.ID, flowDate, delta); err != nil {
|
||||
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "结算卡流量增量失败")
|
||||
}
|
||||
return flowWrite{flowDate: flowDate, conversion: conversion, monthMax: monthMax, delta: delta}, nil
|
||||
}
|
||||
|
||||
// settleExisting 用卡上已有的本月最大快照结算(激活前结算本次没写入时):差额全部记为已结算,不新写快照。
|
||||
func (s *Service) settleExisting(ctx context.Context, tx *gorm.DB, card *model.IotCard, observedAt time.Time) error {
|
||||
flowDate := cardflow.ShanghaiDate(observedAt)
|
||||
days := postgres.NewCardFlowDayStore(tx)
|
||||
monthMax, settled, err := days.MonthTotals(ctx, card.ID, flowDate)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败")
|
||||
}
|
||||
if err := days.AddSettledToLatest(ctx, card.ID, flowDate, cardflow.MonthlyDelta(monthMax, settled)); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "激活前结算已有快照失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDailyUsageRows 为采集时刻每个有效套餐确保当天一行套餐日用量(没扣也写 0)。
|
||||
func ensureDailyUsageRows(ctx context.Context, tx *gorm.DB, packages []*model.PackageUsage, flowDate time.Time) error {
|
||||
ids := make([]uint, 0, len(packages))
|
||||
cumulative := make(map[uint]int64, len(packages))
|
||||
for _, pkg := range packages {
|
||||
ids = append(ids, pkg.ID)
|
||||
cumulative[pkg.ID] = pkg.DataUsageMB
|
||||
}
|
||||
if err := postgres.EnsureDailyUsageRows(ctx, tx, ids, flowDate, cumulative); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐当天日用量失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictResetDay 在当前连接内按卡的 carrier_id 读取上游流量重置日,不使用调用方预先计算的值;
|
||||
// 查询失败、为空或非法时按卡的运营商类型兜底(联通 27,其余 1)并记录日志。
|
||||
func (s *Service) strictResetDay(ctx context.Context, db *gorm.DB, card *model.IotCard) int {
|
||||
var days []int
|
||||
queryErr := db.WithContext(ctx).Model(&model.Carrier{}).
|
||||
Where("id = ?", card.CarrierID).Limit(1).Pluck("data_reset_day", &days).Error
|
||||
queried := 0
|
||||
if len(days) > 0 {
|
||||
queried = days[0]
|
||||
}
|
||||
resetDay, fallback := cardflow.ResolveResetDay(queried, queryErr == nil && len(days) > 0, card.CarrierType)
|
||||
if fallback {
|
||||
s.warnLogger().Warn("通道上游流量重置日不可用,按运营商类型兜底",
|
||||
zap.Uint("card_id", card.ID), zap.Uint("carrier_id", card.CarrierID),
|
||||
zap.String("carrier_type", card.CarrierType), zap.Int("queried", queried),
|
||||
zap.Int("reset_day", resetDay), zap.Error(queryErr))
|
||||
}
|
||||
return resetDay
|
||||
}
|
||||
193
internal/application/cardobservation/traffic_queue_settings.go
Normal file
193
internal/application/cardobservation/traffic_queue_settings.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// trafficQueueSettingsTTL 是队列配置在进程内的缓存时长;后台修改数秒内生效。
|
||||
const trafficQueueSettingsTTL = 5 * time.Second
|
||||
|
||||
// TrafficQueueSettings 是卡流量采集队列的运行参数。
|
||||
type TrafficQueueSettings struct {
|
||||
Size int
|
||||
ClearOnNewDay bool
|
||||
MaxPointAgeHours int
|
||||
DropToleranceMB float64
|
||||
}
|
||||
|
||||
// DefaultTrafficQueueSettings 返回代码默认值。
|
||||
func DefaultTrafficQueueSettings() TrafficQueueSettings {
|
||||
return TrafficQueueSettings{
|
||||
Size: constants.TrafficQueueDefaultSize, ClearOnNewDay: true,
|
||||
MaxPointAgeHours: constants.TrafficQueueDefaultMaxPointAgeHours, DropToleranceMB: constants.TrafficQueueDefaultDropToleranceMB,
|
||||
}
|
||||
}
|
||||
|
||||
// TrafficIntervalChecker 校验队列配置变更后启用中轮询配置的流量采集间隔是否仍在上限内。
|
||||
type TrafficIntervalChecker func(settings TrafficQueueSettings) error
|
||||
|
||||
// RegisterTrafficQueueConfigDefinitions 注册采集队列系统配置。checker 非空时,队列长度、跨天清空、最长点龄
|
||||
// 修改前用它校验启用中的轮询配置(读取其余三项的当前值组合成修改后的配置)。
|
||||
func RegisterTrafficQueueConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger, current func() TrafficQueueSettings, checker TrafficIntervalChecker) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
minSize, maxSize := int64(constants.TrafficQueueMinSize), int64(constants.TrafficQueueMaxSize)
|
||||
minAge, maxAge := int64(0), int64(24*30)
|
||||
withCheck := func(apply func(*TrafficQueueSettings, string) error) func(string) error {
|
||||
return func(value string) error {
|
||||
next := DefaultTrafficQueueSettings()
|
||||
if current != nil {
|
||||
next = current()
|
||||
}
|
||||
if err := apply(&next, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if checker == nil {
|
||||
return nil
|
||||
}
|
||||
return checker(next)
|
||||
}
|
||||
}
|
||||
definitions := []systemconfig.Definition{
|
||||
{Key: constants.SystemConfigTrafficQueueSize, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt,
|
||||
DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultSize), Min: &minSize, Max: &maxSize,
|
||||
Description: "卡流量采集队列长度 N:攒满 N 个不下降的读数才写最后一个点",
|
||||
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
|
||||
n, err := strconv.Atoi(v)
|
||||
s.Size = n
|
||||
return err
|
||||
})},
|
||||
{Key: constants.SystemConfigTrafficQueueClearOnNewDay, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeBool,
|
||||
DefaultValue: "true", Description: "卡流量采集队列跨天清空:新点与队列中的点不在同一上海日期时先清空",
|
||||
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
|
||||
s.ClearOnNewDay = v == "true"
|
||||
return nil
|
||||
})},
|
||||
{Key: constants.SystemConfigTrafficQueueMaxPointAgeHours, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt,
|
||||
DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultMaxPointAgeHours), Min: &minAge, Max: &maxAge,
|
||||
Description: "卡流量采集队列最长点龄(小时):入队时丢弃与新点相差超过该时长的点,0 表示不限",
|
||||
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
|
||||
n, err := strconv.Atoi(v)
|
||||
s.MaxPointAgeHours = n
|
||||
return err
|
||||
})},
|
||||
{Key: constants.SystemConfigTrafficQueueDropToleranceMB, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeString,
|
||||
DefaultValue: "1", Description: "卡流量采集队列回退容差(MB):相邻两点回退不超过该值算持平,0 表示不允许回退",
|
||||
Validator: func(v string) error {
|
||||
_, err := parseDropTolerance(v)
|
||||
return err
|
||||
}},
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
if _, exists := registry.Get(definition.Key); exists {
|
||||
continue
|
||||
}
|
||||
if err := registry.Register(definition); err != nil && logger != nil {
|
||||
logger.Error("注册卡流量采集队列系统配置失败,将使用代码默认值", zap.String("config_key", definition.Key), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseDropTolerance(value string) (float64, error) {
|
||||
tolerance, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil || math.IsNaN(tolerance) || math.IsInf(tolerance, 0) || tolerance < 0 || tolerance > 1024 {
|
||||
return 0, stderrors.New("回退容差必须是 0~1024 之间的数字(MB)")
|
||||
}
|
||||
return tolerance, nil
|
||||
}
|
||||
|
||||
// trafficConfigReader 读取系统配置的最小接缝。
|
||||
type trafficConfigReader interface {
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
// TrafficQueueSettingsProvider 读取采集队列配置,带短缓存;读取失败时使用代码默认值。
|
||||
type TrafficQueueSettingsProvider struct {
|
||||
reader trafficConfigReader
|
||||
logger *zap.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
cached TrafficQueueSettings
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewTrafficQueueSettingsProvider 创建采集队列配置读取器。
|
||||
func NewTrafficQueueSettingsProvider(reader trafficConfigReader, logger *zap.Logger) *TrafficQueueSettingsProvider {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &TrafficQueueSettingsProvider{reader: reader, logger: logger}
|
||||
}
|
||||
|
||||
// NewDefaultTrafficQueueSettingsProvider 用独立注册表与读取器创建采集队列配置读取器(API 与 Worker 共用同一份数据)。
|
||||
func NewDefaultTrafficQueueSettingsProvider(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *TrafficQueueSettingsProvider {
|
||||
registry := systemconfig.NewRegistry()
|
||||
RegisterTrafficQueueConfigDefinitions(registry, logger, nil, nil)
|
||||
var cache systemconfig.Cache
|
||||
if redisClient != nil {
|
||||
cache = systemconfig.NewRedisCache(redisClient)
|
||||
}
|
||||
reader := systemconfig.NewReader(db, registry, cache, systemconfig.NewLogAlertSink(logger))
|
||||
return NewTrafficQueueSettingsProvider(reader, logger)
|
||||
}
|
||||
|
||||
// Current 返回当前采集队列配置。
|
||||
func (p *TrafficQueueSettingsProvider) Current(ctx context.Context) TrafficQueueSettings {
|
||||
if p == nil || p.reader == nil {
|
||||
return DefaultTrafficQueueSettings()
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
now := time.Now()
|
||||
if now.Before(p.expiresAt) {
|
||||
return p.cached
|
||||
}
|
||||
settings, err := p.load(ctx)
|
||||
if err != nil {
|
||||
p.logger.Warn("读取卡流量采集队列配置失败,使用代码默认值", zap.Error(err))
|
||||
settings = DefaultTrafficQueueSettings()
|
||||
}
|
||||
p.cached, p.expiresAt = settings, now.Add(trafficQueueSettingsTTL)
|
||||
return settings
|
||||
}
|
||||
|
||||
func (p *TrafficQueueSettingsProvider) load(ctx context.Context) (TrafficQueueSettings, error) {
|
||||
settings := DefaultTrafficQueueSettings()
|
||||
raw, err := p.reader.Get(ctx, constants.SystemConfigTrafficQueueSize)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
if settings.Size, err = strconv.Atoi(raw); err != nil || settings.Size < constants.TrafficQueueMinSize {
|
||||
return DefaultTrafficQueueSettings(), stderrors.New("采集队列长度配置非法")
|
||||
}
|
||||
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueClearOnNewDay); err != nil {
|
||||
return DefaultTrafficQueueSettings(), err
|
||||
}
|
||||
settings.ClearOnNewDay = raw == "true"
|
||||
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueMaxPointAgeHours); err != nil {
|
||||
return DefaultTrafficQueueSettings(), err
|
||||
}
|
||||
if settings.MaxPointAgeHours, err = strconv.Atoi(raw); err != nil || settings.MaxPointAgeHours < 0 {
|
||||
return DefaultTrafficQueueSettings(), stderrors.New("采集队列最长点龄配置非法")
|
||||
}
|
||||
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueDropToleranceMB); err != nil {
|
||||
return DefaultTrafficQueueSettings(), err
|
||||
}
|
||||
if settings.DropToleranceMB, err = parseDropTolerance(raw); err != nil {
|
||||
return DefaultTrafficQueueSettings(), err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
312
internal/application/carrierthreshold/cycle.go
Normal file
312
internal/application/carrierthreshold/cycle.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// cycleBatchSize 是单次周期处理扫描的锁行上限。
|
||||
const cycleBatchSize = 200
|
||||
|
||||
// CycleStats 是一次周期处理的可观察结果。
|
||||
//
|
||||
// Scanned 为扫到的待处理持锁数;Unlocked 为本次认领解锁成功的锁数;Resumed 为同时写出复机事件的锁数;
|
||||
// Anomaly 为因运营商配置缺失或重置日非法而解锁并转人工的锁数;Skipped 为本地事实缺失、被并发推进
|
||||
// 或判定失败而未改动的锁数。
|
||||
type CycleStats struct{ Scanned, Unlocked, Resumed, Anomaly, Skipped int }
|
||||
|
||||
// ProcessDueLocks 扫描待处理的持锁锁行:认领解锁,并在新周期条件满足时同事务写复机事件。
|
||||
//
|
||||
// 过期判断按锁行自身运营商的 data_reset_day(支持换运营商后旧锁仍按其归属处理)。解锁与复机事件
|
||||
// 在同一事务提交:事务失败则解锁一起回滚,下一分钟重新处理,不会出现「已解锁但没有复机任务」的中间态。
|
||||
// 任一复机条件不满足时只解锁、不调运营商,并把可观察原因写入锁行。
|
||||
//
|
||||
// 锁行引用的运营商已不存在或重置日非法时无法计算周期归属:这类行按「新周期对仍持锁卡解除通道锁」
|
||||
// 的语义解锁并标记异常转人工,绝不写复机事件、不调运营商,也绝不静默跳过(否则持锁卡会永久禁止复机)。
|
||||
func (s *Service) ProcessDueLocks(ctx context.Context, now time.Time) (CycleStats, error) {
|
||||
stats := CycleStats{}
|
||||
if s == nil || s.db == nil || s.repository == nil {
|
||||
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值周期处理能力未配置")
|
||||
}
|
||||
due, err := s.ScanDueLocks(ctx, now, cycleBatchSize)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Scanned = len(due)
|
||||
if len(due) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
if s.commander == nil {
|
||||
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
|
||||
}
|
||||
var firstErr error
|
||||
for index := range due {
|
||||
item := due[index]
|
||||
if err := s.processDueLock(ctx, &item, &stats); err != nil {
|
||||
stats.Skipped++
|
||||
s.logger.Warn("通道阈值跨期处理单条失败",
|
||||
zap.Uint("lock_id", item.Lock.ID), zap.Uint("card_id", item.Lock.CardID), zap.Error(err))
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats, firstErr
|
||||
}
|
||||
|
||||
// processDueLock 处理单条待处理锁行:周期归属不可判定时解锁并转人工,已跨期时认领解锁并条件复机。
|
||||
func (s *Service) processDueLock(ctx context.Context, item *DueLock, stats *CycleStats) error {
|
||||
if item.Kind == DueLockUnresolvable {
|
||||
return s.processUnresolvableLock(ctx, item, stats)
|
||||
}
|
||||
lock := &item.Lock
|
||||
card, err := s.loadCard(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if card == nil {
|
||||
stats.Skipped++
|
||||
s.logger.Warn("通道阈值跨期锁对应卡不存在,本次不处理",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID))
|
||||
return nil
|
||||
}
|
||||
// 复机条件复用既有单一事实源(有效主套餐 + 流量未耗尽 + 实名满足 + 非风险扩展 + 无其他停因)。
|
||||
ready, reason, err := s.commander.ResumeReady(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
// 判定失败时不解锁:保留锁与拒绝复机的语义,下一分钟重试。
|
||||
return err
|
||||
}
|
||||
unlocked := false
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 复机结果与解锁时间在同一条件更新内落库:已发起复机记 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
|
||||
}
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
unlocked = true
|
||||
if !ready {
|
||||
return nil
|
||||
}
|
||||
return AppendResumeRequested(ctx, tx, s.repository, lock)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !unlocked {
|
||||
stats.Skipped++
|
||||
s.logger.Info("通道阈值跨期锁已被并发处理,跳过", zap.Uint("lock_id", lock.ID))
|
||||
return nil
|
||||
}
|
||||
stats.Unlocked++
|
||||
if !ready {
|
||||
s.logger.Info("通道阈值新周期仅解锁,不调用运营商复机",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.String("reason", reason))
|
||||
return nil
|
||||
}
|
||||
stats.Resumed++
|
||||
s.logger.Info("通道阈值新周期条件满足,已写复机事件",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// processUnresolvableLock 处理周期归属不可判定的锁行:同事务认领解锁并标记异常转人工。
|
||||
//
|
||||
// 只解锁不写复机事件、不调运营商:配置缺失时无法判断是否已跨期,解除通道锁交由既有复机链路
|
||||
// 与人工决定;anomaly_flag 与失败原因使运维可见并转人工核对。解锁与异常标记都是条件更新,
|
||||
// 重复执行不会产生第二次副作用。
|
||||
// 两者 MUST 共用同一个事务句柄:解锁已持有该行锁,若异常标记改走服务自身连接池,另一条连接
|
||||
// 会等待本事务的行锁(自锁),处理将挂死到语句超时并使该 cron 每分钟空转。
|
||||
func (s *Service) processUnresolvableLock(ctx context.Context, item *DueLock, stats *CycleStats) error {
|
||||
lock := &item.Lock
|
||||
s.logger.Warn("通道阈值锁行周期归属不可判定,解锁并转人工核对",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Uint("carrier_id", lock.CarrierID),
|
||||
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, domain.ResumeResultManual)
|
||||
if unlockErr != nil {
|
||||
return unlockErr
|
||||
}
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
unlocked = true
|
||||
_, anomalyErr := s.markAnomalyInTx(ctx, tx, lock.ID, item.Reason)
|
||||
return anomalyErr
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !unlocked {
|
||||
stats.Skipped++
|
||||
s.logger.Info("通道阈值不可判定锁已被并发处理,跳过", zap.Uint("lock_id", lock.ID))
|
||||
return nil
|
||||
}
|
||||
stats.Unlocked++
|
||||
stats.Anomaly++
|
||||
return nil
|
||||
}
|
||||
|
||||
// recoveryBatchSize 是单次恢复扫描的锁行上限。
|
||||
const recoveryBatchSize = 200
|
||||
|
||||
// RecoveryStats 是一次恢复扫描的可观察结果。
|
||||
//
|
||||
// Scanned 为扫到的待确认锁数;Confirmed 为本次回填为运营商已确认的锁数;Pending 为结果仍未知、
|
||||
// 等待下次扫描的锁数;Anomaly 为超过查询窗口仍不可确认、本次标记转人工的锁数;
|
||||
// Skipped 为本地事实缺失或已被并发推进而未改动状态的锁数。
|
||||
type RecoveryStats struct{ Scanned, Confirmed, Pending, Anomaly, Skipped int }
|
||||
|
||||
// RecoverSubmitted 扫描存在已提交子任务的锁:只查询运营商状态回填,绝不重复发起停复机。
|
||||
//
|
||||
// 每个子任务独立判断:查询确认到达目标状态即回填 confirmed 并补写卡状态(覆盖运营商调用成功但
|
||||
// 本地回写失败的场景);仍不可确认则等到下一次扫描;自提交起超过查询窗口仍不可确认时标记异常
|
||||
// 并退出自动扫描转人工,锁行与历史结果一律保留,绝不自动删除。
|
||||
func (s *Service) RecoverSubmitted(ctx context.Context, now time.Time) (RecoveryStats, error) {
|
||||
stats := RecoveryStats{}
|
||||
if s == nil || s.db == nil {
|
||||
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值恢复扫描能力未配置")
|
||||
}
|
||||
locks, err := s.ScanSubmittedLocks(ctx, recoveryBatchSize)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Scanned = len(locks)
|
||||
if len(locks) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
if s.commander == nil {
|
||||
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
|
||||
}
|
||||
var firstErr error
|
||||
for index := range locks {
|
||||
lock := locks[index]
|
||||
if err := s.recoverSubmittedLock(ctx, &lock, now, &stats); err != nil {
|
||||
stats.Skipped++
|
||||
s.logger.Warn("通道阈值恢复扫描单条失败",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats, firstErr
|
||||
}
|
||||
|
||||
// recoverSubmittedLock 处理单条待确认锁:只查询状态回填,不发起任何停复机调用。
|
||||
//
|
||||
// 入口只处理未决子任务(submitted/unknown/failed):调用失败或结果未知同样可能已在运营商侧生效,
|
||||
// 必须继续收敛;confirmed 是终态,pending 表示从未对外调用,都不在本扫描范围。
|
||||
func (s *Service) recoverSubmittedLock(ctx context.Context, lock *model.CarrierTrafficThresholdLock, now time.Time, stats *RecoveryStats) error {
|
||||
if !domain.IsUnresolvedTaskStatus(lock.StopStatus) && !domain.IsUnresolvedTaskStatus(lock.ResumeStatus) {
|
||||
return nil
|
||||
}
|
||||
card, err := s.loadCard(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, known := constants.NetworkStatusOffline, false
|
||||
if card != nil {
|
||||
// 查询失败按「仍未确认」处理,绝不误判为失败终态。
|
||||
status, known, _, err = s.commander.CardNetworkStatus(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
s.logger.Warn("查询运营商卡状态失败,按仍未确认处理",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
|
||||
known = false
|
||||
}
|
||||
}
|
||||
confirmed := 0
|
||||
unconfirmed := 0
|
||||
if domain.IsUnresolvedTaskStatus(lock.StopStatus) {
|
||||
switch {
|
||||
case known && status == constants.NetworkStatusOffline:
|
||||
confirmed++
|
||||
if err := s.confirmStop(ctx, lock, card); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
unconfirmed++
|
||||
}
|
||||
}
|
||||
if domain.IsUnresolvedTaskStatus(lock.ResumeStatus) {
|
||||
switch {
|
||||
case known && status == constants.NetworkStatusOnline:
|
||||
confirmed++
|
||||
if err := s.confirmResume(ctx, lock, card); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
unconfirmed++
|
||||
}
|
||||
}
|
||||
if confirmed == 0 {
|
||||
stats.Pending++
|
||||
} else {
|
||||
stats.Confirmed++
|
||||
}
|
||||
if unconfirmed == 0 {
|
||||
return nil
|
||||
}
|
||||
// 仍有子任务不可确认:窗口内继续等待,超期标记异常并退出自动扫描。
|
||||
if !domain.SubmissionExpired(lock.StopSubmittedAt, now) && !domain.SubmissionExpired(lock.ResumeSubmittedAt, now) {
|
||||
return nil
|
||||
}
|
||||
marked, err := s.markAnomaly(ctx, lock.ID, "运营商停复机结果超过确认窗口仍不可查,请人工核对")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if marked {
|
||||
stats.Anomaly++
|
||||
s.logger.Warn("通道阈值停复机结果超期不可确认,已标记异常转人工",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID),
|
||||
zap.Time("stop_submitted_at", valueOrZero(lock.StopSubmittedAt)),
|
||||
zap.Time("resume_submitted_at", valueOrZero(lock.ResumeSubmittedAt)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// confirmStop 停机已被运营商确认:先补写卡停机状态,再把子任务回填为已确认。
|
||||
// 顺序不可颠倒:先写卡状态才能保证「已确认」的锁不会掩盖未回写的卡事实。
|
||||
func (s *Service) confirmStop(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error {
|
||||
if card != nil && card.NetworkStatus != constants.NetworkStatusOffline {
|
||||
if err := s.commander.ConfirmCardState(ctx, lock.CardID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := s.markTaskConfirmed(ctx, lock.ID, stopTask, lock.StopIntegrationID)
|
||||
return err
|
||||
}
|
||||
|
||||
// confirmResume 复机已被运营商确认:先补写卡在线状态,再把子任务回填为已确认。
|
||||
func (s *Service) confirmResume(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error {
|
||||
if card != nil && card.NetworkStatus != constants.NetworkStatusOnline {
|
||||
if err := s.commander.ConfirmCardState(ctx, lock.CardID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := s.markTaskConfirmed(ctx, lock.ID, resumeTask, lock.ResumeIntegrationID)
|
||||
return err
|
||||
}
|
||||
|
||||
// valueOrZero 在日志中安全展开可空的提交时刻。
|
||||
func valueOrZero(value *time.Time) time.Time {
|
||||
if value == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return *value
|
||||
}
|
||||
129
internal/application/carrierthreshold/evaluate.go
Normal file
129
internal/application/carrierthreshold/evaluate.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// EvaluateInTx 在流量观测事务内判定通道阈值,达量时写周期锁并同事务写停机事件。
|
||||
//
|
||||
// 判定前提由调用方保证:未切换日快照算法的卡只有「读数被接受」(ReadingAccepted)的观测才进入本方法,
|
||||
// 异常下降保护命中的观测不参与判定;已切换的卡读数有效且未被重置日防旧值处理即进入本方法。重复达量观测由部分唯一索引的 23505 识别为
|
||||
// 「该周期已处理」并幂等跳过:不重复建锁、不重复写停机事件,该冲突与流量基线 CAS 的
|
||||
// CodeConflict 语义不同,绝不能混流。返回错误表示必须整体回滚(与既有流量事实同事务)。
|
||||
func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation domain.Evaluation) error {
|
||||
if s == nil || s.db == nil || s.repository == nil {
|
||||
return errors.New(errors.CodeInternalError, "通道流量阈值判定能力未完整配置")
|
||||
}
|
||||
if tx == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "通道流量阈值判定必须传入事务句柄")
|
||||
}
|
||||
if evaluation.CardID == 0 || evaluation.CarrierID == 0 {
|
||||
return nil
|
||||
}
|
||||
var carrier model.Carrier
|
||||
// 只取判定所需列:周期起点必须来自该运营商的 data_reset_day,漏取会让周期判定失去依据。
|
||||
if err := tx.WithContext(ctx).Select("id", "data_reset_day", "traffic_threshold_enabled", "traffic_threshold_value", "traffic_threshold_unit").
|
||||
Where("id = ?", evaluation.CarrierID).First(&carrier).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 卡引用的运营商已不存在:不判定、不建锁,保持与既有卡事实不一致的现状。
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询运营商通道流量阈值配置失败")
|
||||
}
|
||||
threshold := thresholdOf(carrier)
|
||||
if !threshold.Enabled {
|
||||
return nil
|
||||
}
|
||||
if !threshold.Valid() {
|
||||
// 配置半残(启用但无数值/单位,或单位未知)时绝不按 0 判定,跳过并留可观测日志。
|
||||
s.logger.Warn("运营商通道流量阈值配置不完整,跳过达量判定",
|
||||
zap.Uint("carrier_id", carrier.ID), zap.String("unit", threshold.Unit), zap.Float64("value", threshold.Value))
|
||||
return nil
|
||||
}
|
||||
reached, err := threshold.Reached(evaluation.ReadingMB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !reached {
|
||||
return nil
|
||||
}
|
||||
periodStart, err := domain.PeriodStart(evaluation.ObservedAt, carrier.DataResetDay)
|
||||
if err != nil {
|
||||
s.logger.Warn("运营商上游流量重置日非法,跳过达量判定",
|
||||
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,
|
||||
PeriodStart: periodStart,
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !created {
|
||||
s.logger.Info("该计费周期已存在通道阈值停机锁,跳过重复判定",
|
||||
zap.Uint("carrier_id", evaluation.CarrierID), zap.Uint("card_id", evaluation.CardID))
|
||||
return nil
|
||||
}
|
||||
if err := AppendStopRequested(ctx, tx, s.repository, lock); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// thresholdOf 把运营商持久化列转换为领域阈值配置。
|
||||
func thresholdOf(carrier model.Carrier) domain.Threshold {
|
||||
threshold := domain.Threshold{
|
||||
Enabled: carrier.TrafficThresholdEnabled == 1,
|
||||
Unit: carrier.TrafficThresholdUnit,
|
||||
}
|
||||
if carrier.TrafficThresholdValue != nil {
|
||||
threshold.Value = *carrier.TrafficThresholdValue
|
||||
}
|
||||
return threshold
|
||||
}
|
||||
|
||||
// lockKeyValue 返回周期锁在 Outbox 事件中的稳定字符串标识。
|
||||
func lockKeyValue(lockID uint) string {
|
||||
return strconv.FormatUint(uint64(lockID), 10)
|
||||
}
|
||||
159
internal/application/carrierthreshold/event.go
Normal file
159
internal/application/carrierthreshold/event.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
||||
)
|
||||
|
||||
// EventCarrierThresholdStop 是卡流量达到通道阈值后的可靠停机事件。
|
||||
const EventCarrierThresholdStop = "carrier.threshold.stop.requested"
|
||||
|
||||
// EventCarrierThresholdResume 是通道阈值跨期解锁且条件满足后的可靠复机事件。
|
||||
const EventCarrierThresholdResume = "carrier.threshold.resume.requested"
|
||||
|
||||
// carrierThresholdPayloadVersion 是通道阈值事件的载荷版本。
|
||||
const carrierThresholdPayloadVersion = 1
|
||||
|
||||
// periodLockConstraint 是周期锁部分唯一索引名,用于把 23505 精确识别为该周期已处理。
|
||||
const periodLockConstraint = "uq_carrier_traffic_threshold_lock_key"
|
||||
|
||||
// StopPayload 是通道阈值停机事件的载荷,只携带锁与卡标识,消费者按锁 ID 认领提交权。
|
||||
type StopPayload struct {
|
||||
LockID uint `json:"lock_id"`
|
||||
CardID uint `json:"card_id"`
|
||||
CarrierID uint `json:"carrier_id"`
|
||||
}
|
||||
|
||||
// AppendStopRequested 在达量判定事务内幂等写入通道阈值停机事件。
|
||||
// 事件 ID 由锁 ID 派生,同一周期锁重复投递不会创建第二个事件(ENG-OUTBOX-001)。
|
||||
func AppendStopRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, lock *model.CarrierTrafficThresholdLock) error {
|
||||
if repository == nil {
|
||||
return gorm.ErrInvalidDB
|
||||
}
|
||||
if lock == nil || lock.ID == 0 {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
value := lockKeyValue(lock.ID)
|
||||
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: outboxid.Stable(EventCarrierThresholdStop+":", value),
|
||||
EventType: EventCarrierThresholdStop,
|
||||
PayloadVersion: carrierThresholdPayloadVersion,
|
||||
AggregateType: "carrier_traffic_threshold_lock",
|
||||
AggregateID: value,
|
||||
ResourceType: "iot_card",
|
||||
ResourceID: lockKeyValue(lock.CardID),
|
||||
BusinessKey: EventCarrierThresholdStop + ":" + value,
|
||||
Payload: StopPayload{
|
||||
LockID: lock.ID,
|
||||
CardID: lock.CardID,
|
||||
CarrierID: lock.CarrierID,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ResumePayload 是通道阈值复机事件的载荷,只携带锁与卡标识,消费者按锁 ID 认领提交权。
|
||||
type ResumePayload struct {
|
||||
LockID uint `json:"lock_id"`
|
||||
CardID uint `json:"card_id"`
|
||||
CarrierID uint `json:"carrier_id"`
|
||||
}
|
||||
|
||||
// AppendResumeRequested 在周期处理事务内幂等写入通道阈值复机事件。
|
||||
// 事件 ID 由锁 ID 派生:解锁认领与复机事件同事务写入,重复投递不会创建第二个事件(ENG-OUTBOX-001)。
|
||||
func AppendResumeRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, lock *model.CarrierTrafficThresholdLock) error {
|
||||
if repository == nil {
|
||||
return gorm.ErrInvalidDB
|
||||
}
|
||||
if lock == nil || lock.ID == 0 {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
value := lockKeyValue(lock.ID)
|
||||
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: outboxid.Stable(EventCarrierThresholdResume+":", value),
|
||||
EventType: EventCarrierThresholdResume,
|
||||
PayloadVersion: carrierThresholdPayloadVersion,
|
||||
AggregateType: "carrier_traffic_threshold_lock",
|
||||
AggregateID: value,
|
||||
ResourceType: "iot_card",
|
||||
ResourceID: lockKeyValue(lock.CardID),
|
||||
BusinessKey: EventCarrierThresholdResume + ":" + value,
|
||||
Payload: ResumePayload{
|
||||
LockID: lock.ID,
|
||||
CardID: lock.CardID,
|
||||
CarrierID: lock.CarrierID,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// isPeriodLockConflict 判断错误是否为周期锁唯一键冲突,即「该周期已处理」。
|
||||
// 该冲突必须与流量基线 CAS 冲突(CodeConflict)区分:前者幂等跳过,后者由调用方重放重试。
|
||||
func isPeriodLockConflict(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if !stderrors.As(err, &pgErr) {
|
||||
return false
|
||||
}
|
||||
return pgErr.Code == "23505" && pgErr.ConstraintName == periodLockConstraint
|
||||
}
|
||||
|
||||
// Consumer 把通道阈值停复机事件转成一次停复机动作。
|
||||
type Consumer struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewConsumer 创建通道阈值停复机事件消费者。
|
||||
func NewConsumer(service *Service) *Consumer {
|
||||
return &Consumer{service: service}
|
||||
}
|
||||
|
||||
// Consume 按事件类型幂等执行停机或复机;重复投递由锁行认领字段兜住。
|
||||
func (c *Consumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.service == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行能力未配置")
|
||||
}
|
||||
lockID, validationErr := decodeThresholdPayload(envelope)
|
||||
if validationErr != nil {
|
||||
return validationErr
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID})
|
||||
switch envelope.EventType {
|
||||
case EventCarrierThresholdStop:
|
||||
return c.service.ExecuteStop(ctx, lockID)
|
||||
case EventCarrierThresholdResume:
|
||||
return c.service.ExecuteResume(ctx, lockID)
|
||||
default:
|
||||
return outbox.Permanent(gorm.ErrInvalidData)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeThresholdPayload 校验事件类型与载荷版本并取出锁 ID。
|
||||
// 载荷不合法属永久失败:重复投递不会改变结果,必须直接终结而不是重试。
|
||||
func decodeThresholdPayload(envelope outbox.DeliveryEnvelope) (uint, error) {
|
||||
if envelope.PayloadVersion != carrierThresholdPayloadVersion {
|
||||
return 0, outbox.Permanent(gorm.ErrInvalidData)
|
||||
}
|
||||
var payload struct {
|
||||
LockID uint `json:"lock_id"`
|
||||
}
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return 0, outbox.Permanent(err)
|
||||
}
|
||||
if payload.LockID == 0 {
|
||||
return 0, outbox.Permanent(gorm.ErrInvalidData)
|
||||
}
|
||||
return payload.LockID, nil
|
||||
}
|
||||
|
||||
// 编译期断言:通道阈值停复机消费者满足公共 Outbox 的消费边界。
|
||||
var _ outbox.EventConsumer = (*Consumer)(nil)
|
||||
199
internal/application/carrierthreshold/execute.go
Normal file
199
internal/application/carrierthreshold/execute.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// 无法从运营商确认结果时的可安全展示原因(不含内部细节与渠道报文)。
|
||||
const (
|
||||
failedStopReason = "运营商停机调用失败,等待状态查询确认"
|
||||
unknownStopReason = "运营商停机结果未知,等待状态查询确认"
|
||||
failedResumeReason = "运营商复机调用失败,等待状态查询确认"
|
||||
unknownResumeReason = "运营商复机结果未知,等待状态查询确认"
|
||||
)
|
||||
|
||||
// ExecuteStop 执行一次通道阈值达量停机,保证至多一次外部调用。
|
||||
//
|
||||
// 流程:提交认领(stop_submitted_at IS NULL 且仍持锁)→ 卡已停机则直接确认成功,不调运营商 →
|
||||
// 否则复用既有停机重试、Integration Log 与统一审计执行停机。认领失败表示该锁已提交过
|
||||
// (事件重复投递或人工重放),本次只结束,绝不重复调用;结果由恢复扫描查询收敛。
|
||||
// 任何分支都不删除锁行、不解锁。
|
||||
func (s *Service) ExecuteStop(ctx context.Context, lockID uint) error {
|
||||
if err := s.requireExecution(); err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := s.loadLock(ctx, lockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lock == nil {
|
||||
s.logger.Info("通道阈值停机事件对应锁不存在,幂等跳过", zap.Uint("lock_id", lockID))
|
||||
return nil
|
||||
}
|
||||
claimed, err := s.ClaimStopSubmission(ctx, lockID, s.now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
s.logger.Info("通道阈值停机已提交过,只等待恢复扫描确认",
|
||||
zap.Uint("lock_id", lockID), zap.String("stop_status", lock.StopStatus))
|
||||
return nil
|
||||
}
|
||||
card, err := s.loadCard(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if card == nil {
|
||||
// 卡事实不存在时无法调运营商,保留 submitted 由恢复扫描按窗口标记异常转人工。
|
||||
s.logger.Warn("通道阈值停机锁对应卡不存在,等待恢复扫描处理",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
|
||||
return nil
|
||||
}
|
||||
if card.NetworkStatus == constants.NetworkStatusOffline {
|
||||
// 其他停因已先行停机:不重复调用运营商,直接确认本次停机目标已达成。
|
||||
if _, err := s.markTaskConfirmed(ctx, lockID, stopTask, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("卡已停机,通道阈值停机直接确认成功",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.String("stop_reason", card.StopReason))
|
||||
return nil
|
||||
}
|
||||
outcome, callErr := s.commander.StopCardForThreshold(ctx, lock.CardID)
|
||||
if callErr != nil && outcome.Result == "" {
|
||||
// 基础设施故障(读卡、写审计或写卡状态失败)导致结果无法判定:保留 submitted,
|
||||
// 交由恢复扫描查询确认,本次不判定终态、不重复调用。
|
||||
s.logger.Error("通道阈值停机执行失败,等待恢复扫描确认",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.Error(callErr))
|
||||
return nil
|
||||
}
|
||||
if callErr != nil {
|
||||
s.logger.Warn("通道阈值停机运营商调用未成功,已按结果分类回填",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
|
||||
zap.String("result", outcome.Result), zap.Error(callErr))
|
||||
}
|
||||
_, err = s.markTaskOutcome(ctx, lockID, stopTask, []string{domain.TaskStatusSubmitted},
|
||||
taskResultOf(outcome), outcome.IntegrationID, stopFailureReason(outcome))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("通道阈值停机任务已回填结果",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
|
||||
zap.String("result", outcome.Result), zap.String("integration_id", outcome.IntegrationID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteResume 执行一次通道阈值新周期复机,保证至多一次外部调用。
|
||||
//
|
||||
// 流程:提交认领(resume_submitted_at IS NULL)→ 卡已在线则直接确认成功,不调运营商 →
|
||||
// 否则复用既有复机重试、Integration Log 与统一审计执行复机(成功时同一事务写回卡状态,
|
||||
// 且只在停因为通道阈值时清除停因)。认领失败表示已提交过,绝不重复调用。
|
||||
func (s *Service) ExecuteResume(ctx context.Context, lockID uint) error {
|
||||
if err := s.requireExecution(); err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := s.loadLock(ctx, lockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lock == nil {
|
||||
s.logger.Info("通道阈值复机事件对应锁不存在,幂等跳过", zap.Uint("lock_id", lockID))
|
||||
return nil
|
||||
}
|
||||
claimed, err := s.ClaimResumeSubmission(ctx, lockID, s.now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
s.logger.Info("通道阈值复机已提交过,只等待恢复扫描确认",
|
||||
zap.Uint("lock_id", lockID), zap.String("resume_status", lock.ResumeStatus))
|
||||
return nil
|
||||
}
|
||||
card, err := s.loadCard(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if card == nil {
|
||||
s.logger.Warn("通道阈值复机锁对应卡不存在,等待恢复扫描处理",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
|
||||
return nil
|
||||
}
|
||||
if card.NetworkStatus == constants.NetworkStatusOnline {
|
||||
if _, err := s.markTaskConfirmed(ctx, lockID, resumeTask, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("卡已在线,通道阈值复机直接确认成功",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
|
||||
return nil
|
||||
}
|
||||
outcome, callErr := s.commander.ResumeCardForThreshold(ctx, lock.CardID)
|
||||
if callErr != nil && outcome.Result == "" {
|
||||
s.logger.Error("通道阈值复机执行失败,等待恢复扫描确认",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.Error(callErr))
|
||||
return nil
|
||||
}
|
||||
if callErr != nil {
|
||||
s.logger.Warn("通道阈值复机运营商调用未成功,已按结果分类回填",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
|
||||
zap.String("result", outcome.Result), zap.Error(callErr))
|
||||
}
|
||||
_, err = s.markTaskOutcome(ctx, lockID, resumeTask, []string{domain.TaskStatusSubmitted},
|
||||
taskResultOf(outcome), outcome.IntegrationID, resumeFailureReason(outcome))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("通道阈值复机任务已回填结果",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
|
||||
zap.String("result", outcome.Result), zap.String("integration_id", outcome.IntegrationID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireExecution 校验消费者与周期处理所需的端口已配置。
|
||||
func (s *Service) requireExecution() error {
|
||||
if s == nil || s.db == nil || s.repository == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值执行能力未完整配置")
|
||||
}
|
||||
if s.commander == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// taskResultOf 把运营商调用结果映射为子任务终态。
|
||||
func taskResultOf(outcome domain.CommandOutcome) string {
|
||||
switch outcome.Result {
|
||||
case constants.AuditResultSuccess:
|
||||
return domain.TaskStatusConfirmed
|
||||
case constants.AuditResultUnknown:
|
||||
return domain.TaskStatusUnknown
|
||||
default:
|
||||
return domain.TaskStatusFailed
|
||||
}
|
||||
}
|
||||
|
||||
// stopFailureReason 生成停机子任务的可安全失败原因,成功时为空。
|
||||
func stopFailureReason(outcome domain.CommandOutcome) string {
|
||||
if outcome.Confirmed() {
|
||||
return ""
|
||||
}
|
||||
if outcome.Unresolved() {
|
||||
return unknownStopReason
|
||||
}
|
||||
return failedStopReason
|
||||
}
|
||||
|
||||
// resumeFailureReason 生成复机子任务的可安全失败原因,成功时为空。
|
||||
func resumeFailureReason(outcome domain.CommandOutcome) string {
|
||||
if outcome.Confirmed() {
|
||||
return ""
|
||||
}
|
||||
if outcome.Unresolved() {
|
||||
return unknownResumeReason
|
||||
}
|
||||
return failedResumeReason
|
||||
}
|
||||
369
internal/application/carrierthreshold/lock_store.go
Normal file
369
internal/application/carrierthreshold/lock_store.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// activeLockScanLimit 是「按卡取当前周期锁」的候选扫描上限。
|
||||
// 一张卡正常情况下只有当前周期的锁与历史周期锁,取最近若干条足以覆盖换运营商后的场景。
|
||||
const activeLockScanLimit = 8
|
||||
|
||||
// createLockInTx 在调用方事务内插入周期锁,返回 created=false 表示该周期已处理(唯一键冲突)。
|
||||
func (s *Service) createLockInTx(ctx context.Context, tx *gorm.DB, lock *model.CarrierTrafficThresholdLock) (bool, error) {
|
||||
// 唯一冲突在 PostgreSQL 中会中止整个事务,因此插入必须隔离在保存点内:
|
||||
// GORM 对已开启事务的嵌套 Transaction 使用 SAVEPOINT,冲突只回滚本次插入。
|
||||
insertErr := tx.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
|
||||
return inner.Create(lock).Error
|
||||
})
|
||||
if insertErr == nil {
|
||||
return true, nil
|
||||
}
|
||||
if isPeriodLockConflict(insertErr) {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, insertErr, "写入通道流量阈值周期锁失败")
|
||||
}
|
||||
|
||||
// FindLock 按唯一键读取某卡在某运营商某计费周期内的锁;不存在返回 nil。
|
||||
func (s *Service) FindLock(ctx context.Context, carrierID, cardID uint, periodStart time.Time) (*model.CarrierTrafficThresholdLock, error) {
|
||||
if s == nil || s.db == nil || carrierID == 0 || cardID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var lock model.CarrierTrafficThresholdLock
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("carrier_id = ? AND card_id = ? AND period_start = ?", carrierID, cardID, periodStart).
|
||||
First(&lock).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通道流量阈值周期锁失败")
|
||||
}
|
||||
return &lock, nil
|
||||
}
|
||||
|
||||
// ActiveLock 返回该卡在 now 所属计费周期内仍然生效的通道阈值锁;无锁返回 nil。
|
||||
//
|
||||
// 周期归属按锁行自身运营商的 data_reset_day 判断,因此换运营商后旧周期锁不会误判为当前周期。
|
||||
// 锁行引用的运营商已不存在时无法计算周期归属,此时按「仍可能属于当前周期」处理并返回该锁:
|
||||
// 持有通道阈值锁的卡在周期内必须拒绝一切复机,不能因为配置缺失放开复机,只能由周期处理/人工核销。
|
||||
func (s *Service) ActiveLock(ctx context.Context, cardID uint, now time.Time) (*model.CarrierTrafficThresholdLock, error) {
|
||||
if s == nil || s.db == nil || cardID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var locks []model.CarrierTrafficThresholdLock
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("card_id = ? AND status = ?", cardID, domain.LockStatusLocked).
|
||||
Order("period_start DESC").Limit(activeLockScanLimit).Find(&locks).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡当前周期通道流量阈值锁失败")
|
||||
}
|
||||
if len(locks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
resetDays, err := s.carrierResetDays(ctx, lockCarrierIDs(locks))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range locks {
|
||||
resetDay, ok := resetDays[locks[index].CarrierID]
|
||||
if !ok {
|
||||
return &locks[index], nil
|
||||
}
|
||||
periodStart, err := domain.PeriodStart(now, resetDay)
|
||||
if err != nil {
|
||||
// 重置日非法时同样无法判定周期归属,按仍生效处理,避免放开复机。
|
||||
return &locks[index], nil
|
||||
}
|
||||
if locks[index].PeriodStart.Equal(periodStart) {
|
||||
return &locks[index], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ClaimStopSubmission 以 stop_submitted_at IS NULL 条件更新认领停机提交权。
|
||||
//
|
||||
// 返回 true 表示调用方获得提交权、可以调用停机接口;false 表示该锁已提交过(事件重复投递或
|
||||
// 人工重放),调用方只能查询结果。谓词同时要求锁仍处于 locked:已跨期解锁的锁不再停机。
|
||||
func (s *Service) ClaimStopSubmission(ctx context.Context, lockID uint, now time.Time) (bool, error) {
|
||||
if s == nil || s.db == nil || lockID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
claimed := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
|
||||
Where("id = ? AND stop_submitted_at IS NULL AND status = ?", lockID, domain.LockStatusLocked).
|
||||
Updates(map[string]any{"stop_submitted_at": now, "stop_status": domain.TaskStatusSubmitted})
|
||||
if claimed.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领通道阈值停机提交权失败")
|
||||
}
|
||||
return claimed.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// ClaimResumeSubmission 以 resume_submitted_at IS NULL 条件更新认领复机提交权。
|
||||
//
|
||||
// 复机发生在周期处理解锁之后,此时锁已是 unlocked,因此谓词只要求未提交过。
|
||||
func (s *Service) ClaimResumeSubmission(ctx context.Context, lockID uint, now time.Time) (bool, error) {
|
||||
if s == nil || s.db == nil || lockID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
claimed := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
|
||||
Where("id = ? AND resume_submitted_at IS NULL", lockID).
|
||||
Updates(map[string]any{"resume_submitted_at": now, "resume_status": domain.TaskStatusSubmitted})
|
||||
if claimed.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领通道阈值复机提交权失败")
|
||||
}
|
||||
return claimed.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// DueLockKind 描述一条仍在持锁的锁行的跨期判定结果。
|
||||
type DueLockKind string
|
||||
|
||||
const (
|
||||
// DueLockExpired 表示已跨期:锁行 period_start 早于该锁行自身运营商按 data_reset_day 算出的当前周期起点。
|
||||
DueLockExpired DueLockKind = "expired"
|
||||
// DueLockUnresolvable 表示周期归属不可判定:锁行引用的运营商已不存在(含软删)或重置日非法。
|
||||
DueLockUnresolvable DueLockKind = "unresolvable"
|
||||
)
|
||||
|
||||
// unresolvableCarrierReason 是周期归属不可判定时写入锁行的可安全原因。
|
||||
const unresolvableCarrierReason = "锁行引用的运营商已不存在或上游流量重置日非法,已按跨期解除通道锁并转人工核对"
|
||||
|
||||
// DueLock 是周期处理扫描到的一条待处理锁行。
|
||||
type DueLock struct {
|
||||
// Lock 是持锁锁行本身。
|
||||
Lock model.CarrierTrafficThresholdLock
|
||||
// Kind 是跨期判定结果:已跨期或周期归属不可判定。
|
||||
Kind DueLockKind
|
||||
// Reason 是周期归属不可判定时的可安全原因(Kind 为 DueLockExpired 时为空)。
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ScanDueLocks 扫描需要周期处理的持锁锁行,供周期处理解锁与条件复机。
|
||||
//
|
||||
// 过期判断按锁行自身运营商的 data_reset_day 计算其当前周期起点,与锁行 period_start 不一致即已跨期,
|
||||
// 因此换运营商后的旧锁仍按其旧 carrier 的归属被正确识别。锁行引用的运营商已不存在或重置日非法时
|
||||
// 无法计算周期归属,这类行以 DueLockUnresolvable 返回:周期处理必须给出出路(按跨期语义解锁并转人工),
|
||||
// 绝不能让持锁卡因配置缺失而永久禁止复机。
|
||||
func (s *Service) ScanDueLocks(ctx context.Context, now time.Time, limit int) ([]DueLock, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var locks []model.CarrierTrafficThresholdLock
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("status = ?", domain.LockStatusLocked).
|
||||
Order("id ASC").Limit(limit).Find(&locks).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描跨期通道流量阈值锁失败")
|
||||
}
|
||||
if len(locks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
resetDays, err := s.carrierResetDays(ctx, lockCarrierIDs(locks))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
due := make([]DueLock, 0, len(locks))
|
||||
for index := range locks {
|
||||
lock := locks[index]
|
||||
resetDay, ok := resetDays[lock.CarrierID]
|
||||
if !ok {
|
||||
due = append(due, DueLock{Lock: lock, Kind: DueLockUnresolvable, Reason: unresolvableCarrierReason})
|
||||
continue
|
||||
}
|
||||
periodStart, periodErr := domain.PeriodStart(now, resetDay)
|
||||
if periodErr != nil {
|
||||
due = append(due, DueLock{Lock: lock, Kind: DueLockUnresolvable, Reason: unresolvableCarrierReason})
|
||||
continue
|
||||
}
|
||||
if lock.PeriodStart.Before(periodStart) {
|
||||
due = append(due, DueLock{Lock: lock, Kind: DueLockExpired})
|
||||
}
|
||||
}
|
||||
return due, nil
|
||||
}
|
||||
|
||||
// ScanSubmittedLocks 扫描存在未决子任务的锁行,供恢复扫描查询运营商状态回填。
|
||||
//
|
||||
// 未决集合为 {submitted, unknown, failed}(domain.UnresolvedTaskStatuses):调用失败或结果未知的行
|
||||
// 仍可能已在运营商侧生效,因此必须继续用只读状态查询收敛,不得退出链路。
|
||||
// 已标记异常(转人工)的锁必须退出扫描,否则每次扫描都会重复查询同一笔无法收敛的结果。
|
||||
func (s *Service) ScanSubmittedLocks(ctx context.Context, limit int) ([]model.CarrierTrafficThresholdLock, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
unresolved := domain.UnresolvedTaskStatuses()
|
||||
var locks []model.CarrierTrafficThresholdLock
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("anomaly_flag = ? AND (stop_status IN ? OR resume_status IN ?)",
|
||||
domain.AnomalyFlagNone, unresolved, unresolved).
|
||||
Order("id ASC").Limit(limit).Find(&locks).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描待确认通道流量阈值任务失败")
|
||||
}
|
||||
return locks, nil
|
||||
}
|
||||
|
||||
// lockTask 标识锁行上的停复机子任务。
|
||||
type lockTask struct {
|
||||
// statusColumn 是子任务状态列名。
|
||||
statusColumn string
|
||||
// integrationColumn 是该子任务对应的 Integration Log 标识列名。
|
||||
integrationColumn string
|
||||
}
|
||||
|
||||
var (
|
||||
// stopTask 是停机子任务。
|
||||
stopTask = lockTask{statusColumn: "stop_status", integrationColumn: "stop_integration_id"}
|
||||
// resumeTask 是复机子任务。
|
||||
resumeTask = lockTask{statusColumn: "resume_status", integrationColumn: "resume_integration_id"}
|
||||
)
|
||||
|
||||
// failureReasonMaxRunes 与 tb_carrier_traffic_threshold_lock.failure_reason 的长度上限一致。
|
||||
const failureReasonMaxRunes = 500
|
||||
|
||||
// safeFailureReason 裁剪可安全展示的失败原因,超长截断,绝不写入内部细节。
|
||||
func safeFailureReason(reason string) string {
|
||||
trimmed := strings.TrimSpace(reason)
|
||||
runes := []rune(trimmed)
|
||||
if len(runes) <= failureReasonMaxRunes {
|
||||
return trimmed
|
||||
}
|
||||
return string(runes[:failureReasonMaxRunes])
|
||||
}
|
||||
|
||||
// loadLock 按 ID 读取锁行;不存在返回 nil(软删行不可见)。
|
||||
func (s *Service) loadLock(ctx context.Context, lockID uint) (*model.CarrierTrafficThresholdLock, error) {
|
||||
if s == nil || s.db == nil || lockID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var lock model.CarrierTrafficThresholdLock
|
||||
err := s.db.WithContext(ctx).Where("id = ?", lockID).First(&lock).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通道流量阈值周期锁失败")
|
||||
}
|
||||
return &lock, nil
|
||||
}
|
||||
|
||||
// loadCard 按 ID 读取卡事实;不存在返回 nil。
|
||||
func (s *Service) loadCard(ctx context.Context, cardID uint) (*model.IotCard, error) {
|
||||
if s == nil || s.db == nil || cardID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var card model.IotCard
|
||||
err := s.db.WithContext(ctx).Where("id = ?", cardID).First(&card).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值卡事实失败")
|
||||
}
|
||||
return &card, nil
|
||||
}
|
||||
|
||||
// markTaskOutcome 按 expected 状态集合条件更新把子任务推进到终态(ENG-CONC-001)。
|
||||
// 返回 false 表示记录已被并发推进或已处于终态,调用方必须按幂等处理,不再重复执行外部动作。
|
||||
func (s *Service) markTaskOutcome(ctx context.Context, lockID uint, task lockTask, expected []string, result, integrationID, failureReason string) (bool, error) {
|
||||
if s == nil || s.db == nil || lockID == 0 || len(expected) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
updates := map[string]any{task.statusColumn: result}
|
||||
if integrationID != "" {
|
||||
updates[task.integrationColumn] = integrationID
|
||||
}
|
||||
if failureReason != "" {
|
||||
updates["failure_reason"] = safeFailureReason(failureReason)
|
||||
}
|
||||
result_ := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
|
||||
Where("id = ? AND "+task.statusColumn+" IN ?", lockID, expected).
|
||||
Updates(updates)
|
||||
if result_.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result_.Error, "回填通道阈值子任务状态失败")
|
||||
}
|
||||
return result_.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// markTaskConfirmed 由恢复扫描在运营商状态确认成功后把子任务回填为已确认。
|
||||
// expected 取未决集合 {submitted, unknown, failed}:失败与结果未知同样可能已在运营商侧生效,
|
||||
// 必须允许收敛为已确认;confirmed 不在集合内,因此确认只会写入一次。
|
||||
func (s *Service) markTaskConfirmed(ctx context.Context, lockID uint, task lockTask, integrationID string) (bool, error) {
|
||||
return s.markTaskOutcome(ctx, lockID, task, domain.UnresolvedTaskStatuses(), domain.TaskStatusConfirmed, integrationID, "")
|
||||
}
|
||||
|
||||
// markAnomaly 把锁标记为需人工核对并退出自动扫描(使用服务自身连接池,调用方不得已持有该行锁)。
|
||||
// 只应在窗口超期且结果无法确认时调用;已标记的锁不再重复查询。
|
||||
func (s *Service) markAnomaly(ctx context.Context, lockID uint, reason string) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.markAnomalyInTx(ctx, s.db, lockID, reason)
|
||||
}
|
||||
|
||||
// markAnomalyInTx 在调用方事务内把锁标记为需人工核对(anomaly_flag=0 → 1 条件更新)。
|
||||
//
|
||||
// 调用方已在同一事务内写过该行时 MUST 使用本方法:改用服务自身连接池会让另一条连接
|
||||
// 等待本事务持有的行锁(自锁),表现为处理挂死到语句超时。返回 false 表示已被并发标记,
|
||||
// 调用方按幂等处理。
|
||||
func (s *Service) markAnomalyInTx(ctx context.Context, tx *gorm.DB, lockID uint, reason string) (bool, error) {
|
||||
if tx == nil || lockID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
|
||||
Where("id = ? AND anomaly_flag = ?", lockID, domain.AnomalyFlagNone).
|
||||
Updates(map[string]any{
|
||||
"anomaly_flag": domain.AnomalyFlagManual,
|
||||
"failure_reason": safeFailureReason(reason),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记通道阈值锁异常失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// unlockInTx 在调用方事务内按 status=locked 条件更新认领解锁,返回 false 表示已被并发推进。
|
||||
//
|
||||
// 同一条件更新同时写入解锁时间与复机结果;未满足复机条件时同事务写入可观察原因。
|
||||
// 更新集合只覆盖锁生命周期与周期恢复留痕,绝不触碰停机命中事实(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,
|
||||
"unlocked_at": s.now(),
|
||||
"resume_result": resumeResult,
|
||||
}
|
||||
if failureReason != "" {
|
||||
updates["failure_reason"] = safeFailureReason(failureReason)
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
|
||||
Where("id = ? AND status = ?", lockID, domain.LockStatusLocked).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "解除通道流量阈值周期锁失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// lockCarrierIDs 收集锁行引用的运营商 ID 去重集合,用于显式批量查询(ENG-MODEL-001)。
|
||||
func lockCarrierIDs(locks []model.CarrierTrafficThresholdLock) []uint {
|
||||
seen := make(map[uint]struct{}, len(locks))
|
||||
ids := make([]uint, 0, len(locks))
|
||||
for index := range locks {
|
||||
if _, ok := seen[locks[index].CarrierID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[locks[index].CarrierID] = struct{}{}
|
||||
ids = append(ids, locks[index].CarrierID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
107
internal/application/carrierthreshold/service.go
Normal file
107
internal/application/carrierthreshold/service.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Package carrierthreshold 编排运营商通道流量阈值的达量判定与周期锁事实。
|
||||
//
|
||||
// 本包拥有 tb_carrier_traffic_threshold_lock 的全部读写:达量判定在流量观测事务内写入周期锁
|
||||
// 与可靠停机事件,消费者以提交认领字段取得至多一次的外部调用权,周期处理与恢复扫描只查询锁行。
|
||||
// 本包不调用任何运营商接口,也不在数据库事务内发起外部 I/O:停复机执行通过 CardCommander
|
||||
// 端口复用既有停复机单一事实源。
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// CardCommander 是通道阈值停复机对卡与运营商接口的能力边界。
|
||||
//
|
||||
// 实现必须复用既有停复机单一事实源(重试、Integration Log、统一审计、卡状态观测序列)与
|
||||
// 既有套餐/流量/实名/风险判定;本包绝不复制这些规则,也绝不直接调用运营商接口。
|
||||
type CardCommander interface {
|
||||
// StopCardForThreshold 执行通道阈值达量停机,成功时写回卡停机状态与停因。
|
||||
StopCardForThreshold(ctx context.Context, cardID uint) (domain.CommandOutcome, error)
|
||||
// ResumeCardForThreshold 执行新周期复机,成功时写回卡在线状态(只清除通道阈值停因)。
|
||||
ResumeCardForThreshold(ctx context.Context, cardID uint) (domain.CommandOutcome, error)
|
||||
// ResumeReady 判断解锁后的卡是否满足自动复机条件;不满足时返回可安全记录的原因。
|
||||
ResumeReady(ctx context.Context, cardID uint) (ready bool, reason string, err error)
|
||||
// CardNetworkStatus 只查询运营商卡状态并映射为本地网络状态;known 为 false 表示状态不可判定。
|
||||
CardNetworkStatus(ctx context.Context, cardID uint) (status int, known bool, integrationID string, err error)
|
||||
// ConfirmCardState 按已确认的运营商结果补写卡状态,覆盖 Gateway 成功但 DB 更新失败的场景。
|
||||
ConfirmCardState(ctx context.Context, cardID uint, offline bool) error
|
||||
}
|
||||
|
||||
// Service 执行通道阈值达量判定并维护周期锁事实。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
// repository 是公共 Outbox 仓储:达量停机事件与周期复机事件必须与锁事实在同一事务写入(ENG-OUTBOX-001)。
|
||||
repository *outbox.Repository
|
||||
logger *zap.Logger
|
||||
// commander 是停复机执行端口;未注入时消费者与 cron 拒绝执行外部调用。
|
||||
commander CardCommander
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService 创建通道阈值用例;repository 决定达量判定能否写出可靠停复机事件。
|
||||
func NewService(db *gorm.DB, repository *outbox.Repository) *Service {
|
||||
return &Service{db: db, repository: repository, logger: zap.NewNop(), now: time.Now}
|
||||
}
|
||||
|
||||
// SetLogger 注入通道阈值运行日志。
|
||||
func (s *Service) SetLogger(logger *zap.Logger) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
if logger == nil {
|
||||
s.logger = zap.NewNop()
|
||||
return s
|
||||
}
|
||||
s.logger = logger
|
||||
return s
|
||||
}
|
||||
|
||||
// SetCommander 注入停复机执行端口(消费者与周期处理必需)。
|
||||
func (s *Service) SetCommander(commander CardCommander) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
s.commander = commander
|
||||
return s
|
||||
}
|
||||
|
||||
// ChannelThresholdLocked 判断该卡当前计费周期是否持有通道阈值停机锁。
|
||||
//
|
||||
// 供复机入口前置拒绝复用:持锁即拒绝,判定口径与锁生效口径完全一致(按锁行自身运营商的
|
||||
// data_reset_day 判断周期归属),不另立一套判定。
|
||||
func (s *Service) ChannelThresholdLocked(ctx context.Context, cardID uint) (bool, error) {
|
||||
lock, err := s.ActiveLock(ctx, cardID, s.now())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return lock != nil, nil
|
||||
}
|
||||
|
||||
// carrierResetDays 按运营商 ID 集合显式查询上游流量重置日(ENG-MODEL-001:不使用关联标签)。
|
||||
// 返回结果只包含仍然存在的运营商,缺失 ID 由调用方按各自语义处理。
|
||||
func (s *Service) carrierResetDays(ctx context.Context, carrierIDs []uint) (map[uint]int, error) {
|
||||
resetDays := make(map[uint]int, len(carrierIDs))
|
||||
if len(carrierIDs) == 0 {
|
||||
return resetDays, nil
|
||||
}
|
||||
var carriers []model.Carrier
|
||||
if err := s.db.WithContext(ctx).
|
||||
Select("id", "data_reset_day").
|
||||
Where("id IN ?", carrierIDs).
|
||||
Find(&carriers).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商上游流量重置日失败")
|
||||
}
|
||||
for index := range carriers {
|
||||
resetDays[carriers[index].ID] = carriers[index].DataResetDay
|
||||
}
|
||||
return resetDays, nil
|
||||
}
|
||||
157
internal/application/distributionwithdrawal/audit.go
Normal file
157
internal/application/distributionwithdrawal/audit.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Package distributionwithdrawal 收口代理分销注册、提现资料资格与提现企业微信终审的用例。
|
||||
// 三者都以审批尝试/资料版本/注册记录主键作为通用审批业务标识,终态消费幂等且可重放。
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// VerificationCodeVerifier 是公开扫码注册复用的短信验证码接缝。
|
||||
// CheckCode 只校验不消费;业务事实落库成功后再由 ConsumeCode 原子消费,
|
||||
// 使落库前的任何失败都不会消费验证码,重试无需重新获取短信验证码。
|
||||
type VerificationCodeVerifier interface {
|
||||
CheckCode(ctx context.Context, phone string, code string) error
|
||||
ConsumeCode(ctx context.Context, phone string, code string) error
|
||||
}
|
||||
|
||||
// AuditChange 描述分销注册、提现资格与提现审批事实的实际变化。
|
||||
// 日志与审计不得记录密码、完整证件号、完整手机号或附件内容。
|
||||
type AuditChange struct {
|
||||
// EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。
|
||||
EventID string
|
||||
// ActionCode 是已注册的审计动作码。
|
||||
ActionCode string
|
||||
// Summary 是给人工阅读的中文摘要。
|
||||
Summary string
|
||||
// CorrelationID 是来源业务链路标识。
|
||||
CorrelationID string
|
||||
// Registration 是本次动作后的扫码注册记录事实。
|
||||
Registration *model.AgentDistributionRegistration
|
||||
// ParentShop 是扫码注册使用的上级店铺。
|
||||
ParentShop *model.Shop
|
||||
// Shop 是本次动作所属或引用的店铺。
|
||||
Shop *model.Shop
|
||||
// CreatedShop 是注册审批通过时新建的店铺。
|
||||
// CreatedShop.DistributionCode 是本次为新店铺生成的随机码;
|
||||
// AppliedDistributionCode 是注册时使用的上级店铺码快照,二者必须区分,不得混用。
|
||||
CreatedShop *model.Shop
|
||||
// AppliedDistributionCode 是注册提交时使用的上级店铺分销码快照。
|
||||
AppliedDistributionCode string
|
||||
// Qualification 是本次动作后的提现资料资格版本。
|
||||
Qualification *model.WithdrawalQualification
|
||||
// Withdrawal 是本次动作后的提现申请事实。
|
||||
Withdrawal *model.CommissionWithdrawalRequest
|
||||
// Attempt 是本次动作对应的提现审批尝试记录。
|
||||
Attempt *model.CommissionWithdrawalRequestAttempt
|
||||
// Wallet 是本次动作影响的佣金钱包。
|
||||
Wallet *model.AgentWallet
|
||||
// Transaction 是本次动作产生的钱包流水。
|
||||
Transaction *model.AgentWalletTransaction
|
||||
// BeforeData 与 AfterData 是脱敏前后的字段快照。
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
// Result 是审计结果,空值按成功处理。
|
||||
Result string
|
||||
// ErrorCode 与 ErrorSummary 是失败或拒绝审计的稳定错误信息。
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
}
|
||||
|
||||
// AuditWriter 在业务事务内追加统一 Audit Event。
|
||||
type AuditWriter interface {
|
||||
WriteDistributionWithdrawal(ctx context.Context, tx *gorm.DB, change AuditChange) error
|
||||
}
|
||||
|
||||
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
|
||||
func RecordFailure(ctx context.Context, db *gorm.DB, writer AuditWriter, change AuditChange, businessErr error) {
|
||||
if writer == nil || db == nil || businessErr == nil {
|
||||
return
|
||||
}
|
||||
appErr := changeError(businessErr)
|
||||
change.Result = constants.AuditResultFailed
|
||||
switch appErr.Code {
|
||||
case errors.CodeForbidden, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeConflict,
|
||||
errors.CodeInvalidStatus, errors.CodeInsufficientBalance, errors.CodeShopLevelExceeded:
|
||||
change.Result = constants.AuditResultDenied
|
||||
}
|
||||
if change.ErrorCode == "" {
|
||||
change.ErrorCode = strconv.Itoa(appErr.Code)
|
||||
}
|
||||
if change.ErrorSummary == "" {
|
||||
change.ErrorSummary = appErr.Message
|
||||
}
|
||||
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return writer.WriteDistributionWithdrawal(ctx, tx, change)
|
||||
}); err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
change.ActionCode, "", "", change.CorrelationID, change.ErrorCode, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// changeError 归一化底层错误为稳定 AppError,避免失败审计泄露底层文本。
|
||||
func changeError(err error) *errors.AppError {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr
|
||||
}
|
||||
return errors.New(errors.CodeInternalError, "分销注册或提现审批操作失败")
|
||||
}
|
||||
|
||||
// approvalSnapshots 生成通用审批的提交人快照与业务表单快照。
|
||||
func approvalSnapshots(accountID uint, accountName string, business map[string]any) ([]byte, []byte, error) {
|
||||
submitter, err := marshalJSON(map[string]any{
|
||||
"account_id": accountID, "account_name": accountName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
request, err := marshalJSON(business)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return submitter, request, nil
|
||||
}
|
||||
|
||||
// marshalJSON 使用 sonic 序列化业务快照,禁止写入密码、完整证件号或附件内容。
|
||||
func marshalJSON(value any) ([]byte, error) {
|
||||
payload, err := sonic.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化审批业务快照失败")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// createApprovalInTx 在业务事务内创建通用审批实例并返回引用。
|
||||
func createApprovalInTx(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
port approvalapp.Port,
|
||||
preparation approvalapp.Preparation,
|
||||
businessType string,
|
||||
businessID uint,
|
||||
submitterAccountID uint,
|
||||
submitterSnapshot []byte,
|
||||
requestSnapshot []byte,
|
||||
correlationID string,
|
||||
) (approvalapp.Reference, error) {
|
||||
if port == nil {
|
||||
return approvalapp.Reference{}, errors.New(errors.CodeServiceUnavailable, "审批能力尚未配置")
|
||||
}
|
||||
return port.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: businessType, BusinessID: businessID,
|
||||
SubmitterAccountID: submitterAccountID, SubmitterSnapshot: submitterSnapshot,
|
||||
RequestSnapshot: requestSnapshot, CorrelationID: correlationID,
|
||||
})
|
||||
}
|
||||
463
internal/application/distributionwithdrawal/qualification.go
Normal file
463
internal/application/distributionwithdrawal/qualification.go
Normal file
@@ -0,0 +1,463 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// QualificationResult 返回已落库的资料版本与审批实例引用。
|
||||
type QualificationResult struct {
|
||||
QualificationID uint
|
||||
Status int
|
||||
ApprovalInstanceID uint
|
||||
ApprovalStatus int
|
||||
}
|
||||
|
||||
// QualificationService 受理提现资料资格的提交、替换、作废与停用失效。
|
||||
// 资格事实按版本不可变保存;替换合同或法人身份证即新增版本并在同一事务内失效旧有效版本。
|
||||
type QualificationService struct {
|
||||
db *gorm.DB
|
||||
approval approvalapp.Port
|
||||
audit AuditWriter
|
||||
}
|
||||
|
||||
// NewQualificationService 创建提现资料资格用例。
|
||||
func NewQualificationService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *QualificationService {
|
||||
return &QualificationService{db: db, approval: approval, audit: audit}
|
||||
}
|
||||
|
||||
// Submit 提交或替换本人代理店铺的提现资料资格。
|
||||
// 已有待审批版本时拒绝;已有效版本在合同或法人身份证未变化时拒绝重复提交。
|
||||
func (s *QualificationService) Submit(
|
||||
ctx context.Context,
|
||||
shopID uint,
|
||||
input distributiondomain.QualificationInput,
|
||||
) (*QualificationResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
|
||||
}
|
||||
if err := ensureOwnAgentShop(ctx, shopID); err != nil {
|
||||
// 越权提交资格属关键拒绝,必须留痕:以目标店铺为主要资源记录拒绝事实。
|
||||
RecordFailure(ctx, s.db, s.audit, AuditChange{
|
||||
// 不手工构造 EventID:本条是失败/拒绝事实,同一店铺可被拒绝多次,
|
||||
// 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。
|
||||
// 由审计 Writer 生成唯一 evt_<uuid>(与既有 recordRefundFailure 的做法一致)。
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected,
|
||||
Summary: "提交提现资料资格被拒绝:越权或非本人店铺",
|
||||
Shop: failureShopResolved(ctx, s.db, shopID, nil),
|
||||
}, err)
|
||||
return nil, err
|
||||
}
|
||||
normalized, err := distributiondomain.ValidateQualificationInput(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
submitter, err := resolveShopPrimaryAccount(ctx, s.db, shopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
correlationID := "withdrawal_qualification:" + uuid.NewString()
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeWithdrawalQualification,
|
||||
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shop, err := loadShop(ctx, s.db, shopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version := &model.WithdrawalQualification{
|
||||
ShopID: shopID, SubjectType: normalized.SubjectType, SubjectCode: normalized.SubjectCode,
|
||||
LegalPersonIDCard: normalized.LegalPersonIDCard, ContractFileKey: normalized.ContractFileKey,
|
||||
IDCardFrontFileKey: normalized.IDCardFrontFileKey, IDCardBackFileKey: normalized.IDCardBackFileKey,
|
||||
BusinessLicenseFileKey: normalized.BusinessLicenseFileKey, ShopFrontFileKey: normalized.ShopFrontFileKey,
|
||||
InvoiceFileKey: normalized.InvoiceFileKey, InvoiceTitle: normalized.InvoiceTitle,
|
||||
InvoiceSubjectCode: normalized.InvoiceSubjectCode,
|
||||
Status: constants.WithdrawalQualificationStatusPending,
|
||||
Creator: operatorID, Updater: operatorID,
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
|
||||
qualificationApprovalForm(version, shop))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &QualificationResult{}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
replaced, err := invalidateReplacedVersion(ctx, tx, shopID, normalized, operatorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(version).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现资料资格版本失败")
|
||||
}
|
||||
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
|
||||
constants.ApprovalBusinessTypeWithdrawalQualification, version.ID, submitter.ID,
|
||||
submitterSnapshot, requestSnapshot, correlationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := attachQualificationInstance(ctx, tx, version, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":submit",
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationSubmitted,
|
||||
Summary: qualificationSubmitSummary(replaced),
|
||||
CorrelationID: correlationID, Qualification: version, Shop: shop,
|
||||
AfterData: qualificationAuditSnapshot(version),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
result.QualificationID = version.ID
|
||||
result.Status = version.Status
|
||||
result.ApprovalInstanceID = reference.InstanceID
|
||||
result.ApprovalStatus = reference.Status
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// 提交在创建资料版本前被拒绝(存在待审批版本或参数非法),此时没有资料版本可作主要资源,
|
||||
// 以店铺为主要资源记录拒绝事实。
|
||||
RecordFailure(ctx, s.db, s.audit, AuditChange{
|
||||
// 不手工构造 EventID:本条是失败/拒绝事实,同一店铺可被拒绝多次,
|
||||
// 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。
|
||||
// 由审计 Writer 生成唯一 evt_<uuid>(与既有 recordRefundFailure 的做法一致)。
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected,
|
||||
Summary: "提交提现资料资格被拒绝", CorrelationID: correlationID,
|
||||
Shop: shop,
|
||||
}, err)
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Void 由超级管理员填写原因后作废有效提现资料资格。
|
||||
// 原因必填;已失效或非有效版本返回稳定冲突错误。
|
||||
func (s *QualificationService) Void(ctx context.Context, id uint, reason string) error {
|
||||
if s == nil || s.db == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
businessErr := errors.New(errors.CodeInvalidParam, "作废提现资料资格必须填写原因")
|
||||
// 关键拒绝必须留痕:作废原因必填是权限相关拒绝,按超管作废动作记录拒绝事实。
|
||||
RecordFailure(ctx, s.db, s.audit, AuditChange{
|
||||
// 不手工构造 EventID:该拒绝与「作废成功」是同一实体的两次不同发生,
|
||||
// 手工 ID 会让随后的成功作废审计被 event_id 唯一约束吞掉,造成审计与事实相反。
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
|
||||
Summary: "作废提现资料资格被拒绝:未填写原因",
|
||||
Qualification: &model.WithdrawalQualification{ID: id},
|
||||
}, businessErr)
|
||||
return businessErr
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
var version model.WithdrawalQualification
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&version, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "提现资料资格不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败")
|
||||
}
|
||||
if version.Status != constants.WithdrawalQualificationStatusApproved {
|
||||
return errors.New(errors.CodeConflict, "仅有效提现资料资格可作废")
|
||||
}
|
||||
before := qualificationAuditSnapshot(&version)
|
||||
if err := invalidateVersion(ctx, tx, &version, reason, operatorID); err != nil {
|
||||
return err
|
||||
}
|
||||
shop, err := loadShop(ctx, tx, version.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":void",
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
|
||||
Summary: "超级管理员作废提现资料资格", Qualification: &version, Shop: shop,
|
||||
BeforeData: before, AfterData: qualificationAuditSnapshot(&version),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
// 失败审计必须可追溯且恰好有一个主要资源:带上目标资料版本(至少含 ID)。
|
||||
RecordFailure(ctx, s.db, s.audit, AuditChange{
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
|
||||
Summary: "作废提现资料资格失败",
|
||||
Qualification: &model.WithdrawalQualification{ID: id},
|
||||
}, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateByShopDisable 在店铺停用事务内使该店铺全部有效资格失效。
|
||||
// 历史版本与审批结果保留;由调用方保证与店铺停用处于同一事务。
|
||||
func (s *QualificationService) InvalidateByShopDisable(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
reason string,
|
||||
) error {
|
||||
if tx == nil || shopID == 0 {
|
||||
return nil
|
||||
}
|
||||
var versions []model.WithdrawalQualification
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
|
||||
Order("id ASC").Find(&versions).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待失效提现资料资格失败")
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalQualificationStatusInvalidated,
|
||||
"invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效提现资料资格失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
shop, err := loadShop(ctx, tx, shopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
first := versions[0]
|
||||
first.Status = constants.WithdrawalQualificationStatusInvalidated
|
||||
first.InvalidReason = reason
|
||||
first.InvalidatedAt = &now
|
||||
summary := "代理店铺停用,全部有效提现资料资格失效"
|
||||
if strings.Contains(reason, "删除") {
|
||||
summary = "代理店铺已删除,全部有效提现资料资格失效"
|
||||
}
|
||||
// 不手工构造 EventID:同一店铺可先停用失效、后删除失效,属同一实体的两次不同发生,
|
||||
// 手工 ID 会让第二次失效审计被吞掉。
|
||||
return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationInvalidated,
|
||||
Summary: summary, Qualification: &first, Shop: shop,
|
||||
AfterData: map[string]any{
|
||||
"shop_id": shopID, "invalidated_count": result.RowsAffected,
|
||||
"status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// invalidateReplacedVersion 在替换合同或法人身份证时失效旧有效版本。
|
||||
// 返回被失效的版本;没有需失效的版本时返回 nil。
|
||||
func invalidateReplacedVersion(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
input distributiondomain.QualificationInput,
|
||||
operatorID uint,
|
||||
) (*model.WithdrawalQualification, error) {
|
||||
var pending int64
|
||||
if err := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusPending).
|
||||
Count(&pending).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待审批提现资料资格失败")
|
||||
}
|
||||
if pending > 0 {
|
||||
return nil, errors.New(errors.CodeConflict, "已存在待审批的提现资料资格,请等待审批结果")
|
||||
}
|
||||
var current model.WithdrawalQualification
|
||||
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
|
||||
First(¤t).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定有效提现资料资格失败")
|
||||
}
|
||||
if !qualificationRequiresApproval(¤t, input) {
|
||||
return nil, errors.New(errors.CodeConflict, "提现资料资格已生效,合同与法人身份证未变化")
|
||||
}
|
||||
if err := invalidateVersion(ctx, tx, ¤t, "代理替换合同或法人身份证资料", operatorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ¤t, nil
|
||||
}
|
||||
|
||||
// qualificationRequiresApproval 判断本次提交是否改变了合同或法人身份证事实。
|
||||
func qualificationRequiresApproval(
|
||||
current *model.WithdrawalQualification,
|
||||
input distributiondomain.QualificationInput,
|
||||
) bool {
|
||||
return current.ContractFileKey != input.ContractFileKey ||
|
||||
current.IDCardFrontFileKey != input.IDCardFrontFileKey ||
|
||||
current.IDCardBackFileKey != input.IDCardBackFileKey ||
|
||||
current.SubjectCode != input.SubjectCode ||
|
||||
current.LegalPersonIDCard != input.LegalPersonIDCard
|
||||
}
|
||||
|
||||
// invalidateVersion 条件更新单个资料版本为已失效。
|
||||
func invalidateVersion(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
version *model.WithdrawalQualification,
|
||||
reason string,
|
||||
operatorID uint,
|
||||
) error {
|
||||
now := time.Now().UTC()
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("id = ? AND status = ?", version.ID, version.Status).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason,
|
||||
"invalidated_at": now, "invalidated_by": operatorID, "updater": operatorID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效提现资料资格版本失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
|
||||
}
|
||||
version.Status = constants.WithdrawalQualificationStatusInvalidated
|
||||
version.InvalidReason = reason
|
||||
version.InvalidatedAt = &now
|
||||
version.InvalidatedBy = operatorID
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachQualificationInstance 回写资料版本关联的审批实例,写入一次后不可修改。
|
||||
func attachQualificationInstance(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
version *model.WithdrawalQualification,
|
||||
instanceID uint,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", version.ID).
|
||||
Update("approval_instance_id", instanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联提现资料资格审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现资料资格审批实例关联已变化")
|
||||
}
|
||||
version.ApprovalInstanceID = &instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// qualificationApprovalForm 生成企业微信审批表单业务快照。
|
||||
// 证件号按脱敏值写入,附件只写入对象存储 Key 引用,不写入附件内容。
|
||||
func qualificationApprovalForm(version *model.WithdrawalQualification, shop *model.Shop) map[string]any {
|
||||
shopName := ""
|
||||
if shop != nil {
|
||||
shopName = shop.ShopName
|
||||
}
|
||||
return map[string]any{
|
||||
constants.ApprovalFieldQualificationShopID: version.ShopID,
|
||||
constants.ApprovalFieldQualificationShopName: shopName,
|
||||
constants.ApprovalFieldQualificationSubjectType: constants.GetWithdrawalQualificationSubjectTypeName(version.SubjectType),
|
||||
constants.ApprovalFieldQualificationSubjectCodeMasked: distributiondomain.MaskSubjectCode(version.SubjectCode),
|
||||
constants.ApprovalFieldQualificationLegalPersonMasked: distributiondomain.MaskSubjectCode(version.LegalPersonIDCard),
|
||||
constants.ApprovalFieldQualificationContractKey: version.ContractFileKey,
|
||||
constants.ApprovalFieldQualificationIDCardFrontKey: version.IDCardFrontFileKey,
|
||||
constants.ApprovalFieldQualificationIDCardBackKey: version.IDCardBackFileKey,
|
||||
constants.ApprovalFieldQualificationBusinessLicenseKey: version.BusinessLicenseFileKey,
|
||||
constants.ApprovalFieldQualificationShopFrontKey: version.ShopFrontFileKey,
|
||||
constants.ApprovalFieldQualificationInvoiceKey: version.InvoiceFileKey,
|
||||
constants.ApprovalFieldQualificationInvoiceTitle: version.InvoiceTitle,
|
||||
}
|
||||
}
|
||||
|
||||
// qualificationAuditSnapshot 生成资料版本审计快照,证件号按脱敏值记录,不含附件内容。
|
||||
func qualificationAuditSnapshot(version *model.WithdrawalQualification) map[string]any {
|
||||
instanceID := uint(0)
|
||||
if version.ApprovalInstanceID != nil {
|
||||
instanceID = *version.ApprovalInstanceID
|
||||
}
|
||||
return map[string]any{
|
||||
"id": version.ID, "shop_id": version.ShopID, "subject_type": version.SubjectType,
|
||||
"subject_code_masked": distributiondomain.MaskSubjectCode(version.SubjectCode),
|
||||
"status": version.Status, "approval_instance_id": instanceID,
|
||||
"invalid_reason": version.InvalidReason,
|
||||
"attachment_count": 3 + boolToInt(version.BusinessLicenseFileKey != "") +
|
||||
boolToInt(version.ShopFrontFileKey != "") + boolToInt(version.InvoiceFileKey != ""),
|
||||
}
|
||||
}
|
||||
|
||||
// qualificationSubmitSummary 区分首次提交与替换提交的审计摘要。
|
||||
func qualificationSubmitSummary(replaced *model.WithdrawalQualification) string {
|
||||
if replaced != nil {
|
||||
return "替换合同或法人身份证资料,旧有效提现资料资格已失效"
|
||||
}
|
||||
return "提交提现资料资格"
|
||||
}
|
||||
|
||||
// ensureOwnAgentShop 校验当前账号为代理身份且目标即本人店铺。
|
||||
func ensureOwnAgentShop(ctx context.Context, shopID uint) error {
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
|
||||
return errors.New(errors.CodeForbidden, "仅代理商用户可提交提现资料资格")
|
||||
}
|
||||
if shopID == 0 || shopID != middleware.GetShopIDFromContext(ctx) {
|
||||
return errors.New(errors.CodeForbidden, "仅可为本人店铺提交提现资料资格")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveShopPrimaryAccount 解析店铺启用的主账号,作为审批发起主体。
|
||||
func resolveShopPrimaryAccount(ctx context.Context, db *gorm.DB, shopID uint) (*model.Account, error) {
|
||||
var account model.Account
|
||||
if err := db.WithContext(ctx).
|
||||
Where("shop_id = ? AND status = ? AND is_primary = TRUE", shopID, constants.StatusEnabled).
|
||||
First(&account).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "店铺缺少启用的主账号,无法提交审批")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺主账号失败")
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// loadShopOrNil 读取店铺事实;店铺已软删除或不存在时返回 nil,供终态收敛使用。
|
||||
func loadShopOrNil(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop {
|
||||
shop, err := loadShop(ctx, db, shopID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return shop
|
||||
}
|
||||
|
||||
// loadShop 读取店铺事实,未找到返回稳定不存在错误。
|
||||
func loadShop(ctx context.Context, db *gorm.DB, shopID uint) (*model.Shop, error) {
|
||||
var shop model.Shop
|
||||
if err := db.WithContext(ctx).First(&shop, shopID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "店铺不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺失败")
|
||||
}
|
||||
return &shop, nil
|
||||
}
|
||||
|
||||
// boolToInt 将布尔值转换为 0/1,用于审计计数。
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// QualificationApprovalHandler 将渠道无关企业微信终态应用到提现资料资格版本。
|
||||
// 通过才使版本生效;驳回只标记该版本,不影响其他版本已记录的审批结果。
|
||||
type QualificationApprovalHandler struct {
|
||||
db *gorm.DB
|
||||
audit AuditWriter
|
||||
}
|
||||
|
||||
// NewQualificationApprovalHandler 创建提现资料资格审批终态消费者。
|
||||
func NewQualificationApprovalHandler(db *gorm.DB, audit AuditWriter) *QualificationApprovalHandler {
|
||||
return &QualificationApprovalHandler{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// Handle 幂等消费标准审批终态。
|
||||
// 业务标识为资料版本主键;先锁定版本并校验审批实例一致,再以条件更新推进状态。
|
||||
func (h *QualificationApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
|
||||
if h == nil || h.db == nil || h.audit == nil {
|
||||
return errors.New(errors.CodeInternalError, "提现资料资格审批终态能力未配置")
|
||||
}
|
||||
if event.BusinessType != constants.ApprovalBusinessTypeWithdrawalQualification ||
|
||||
event.BusinessID == 0 || event.InstanceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "提现资料资格审批终态参数无效")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
|
||||
})
|
||||
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var version model.WithdrawalQualification
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&version, event.BusinessID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "提现资料资格版本不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败")
|
||||
}
|
||||
if version.ApprovalInstanceID == nil || *version.ApprovalInstanceID != event.InstanceID {
|
||||
return errors.New(errors.CodeConflict, "提现资料资格版本关联的审批实例不一致")
|
||||
}
|
||||
if version.Status != constants.WithdrawalQualificationStatusPending {
|
||||
// 已是终态(含被替换或作废):重复或乱序回调不再改变事实。
|
||||
return nil
|
||||
}
|
||||
// 店铺可能已被软删除:终态必须仍能收敛,不得把「店铺不存在」当成致命错误,
|
||||
// 否则该版本永久卡在待审批且终态事件永久重投。审计的店铺资源此时允许为空。
|
||||
shop := loadShopOrNil(ctx, tx, version.ShopID)
|
||||
before := qualificationAuditSnapshot(&version)
|
||||
switch event.Decision {
|
||||
case constants.ApprovalDecisionApproved:
|
||||
return h.applyApproved(ctx, tx, &version, shop, before, event)
|
||||
case constants.ApprovalDecisionRejected,
|
||||
constants.ApprovalDecisionCancelled,
|
||||
constants.ApprovalDecisionDeleted,
|
||||
constants.ApprovalDecisionRevokedAfterApproved:
|
||||
return h.applyRejected(ctx, tx, &version, shop, before, event)
|
||||
case constants.ApprovalDecisionSubmissionFailed:
|
||||
reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID)
|
||||
if err != nil || !failed {
|
||||
return err
|
||||
}
|
||||
return h.closeVersion(ctx, tx, &version, shop, before, event,
|
||||
constants.WithdrawalQualificationStatusSubmissionFailed, constants.AuditActionWithdrawalQualificationRejected,
|
||||
"submission_failed", "提现资料资格审批提交失败:"+reason)
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的提现资料资格审批终态")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// applyApproved 使资料版本生效。
|
||||
// 代理已提交替换版本时该版本已被失效,条件更新不再命中,不会覆盖更新版本。
|
||||
func (h *QualificationApprovalHandler) applyApproved(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
version *model.WithdrawalQualification,
|
||||
shop *model.Shop,
|
||||
before map[string]any,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
now := time.Now().UTC()
|
||||
if qualificationShopDisabled(ctx, tx, version.ShopID) {
|
||||
// 店铺停用或不存在时资格必须失效:若停留在待审批,则「有待审批版本」门禁会让该店铺
|
||||
// 永远无法获得有效资格(作废仅接受有效版本),因此就地收敛为已失效终态并写审计。
|
||||
return h.invalidateForDisabledShop(ctx, tx, version, before, event, now)
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalQualificationStatusApproved,
|
||||
"decided_at": now, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "使提现资料资格版本生效失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
|
||||
}
|
||||
version.Status = constants.WithdrawalQualificationStatusApproved
|
||||
version.DecidedAt = &now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":approved",
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationApproved,
|
||||
Summary: "企业微信通过提现资料资格,版本已生效",
|
||||
CorrelationID: event.CorrelationID, Qualification: version, Shop: shop,
|
||||
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
|
||||
})
|
||||
}
|
||||
|
||||
// invalidateForDisabledShop 在店铺停用或不存在时把待审批资料版本收敛为已失效。
|
||||
// 与代理停用联动失效语义一致(invalidated_by=0 表示系统联动),使该店铺可重新提交资格。
|
||||
func (h *QualificationApprovalHandler) invalidateForDisabledShop(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
version *model.WithdrawalQualification,
|
||||
before map[string]any,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
now time.Time,
|
||||
) error {
|
||||
reason := "代理店铺已停用,资格自动失效"
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalQualificationStatusInvalidated,
|
||||
"invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0,
|
||||
"decided_at": now, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效停用店铺的提现资料资格失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
|
||||
}
|
||||
version.Status = constants.WithdrawalQualificationStatusInvalidated
|
||||
version.InvalidReason = reason
|
||||
version.InvalidatedAt = &now
|
||||
version.InvalidatedBy = 0
|
||||
version.DecidedAt = &now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":disabled",
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationInvalidated,
|
||||
Summary: "企业微信通过时店铺已停用,提现资料资格直接失效",
|
||||
CorrelationID: event.CorrelationID, Qualification: version,
|
||||
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
|
||||
})
|
||||
}
|
||||
|
||||
// qualificationShopDisabled 判断资料版本所属店铺是否已停用或不存在。
|
||||
func qualificationShopDisabled(ctx context.Context, tx *gorm.DB, shopID uint) bool {
|
||||
var enabled int64
|
||||
if err := tx.WithContext(ctx).Model(&model.Shop{}).
|
||||
Where("id = ? AND status = ?", shopID, constants.ShopStatusEnabled).
|
||||
Count(&enabled).Error; err != nil {
|
||||
return true
|
||||
}
|
||||
return enabled == 0
|
||||
}
|
||||
|
||||
// applyRejected 标记资料版本已驳回,不影响其他版本已记录的审批结果。
|
||||
func (h *QualificationApprovalHandler) applyRejected(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
version *model.WithdrawalQualification,
|
||||
shop *model.Shop,
|
||||
before map[string]any,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
return h.closeVersion(ctx, tx, version, shop, before, event,
|
||||
constants.WithdrawalQualificationStatusRejected, constants.AuditActionWithdrawalQualificationRejected,
|
||||
"rejected", "企业微信未通过提现资料资格")
|
||||
}
|
||||
|
||||
// closeVersion 以待审批状态条件更新把资料版本置为未生效终态(已驳回或审批提交失败),不影响其他版本。
|
||||
func (h *QualificationApprovalHandler) closeVersion(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
version *model.WithdrawalQualification,
|
||||
shop *model.Shop,
|
||||
before map[string]any,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
targetStatus int,
|
||||
actionCode, eventSuffix, summary string,
|
||||
) error {
|
||||
now := time.Now().UTC()
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": targetStatus,
|
||||
"decided_at": now, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束提现资料资格版本失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
|
||||
}
|
||||
version.Status = targetStatus
|
||||
version.DecidedAt = &now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":" + eventSuffix,
|
||||
ActionCode: actionCode,
|
||||
Summary: summary,
|
||||
CorrelationID: event.CorrelationID, Qualification: version, Shop: shop,
|
||||
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
|
||||
})
|
||||
}
|
||||
467
internal/application/distributionwithdrawal/registration.go
Normal file
467
internal/application/distributionwithdrawal/registration.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// RegistrationResult 返回已落库的待审批注册记录与审批实例引用。
|
||||
type RegistrationResult struct {
|
||||
RegistrationID uint
|
||||
Status int
|
||||
ApprovalInstanceID uint
|
||||
ApprovalStatus int
|
||||
}
|
||||
|
||||
// RegistrationService 受理公开扫码注册。
|
||||
// 只创建待审批注册记录与审批实例,不创建店铺、账号、钱包或上下级归属。
|
||||
type RegistrationService struct {
|
||||
db *gorm.DB
|
||||
verifier VerificationCodeVerifier
|
||||
approval approvalapp.Port
|
||||
recovery approvalapp.RecoveryPort
|
||||
audit AuditWriter
|
||||
}
|
||||
|
||||
// NewRegistrationService 创建公开扫码注册用例。
|
||||
func NewRegistrationService(db *gorm.DB, verifier VerificationCodeVerifier, approval approvalapp.Port, audit AuditWriter) *RegistrationService {
|
||||
return &RegistrationService{db: db, verifier: verifier, approval: approval, audit: audit}
|
||||
}
|
||||
|
||||
// SetRecoveryPort 注入通用审批原实例恢复接缝。
|
||||
func (s *RegistrationService) SetRecoveryPort(recovery approvalapp.RecoveryPort) {
|
||||
s.recovery = recovery
|
||||
}
|
||||
|
||||
// RecoverApproval 恢复代理注册原审批提交事实,不创建新审批实例或业务实体。
|
||||
func (s *RegistrationService) RecoverApproval(ctx context.Context, id uint) error {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType == constants.UserTypeEnterprise || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if middleware.GetUserIDFromContext(ctx) == 0 {
|
||||
return errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
if s == nil || s.db == nil || s.recovery == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "代理注册审批恢复能力尚未配置")
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
registration, err := lockRegistrationForUpdate(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if registration.Status != constants.AgentDistributionRegistrationStatusPending {
|
||||
return errors.New(errors.CodeConflict, "终态申请不可恢复")
|
||||
}
|
||||
var parent model.Shop
|
||||
if err := tx.Where("id = ?", registration.ParentShopID).First(&parent).Error; err != nil || parent.Status != constants.StatusEnabled {
|
||||
return errors.New(errors.CodeConflict, "上级店铺已停用,不能恢复审批")
|
||||
}
|
||||
if userType == constants.UserTypeAgent && (middleware.GetShopIDFromContext(ctx) == 0 || middleware.GetShopIDFromContext(ctx) != registration.ParentShopID) {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if registration.ApprovalInstanceID == nil || *registration.ApprovalInstanceID == 0 {
|
||||
return errors.New(errors.CodeConflict, "注册申请未关联审批实例")
|
||||
}
|
||||
instanceID := *registration.ApprovalInstanceID
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND business_type = ? AND business_id = ?", instanceID, constants.ApprovalBusinessTypeAgentDistribution, registration.ID).First(&instance).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "审批实例业务关联不一致")
|
||||
}
|
||||
var wc model.WeComApprovalContext
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeAgentDistribution).First(&wc).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "企业微信审批上下文不存在")
|
||||
}
|
||||
branch := "active"
|
||||
var recoveryErr error
|
||||
if wc.SPNo != "" {
|
||||
branch = "sp_no"
|
||||
recoveryErr = s.recovery.EnqueueSubmittedSync(ctx, tx, instanceID)
|
||||
} else if wc.SubmissionStatus == constants.WeComSubmissionStatusUnknown || instance.Status == constants.ApprovalStatusSubmissionUnknown {
|
||||
branch = "unknown"
|
||||
recoveryErr = s.recovery.EnqueueUnknownConfirm(ctx, tx, instanceID)
|
||||
} else if instance.Status == constants.ApprovalStatusSubmissionFailed && wc.SubmissionStatus == constants.WeComSubmissionStatusFailed {
|
||||
// 提交失败已由决策终止注册记录,不再重放。
|
||||
return errors.New(errors.CodeConflict, constants.ApprovalSubmissionFailedNotRecoverableMessage)
|
||||
} else if instance.Status == constants.ApprovalStatusSubmitting || instance.Status == constants.ApprovalStatusPending || wc.SubmissionStatus == constants.WeComSubmissionStatusReady || wc.SubmissionStatus == constants.WeComSubmissionStatusSending {
|
||||
} else {
|
||||
return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复")
|
||||
}
|
||||
if recoveryErr != nil {
|
||||
return errors.Wrap(errors.CodeConflict, recoveryErr, "恢复审批提交失败")
|
||||
}
|
||||
if s.audit != nil {
|
||||
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{EventID: "agent-distribution:" + uintText(id) + ":recover:" + branch, ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "人工恢复代理注册审批提交", Registration: registration, ParentShop: &parent, BeforeData: map[string]any{"submission_status": wc.SubmissionStatus, "branch": branch}, AfterData: map[string]any{"result": "accepted"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 已通过或已驳回的终态记录不阻塞重新注册;同一关键字段的并发提交由事务级 advisory lock 串行裁决。
|
||||
func (s *RegistrationService) Register(
|
||||
ctx context.Context,
|
||||
input distributiondomain.RegistrationInput,
|
||||
code string,
|
||||
) (*RegistrationResult, error) {
|
||||
if s == nil || s.db == nil || s.verifier == nil || s.approval == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置")
|
||||
}
|
||||
normalized, err := distributiondomain.ValidateRegistrationInput(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "短信验证码不能为空")
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
|
||||
}
|
||||
parent, err := s.findDistributionParent(ctx, normalized.DistributionCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 只校验不消费:验证码在注册记录与审批实例落库成功后才消费,落库前的失败不消耗验证码。
|
||||
if err := s.verifier.CheckCode(ctx, normalized.Phone, code); err != nil {
|
||||
return nil, verificationFailure(err)
|
||||
}
|
||||
submitter, err := resolveRegistrationSubmitter(ctx, s.db, parent.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
correlationID := "agent_distribution:registration:" + uuid.NewString()
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeAgentDistribution,
|
||||
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registration := &model.AgentDistributionRegistration{
|
||||
DistributionCode: normalized.DistributionCode, ParentShopID: parent.ID,
|
||||
Phone: normalized.Phone, PasswordHash: string(passwordHash),
|
||||
ShopName: normalized.ShopName, ShopCode: normalized.ShopCode, Username: normalized.Username,
|
||||
ContactName: normalized.ContactName, Province: normalized.Province,
|
||||
City: normalized.City, District: normalized.District, Address: normalized.Address,
|
||||
Status: constants.AgentDistributionRegistrationStatusPending,
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
|
||||
registrationApprovalForm(normalized, parent, s.parentBusinessOwnerName(ctx, parent)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &RegistrationResult{}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 关键字段门禁与注册记录插入必须同处一个串行化区间:先取 advisory lock,
|
||||
// 再在同一事务内校验并落库,避免并发提交落下两条指向同一手机号/用户名/店铺编号的待审批申请。
|
||||
if err := lockRegistrationKeyScopes(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureRegistrationKeysAvailable(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureNoPendingRegistration(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(registration).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建待审批注册记录失败")
|
||||
}
|
||||
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
|
||||
constants.ApprovalBusinessTypeAgentDistribution, registration.ID, submitter.ID,
|
||||
submitterSnapshot, requestSnapshot, correlationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := attachRegistrationInstance(ctx, tx, registration, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
// 公开注册提交不写审计:该链路在 personal.go 的 Use() 之前注册,不经任何认证中间件,
|
||||
// 因而没有可信的 actor/source(Append 会以「审计操作者或入口不符合动作注册规则」失败)。
|
||||
// tasks 1.7 只要求分销码生成、注册通过/驳回与资格相关审计,提交动作不在其列,
|
||||
// 故移除该非必需审计而不是伪造操作者身份。
|
||||
result.RegistrationID = registration.ID
|
||||
result.Status = registration.Status
|
||||
result.ApprovalInstanceID = reference.InstanceID
|
||||
result.ApprovalStatus = reference.Status
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 注册记录与审批实例已落库:此后消费验证码失败只记录日志,不回滚既有事实,也不改变对外成功结果。
|
||||
// 消费失败(验证码已过期或已被并发的另一次提交消费)由验证码实现侧记录。
|
||||
_ = s.verifier.ConsumeCode(ctx, normalized.Phone, code)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// registrationKeyScopePrefix 是注册关键字段串行化点的键前缀,与其它用例的 advisory lock 键空间隔离。
|
||||
const registrationKeyScopePrefix = "agent-distribution-registration:"
|
||||
|
||||
// lockRegistrationKeyScopes 在事务内为注册关键字段(手机号、用户名、店铺编号)取稳定串行化点。
|
||||
// 目标关键字段的待审批记录可能尚不存在,行锁无法覆盖「首次并发提交」,
|
||||
// 因此按 key 字符串升序取事务级 advisory lock;升序保证并发提交不会形成 A→B / B→A 死锁环。
|
||||
// 锁随本次事务提交或回滚自动释放。
|
||||
func lockRegistrationKeyScopes(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
|
||||
keys := []string{
|
||||
registrationKeyScopePrefix + "phone:" + phone,
|
||||
registrationKeyScopePrefix + "username:" + username,
|
||||
registrationKeyScopePrefix + "shop_code:" + shopCode,
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for _, key := range keys {
|
||||
if err := tx.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(hashtext(?))", key).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定注册关键字段串行化点失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureRegistrationKeysAvailable 校验注册关键字段未被既有账号或店铺占用。
|
||||
// 手机号与用户名对应 tb_account 的条件唯一索引,店铺编号对应 tb_shop 的条件唯一索引;
|
||||
// 查询沿用 GORM 默认软删除范围,软删除账号或店铺占用的关键字段可被重新注册。
|
||||
func ensureRegistrationKeysAvailable(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
|
||||
if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "phone", phone); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册手机号失败")
|
||||
} else if exists {
|
||||
return errors.New(errors.CodePhoneExists, "手机号已被使用")
|
||||
}
|
||||
if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "username", username); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册用户名失败")
|
||||
} else if exists {
|
||||
return errors.New(errors.CodeUsernameExists, "用户名已存在")
|
||||
}
|
||||
if exists, err := registrationKeyTaken(ctx, tx, &model.Shop{}, "shop_code", shopCode); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册店铺编号失败")
|
||||
} else if exists {
|
||||
return errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registrationKeyTaken 判断目标表(默认软删除范围)是否已存在占用该关键字段的记录。
|
||||
func registrationKeyTaken(ctx context.Context, tx *gorm.DB, target any, column, value string) (bool, error) {
|
||||
var count int64
|
||||
if err := tx.WithContext(ctx).Model(target).Where(column+" = ?", value).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// ensureNoPendingRegistration 校验关键字段没有正在等待审批的注册申请。
|
||||
// 已通过或已驳回的终态记录不阻塞重新注册:资料填错后重新扫码必须能形成新的申请与新审批实例。
|
||||
func ensureNoPendingRegistration(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
|
||||
var pending model.AgentDistributionRegistration
|
||||
err := tx.WithContext(ctx).
|
||||
Where("status = ? AND (phone = ? OR username = ? OR shop_code = ?)",
|
||||
constants.AgentDistributionRegistrationStatusPending, phone, username, shopCode).
|
||||
Order("id ASC").First(&pending).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
return pendingKeyConflict(&pending, phone, username)
|
||||
case stderrors.Is(err, gorm.ErrRecordNotFound):
|
||||
return nil
|
||||
default:
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验待审批注册申请失败")
|
||||
}
|
||||
}
|
||||
|
||||
// pendingKeyConflict 把命中的待审批记录映射为指明冲突字段的冲突错误。
|
||||
// 查询条件保证三个关键字段至少一个命中,店铺编号作为兜底分支。
|
||||
func pendingKeyConflict(pending *model.AgentDistributionRegistration, phone, username string) error {
|
||||
switch {
|
||||
case pending.Phone == phone:
|
||||
return errors.New(errors.CodeConflict, "该手机号已有待审批的注册申请,请等待审批结果")
|
||||
case pending.Username == username:
|
||||
return errors.New(errors.CodeConflict, "该用户名已有待审批的注册申请,请等待审批结果")
|
||||
default:
|
||||
return errors.New(errors.CodeConflict, "该店铺编号已有待审批的注册申请,请等待审批结果")
|
||||
}
|
||||
}
|
||||
|
||||
// findDistributionParent 按分销码定位上级店铺;未命中与已停用返回各自的可定位错误。
|
||||
// 软删除店铺不参与匹配,与店铺唯一索引的生效范围一致。
|
||||
func (s *RegistrationService) findDistributionParent(ctx context.Context, distributionCode string) (*model.Shop, error) {
|
||||
var parent model.Shop
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("distribution_code = ?", distributionCode).
|
||||
First(&parent).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "分销码无效")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
|
||||
}
|
||||
if parent.Status != constants.ShopStatusEnabled {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "分销码所属店铺已停用")
|
||||
}
|
||||
return &parent, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// resolveRegistrationSubmitter 解析扫码注册的审批发起身份。
|
||||
// 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体;
|
||||
// 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。
|
||||
func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID uint) (*model.Account, error) {
|
||||
var account model.Account
|
||||
if err := db.WithContext(ctx).
|
||||
Where("shop_id = ? AND status = ? AND is_primary = TRUE", parentShopID, constants.StatusEnabled).
|
||||
First(&account).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "上级店铺未配置启用的主账号,请联系平台处理")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败")
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// registrationApprovalForm 生成企业微信审批表单业务快照。
|
||||
// 手机号按脱敏值写入,禁止把完整手机号或密码写入审批表单与审计。
|
||||
//
|
||||
// 业务员取上级店铺当前业务员的名称快照:上级无业务员时以空值提交并显式标记为「无」,
|
||||
// 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,
|
||||
constants.ApprovalFieldDistributionParentShopName: parent.ShopName,
|
||||
constants.ApprovalFieldDistributionShopName: input.ShopName,
|
||||
constants.ApprovalFieldDistributionShopCode: input.ShopCode,
|
||||
constants.ApprovalFieldDistributionUsername: input.Username,
|
||||
constants.ApprovalFieldDistributionPhoneMasked: distributiondomain.MaskPhone(input.Phone),
|
||||
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,
|
||||
tx *gorm.DB,
|
||||
registration *model.AgentDistributionRegistration,
|
||||
instanceID uint,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", registration.ID).
|
||||
Update("approval_instance_id", instanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联扫码注册审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "扫码注册审批实例关联已变化")
|
||||
}
|
||||
registration.ApprovalInstanceID = &instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// registrationAuditSnapshot 生成注册记录审计快照,手机号按脱敏值记录,不含密码哈希。
|
||||
func registrationAuditSnapshot(registration *model.AgentDistributionRegistration) map[string]any {
|
||||
instanceID := uint(0)
|
||||
if registration.ApprovalInstanceID != nil {
|
||||
instanceID = *registration.ApprovalInstanceID
|
||||
}
|
||||
return map[string]any{
|
||||
"id": registration.ID, "parent_shop_id": registration.ParentShopID,
|
||||
"distribution_code_masked": distributiondomain.MaskDistributionCode(registration.DistributionCode),
|
||||
"phone_masked": distributiondomain.MaskPhone(registration.Phone),
|
||||
"username": registration.Username, "shop_code": registration.ShopCode,
|
||||
"status": registration.Status, "approval_instance_id": instanceID,
|
||||
}
|
||||
}
|
||||
|
||||
// uintText 将无符号整数转换为审计标识与键的十进制文本。
|
||||
func uintText(value uint) string {
|
||||
return strconv.FormatUint(uint64(value), 10)
|
||||
}
|
||||
|
||||
// intText 将整数转换为审计标识与键的十进制文本。
|
||||
func intText(value int) string {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
|
||||
// composeAuditEventID 拼接审计事件标识,并约束在审计列宽内。
|
||||
func composeAuditEventID(parts ...string) (string, error) {
|
||||
eventID := strings.Join(parts, ":")
|
||||
if len(eventID) > 128 {
|
||||
return "", errors.New(errors.CodeInternalError, "审计事件标识超出长度限制")
|
||||
}
|
||||
return eventID, nil
|
||||
}
|
||||
|
||||
// lockRegistrationForUpdate 以行锁读取注册记录,未找到返回稳定不存在错误。
|
||||
func lockRegistrationForUpdate(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
id uint,
|
||||
) (*model.AgentDistributionRegistration, error) {
|
||||
var registration model.AgentDistributionRegistration
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(®istration, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "扫码注册记录不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定扫码注册记录失败")
|
||||
}
|
||||
return ®istration, nil
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// SubordinateCacheInvalidator 在审批通过事务提交后清理上级店铺下级集合缓存。
|
||||
type SubordinateCacheInvalidator interface {
|
||||
InvalidateSubordinateCache(ctx context.Context, shopID uint)
|
||||
}
|
||||
|
||||
// DistributionApprovalHandler 将渠道无关企业微信终态应用到代理扫码注册记录。
|
||||
// 通过才在单一事务内创建启用店铺、代理主账号、所需钱包、上级层级与业务员快照;
|
||||
// 驳回只标记注册记录,不创建任何实体;重复或乱序回调不重复创建账号、层级或钱包。
|
||||
type DistributionApprovalHandler struct {
|
||||
db *gorm.DB
|
||||
audit AuditWriter
|
||||
cache SubordinateCacheInvalidator
|
||||
}
|
||||
|
||||
// NewDistributionApprovalHandler 创建代理分销注册审批终态消费者。
|
||||
func NewDistributionApprovalHandler(
|
||||
db *gorm.DB,
|
||||
audit AuditWriter,
|
||||
cache SubordinateCacheInvalidator,
|
||||
) *DistributionApprovalHandler {
|
||||
return &DistributionApprovalHandler{db: db, audit: audit, cache: cache}
|
||||
}
|
||||
|
||||
// Handle 幂等消费标准审批终态。
|
||||
// 业务标识为待审批注册记录主键;先锁定注册记录并校验审批实例一致,再按条件更新推进状态。
|
||||
func (h *DistributionApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
|
||||
if h == nil || h.db == nil || h.audit == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理分销注册审批终态能力未配置")
|
||||
}
|
||||
if event.BusinessType != constants.ApprovalBusinessTypeAgentDistribution ||
|
||||
event.BusinessID == 0 || event.InstanceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理分销注册审批终态参数无效")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
|
||||
})
|
||||
parentShopID := uint(0)
|
||||
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
registration, err := lockRegistrationForUpdate(ctx, tx, event.BusinessID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if registration.ApprovalInstanceID == nil || *registration.ApprovalInstanceID != event.InstanceID {
|
||||
return errors.New(errors.CodeConflict, "扫码注册记录关联的审批实例不一致")
|
||||
}
|
||||
if registration.Status != constants.AgentDistributionRegistrationStatusPending {
|
||||
// 已是终态:重复或乱序回调不再改变事实。
|
||||
return nil
|
||||
}
|
||||
switch event.Decision {
|
||||
case constants.ApprovalDecisionApproved:
|
||||
parentShopID = registration.ParentShopID
|
||||
return h.applyApproved(ctx, tx, registration, event)
|
||||
case constants.ApprovalDecisionRejected,
|
||||
constants.ApprovalDecisionCancelled,
|
||||
constants.ApprovalDecisionDeleted:
|
||||
return h.applyRejected(ctx, tx, registration, event)
|
||||
case constants.ApprovalDecisionRevokedAfterApproved:
|
||||
// 注册记录无已建立的对外资金事实;通过后撤销按驳回处理并保留渠道决策痕迹。
|
||||
return h.applyRejected(ctx, tx, registration, event)
|
||||
case constants.ApprovalDecisionSubmissionFailed:
|
||||
reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID)
|
||||
if err != nil || !failed {
|
||||
return err
|
||||
}
|
||||
return h.closeRegistration(ctx, tx, registration, event,
|
||||
constants.AgentDistributionRegistrationStatusSubmissionFailed, reason,
|
||||
"submission_failed", "扫码注册审批提交失败,未创建任何实体")
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的代理分销注册审批终态")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parentShopID != 0 && h.cache != nil {
|
||||
// 缓存清理必须在事务提交后执行,避免回滚后缓存与库内事实不一致。
|
||||
h.cache.InvalidateSubordinateCache(ctx, parentShopID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyApproved 在同一事务内建立店铺、账号、钱包、层级与业务员快照。
|
||||
// 上级店铺必须仍然存在且启用;手机号、用户名或店铺编号已被既有账号/店铺占用时整体回滚,不留半套实体。
|
||||
// 审批路径不取提交侧的关键字段 advisory lock:提交侧的校验与插入同处一个串行化区间,
|
||||
// 且账号/店铺写入与注册记录状态推进同事务提交,因此提交侧只会看到「已提交的账号/店铺」或「仍待审批的冲突记录」,
|
||||
// 两种情况都会拒绝。
|
||||
func (h *DistributionApprovalHandler) applyApproved(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
registration *model.AgentDistributionRegistration,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
parent, err := loadEnabledParentShop(ctx, tx, registration.ParentShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
level := parent.Level + 1
|
||||
if level > constants.ShopMaxLevel {
|
||||
return errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级")
|
||||
}
|
||||
// 提交时的关键字段门禁可能已被此后的并发事实占用(平台手工建店、历史待审批记录):
|
||||
// 此处复检把裸唯一索引错误换成可定位错误码,仍整体回滚,注册记录保持待审批。
|
||||
if err := ensureRegistrationKeysAvailable(ctx, tx, registration.Phone, registration.Username, registration.ShopCode); err != nil {
|
||||
return err
|
||||
}
|
||||
role, err := loadEnabledCustomerRole(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shop := &model.Shop{
|
||||
ShopName: registration.ShopName, ShopCode: registration.ShopCode,
|
||||
ParentID: &parent.ID, Level: level,
|
||||
ContactName: registration.ContactName, Province: registration.Province,
|
||||
City: registration.City, District: registration.District, Address: registration.Address,
|
||||
Status: constants.ShopStatusEnabled,
|
||||
}
|
||||
shop.BusinessOwnerAccountID = parent.BusinessOwnerAccountID
|
||||
shop.Creator = registration.ID
|
||||
shop.Updater = registration.ID
|
||||
// 新店铺生成自己的分销码:注册记录上的分销码是上级店铺快照,复用会与父店铺同码并命中唯一索引。
|
||||
if err := shopapp.CreateShopWithDistributionCode(ctx, tx, shop); err != nil {
|
||||
return err
|
||||
}
|
||||
account := &model.Account{
|
||||
Username: registration.Username, Phone: registration.Phone,
|
||||
Password: registration.PasswordHash, UserType: constants.UserTypeAgent,
|
||||
ShopID: &shop.ID, Status: constants.StatusEnabled, IsPrimary: true,
|
||||
}
|
||||
account.Creator = registration.ID
|
||||
account.Updater = registration.ID
|
||||
if err := tx.WithContext(ctx).Create(account).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建扫码注册代理账号失败")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&model.AccountRole{
|
||||
AccountID: account.ID, RoleID: role.ID, Status: constants.StatusEnabled,
|
||||
Creator: registration.ID, Updater: registration.ID,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "为扫码注册代理账号分配角色失败")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&model.ShopRole{
|
||||
ShopID: shop.ID, RoleID: role.ID, Status: constants.StatusEnabled,
|
||||
Creator: registration.ID, Updater: registration.ID,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "设置扫码注册店铺默认角色失败")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create([]*model.AgentWallet{
|
||||
{
|
||||
ShopID: shop.ID, WalletType: constants.AgentWalletTypeMain,
|
||||
CreditEnabled: role.DefaultCreditEnabled, CreditLimit: role.DefaultCreditLimit,
|
||||
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
|
||||
},
|
||||
{
|
||||
ShopID: shop.ID, WalletType: constants.AgentWalletTypeCommission,
|
||||
CreditEnabled: false, CreditLimit: 0,
|
||||
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
|
||||
},
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "初始化扫码注册店铺钱包失败")
|
||||
}
|
||||
if err := markRegistrationApproved(ctx, tx, registration); err != nil {
|
||||
return err
|
||||
}
|
||||
// 建店与业务员归属的访问审计不在本用例职责内:该动作面向后台账号入口,
|
||||
// 由审批消费任务触发的建店无法提供其要求的操作者/数据范围投影,
|
||||
// 强行写入会以「账号权限或组织审计操作者不完整」失败并中止事务。
|
||||
// 新建店铺已作为 CreatedShop 资源记录在本用例的分销审计中。
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "agent-distribution:" + uintText(registration.ID) + ":approved",
|
||||
ActionCode: constants.AuditActionAgentDistributionRegistrationApproved,
|
||||
Summary: "企业微信通过扫码注册,已创建店铺与代理账号",
|
||||
CorrelationID: event.CorrelationID, Registration: registration,
|
||||
ParentShop: parent, CreatedShop: shop,
|
||||
AppliedDistributionCode: registration.DistributionCode,
|
||||
AfterData: registrationAuditSnapshot(registration),
|
||||
})
|
||||
}
|
||||
|
||||
// applyRejected 只标记注册记录终态,不创建店铺、账号、钱包或层级。
|
||||
func (h *DistributionApprovalHandler) applyRejected(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
registration *model.AgentDistributionRegistration,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
return h.closeRegistration(ctx, tx, registration, event,
|
||||
constants.AgentDistributionRegistrationStatusRejected, rejectionReason(event.Decision),
|
||||
"rejected", "企业微信未通过扫码注册,未创建任何实体")
|
||||
}
|
||||
|
||||
// closeRegistration 以待审批状态条件更新把注册记录置为不创建实体的终态(已驳回或审批提交失败)。
|
||||
func (h *DistributionApprovalHandler) closeRegistration(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
registration *model.AgentDistributionRegistration,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
targetStatus int,
|
||||
reason, eventSuffix, summary string,
|
||||
) error {
|
||||
now := time.Now().UTC()
|
||||
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
|
||||
Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": targetStatus,
|
||||
"reject_reason": reason, "decided_at": now, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束扫码注册记录失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "扫码注册记录状态已变化")
|
||||
}
|
||||
before := registration.Status
|
||||
registration.Status = targetStatus
|
||||
registration.RejectReason = reason
|
||||
registration.DecidedAt = &now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "agent-distribution:" + uintText(registration.ID) + ":" + eventSuffix,
|
||||
ActionCode: constants.AuditActionAgentDistributionRegistrationRejected,
|
||||
Summary: summary,
|
||||
CorrelationID: event.CorrelationID, Registration: registration,
|
||||
BeforeData: map[string]any{"status": before},
|
||||
AfterData: registrationAuditSnapshot(registration),
|
||||
})
|
||||
}
|
||||
|
||||
// markRegistrationApproved 以待审批状态条件更新标记注册记录已通过,重复回调不重复推进。
|
||||
func markRegistrationApproved(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
registration *model.AgentDistributionRegistration,
|
||||
) error {
|
||||
now := time.Now().UTC()
|
||||
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
|
||||
Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.AgentDistributionRegistrationStatusApproved,
|
||||
"decided_at": now, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记扫码注册记录已通过失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "扫码注册记录状态已变化")
|
||||
}
|
||||
registration.Status = constants.AgentDistributionRegistrationStatusApproved
|
||||
registration.DecidedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectionReason 把渠道决策映射为可查询的中文驳回原因。
|
||||
func rejectionReason(decision string) string {
|
||||
switch decision {
|
||||
case constants.ApprovalDecisionCancelled:
|
||||
return "企业微信审批已撤销"
|
||||
case constants.ApprovalDecisionDeleted:
|
||||
return "企业微信审批已删除"
|
||||
case constants.ApprovalDecisionRevokedAfterApproved:
|
||||
return "企业微信审批通过后撤销"
|
||||
default:
|
||||
return "企业微信审批已驳回"
|
||||
}
|
||||
}
|
||||
|
||||
// loadEnabledParentShop 校验分销码所属店铺仍存在且启用。
|
||||
func loadEnabledParentShop(ctx context.Context, tx *gorm.DB, shopID uint) (*model.Shop, error) {
|
||||
var parent model.Shop
|
||||
if err := tx.WithContext(ctx).First(&parent, shopID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "上级店铺不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺失败")
|
||||
}
|
||||
if parent.Status != constants.ShopStatusEnabled {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "上级店铺已停用,不允许注册下级")
|
||||
}
|
||||
return &parent, nil
|
||||
}
|
||||
|
||||
// loadEnabledCustomerRole 读取启用的客户角色,用于新建代理店铺的默认角色与信用额度。
|
||||
func loadEnabledCustomerRole(ctx context.Context, tx *gorm.DB) (*model.Role, error) {
|
||||
var role model.Role
|
||||
if err := tx.WithContext(ctx).
|
||||
Where("role_type = ? AND status = ?", constants.RoleTypeCustomer, constants.StatusEnabled).
|
||||
Order("id ASC").First(&role).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "缺少启用的客户角色,无法创建代理店铺")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询启用客户角色失败")
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
909
internal/application/distributionwithdrawal/withdrawal.go
Normal file
909
internal/application/distributionwithdrawal/withdrawal.go
Normal file
@@ -0,0 +1,909 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
||||
"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"
|
||||
)
|
||||
|
||||
// WithdrawalPolicy 是提现申请使用的当前配置快照,由调用方从提现配置读取。
|
||||
type WithdrawalPolicy struct {
|
||||
MinAmount int64
|
||||
FeeRate int64
|
||||
DailyWithdrawalLimit int
|
||||
}
|
||||
|
||||
// WithdrawalInput 是提现申请或重提的规范化输入。
|
||||
type WithdrawalInput struct {
|
||||
Amount int64
|
||||
WithdrawalMethod string
|
||||
AccountName string
|
||||
AccountNumber string
|
||||
InvoiceKeys []string
|
||||
}
|
||||
|
||||
// WithdrawalResult 返回已原子保存的提现申请与审批尝试记录。
|
||||
type WithdrawalResult struct {
|
||||
RequestID uint
|
||||
WithdrawalNo string
|
||||
AttemptID uint
|
||||
AttemptNo int
|
||||
Amount int64
|
||||
Fee int64
|
||||
FeeRate int64
|
||||
ActualAmount int64
|
||||
Status int
|
||||
ApprovalInstanceID uint
|
||||
ApprovalStatus int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// WithdrawalService 创建与重提提现申请。
|
||||
// 申请、审批尝试记录、审批实例与佣金钱包冻结在同一事务完成;
|
||||
// 余额不足、资格无效或非本人代理时不创建申请、审批实例或任何冻结。
|
||||
type WithdrawalService struct {
|
||||
db *gorm.DB
|
||||
approval approvalapp.Port
|
||||
audit AuditWriter
|
||||
}
|
||||
|
||||
// NewWithdrawalService 创建提现申请用例。
|
||||
func NewWithdrawalService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *WithdrawalService {
|
||||
return &WithdrawalService{db: db, approval: approval, audit: audit}
|
||||
}
|
||||
|
||||
// Create 为本人代理店铺创建提现申请。
|
||||
func (s *WithdrawalService) Create(
|
||||
ctx context.Context,
|
||||
shopID uint,
|
||||
policy WithdrawalPolicy,
|
||||
input WithdrawalInput,
|
||||
) (*WithdrawalResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureOwnAgentShop(ctx, shopID); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
|
||||
}
|
||||
return s.submit(ctx, shopID, policy, nil, input)
|
||||
}
|
||||
|
||||
// Resubmit 由本人代理修改金额、收款信息与本次发票后重提已被企业微信驳回的提现申请。
|
||||
// 事务内先释放旧未结算尝试的冻结,再按新金额冻结;历史快照与审批结果不被覆盖。
|
||||
func (s *WithdrawalService) Resubmit(
|
||||
ctx context.Context,
|
||||
requestID uint,
|
||||
policy WithdrawalPolicy,
|
||||
input WithdrawalInput,
|
||||
) (*WithdrawalResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if requestID == 0 {
|
||||
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
|
||||
}
|
||||
return s.submit(ctx, 0, policy, &requestID, input)
|
||||
}
|
||||
|
||||
// ensureReady 校验依赖完整,缺失时失败关闭,避免绕过企业微信终审。
|
||||
func (s *WithdrawalService) ensureReady() error {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "提现申请能力尚未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// submit 在同一事务内完成资格校验、钱包加锁冻结、写申请与审批尝试记录、创建审批实例。
|
||||
func (s *WithdrawalService) submit(
|
||||
ctx context.Context,
|
||||
shopID uint,
|
||||
policy WithdrawalPolicy,
|
||||
resubmitRequestID *uint,
|
||||
input WithdrawalInput,
|
||||
) (*WithdrawalResult, error) {
|
||||
submitter, err := currentSubmitter(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.Amount <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "提现金额必须大于 0")
|
||||
}
|
||||
if policy.MinAmount > 0 && input.Amount < policy.MinAmount {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "提现金额低于当前最低提现额度")
|
||||
}
|
||||
correlationID := "commission_withdrawal:" + uuid.NewString()
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeCommissionWithdrawal,
|
||||
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &WithdrawalResult{}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var request *model.CommissionWithdrawalRequest
|
||||
if resubmitRequestID != nil {
|
||||
request, err = lockWithdrawalRequest(ctx, tx, *resubmitRequestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if request.ShopID != submitter.ShopID {
|
||||
return errors.New(errors.CodeNotFound, "提现申请不存在")
|
||||
}
|
||||
if !isWithdrawalResubmittable(request.Status) {
|
||||
return errors.New(errors.CodeConflict, "仅已被驳回或审批提交失败的提现申请可重提")
|
||||
}
|
||||
if request.ApprovalInstanceID == nil {
|
||||
return errors.New(errors.CodeConflict, "存量提现申请不支持企业微信重提")
|
||||
}
|
||||
// 重提先释放旧未结算尝试的冻结,避免产生第二笔冻结。
|
||||
if _, err := releaseUnsettledAttemptsForRequest(ctx, tx, request.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
shopID = request.ShopID
|
||||
}
|
||||
if err := ensureOwnAgentShopForShopID(ctx, submitter, shopID); err != nil {
|
||||
return err
|
||||
}
|
||||
qualification, err := loadValidQualification(ctx, tx, shopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
wallet, err := lockCommissionWallet(ctx, tx, shopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fee := input.Amount * policy.FeeRate / 10000
|
||||
actualAmount := input.Amount - fee
|
||||
if err := freezeCommissionBalance(ctx, tx, wallet, input.Amount); err != nil {
|
||||
return err
|
||||
}
|
||||
accountInfo, err := marshalJSON(map[string]string{
|
||||
"account_name": input.AccountName, "account_number": input.AccountNumber,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
invoiceKeys, err := marshalJSON(normalizeInvoiceKeys(input.InvoiceKeys))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if request == nil {
|
||||
request = &model.CommissionWithdrawalRequest{
|
||||
WithdrawalNo: generateWithdrawalNo(),
|
||||
ShopID: shopID,
|
||||
AgentID: submitter.ID,
|
||||
ApplicantID: submitter.ID,
|
||||
Amount: input.Amount,
|
||||
FeeRate: policy.FeeRate,
|
||||
Fee: fee,
|
||||
ActualAmount: actualAmount,
|
||||
WithdrawalMethod: input.WithdrawalMethod,
|
||||
AccountInfo: accountInfo,
|
||||
Status: constants.WithdrawalStatusPending,
|
||||
}
|
||||
request.Creator = submitter.ID
|
||||
request.Updater = submitter.ID
|
||||
if err := tx.WithContext(ctx).Create(request).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现申请失败")
|
||||
}
|
||||
} else {
|
||||
if err := updateWithdrawalRequestForResubmit(ctx, tx, request, input, policy, fee, actualAmount, accountInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
attemptNo, err := nextWithdrawalAttemptNo(ctx, tx, request.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt := &model.CommissionWithdrawalRequestAttempt{
|
||||
RequestID: request.ID, AttemptNo: attemptNo,
|
||||
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, "创建提现审批尝试记录失败")
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
|
||||
withdrawalApprovalForm(request, attempt, shopName(ctx, tx, shopID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
|
||||
constants.ApprovalBusinessTypeCommissionWithdrawal, attempt.ID, submitter.ID,
|
||||
submitterSnapshot, requestSnapshot, correlationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := attachWithdrawalAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateWithdrawalLatest(ctx, tx, request, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
transaction, err := recordWithdrawalFreezeTransaction(ctx, tx, wallet, request, submitter.ID, input.Amount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventID, err := composeAuditEventID(
|
||||
"commission-withdrawal", uintText(request.ID), "attempt", intText(attempt.AttemptNo), "submit")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: eventID, ActionCode: constants.AuditActionCommissionWithdrawalAttemptSubmitted,
|
||||
Summary: withdrawalSubmitSummary(resubmitRequestID != nil), CorrelationID: correlationID,
|
||||
Withdrawal: request, Attempt: attempt, Wallet: wallet, Transaction: transaction,
|
||||
AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
result.RequestID = request.ID
|
||||
result.WithdrawalNo = request.WithdrawalNo
|
||||
result.AttemptID = attempt.ID
|
||||
result.AttemptNo = attempt.AttemptNo
|
||||
result.Amount = attempt.Amount
|
||||
result.Fee = attempt.Fee
|
||||
result.FeeRate = attempt.FeeRate
|
||||
result.ActualAmount = attempt.ActualAmount
|
||||
result.Status = request.Status
|
||||
result.ApprovalInstanceID = reference.InstanceID
|
||||
result.ApprovalStatus = reference.Status
|
||||
result.CreatedAt = request.CreatedAt
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// 失败审计必须可追溯:带上店铺(shopID 在手上),使审计恰好有一个主要资源。
|
||||
RecordFailure(ctx, s.db, s.audit, AuditChange{
|
||||
// 不手工构造 EventID:同一店铺的提现可被拒绝多次,手工 ID 会让后续拒绝被
|
||||
// event_id 唯一约束吞掉;由审计 Writer 生成唯一 evt_<uuid>。
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalAttemptRejected,
|
||||
Summary: "提交提现申请被拒绝", CorrelationID: correlationID,
|
||||
Shop: failureShopResolved(ctx, s.db, shopID, resubmitRequestID),
|
||||
}, err)
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// failureShopResolved 为失败审计解析店铺引用:优先用入参 shopID,
|
||||
// Resubmit 场景下 shopID 为空则从提现申请行回查店铺,保证拒绝事实有可追溯的店铺主资源。
|
||||
func failureShopResolved(ctx context.Context, db *gorm.DB, shopID uint, requestID *uint) *model.Shop {
|
||||
if shopID == 0 && requestID != nil {
|
||||
var request model.CommissionWithdrawalRequest
|
||||
if err := db.WithContext(ctx).Select("id", "shop_id").First(&request, *requestID).Error; err == nil {
|
||||
shopID = request.ShopID
|
||||
}
|
||||
}
|
||||
return failureShop(ctx, db, shopID)
|
||||
}
|
||||
|
||||
// failureShop 为失败审计解析店铺引用;店铺查询失败时退回仅含 ID 的最小引用,
|
||||
// 保证拒绝事实仍有可追溯的店铺主资源。
|
||||
func failureShop(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop {
|
||||
if shopID == 0 {
|
||||
return nil
|
||||
}
|
||||
if shop := loadShopOrNil(ctx, db, shopID); shop != nil {
|
||||
return shop
|
||||
}
|
||||
// gorm.Model 的 ID 是提升字段,无法在复合字面量中设置,这里显式赋值。
|
||||
minimal := &model.Shop{}
|
||||
minimal.ID = shopID
|
||||
return minimal
|
||||
}
|
||||
|
||||
// withdrawalSubmitter 是发起提现的真实操作者。
|
||||
type withdrawalSubmitter struct {
|
||||
ID uint
|
||||
Username string
|
||||
ShopID uint
|
||||
}
|
||||
|
||||
// currentSubmitter 从上下文取当前代理账号与其店铺,未认证时拒绝。
|
||||
func currentSubmitter(ctx context.Context) (withdrawalSubmitter, error) {
|
||||
accountID := middleware.GetUserIDFromContext(ctx)
|
||||
if accountID == 0 {
|
||||
return withdrawalSubmitter{}, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
if shopID == 0 {
|
||||
return withdrawalSubmitter{}, errors.New(errors.CodeForbidden, "代理账号缺少店铺信息")
|
||||
}
|
||||
return withdrawalSubmitter{
|
||||
ID: accountID, Username: middleware.GetUsernameFromContext(ctx), ShopID: shopID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureOwnAgentShopForShopID 复核代理身份与店铺归属,越权与不存在返回同一结果。
|
||||
func ensureOwnAgentShopForShopID(ctx context.Context, submitter withdrawalSubmitter, shopID uint) error {
|
||||
if shopID == 0 || shopID != submitter.ShopID {
|
||||
return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
|
||||
return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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,
|
||||
) (qualificationCheck, error) {
|
||||
check := qualificationCheck{CheckedAt: time.Now().UTC()}
|
||||
var latest model.WithdrawalQualification
|
||||
err := tx.WithContext(ctx).
|
||||
Where("shop_id = ?", shopID).
|
||||
Order("id DESC").
|
||||
First(&latest).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureMissing
|
||||
return check, nil
|
||||
}
|
||||
if err != nil {
|
||||
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 {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
|
||||
return check, nil
|
||||
}
|
||||
return check, errors.Wrap(errors.CodeDatabaseError, err, "查询提现店铺状态失败")
|
||||
}
|
||||
if shop.Status != constants.ShopStatusEnabled {
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
|
||||
return check, 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 校验申请级发票仅在企业主体且已登记发票资料时提交。
|
||||
func validateWithdrawalInvoice(qualification *model.WithdrawalQualification, invoiceKeys []string) error {
|
||||
keys := normalizeInvoiceKeys(invoiceKeys)
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
if qualification.SubjectType != constants.WithdrawalQualificationSubjectTypeEnterprise {
|
||||
return errors.New(errors.CodeInvalidParam, "发票仅企业主体可提交")
|
||||
}
|
||||
if qualification.InvoiceSubjectCode == "" || qualification.InvoiceTitle == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "有效提现资料资格未登记发票资料")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeInvoiceKeys 归一化发票对象键列表,去除空串。
|
||||
func normalizeInvoiceKeys(keys []string) []string {
|
||||
result := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if trimmed := strings.TrimSpace(key); trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ensureDailyWithdrawalLimit 校验当日提现次数上限;重提不占用新的当日次数。
|
||||
func ensureDailyWithdrawalLimit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
limit int,
|
||||
countNewRequest bool,
|
||||
) error {
|
||||
if !countNewRequest || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayCount int64
|
||||
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("shop_id = ? AND created_at >= ? AND created_at <= ?", shopID, today+" 00:00:00", today+" 23:59:59").
|
||||
Count(&todayCount).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询当日提现次数失败")
|
||||
}
|
||||
if int(todayCount) >= limit {
|
||||
return errors.New(errors.CodeInvalidParam, "今日提现次数已达上限")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lockWithdrawalRequest 以行锁读取提现申请,未找到返回稳定不存在错误。
|
||||
func lockWithdrawalRequest(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
id uint,
|
||||
) (*model.CommissionWithdrawalRequest, error) {
|
||||
var request model.CommissionWithdrawalRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&request, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定提现申请失败")
|
||||
}
|
||||
return &request, nil
|
||||
}
|
||||
|
||||
// lockCommissionWallet 以行锁读取店铺佣金钱包。
|
||||
func lockCommissionWallet(ctx context.Context, tx *gorm.DB, shopID uint) (*model.AgentWallet, error) {
|
||||
var wallet model.AgentWallet
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeCommission).
|
||||
First(&wallet).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺佣金钱包失败")
|
||||
}
|
||||
return &wallet, nil
|
||||
}
|
||||
|
||||
// freezeCommissionBalance 以条件更新冻结可提现余额,影响行数不为 1 时判定余额不足。
|
||||
func freezeCommissionBalance(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
wallet *model.AgentWallet,
|
||||
amount int64,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND balance - frozen_balance >= ?",
|
||||
wallet.ID, constants.AgentWalletTypeCommission, amount).
|
||||
Updates(map[string]any{
|
||||
"frozen_balance": gorm.Expr("frozen_balance + ?", amount),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "冻结可提现余额失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeInsufficientBalance, "可提现余额不足或并发冲突,请稍后重试")
|
||||
}
|
||||
wallet.FrozenBalance += amount
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseCommissionBalance 以条件更新释放冻结余额并返回释放是否发生。
|
||||
// 释放金额取尝试记录事实,重复释放不会重复调整余额。
|
||||
func releaseCommissionBalance(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
walletID uint,
|
||||
amount int64,
|
||||
) (bool, error) {
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND frozen_balance >= ?",
|
||||
walletID, constants.AgentWalletTypeCommission, amount).
|
||||
Updates(map[string]any{
|
||||
"frozen_balance": gorm.Expr("frozen_balance - ?", amount),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "释放冻结余额失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// nextWithdrawalAttemptNo 返回该申请的下一条审批尝试序号;申请行已加锁,序号在同一事务内唯一。
|
||||
func nextWithdrawalAttemptNo(ctx context.Context, tx *gorm.DB, requestID uint) (int, error) {
|
||||
var row struct {
|
||||
MaxAttemptNo int
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
|
||||
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
|
||||
Where("request_id = ?", requestID).Scan(&row).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试序号失败")
|
||||
}
|
||||
if row.MaxAttemptNo >= constants.WithdrawalAttemptMaxCount {
|
||||
return 0, errors.New(errors.CodeConflict, "提现重提次数已达上限,请联系平台处理")
|
||||
}
|
||||
return row.MaxAttemptNo + 1, nil
|
||||
}
|
||||
|
||||
// withdrawalResubmittableStatuses 允许重提的提现状态:已驳回与审批提交失败,两者冻结均已释放。
|
||||
var withdrawalResubmittableStatuses = []int{constants.WithdrawalStatusRejected, constants.WithdrawalStatusSubmissionFailed}
|
||||
|
||||
// isWithdrawalResubmittable 判断提现申请当前状态是否允许重提。
|
||||
func isWithdrawalResubmittable(status int) bool {
|
||||
for _, allowed := range withdrawalResubmittableStatuses {
|
||||
if status == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// updateWithdrawalRequestForResubmit 以已驳回或审批提交失败状态条件更新申请为最新尝试的镜像。
|
||||
func updateWithdrawalRequestForResubmit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
input WithdrawalInput,
|
||||
policy WithdrawalPolicy,
|
||||
fee int64,
|
||||
actualAmount int64,
|
||||
accountInfo []byte,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ? AND status IN ?", request.ID, withdrawalResubmittableStatuses).
|
||||
Updates(map[string]any{
|
||||
"amount": input.Amount, "fee": fee, "fee_rate": policy.FeeRate, "actual_amount": actualAmount,
|
||||
"withdrawal_method": input.WithdrawalMethod, "account_info": accountInfo,
|
||||
"status": constants.WithdrawalStatusPending, "processed_at": nil,
|
||||
"reject_reason": "", "updater": request.ApplicantID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新提现申请重提内容失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现申请状态已变化,请刷新后重试")
|
||||
}
|
||||
request.Amount = input.Amount
|
||||
request.Fee = fee
|
||||
request.FeeRate = policy.FeeRate
|
||||
request.ActualAmount = actualAmount
|
||||
request.WithdrawalMethod = input.WithdrawalMethod
|
||||
request.AccountInfo = accountInfo
|
||||
request.Status = constants.WithdrawalStatusPending
|
||||
request.ProcessedAt = nil
|
||||
request.RejectReason = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachWithdrawalAttemptInstance 回写尝试记录关联的审批实例,写入一次后不可修改。
|
||||
func attachWithdrawalAttemptInstance(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
instanceID uint,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", attempt.ID).
|
||||
Update("approval_instance_id", instanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联提现审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现审批实例关联已变化")
|
||||
}
|
||||
attempt.ApprovalInstanceID = &instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateWithdrawalLatest 回填申请的最新尝试与审批实例引用,仅用于列表投影。
|
||||
func updateWithdrawalLatest(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
instanceID uint,
|
||||
) error {
|
||||
updates := map[string]any{
|
||||
"latest_attempt_id": attempt.ID, "latest_approval_instance_id": instanceID,
|
||||
}
|
||||
if request.ApprovalInstanceID == nil {
|
||||
// 首次接入企业微信审批时记录稳定门禁标识,本地人工终审据此拒绝。
|
||||
updates["approval_instance_id"] = instanceID
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ?", request.ID).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "回填提现申请最新审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现申请最新审批实例回填已变化")
|
||||
}
|
||||
request.LatestAttemptID = attempt.ID
|
||||
request.LatestApprovalInstanceID = instanceID
|
||||
if request.ApprovalInstanceID == nil {
|
||||
request.ApprovalInstanceID = &instanceID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordWithdrawalFreezeTransaction 写入提现冻结钱包流水,金额为负且状态为处理中。
|
||||
func recordWithdrawalFreezeTransaction(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
wallet *model.AgentWallet,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
operatorID uint,
|
||||
amount int64,
|
||||
) (*model.AgentWalletTransaction, error) {
|
||||
remark := "提现冻结,单号:" + request.WithdrawalNo
|
||||
refType := constants.ReferenceTypeWithdrawal
|
||||
refID := request.ID
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: operatorID,
|
||||
TransactionType: constants.AgentTransactionTypeWithdrawal,
|
||||
Amount: -amount,
|
||||
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - amount,
|
||||
Status: constants.TransactionStatusProcessing,
|
||||
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
|
||||
Creator: operatorID, ShopIDTag: request.ShopID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现冻结钱包流水失败")
|
||||
}
|
||||
return transaction, nil
|
||||
}
|
||||
|
||||
// shopName 读取店铺名称用于审批表单展示,缺失时留空。
|
||||
func shopName(ctx context.Context, db *gorm.DB, shopID uint) string {
|
||||
var shop model.Shop
|
||||
if err := db.WithContext(ctx).Select("id", "shop_name").First(&shop, shopID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return shop.ShopName
|
||||
}
|
||||
|
||||
// withdrawalApprovalForm 生成企业微信审批表单业务快照。
|
||||
// 收款账号按原值写入供审批人核验,其他敏感内容不写入。
|
||||
func withdrawalApprovalForm(
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
shopNameValue string,
|
||||
) map[string]any {
|
||||
accountName, accountNumber := decodeAccountInfo(attempt.AccountInfo)
|
||||
return map[string]any{
|
||||
constants.ApprovalFieldWithdrawalNo: request.WithdrawalNo,
|
||||
constants.ApprovalFieldWithdrawalAttemptNo: attempt.AttemptNo,
|
||||
constants.ApprovalFieldWithdrawalShopID: request.ShopID,
|
||||
constants.ApprovalFieldWithdrawalShopName: shopNameValue,
|
||||
constants.ApprovalFieldWithdrawalAmount: formatAmountYuan(attempt.Amount),
|
||||
constants.ApprovalFieldWithdrawalAmountCent: attempt.Amount,
|
||||
constants.ApprovalFieldWithdrawalFee: formatAmountYuan(attempt.Fee),
|
||||
constants.ApprovalFieldWithdrawalActualAmount: formatAmountYuan(attempt.ActualAmount),
|
||||
constants.ApprovalFieldWithdrawalMethod: attempt.WithdrawalMethod,
|
||||
constants.ApprovalFieldWithdrawalAccountName: accountName,
|
||||
constants.ApprovalFieldWithdrawalAccountNumber: accountNumber,
|
||||
constants.ApprovalFieldWithdrawalInvoiceKey: decodeInvoiceKeys(attempt.InvoiceKeys),
|
||||
}
|
||||
}
|
||||
|
||||
// decodeAccountInfo 解析收款账户信息快照,解析失败时留空。
|
||||
func decodeAccountInfo(payload []byte) (string, string) {
|
||||
var info map[string]string
|
||||
if err := sonic.Unmarshal(payload, &info); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return info["account_name"], info["account_number"]
|
||||
}
|
||||
|
||||
// decodeInvoiceKeys 解析发票对象键列表,解析失败时返回空列表。
|
||||
func decodeInvoiceKeys(payload []byte) []string {
|
||||
var keys []string
|
||||
if err := sonic.Unmarshal(payload, &keys); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// withdrawalAuditSnapshot 生成提现审计快照,不含收款账号与发票内容。
|
||||
func withdrawalAuditSnapshot(
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
wallet *model.AgentWallet,
|
||||
) map[string]any {
|
||||
snapshot := map[string]any{
|
||||
"id": request.ID, "withdrawal_no": request.WithdrawalNo, "shop_id": request.ShopID,
|
||||
"amount": attempt.Amount, "fee": attempt.Fee, "fee_rate": attempt.FeeRate,
|
||||
"actual_amount": attempt.ActualAmount, "withdrawal_method": attempt.WithdrawalMethod,
|
||||
"status": request.Status, "attempt_id": attempt.ID, "attempt_no": attempt.AttemptNo,
|
||||
"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
|
||||
snapshot["wallet_frozen_balance"] = wallet.FrozenBalance
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// withdrawalSubmitSummary 区分首次提交与重提的审计摘要。
|
||||
func withdrawalSubmitSummary(resubmit bool) string {
|
||||
if resubmit {
|
||||
return "重提佣金提现申请,已释放旧未结算冻结"
|
||||
}
|
||||
return "提交佣金提现申请并冻结可提现余额"
|
||||
}
|
||||
|
||||
// ReleaseUnsettledAttemptsInTx 幂等释放指定店铺全部未结算的提现审批尝试冻结。
|
||||
// 释放金额取 try.amount 事实,释放完成写入 released_at;已释放的尝试不会被重复释放。
|
||||
// 供后续佣金回溯在扣减佣金余额前先释放冻结,返回本次实际释放金额合计。
|
||||
func (s *WithdrawalService) ReleaseUnsettledAttemptsInTx(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
) (int64, error) {
|
||||
if s == nil || s.db == nil || tx == nil || shopID == 0 {
|
||||
return 0, errors.New(errors.CodeInvalidParam, "提现冻结释放参数无效")
|
||||
}
|
||||
return releaseUnsettledAttempts(ctx, tx, shopID)
|
||||
}
|
||||
|
||||
// releaseUnsettledAttemptsForRequest 在重提事务内释放指定申请的全部未结算尝试冻结。
|
||||
func releaseUnsettledAttemptsForRequest(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
requestID uint,
|
||||
) (int64, error) {
|
||||
return releaseUnsettledForRequests(ctx, tx, []uint{requestID})
|
||||
}
|
||||
|
||||
// releaseUnsettledAttempts 释放指定店铺范围内未结算的提现审批尝试冻结。
|
||||
// 全局加锁顺序固定为「申请 → 尝试 → 钱包」:本函数先锁申请行,再交由释放原语锁尝试行。
|
||||
func releaseUnsettledAttempts(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
) (int64, error) {
|
||||
var requestIDs []uint
|
||||
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("shop_id = ?", shopID).Order("id ASC").Pluck("id", &requestIDs).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺提现申请失败")
|
||||
}
|
||||
return releaseUnsettledForRequests(ctx, tx, requestIDs)
|
||||
}
|
||||
|
||||
// releaseUnsettledForRequests 通过共享释放原语释放尝试冻结并解冻钱包。
|
||||
// 释放金额一律取尝试记录事实;原语的 released_at 条件更新保证重复调用不重复释放。
|
||||
func releaseUnsettledForRequests(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
requestIDs []uint,
|
||||
) (int64, error) {
|
||||
if len(requestIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
requests := make(map[uint]*model.CommissionWithdrawalRequest, len(requestIDs))
|
||||
for _, requestID := range requestIDs {
|
||||
request, err := lockWithdrawalRequest(ctx, tx, requestID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
requests[requestID] = request
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
amounts, err := postgres.ReleaseUnsettledForRequestsInTx(ctx, tx, requestIDs, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := int64(0)
|
||||
for _, requestID := range requestIDs {
|
||||
amount, exists := amounts[requestID]
|
||||
if !exists || amount == 0 {
|
||||
continue
|
||||
}
|
||||
request := requests[requestID]
|
||||
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ok, err := releaseCommissionBalance(ctx, tx, wallet.ID, amount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
return 0, errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对")
|
||||
}
|
||||
total += amount
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// generateWithdrawalNo 生成提现单号,格式:W + 时间戳 + 随机数。
|
||||
func generateWithdrawalNo() string {
|
||||
return "W" + time.Now().Format("20060102150405") + randomDigits(6)
|
||||
}
|
||||
|
||||
// randomDigits 生成指定位数的数字随机串,用于提现单号。
|
||||
func randomDigits(length int) string {
|
||||
const digits = "0123456789"
|
||||
buf := make([]byte, 0, length)
|
||||
limit := big.NewInt(int64(len(digits)))
|
||||
for range length {
|
||||
value, err := rand.Int(rand.Reader, limit)
|
||||
if err != nil {
|
||||
return strings.Repeat("0", length)
|
||||
}
|
||||
buf = append(buf, digits[value.Int64()])
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// formatAmountYuan 将分金额格式化为元字符串,仅用于展示与审批表单。
|
||||
func formatAmountYuan(amount int64) string {
|
||||
negative := amount < 0
|
||||
if negative {
|
||||
amount = -amount
|
||||
}
|
||||
value := distributiondomain.FormatCentYuan(amount)
|
||||
if negative {
|
||||
return "-" + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// WithdrawalApprovalHandler 将渠道无关企业微信终态应用到提现申请。
|
||||
// 通过时仅一次从冻结余额扣减并保持 WithdrawalStatusApproved=2,同时写入到账时间;
|
||||
// 驳回、撤销与删除仅一次释放本次尝试的冻结余额并记录释放时间;
|
||||
// 通过后撤销不回滚、不重新冻结、不自动重提,只写入正交异常标记与原因。
|
||||
type WithdrawalApprovalHandler struct {
|
||||
db *gorm.DB
|
||||
audit AuditWriter
|
||||
}
|
||||
|
||||
// NewWithdrawalApprovalHandler 创建佣金提现审批终态消费者。
|
||||
func NewWithdrawalApprovalHandler(db *gorm.DB, audit AuditWriter) *WithdrawalApprovalHandler {
|
||||
return &WithdrawalApprovalHandler{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// Handle 幂等消费标准审批终态。
|
||||
// 业务标识为提现审批尝试记录主键;先锁定尝试记录并校验审批实例一致,再按条件更新推进状态。
|
||||
func (h *WithdrawalApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
|
||||
if h == nil || h.db == nil || h.audit == nil {
|
||||
return errors.New(errors.CodeInternalError, "佣金提现审批终态能力未配置")
|
||||
}
|
||||
if event.BusinessType != constants.ApprovalBusinessTypeCommissionWithdrawal ||
|
||||
event.BusinessID == 0 || event.InstanceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "佣金提现审批终态参数无效")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
|
||||
})
|
||||
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 全库统一加锁顺序:申请行 → 尝试行 → 钱包行(钱包永远最后)。
|
||||
// 因此先用不加锁读取得 request_id,再按序加锁,避免与退款回扣路径形成死锁环。
|
||||
var lookup model.CommissionWithdrawalRequestAttempt
|
||||
if err := tx.WithContext(ctx).Select("id", "request_id").
|
||||
First(&lookup, event.BusinessID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "提现审批尝试记录不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试记录失败")
|
||||
}
|
||||
request, err := lockWithdrawalRequest(ctx, tx, lookup.RequestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var attempt model.CommissionWithdrawalRequestAttempt
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&attempt, event.BusinessID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "提现审批尝试记录不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现审批尝试记录失败")
|
||||
}
|
||||
if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != event.InstanceID {
|
||||
return errors.New(errors.CodeConflict, "提现审批尝试记录关联的审批实例不一致")
|
||||
}
|
||||
if attempt.RequestID != request.ID {
|
||||
return errors.New(errors.CodeConflict, "提现审批尝试记录归属已变化")
|
||||
}
|
||||
if request.LatestAttemptID != attempt.ID {
|
||||
// 已被更新尝试取代的历史尝试终态不再改变申请事实。
|
||||
return nil
|
||||
}
|
||||
switch event.Decision {
|
||||
case constants.ApprovalDecisionApproved:
|
||||
return h.applyApproved(ctx, tx, request, &attempt, event)
|
||||
case constants.ApprovalDecisionRejected,
|
||||
constants.ApprovalDecisionCancelled,
|
||||
constants.ApprovalDecisionDeleted:
|
||||
return h.applyClosed(ctx, tx, request, &attempt, event)
|
||||
case constants.ApprovalDecisionRevokedAfterApproved:
|
||||
return h.applyRevoked(ctx, tx, request, &attempt, event)
|
||||
case constants.ApprovalDecisionSubmissionFailed:
|
||||
reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID)
|
||||
if err != nil || !failed {
|
||||
return err
|
||||
}
|
||||
return h.closeAttempt(ctx, tx, request, &attempt, event, withdrawalCloseOutcome{
|
||||
status: constants.WithdrawalStatusSubmissionFailed, reason: reason, eventSuffix: "submission_failed",
|
||||
summary: "佣金提现审批提交失败,已释放本次尝试冻结余额",
|
||||
releaseRemark: "审批提交失败,释放提现冻结,单号:",
|
||||
})
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的提现申请审批终态")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// applyApproved 仅一次从冻结余额扣减,保持已通过状态并写入到账时间。
|
||||
// 幂等守卫为「申请仍待审核 + paid_at 为空 + 尝试未释放」的条件更新且影响行数为 1。
|
||||
func (h *WithdrawalApprovalHandler) applyApproved(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if attempt.ReleasedAt != nil {
|
||||
// 已结算的尝试不再扣减,避免重复扣款。
|
||||
return nil
|
||||
}
|
||||
if wallet.FrozenBalance < attempt.Amount {
|
||||
return errors.New(errors.CodeConflict, "冻结余额不足以完成提现扣减,请人工核对")
|
||||
}
|
||||
// 通过即视为已到账:先以 released_at IS NULL 条件更新标记本次冻结已结算,保证重复回调不重复扣减。
|
||||
settled, err := markAttemptReleased(ctx, tx, attempt, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !settled {
|
||||
return nil
|
||||
}
|
||||
// 通过时保持状态 2 并写入到账时间,禁止使用已到账状态值 4。
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ? AND status = ? AND paid_at IS NULL", request.ID, constants.WithdrawalStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalStatusApproved,
|
||||
"paid_at": now,
|
||||
"processed_at": now,
|
||||
"updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现申请已通过失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现申请状态已变化")
|
||||
}
|
||||
if err := deductFrozenBalance(ctx, tx, wallet, attempt.Amount); err != nil {
|
||||
return err
|
||||
}
|
||||
transaction, err := recordWithdrawalDeductTransaction(ctx, tx, wallet, request, attempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.ReleasedAt = &now
|
||||
before := map[string]any{"status": constants.WithdrawalStatusPending, "paid_at": nil, "frozen_balance": wallet.FrozenBalance + attempt.Amount}
|
||||
request.Status = constants.WithdrawalStatusApproved
|
||||
request.PaidAt = &now
|
||||
request.ProcessedAt = &now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":approved",
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalAttemptApproved,
|
||||
Summary: "企业微信通过佣金提现,已从冻结余额扣减并记录到账时间",
|
||||
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
|
||||
Wallet: wallet, Transaction: transaction,
|
||||
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
|
||||
})
|
||||
}
|
||||
|
||||
// applyClosed 处理最终驳回、撤销与删除:仅一次释放本次尝试冻结并记录释放时间。
|
||||
// 幂等守卫为「尝试已结算时间仍为空」的条件更新且影响行数为 1。
|
||||
func (h *WithdrawalApprovalHandler) applyClosed(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
return h.closeAttempt(ctx, tx, request, attempt, event, withdrawalCloseOutcome{
|
||||
status: constants.WithdrawalStatusRejected, reason: rejectionReason(event.Decision), eventSuffix: "closed",
|
||||
summary: "企业微信未通过佣金提现,已释放本次尝试冻结余额",
|
||||
releaseRemark: "企业微信未通过,释放提现冻结,单号:",
|
||||
})
|
||||
}
|
||||
|
||||
// withdrawalCloseOutcome 描述提现尝试未通过时的终态、原因、审计与释放流水文案。
|
||||
type withdrawalCloseOutcome struct {
|
||||
status int
|
||||
reason string
|
||||
eventSuffix string
|
||||
summary string
|
||||
releaseRemark string
|
||||
}
|
||||
|
||||
// closeAttempt 在同一事务内仅一次释放本次尝试冻结、写释放流水,并把申请从待审核置为未通过终态。
|
||||
// 幂等守卫为「尝试已结算时间仍为空」的条件更新且影响行数为 1。
|
||||
func (h *WithdrawalApprovalHandler) closeAttempt(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
outcome withdrawalCloseOutcome,
|
||||
) error {
|
||||
if attempt.ReleasedAt != nil {
|
||||
return nil
|
||||
}
|
||||
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
released, err := markAttemptReleased(ctx, tx, attempt, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !released {
|
||||
return nil
|
||||
}
|
||||
ok, err := releaseCommissionBalance(ctx, tx, wallet.ID, attempt.Amount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对")
|
||||
}
|
||||
reason := outcome.reason
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": outcome.status,
|
||||
"processed_at": now,
|
||||
"reject_reason": reason,
|
||||
"updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束提现申请失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现申请状态已变化")
|
||||
}
|
||||
frozenBefore := wallet.FrozenBalance + attempt.Amount
|
||||
wallet.FrozenBalance = frozenBefore - attempt.Amount
|
||||
transaction, err := recordWithdrawalReleaseTransaction(ctx, tx, wallet, request, attempt, outcome.releaseRemark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.ReleasedAt = &now
|
||||
before := map[string]any{"status": constants.WithdrawalStatusPending, "frozen_balance": frozenBefore}
|
||||
request.Status = outcome.status
|
||||
request.ProcessedAt = &now
|
||||
request.RejectReason = reason
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":" + outcome.eventSuffix,
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalAttemptClosed,
|
||||
Summary: outcome.summary,
|
||||
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
|
||||
Wallet: wallet, Transaction: transaction,
|
||||
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
|
||||
})
|
||||
}
|
||||
|
||||
// applyRevoked 处理通过后撤销。
|
||||
// 已通过:不回滚已到账金额、不重新冻结、不自动重提,只写正交异常标记与原因。
|
||||
// 仍在待审核(渠道乱序投递):按驳回同等处理,释放本次尝试冻结并转驳回状态。
|
||||
func (h *WithdrawalApprovalHandler) applyRevoked(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
if request.Status == constants.WithdrawalStatusPending {
|
||||
return h.applyClosed(ctx, tx, request, attempt, event)
|
||||
}
|
||||
if request.Status != constants.WithdrawalStatusApproved {
|
||||
// 已驳回等终态不再改变事实。
|
||||
return nil
|
||||
}
|
||||
if request.AnomalyFlag == constants.WithdrawalAnomalyFlagRevokedAfterApproved {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
reason := "企业微信通过后撤销:" + rejectionReason(event.Decision)
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ? AND status = ? AND anomaly_flag = ?",
|
||||
request.ID, constants.WithdrawalStatusApproved, constants.WithdrawalAnomalyFlagNone).
|
||||
Updates(map[string]any{
|
||||
"anomaly_flag": constants.WithdrawalAnomalyFlagRevokedAfterApproved,
|
||||
"anomaly_reason": reason, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入提现异常标记失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现申请异常标记已变化")
|
||||
}
|
||||
before := map[string]any{
|
||||
"status": request.Status, "anomaly_flag": constants.WithdrawalAnomalyFlagNone,
|
||||
"paid_at": request.PaidAt, "amount": attempt.Amount,
|
||||
}
|
||||
request.AnomalyFlag = constants.WithdrawalAnomalyFlagRevokedAfterApproved
|
||||
request.AnomalyReason = reason
|
||||
request.UpdatedAt = now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":anomaly",
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalAnomalyFlagged,
|
||||
Summary: "企业微信通过后撤销,已到账金额不回滚、不重新冻结,仅写入异常标记",
|
||||
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
|
||||
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, nil),
|
||||
})
|
||||
}
|
||||
|
||||
// markAttemptReleased 以未释放条件更新写入尝试释放时间,返回是否本次完成释放。
|
||||
func markAttemptReleased(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
now time.Time,
|
||||
) (bool, error) {
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
|
||||
Where("id = ? AND released_at IS NULL", attempt.ID).
|
||||
Update("released_at", now)
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "写入提现尝试释放时间失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// deductFrozenBalance 以冻结余额充足条件更新同时扣减余额与冻结余额。
|
||||
func deductFrozenBalance(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, amount int64) error {
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND frozen_balance >= ?",
|
||||
wallet.ID, constants.AgentWalletTypeCommission, amount).
|
||||
Updates(map[string]any{
|
||||
"balance": gorm.Expr("balance - ?", amount),
|
||||
"frozen_balance": gorm.Expr("frozen_balance - ?", amount),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "从冻结余额扣减提现金额失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "冻结余额不足或已被并发处理")
|
||||
}
|
||||
wallet.Balance -= amount
|
||||
wallet.FrozenBalance -= amount
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordWithdrawalDeductTransaction 写入通过时的钱包流水,余额与冻结余额同时减少。
|
||||
func recordWithdrawalDeductTransaction(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
wallet *model.AgentWallet,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
) (*model.AgentWalletTransaction, error) {
|
||||
remark := "企业微信终审通过,提现到账,单号:" + request.WithdrawalNo
|
||||
refType := constants.ReferenceTypeWithdrawal
|
||||
refID := request.ID
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: request.ApplicantID,
|
||||
TransactionType: constants.AgentTransactionTypeWithdrawal,
|
||||
Amount: -attempt.Amount,
|
||||
BalanceBefore: wallet.Balance + attempt.Amount, BalanceAfter: wallet.Balance,
|
||||
Status: constants.TransactionStatusSuccess,
|
||||
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
|
||||
Creator: request.ApplicantID, ShopIDTag: request.ShopID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现到账钱包流水失败")
|
||||
}
|
||||
return transaction, nil
|
||||
}
|
||||
|
||||
// recordWithdrawalReleaseTransaction 写入未通过(驳回或审批提交失败)时的钱包流水,仅冻结余额减少。
|
||||
func recordWithdrawalReleaseTransaction(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
wallet *model.AgentWallet,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
remarkPrefix string,
|
||||
) (*model.AgentWalletTransaction, error) {
|
||||
remark := remarkPrefix + request.WithdrawalNo
|
||||
refType := constants.ReferenceTypeWithdrawal
|
||||
refID := request.ID
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: request.ApplicantID,
|
||||
TransactionType: constants.AgentTransactionTypeRefund,
|
||||
Amount: attempt.Amount,
|
||||
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance,
|
||||
Status: constants.TransactionStatusSuccess,
|
||||
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
|
||||
Creator: request.ApplicantID, ShopIDTag: request.ShopID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现释放钱包流水失败")
|
||||
}
|
||||
return transaction, nil
|
||||
}
|
||||
763
internal/application/employeecollection/application.go
Normal file
763
internal/application/employeecollection/application.go
Normal file
@@ -0,0 +1,763 @@
|
||||
package employeecollection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// ApplicationAllocationCommand 描述核销申请中单张账单的本次分摊。
|
||||
type ApplicationAllocationCommand struct {
|
||||
BillID uint
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// SubmitApplicationCommand 描述创建或重提核销申请的稳定输入。
|
||||
type SubmitApplicationCommand struct {
|
||||
PaymentMethodID uint
|
||||
PaidAmount int64
|
||||
PayerName string
|
||||
PaidAt time.Time
|
||||
ExternalTransactionNo string
|
||||
PaymentVoucherKeys []string
|
||||
Remark string
|
||||
ActingReason string
|
||||
Allocations []ApplicationAllocationCommand
|
||||
}
|
||||
|
||||
// ApplicationSubmitResult 返回已原子保存的申请、审批尝试记录与分摊。
|
||||
type ApplicationSubmitResult struct {
|
||||
Application *model.EmployeeCollectionApplication
|
||||
Attempt *model.EmployeeCollectionApplicationAttempt
|
||||
Allocations []*model.EmployeeCollectionApplicationAllocation
|
||||
Bills []*model.EmployeeCollectionBill
|
||||
InstanceID uint
|
||||
InstanceStatus int
|
||||
}
|
||||
|
||||
// ApplicationService 创建与重提核销申请,并恢复原审批提交事实。
|
||||
// 申请、审批尝试记录、审批实例与账单预占在同一事务完成;任一校验失败都不留下半成品事实。
|
||||
type ApplicationService struct {
|
||||
db *gorm.DB
|
||||
approval approvalapp.Port
|
||||
recovery approvalapp.RecoveryPort
|
||||
audit ApplicationAuditWriter
|
||||
}
|
||||
|
||||
// NewApplicationService 创建核销申请用例。
|
||||
func NewApplicationService(db *gorm.DB, approval approvalapp.Port, audit ApplicationAuditWriter) *ApplicationService {
|
||||
return &ApplicationService{db: db, approval: approval, audit: audit}
|
||||
}
|
||||
|
||||
// SetRecoveryPort 注入通用审批原实例恢复接缝。
|
||||
func (s *ApplicationService) SetRecoveryPort(recovery approvalapp.RecoveryPort) {
|
||||
s.recovery = recovery
|
||||
}
|
||||
|
||||
// RecoveryResult 返回原审批恢复动作的安全结果。
|
||||
type RecoveryResult struct {
|
||||
ApplicationID uint
|
||||
ApprovalInstanceID uint
|
||||
Action string
|
||||
Result string
|
||||
SubmissionStatus int
|
||||
LastRecoveryAt *time.Time
|
||||
Recoverable bool
|
||||
}
|
||||
|
||||
// Create 为本人可见账单创建核销申请;超级管理员可为账单欠款人代办并必须填写代办原因。
|
||||
func (s *ApplicationService) Create(ctx context.Context, command SubmitApplicationCommand) (*ApplicationSubmitResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
caller, err := currentApplicationCaller(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.submit(ctx, caller, 0, command)
|
||||
}
|
||||
|
||||
// Resubmit 修改并重提已驳回的核销申请,新增审批尝试记录与新的企业微信审批实例。
|
||||
func (s *ApplicationService) Resubmit(ctx context.Context, applicationID uint, command SubmitApplicationCommand) (*ApplicationSubmitResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
caller, err := currentApplicationCaller(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if applicationID == 0 {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
|
||||
}
|
||||
return s.submit(ctx, caller, applicationID, command)
|
||||
}
|
||||
|
||||
// RecoverApproval 恢复员工核销申请的原审批事实,不创建新尝试或审批实例。
|
||||
func (s *ApplicationService) RecoverApproval(ctx context.Context, applicationID uint) (*RecoveryResult, error) {
|
||||
if applicationID == 0 {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
|
||||
}
|
||||
actorID := middleware.GetUserIDFromContext(ctx)
|
||||
if actorID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType == constants.UserTypeAgent || userType == constants.UserTypeEnterprise {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if s == nil || s.db == nil || s.recovery == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "核销审批恢复能力尚未配置")
|
||||
}
|
||||
var result RecoveryResult
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
application, err := lockApplication(ctx, tx, applicationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if application.ApplicantAccountID != actorID && userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
|
||||
}
|
||||
if application.Status != constants.EmployeeCollectionApplicationStatusPending || application.LatestAttemptID == 0 || application.LatestApprovalInstanceID == 0 {
|
||||
return errors.New(errors.CodeConflict, "终态申请不可恢复")
|
||||
}
|
||||
var attempt model.EmployeeCollectionApplicationAttempt
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND application_id = ?", application.LatestAttemptID, application.ID).First(&attempt).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "核销审批尝试关联不一致")
|
||||
}
|
||||
if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != application.LatestApprovalInstanceID {
|
||||
return errors.New(errors.CodeConflict, "核销审批实例关联不一致")
|
||||
}
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND business_type = ? AND business_id = ?", application.LatestApprovalInstanceID, constants.ApprovalBusinessTypeEmployeeCollection, attempt.ID).First(&instance).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "审批实例业务关联不一致")
|
||||
}
|
||||
if instance.Status != constants.ApprovalStatusSubmitting && instance.Status != constants.ApprovalStatusPending && instance.Status != constants.ApprovalStatusSubmissionFailed && instance.Status != constants.ApprovalStatusSubmissionUnknown {
|
||||
return errors.New(errors.CodeConflict, "审批终态不允许恢复")
|
||||
}
|
||||
var wc model.WeComApprovalContext
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("approval_instance_id = ? AND business_type = ?", instance.ID, constants.ApprovalBusinessTypeEmployeeCollection).First(&wc).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "企业微信审批上下文不存在")
|
||||
}
|
||||
beforeStatus := fmt.Sprintf("%d", wc.SubmissionStatus)
|
||||
action, recoveryErr := "active", error(nil)
|
||||
if wc.SPNo != "" {
|
||||
action = "sp_no"
|
||||
recoveryErr = s.recovery.EnqueueSubmittedSync(ctx, tx, instance.ID)
|
||||
} else if wc.SubmissionStatus == constants.WeComSubmissionStatusUnknown || instance.Status == constants.ApprovalStatusSubmissionUnknown {
|
||||
action = "unknown"
|
||||
recoveryErr = s.recovery.EnqueueUnknownConfirm(ctx, tx, instance.ID)
|
||||
} else if instance.Status == constants.ApprovalStatusSubmissionFailed && wc.SubmissionStatus == constants.WeComSubmissionStatusFailed {
|
||||
// 提交失败已由决策终止业务单,重放会造成“业务单终态、企微又生成审批”的分裂。
|
||||
return errors.New(errors.CodeConflict, constants.ApprovalSubmissionFailedNotRecoverableMessage)
|
||||
} else if instance.Status != constants.ApprovalStatusSubmitting && instance.Status != constants.ApprovalStatusPending && wc.SubmissionStatus != constants.WeComSubmissionStatusReady && wc.SubmissionStatus != constants.WeComSubmissionStatusSending {
|
||||
return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复")
|
||||
}
|
||||
if recoveryErr != nil {
|
||||
return errors.Wrap(errors.CodeConflict, recoveryErr, "恢复审批提交失败")
|
||||
}
|
||||
if err := s.audit.WriteEmployeeCollectionRecovery(ctx, tx, application, &attempt, &instance, actorID, beforeStatus, action, "accepted"); err != nil {
|
||||
return err
|
||||
}
|
||||
result = RecoveryResult{ApplicationID: application.ID, ApprovalInstanceID: instance.ID, Action: action, Result: "accepted", SubmissionStatus: wc.SubmissionStatus, LastRecoveryAt: wc.LastRecoveryAt, Recoverable: true}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ensureReady 校验用例依赖完整,缺失时失败关闭,避免绕过企业微信终审。
|
||||
func (s *ApplicationService) ensureReady() error {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "核销申请能力尚未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applicationCaller 是发起核销申请的真实操作者。
|
||||
type applicationCaller struct {
|
||||
AccountID uint
|
||||
AccountName string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// currentApplicationCaller 从上下文取当前操作者,未认证时拒绝。
|
||||
func currentApplicationCaller(ctx context.Context) (applicationCaller, error) {
|
||||
accountID := middleware.GetUserIDFromContext(ctx)
|
||||
if accountID == 0 {
|
||||
return applicationCaller{}, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
return applicationCaller{
|
||||
AccountID: accountID,
|
||||
AccountName: middleware.GetUsernameFromContext(ctx),
|
||||
IsAdmin: middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// submit 在同一事务内完成校验、加锁、写申请、写审批尝试记录、创建审批实例与账单预占。
|
||||
// 加锁次序全仓统一为「申请行 → 账单行(ID 升序)」,与审批终态消费者保持一致,避免死锁。
|
||||
func (s *ApplicationService) submit(
|
||||
ctx context.Context,
|
||||
caller applicationCaller,
|
||||
applicationID uint,
|
||||
command SubmitApplicationCommand,
|
||||
) (*ApplicationSubmitResult, error) {
|
||||
if command.PaymentMethodID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "核销申请必须选择线下收款方式")
|
||||
}
|
||||
allocationCommands, err := sortedAllocationCommands(command.Allocations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
correlationID := "employee_collection:application:" + uuid.NewString()
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeEmployeeCollection,
|
||||
SubmitterAccountID: caller.AccountID, CorrelationID: correlationID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result *ApplicationSubmitResult
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var existing *model.EmployeeCollectionApplication
|
||||
if applicationID != 0 {
|
||||
existing, err = lockApplication(ctx, tx, applicationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := employeecollectiondomain.ValidateApplicationResubmit(existing.Status); err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.ApplicantAccountID != caller.AccountID && !caller.IsAdmin {
|
||||
return errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
|
||||
}
|
||||
}
|
||||
bills, err := lockBillsInAscendingOrder(ctx, tx, allocationCommands)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paymentMethod, err := loadEnabledPaymentMethod(ctx, tx, command.PaymentMethodID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applicantAccountID, err := resolveApplicantAccountID(caller, existing, bills)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acting := applicantAccountID != caller.AccountID
|
||||
normalized, err := employeecollectiondomain.NormalizeApplicationInput(employeecollectiondomain.ApplicationInput{
|
||||
PaidAmount: command.PaidAmount, PayerName: command.PayerName, PaidAt: command.PaidAt,
|
||||
ExternalTransactionNo: command.ExternalTransactionNo, Remark: command.Remark,
|
||||
ActingReason: command.ActingReason, PaymentVoucherKeys: command.PaymentVoucherKeys,
|
||||
}, acting)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates := make([]employeecollectiondomain.AllocationCandidate, 0, len(allocationCommands))
|
||||
for _, item := range allocationCommands {
|
||||
bill := bills[item.BillID]
|
||||
candidates = append(candidates, employeecollectiondomain.AllocationCandidate{
|
||||
BillID: item.BillID, BillStatus: bill.Status, Amount: item.Amount,
|
||||
Available: billAmounts(bill).Available(),
|
||||
})
|
||||
}
|
||||
if err := employeecollectiondomain.ValidateAllocations(normalized.PaidAmount, candidates); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
application, beforeData, err := prepareApplication(
|
||||
ctx, tx, caller, existing, applicantAccountID, paymentMethod, normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attemptNo, err := nextAttemptNo(ctx, tx, application.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt, err := buildAttempt(ctx, tx, application.ID, attemptNo, caller, paymentMethod, normalized, bills, allocationCommands)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := approvalSnapshots(application.ID, caller, paymentMethod, normalized, bills, allocationCommands)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeEmployeeCollection,
|
||||
BusinessID: attempt.ID, SubmitterAccountID: caller.AccountID,
|
||||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||||
CorrelationID: correlationID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateApplicationLatest(ctx, tx, application, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
allocations, reservedBills, err := createAllocations(ctx, tx, application.ID, attempt.ID, caller, bills, allocationCommands)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
submitEventID, err := composeAuditEventID(
|
||||
"employee_collection", "application", uintText(application.ID), "attempt", intText(attempt.AttemptNo), "submit")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
|
||||
EventID: submitEventID,
|
||||
ActionCode: constants.AuditActionEmployeeCollectionApplicationSubmitted,
|
||||
Summary: submitSummary(existing != nil),
|
||||
Application: application, Attempt: attempt, Allocations: allocations, Bills: reservedBills,
|
||||
BeforeData: beforeData, AfterData: applicationAuditSnapshot(application),
|
||||
CorrelationID: correlationID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
result = &ApplicationSubmitResult{
|
||||
Application: application, Attempt: attempt, Allocations: allocations,
|
||||
Bills: reservedBills, InstanceID: reference.InstanceID, InstanceStatus: reference.Status,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// submitSummary 区分首次提交与重提的审计摘要。
|
||||
func submitSummary(resubmit bool) string {
|
||||
if resubmit {
|
||||
return "重提员工代收款核销申请"
|
||||
}
|
||||
return "提交员工代收款核销申请"
|
||||
}
|
||||
|
||||
// sortedAllocationCommands 校验分摊入参基本形态并按账单 ID 升序返回,保证锁序唯一。
|
||||
func sortedAllocationCommands(items []ApplicationAllocationCommand) ([]ApplicationAllocationCommand, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "核销申请至少需要一个账单分摊")
|
||||
}
|
||||
if len(items) > constants.EmployeeCollectionAllocationMaxCount {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "核销申请账单分摊数量超出限制")
|
||||
}
|
||||
seen := make(map[uint]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if item.BillID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "账单分摊缺少目标账单")
|
||||
}
|
||||
if item.Amount <= 0 {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
|
||||
}
|
||||
if _, exists := seen[item.BillID]; exists {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "同一账单不能重复分摊")
|
||||
}
|
||||
seen[item.BillID] = struct{}{}
|
||||
}
|
||||
sorted := append([]ApplicationAllocationCommand(nil), items...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].BillID < sorted[j].BillID })
|
||||
return sorted, nil
|
||||
}
|
||||
|
||||
// lockApplication 以行锁读取核销申请,未找到返回稳定不存在错误。
|
||||
func lockApplication(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionApplication, error) {
|
||||
var application model.EmployeeCollectionApplication
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&application, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定核销申请失败")
|
||||
}
|
||||
return &application, nil
|
||||
}
|
||||
|
||||
// lockBillsInAscendingOrder 按账单 ID 升序逐行加锁并返回账单事实。
|
||||
// 单条 `WHERE id IN (...) ORDER BY id FOR UPDATE` 在 PostgreSQL 中先取行加锁再排序,
|
||||
// 无法保证加锁次序;因此对每个账单各发一条只锁一行的语句,由调用方保证 ID 升序且不重复。
|
||||
func lockBillsInAscendingOrder(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
items []ApplicationAllocationCommand,
|
||||
) (map[uint]*model.EmployeeCollectionBill, error) {
|
||||
bills := make(map[uint]*model.EmployeeCollectionBill, len(items))
|
||||
for _, item := range items {
|
||||
var bill model.EmployeeCollectionBill
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&bill, item.BillID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定员工代收款账单失败")
|
||||
}
|
||||
bills[item.BillID] = &bill
|
||||
}
|
||||
return bills, nil
|
||||
}
|
||||
|
||||
// loadEnabledPaymentMethod 读取启用中的线下收款方式字典项作为冻结来源。
|
||||
func loadEnabledPaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
|
||||
var paymentMethod model.EmployeeCollectionPaymentMethod
|
||||
if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
|
||||
}
|
||||
if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
|
||||
}
|
||||
return &paymentMethod, nil
|
||||
}
|
||||
|
||||
// resolveApplicantAccountID 依据所选账单确定申请人。
|
||||
// 新建:非超级管理员只能选择本人欠款账单;超级管理员代办时全部账单必须属于同一欠款人。
|
||||
// 重提:被选账单必须仍属于原申请人,申请人的其他越权访问与不存在返回同一错误。
|
||||
func resolveApplicantAccountID(
|
||||
caller applicationCaller,
|
||||
existing *model.EmployeeCollectionApplication,
|
||||
bills map[uint]*model.EmployeeCollectionBill,
|
||||
) (uint, error) {
|
||||
if existing != nil {
|
||||
applicant := existing.ApplicantAccountID
|
||||
for _, bill := range bills {
|
||||
if bill.DebtorAccountID != applicant {
|
||||
return 0, errors.New(errors.CodeEmployeeCollectionBillNotFound)
|
||||
}
|
||||
}
|
||||
return applicant, nil
|
||||
}
|
||||
applicant := uint(0)
|
||||
for _, bill := range bills {
|
||||
if !caller.IsAdmin && bill.DebtorAccountID != caller.AccountID {
|
||||
return 0, errors.New(errors.CodeEmployeeCollectionBillNotFound)
|
||||
}
|
||||
if applicant == 0 {
|
||||
applicant = bill.DebtorAccountID
|
||||
continue
|
||||
}
|
||||
if applicant != bill.DebtorAccountID {
|
||||
return 0, errors.New(errors.CodeInvalidParam, "代办核销申请时全部账单必须属于同一欠款人")
|
||||
}
|
||||
}
|
||||
return applicant, nil
|
||||
}
|
||||
|
||||
// billAmounts 将账单持久化事实映射为领域金额事实。
|
||||
func billAmounts(bill *model.EmployeeCollectionBill) employeecollectiondomain.BillAmounts {
|
||||
return employeecollectiondomain.BillAmounts{
|
||||
Receivable: bill.ReceivableAmount, Received: bill.ReceivedAmount, Reserved: bill.ReservedAmount,
|
||||
Closed: bill.Status == constants.EmployeeCollectionBillStatusClosed,
|
||||
}
|
||||
}
|
||||
|
||||
// nextAttemptNo 返回该申请的下一条审批尝试序号;申请行已加锁,序号在同一事务内唯一。
|
||||
func nextAttemptNo(ctx context.Context, tx *gorm.DB, applicationID uint) (int, error) {
|
||||
var row struct {
|
||||
MaxAttemptNo int
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAttempt{}).
|
||||
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
|
||||
Where("application_id = ?", applicationID).Scan(&row).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试序号失败")
|
||||
}
|
||||
return row.MaxAttemptNo + 1, nil
|
||||
}
|
||||
|
||||
// prepareApplication 新建或就地更新核销申请,返回申请事实与变更前快照。
|
||||
// 重提使用 expected-status 条件更新,状态已变化时返回冲突。
|
||||
func prepareApplication(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
caller applicationCaller,
|
||||
existing *model.EmployeeCollectionApplication,
|
||||
applicantAccountID uint,
|
||||
paymentMethod *model.EmployeeCollectionPaymentMethod,
|
||||
normalized employeecollectiondomain.NormalizedApplicationInput,
|
||||
) (*model.EmployeeCollectionApplication, map[string]any, error) {
|
||||
actingOperatorID := uint(0)
|
||||
if applicantAccountID != caller.AccountID {
|
||||
actingOperatorID = caller.AccountID
|
||||
}
|
||||
if existing == nil {
|
||||
application := &model.EmployeeCollectionApplication{
|
||||
ApplicantAccountID: applicantAccountID, ActingOperatorID: actingOperatorID,
|
||||
ActingReason: normalized.ActingReason,
|
||||
PaymentMethodID: paymentMethod.ID, PaymentMethodCode: paymentMethod.Code,
|
||||
PaymentMethodName: paymentMethod.Name, PaidAmount: normalized.PaidAmount,
|
||||
PayerName: normalized.PayerName, PaidAt: normalized.PaidAt,
|
||||
ExternalTransactionNo: normalized.ExternalTransactionNo,
|
||||
PaymentVoucherKeys: model.StringJSONBArray(normalized.PaymentVoucherKeys),
|
||||
Remark: normalized.Remark,
|
||||
Status: constants.EmployeeCollectionApplicationStatusPending,
|
||||
Creator: caller.AccountID, Updater: caller.AccountID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(application).Error; err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "创建核销申请失败")
|
||||
}
|
||||
return application, nil, nil
|
||||
}
|
||||
beforeData := applicationAuditSnapshot(existing)
|
||||
expectedStatus := existing.Status
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
|
||||
Where("id = ? AND status = ?", existing.ID, expectedStatus).
|
||||
Updates(map[string]any{
|
||||
"acting_operator_id": actingOperatorID,
|
||||
"acting_reason": normalized.ActingReason,
|
||||
"payment_method_id": paymentMethod.ID,
|
||||
"payment_method_code": paymentMethod.Code,
|
||||
"payment_method_name": paymentMethod.Name,
|
||||
"paid_amount": normalized.PaidAmount,
|
||||
"payer_name": normalized.PayerName,
|
||||
"paid_at": normalized.PaidAt,
|
||||
"external_transaction_no": normalized.ExternalTransactionNo,
|
||||
"payment_voucher_keys": model.StringJSONBArray(normalized.PaymentVoucherKeys),
|
||||
"remark": normalized.Remark,
|
||||
"status": constants.EmployeeCollectionApplicationStatusPending,
|
||||
"decided_at": nil,
|
||||
"terminal_reason": "",
|
||||
"updater": caller.AccountID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, nil, errors.New(errors.CodeConflict, "核销申请状态已变化,请刷新后重试")
|
||||
}
|
||||
existing.ActingOperatorID = actingOperatorID
|
||||
existing.ActingReason = normalized.ActingReason
|
||||
existing.PaymentMethodID = paymentMethod.ID
|
||||
existing.PaymentMethodCode = paymentMethod.Code
|
||||
existing.PaymentMethodName = paymentMethod.Name
|
||||
existing.PaidAmount = normalized.PaidAmount
|
||||
existing.PayerName = normalized.PayerName
|
||||
existing.PaidAt = normalized.PaidAt
|
||||
existing.ExternalTransactionNo = normalized.ExternalTransactionNo
|
||||
existing.PaymentVoucherKeys = model.StringJSONBArray(normalized.PaymentVoucherKeys)
|
||||
existing.Remark = normalized.Remark
|
||||
existing.Status = constants.EmployeeCollectionApplicationStatusPending
|
||||
existing.DecidedAt = nil
|
||||
existing.TerminalReason = ""
|
||||
existing.Updater = caller.AccountID
|
||||
return existing, beforeData, nil
|
||||
}
|
||||
|
||||
// buildAttempt 新增一条不可变审批尝试记录,冻结当次收款方式、外部付款、附件与账单分摊快照。
|
||||
func buildAttempt(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
applicationID uint,
|
||||
attemptNo int,
|
||||
caller applicationCaller,
|
||||
paymentMethod *model.EmployeeCollectionPaymentMethod,
|
||||
normalized employeecollectiondomain.NormalizedApplicationInput,
|
||||
bills map[uint]*model.EmployeeCollectionBill,
|
||||
items []ApplicationAllocationCommand,
|
||||
) (*model.EmployeeCollectionApplicationAttempt, error) {
|
||||
snapshot, err := allocationSnapshot(bills, items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attempt := &model.EmployeeCollectionApplicationAttempt{
|
||||
ApplicationID: applicationID, AttemptNo: attemptNo,
|
||||
PaymentMethodID: paymentMethod.ID, PaymentMethodCode: paymentMethod.Code, PaymentMethodName: paymentMethod.Name,
|
||||
PaidAmount: normalized.PaidAmount, PayerName: normalized.PayerName, PaidAt: normalized.PaidAt,
|
||||
ExternalTransactionNo: normalized.ExternalTransactionNo,
|
||||
PaymentVoucherKeys: model.StringJSONBArray(normalized.PaymentVoucherKeys),
|
||||
Remark: normalized.Remark, SubmittedByAccountID: caller.AccountID,
|
||||
ActingReason: normalized.ActingReason, AllocationSnapshot: snapshot,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建核销审批尝试记录失败")
|
||||
}
|
||||
return attempt, nil
|
||||
}
|
||||
|
||||
// allocationSnapshot 生成账单分摊快照,只保存账单摘要与金额,不含付款凭证内容。
|
||||
func allocationSnapshot(
|
||||
bills map[uint]*model.EmployeeCollectionBill,
|
||||
items []ApplicationAllocationCommand,
|
||||
) ([]byte, error) {
|
||||
entries := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
bill := bills[item.BillID]
|
||||
entries = append(entries, map[string]any{
|
||||
"bill_id": bill.ID, "source_type": bill.SourceType, "source_no": bill.SourceNo,
|
||||
"bill_status": bill.Status, "receivable_amount": bill.ReceivableAmount,
|
||||
"received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount,
|
||||
"amount": item.Amount,
|
||||
})
|
||||
}
|
||||
payload, err := sonic.Marshal(entries)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化账单分摊快照失败")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// approvalSnapshots 生成通用审批的提交人快照与企业微信表单业务快照。
|
||||
// 表单快照必须包含审批人核验所需的付款信息,因此保留经人工确认的完整外部流水号。
|
||||
func approvalSnapshots(
|
||||
applicationID uint,
|
||||
caller applicationCaller,
|
||||
paymentMethod *model.EmployeeCollectionPaymentMethod,
|
||||
normalized employeecollectiondomain.NormalizedApplicationInput,
|
||||
bills map[uint]*model.EmployeeCollectionBill,
|
||||
items []ApplicationAllocationCommand,
|
||||
) ([]byte, []byte, error) {
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||||
"account_id": caller.AccountID, "account_name": caller.AccountName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码核销申请提交人快照失败")
|
||||
}
|
||||
requestSnapshot, err := sonic.Marshal(map[string]any{
|
||||
constants.ApprovalFieldCollectionApplicationID: applicationID,
|
||||
constants.ApprovalFieldCollectionPaymentMethod: paymentMethod.Name,
|
||||
constants.ApprovalFieldCollectionPaidAmount: formatAmountYuan(normalized.PaidAmount),
|
||||
constants.ApprovalFieldCollectionPaidAmountCent: normalized.PaidAmount,
|
||||
constants.ApprovalFieldCollectionPayerName: normalized.PayerName,
|
||||
constants.ApprovalFieldCollectionPaidAt: normalized.PaidAt.Format(time.RFC3339),
|
||||
constants.ApprovalFieldCollectionExternalTransactionNo: normalized.ExternalTransactionNo,
|
||||
constants.ApprovalFieldPaymentVoucherKey: normalized.PaymentVoucherKeys,
|
||||
constants.ApprovalFieldRemark: normalized.Remark,
|
||||
constants.ApprovalFieldSubmitterID: caller.AccountID,
|
||||
constants.ApprovalFieldSubmitterName: caller.AccountName,
|
||||
constants.ApprovalFieldCollectionBillCount: len(items),
|
||||
constants.ApprovalFieldCollectionBillSummary: allocationSummary(bills, items),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码核销审批业务快照失败")
|
||||
}
|
||||
return submitterSnapshot, requestSnapshot, nil
|
||||
}
|
||||
|
||||
// allocationSummary 生成给审批人阅读的账单分摊摘要。
|
||||
func allocationSummary(bills map[uint]*model.EmployeeCollectionBill, items []ApplicationAllocationCommand) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
bill := bills[item.BillID]
|
||||
parts = append(parts, fmt.Sprintf("账单%d(%s)应收%s 本次分摊%s",
|
||||
bill.ID, bill.SourceNo, formatAmountYuan(bill.ReceivableAmount), formatAmountYuan(item.Amount)))
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// formatAmountYuan 将分金额格式化为元字符串,仅用于展示与审批表单。
|
||||
func formatAmountYuan(amount int64) string {
|
||||
return fmt.Sprintf("%d.%02d", amount/100, amount%100)
|
||||
}
|
||||
|
||||
// attachAttemptInstance 把审批实例 ID 回写到本次审批尝试记录,写入一次后不可修改。
|
||||
func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.EmployeeCollectionApplicationAttempt, instanceID uint) error {
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAttempt{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", attempt.ID).
|
||||
Update("approval_instance_id", instanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联核销审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "核销审批实例关联已变化")
|
||||
}
|
||||
attempt.ApprovalInstanceID = &instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateApplicationLatest 更新申请的最新审批尝试与审批实例引用,仅用于展示。
|
||||
func updateApplicationLatest(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
application *model.EmployeeCollectionApplication,
|
||||
attempt *model.EmployeeCollectionApplicationAttempt,
|
||||
instanceID uint,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
|
||||
Where("id = ?", application.ID).
|
||||
Updates(map[string]any{
|
||||
"latest_attempt_id": attempt.ID, "latest_approval_instance_id": instanceID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请最新审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "核销申请最新审批实例更新已变化")
|
||||
}
|
||||
application.LatestAttemptID = attempt.ID
|
||||
application.LatestApprovalInstanceID = instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// createAllocations 写入分摊行并按账单 ID 升序预占余额。
|
||||
// 预占使用条件更新并要求 RowsAffected 为 1,避免并发申请超额占用同一账单。
|
||||
func createAllocations(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
applicationID uint,
|
||||
attemptID uint,
|
||||
caller applicationCaller,
|
||||
bills map[uint]*model.EmployeeCollectionBill,
|
||||
items []ApplicationAllocationCommand,
|
||||
) ([]*model.EmployeeCollectionApplicationAllocation, []*model.EmployeeCollectionBill, error) {
|
||||
allocations := make([]*model.EmployeeCollectionApplicationAllocation, 0, len(items))
|
||||
reservedBills := make([]*model.EmployeeCollectionBill, 0, len(items))
|
||||
for _, item := range items {
|
||||
bill := bills[item.BillID]
|
||||
allocation := &model.EmployeeCollectionApplicationAllocation{
|
||||
ApplicationID: applicationID, AttemptID: attemptID, BillID: bill.ID,
|
||||
Amount: item.Amount, Status: constants.EmployeeCollectionAllocationStatusPending,
|
||||
Creator: caller.AccountID, Updater: caller.AccountID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(allocation).Error; err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "写入核销分摊失败")
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
|
||||
Where("id = ? AND reserved_amount + ? <= receivable_amount - received_amount", bill.ID, item.Amount).
|
||||
Updates(map[string]any{
|
||||
"reserved_amount": gorm.Expr("reserved_amount + ?", item.Amount),
|
||||
"updater": caller.AccountID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "预占账单可核销余额失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, nil, errors.New(errors.CodeEmployeeCollectionAllocationExceeded)
|
||||
}
|
||||
bill.ReservedAmount += item.Amount
|
||||
allocations = append(allocations, allocation)
|
||||
reservedBills = append(reservedBills, bill)
|
||||
}
|
||||
return allocations, reservedBills, nil
|
||||
}
|
||||
|
||||
// applicationAuditSnapshot 生成申请审计快照,外部交易流水号按脱敏值记录。
|
||||
func applicationAuditSnapshot(application *model.EmployeeCollectionApplication) map[string]any {
|
||||
return map[string]any{
|
||||
"id": application.ID, "applicant_account_id": application.ApplicantAccountID,
|
||||
"acting_operator_id": application.ActingOperatorID,
|
||||
"payment_method_id": application.PaymentMethodID, "payment_method_code": application.PaymentMethodCode,
|
||||
"paid_amount": application.PaidAmount, "payer_name": application.PayerName,
|
||||
"external_transaction_no_masked": employeecollectiondomain.MaskExternalTransactionNo(application.ExternalTransactionNo),
|
||||
"voucher_count": len(application.PaymentVoucherKeys),
|
||||
"status": application.Status,
|
||||
"latest_attempt_id": application.LatestAttemptID,
|
||||
"latest_approval_instance_id": application.LatestApprovalInstanceID,
|
||||
}
|
||||
}
|
||||
38
internal/application/employeecollection/application_audit.go
Normal file
38
internal/application/employeecollection/application_audit.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package employeecollection
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
// ApplicationAudit 描述核销申请、审批尝试记录与受影响账单的事实变化。
|
||||
type ApplicationAudit struct {
|
||||
// EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。
|
||||
EventID string
|
||||
// ActionCode 是已注册的核销申请审计动作码。
|
||||
ActionCode string
|
||||
// Summary 是给人工阅读的中文摘要。
|
||||
Summary string
|
||||
// Application 是本次动作后的核销申请事实。
|
||||
Application *model.EmployeeCollectionApplication
|
||||
// Attempt 是本次动作对应的审批尝试记录。
|
||||
Attempt *model.EmployeeCollectionApplicationAttempt
|
||||
// Allocations 是本次动作涉及的分摊事实。
|
||||
Allocations []*model.EmployeeCollectionApplicationAllocation
|
||||
// Bills 是本次动作影响的员工代收款账单事实。
|
||||
Bills []*model.EmployeeCollectionBill
|
||||
// BeforeData 与 AfterData 是脱敏前后的字段快照,不得包含付款凭证内容。
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
// CorrelationID 是申请链路标识。
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// ApplicationAuditWriter 在员工代收款核销事务内追加统一 Audit Event。
|
||||
type ApplicationAuditWriter interface {
|
||||
WriteEmployeeCollectionApplication(ctx context.Context, tx *gorm.DB, change ApplicationAudit) error
|
||||
WriteEmployeeCollectionRecovery(ctx context.Context, tx *gorm.DB, application *model.EmployeeCollectionApplication, attempt *model.EmployeeCollectionApplicationAttempt, instance *model.ApprovalInstance, actorID uint, beforeStatus, action, result string) error
|
||||
}
|
||||
515
internal/application/employeecollection/approval_decision.go
Normal file
515
internal/application/employeecollection/approval_decision.go
Normal file
@@ -0,0 +1,515 @@
|
||||
package employeecollection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ApprovalDecisionHandler 将渠道无关企业微信审批终态应用到员工代收款核销申请。
|
||||
// 通过才增加账单已核销金额,驳回才释放预占;重复、乱序或延迟回调都不重复入账。
|
||||
type ApprovalDecisionHandler struct {
|
||||
db *gorm.DB
|
||||
audit ApplicationAuditWriter
|
||||
}
|
||||
|
||||
// NewApprovalDecisionHandler 创建员工代收款核销审批终态消费者。
|
||||
func NewApprovalDecisionHandler(db *gorm.DB, audit ApplicationAuditWriter) *ApprovalDecisionHandler {
|
||||
return &ApprovalDecisionHandler{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// Handle 幂等消费标准审批终态。
|
||||
// 业务标识为审批尝试记录主键:先锁定尝试记录并校验审批实例一致,再按申请与账单 ID 升序加锁。
|
||||
func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
|
||||
if h == nil || h.db == nil || h.audit == nil {
|
||||
return errors.New(errors.CodeInternalError, "员工代收款核销审批终态能力未配置")
|
||||
}
|
||||
if event.BusinessType != constants.ApprovalBusinessTypeEmployeeCollection || event.BusinessID == 0 || event.InstanceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "员工代收款核销审批终态参数无效")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
|
||||
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var attempt model.EmployeeCollectionApplicationAttempt
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&attempt, event.BusinessID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "核销审批尝试记录不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定核销审批尝试记录失败")
|
||||
}
|
||||
if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != event.InstanceID {
|
||||
return errors.New(errors.CodeConflict, "核销审批尝试记录关联的审批实例不一致")
|
||||
}
|
||||
application, err := lockApplication(ctx, tx, attempt.ApplicationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if application.LatestAttemptID != attempt.ID {
|
||||
// 已被更新尝试取代的历史尝试终态不再改变申请事实。
|
||||
return nil
|
||||
}
|
||||
switch event.Decision {
|
||||
case constants.ApprovalDecisionApproved:
|
||||
return h.applyApproved(ctx, tx, application, &attempt, event)
|
||||
case constants.ApprovalDecisionRejected:
|
||||
return h.applyClosed(ctx, tx, application, &attempt, event,
|
||||
constants.EmployeeCollectionApplicationStatusRejected, "企业微信审批已驳回")
|
||||
case constants.ApprovalDecisionCancelled:
|
||||
return h.applyClosed(ctx, tx, application, &attempt, event,
|
||||
constants.EmployeeCollectionApplicationStatusRevoked, "企业微信审批已撤销")
|
||||
case constants.ApprovalDecisionDeleted:
|
||||
return h.applyClosed(ctx, tx, application, &attempt, event,
|
||||
constants.EmployeeCollectionApplicationStatusRevoked, "企业微信审批已删除")
|
||||
case constants.ApprovalDecisionRevokedAfterApproved:
|
||||
return h.applyRevoked(ctx, tx, application, &attempt, event)
|
||||
case constants.ApprovalDecisionSubmissionFailed:
|
||||
reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID)
|
||||
if err != nil || !failed {
|
||||
return err
|
||||
}
|
||||
return h.applyClosed(ctx, tx, application, &attempt, event,
|
||||
constants.EmployeeCollectionApplicationStatusSubmissionFailed, reason)
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的核销申请审批终态")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// applyApproved 将本次尝试的全部预占分摊转入已核销并重算账单状态。
|
||||
// 仅当申请仍处于审批中时推进,重复或乱序回调不重复增加已核销金额。
|
||||
func (h *ApprovalDecisionHandler) applyApproved(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
application *model.EmployeeCollectionApplication,
|
||||
attempt *model.EmployeeCollectionApplicationAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
if application.Status != constants.EmployeeCollectionApplicationStatusPending {
|
||||
return nil
|
||||
}
|
||||
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, allocation := range allocations {
|
||||
if err := approveAllocation(ctx, tx, allocation, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := settleBillReservation(ctx, tx, allocation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
|
||||
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.EmployeeCollectionApplicationStatusApproved,
|
||||
"decided_at": now, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请已通过失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "核销申请状态已变化")
|
||||
}
|
||||
before := application.Status
|
||||
application.Status = constants.EmployeeCollectionApplicationStatusApproved
|
||||
application.DecidedAt = &now
|
||||
application.Updater = 0
|
||||
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
approvedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "approved")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
|
||||
EventID: approvedEventID,
|
||||
ActionCode: constants.AuditActionEmployeeCollectionApplicationApproved,
|
||||
Summary: "企业微信审批通过,核销分摊转入已核销",
|
||||
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
|
||||
BeforeData: map[string]any{"status": before},
|
||||
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
|
||||
})
|
||||
}
|
||||
|
||||
// applyClosed 处理最终驳回、渠道撤销、删除与审批提交失败:释放全部预占并把申请置为对应终态。
|
||||
func (h *ApprovalDecisionHandler) applyClosed(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
application *model.EmployeeCollectionApplication,
|
||||
attempt *model.EmployeeCollectionApplicationAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
targetStatus int,
|
||||
reason string,
|
||||
) error {
|
||||
if application.Status != constants.EmployeeCollectionApplicationStatusPending {
|
||||
return nil
|
||||
}
|
||||
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, allocation := range allocations {
|
||||
if err := releaseAllocation(ctx, tx, allocation, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := releaseBillReservation(ctx, tx, allocation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
terminalReason := ""
|
||||
if targetStatus == constants.EmployeeCollectionApplicationStatusRevoked ||
|
||||
targetStatus == constants.EmployeeCollectionApplicationStatusSubmissionFailed {
|
||||
terminalReason = reason
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
|
||||
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": targetStatus, "decided_at": now,
|
||||
"terminal_reason": terminalReason, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请终态失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "核销申请状态已变化")
|
||||
}
|
||||
before := application.Status
|
||||
application.Status = targetStatus
|
||||
application.DecidedAt = &now
|
||||
application.TerminalReason = terminalReason
|
||||
application.Updater = 0
|
||||
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
closedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, decisionEventSuffix(event.Decision))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
summary := "企业微信审批未通过,核销申请预占已释放"
|
||||
if targetStatus == constants.EmployeeCollectionApplicationStatusSubmissionFailed {
|
||||
summary = "审批提交失败,核销申请终止,预占已释放"
|
||||
}
|
||||
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
|
||||
EventID: closedEventID,
|
||||
ActionCode: constants.AuditActionEmployeeCollectionApplicationRejected,
|
||||
Summary: summary,
|
||||
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
|
||||
BeforeData: map[string]any{"status": before},
|
||||
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
|
||||
})
|
||||
}
|
||||
|
||||
// applyRevoked 处理通过后撤销。
|
||||
// 申请已通过:不回滚已核销金额,只转异常终态并禁止自动重提。
|
||||
// 申请仍在审批中(渠道乱序投递):释放全部审批中预占并转异常终态,避免预占永久占用账单。
|
||||
func (h *ApprovalDecisionHandler) applyRevoked(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
application *model.EmployeeCollectionApplication,
|
||||
attempt *model.EmployeeCollectionApplicationAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
switch application.Status {
|
||||
case constants.EmployeeCollectionApplicationStatusApproved:
|
||||
return h.revokeApproved(ctx, tx, application, attempt, event)
|
||||
case constants.EmployeeCollectionApplicationStatusPending:
|
||||
return h.revokePending(ctx, tx, application, attempt, event)
|
||||
default:
|
||||
// 已驳回、已撤销等终态不再改变事实。
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// revokeApproved 在已通过态撤销:保留已核销金额,仅转异常终态。
|
||||
func (h *ApprovalDecisionHandler) revokeApproved(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
application *model.EmployeeCollectionApplication,
|
||||
attempt *model.EmployeeCollectionApplicationAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
now := time.Now().UTC()
|
||||
terminalReason := "企业微信通过后撤销"
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
|
||||
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusApproved).
|
||||
Updates(map[string]any{
|
||||
"status": constants.EmployeeCollectionApplicationStatusRevoked,
|
||||
"decided_at": now, "terminal_reason": terminalReason, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请通过后撤销失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "核销申请状态已变化")
|
||||
}
|
||||
before := application.Status
|
||||
application.Status = constants.EmployeeCollectionApplicationStatusRevoked
|
||||
application.DecidedAt = &now
|
||||
application.TerminalReason = terminalReason
|
||||
application.Updater = 0
|
||||
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
revokedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "revoked")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
|
||||
EventID: revokedEventID,
|
||||
ActionCode: constants.AuditActionEmployeeCollectionApplicationRevoked,
|
||||
Summary: "企业微信通过后撤销,已核销金额不回滚",
|
||||
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
|
||||
BeforeData: map[string]any{"status": before},
|
||||
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
|
||||
})
|
||||
}
|
||||
|
||||
// revokePending 在审批中态撤销:释放全部审批中预占并转异常终态。
|
||||
func (h *ApprovalDecisionHandler) revokePending(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
application *model.EmployeeCollectionApplication,
|
||||
attempt *model.EmployeeCollectionApplicationAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, allocation := range allocations {
|
||||
if err := releaseAllocation(ctx, tx, allocation, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := releaseBillReservation(ctx, tx, allocation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
terminalReason := "企业微信通过后在本地审批中状态被撤销"
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
|
||||
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.EmployeeCollectionApplicationStatusRevoked,
|
||||
"decided_at": now, "terminal_reason": terminalReason, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请撤销失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "核销申请状态已变化")
|
||||
}
|
||||
before := application.Status
|
||||
application.Status = constants.EmployeeCollectionApplicationStatusRevoked
|
||||
application.DecidedAt = &now
|
||||
application.TerminalReason = terminalReason
|
||||
application.Updater = 0
|
||||
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
revokedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "revoked")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
|
||||
EventID: revokedEventID,
|
||||
ActionCode: constants.AuditActionEmployeeCollectionApplicationRevoked,
|
||||
Summary: "企业微信通过后撤销,申请仍在审批中,已释放预占",
|
||||
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
|
||||
BeforeData: map[string]any{"status": before},
|
||||
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
|
||||
})
|
||||
}
|
||||
|
||||
// loadAttemptAllocations 按账单 ID 升序读取本次尝试的分摊,保证后续加锁与写入次序唯一。
|
||||
func loadAttemptAllocations(ctx context.Context, tx *gorm.DB, attemptID uint) ([]*model.EmployeeCollectionApplicationAllocation, error) {
|
||||
var allocations []model.EmployeeCollectionApplicationAllocation
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("attempt_id = ?", attemptID).Order("bill_id ASC, id ASC").Find(&allocations).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试分摊失败")
|
||||
}
|
||||
if len(allocations) == 0 {
|
||||
return nil, errors.New(errors.CodeConflict, "核销审批尝试记录缺少分摊事实")
|
||||
}
|
||||
result := make([]*model.EmployeeCollectionApplicationAllocation, 0, len(allocations))
|
||||
for index := range allocations {
|
||||
result = append(result, &allocations[index])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// allocationCommandsOf 提取分摊涉及的账单与金额,用于复用升序加锁函数。
|
||||
func allocationCommandsOf(allocations []*model.EmployeeCollectionApplicationAllocation) []ApplicationAllocationCommand {
|
||||
items := make([]ApplicationAllocationCommand, 0, len(allocations))
|
||||
for _, allocation := range allocations {
|
||||
items = append(items, ApplicationAllocationCommand{BillID: allocation.BillID, Amount: allocation.Amount})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// approveAllocation 把分摊从审批中预占条件更新为已通过;重复处理时返回冲突。
|
||||
func approveAllocation(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
allocation *model.EmployeeCollectionApplicationAllocation,
|
||||
now time.Time,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}).
|
||||
Where("id = ? AND status = ?", allocation.ID, constants.EmployeeCollectionAllocationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.EmployeeCollectionAllocationStatusApproved, "released_at": now, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销分摊已通过失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "核销分摊状态已变化")
|
||||
}
|
||||
allocation.Status = constants.EmployeeCollectionAllocationStatusApproved
|
||||
allocation.ReleasedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseAllocation 把分摊从审批中预占条件更新为已释放;重复处理时返回冲突。
|
||||
func releaseAllocation(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
allocation *model.EmployeeCollectionApplicationAllocation,
|
||||
now time.Time,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}).
|
||||
Where("id = ? AND status = ?", allocation.ID, constants.EmployeeCollectionAllocationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.EmployeeCollectionAllocationStatusReleased, "released_at": now, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "释放核销分摊预占失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "核销分摊状态已变化")
|
||||
}
|
||||
allocation.Status = constants.EmployeeCollectionAllocationStatusReleased
|
||||
allocation.ReleasedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
// settleBillReservation 将账单预占转为已核销并重算账单状态。
|
||||
// 条件更新要求账单预占不小于分摊金额,并检查 RowsAffected,避免并发下重复入账。
|
||||
func settleBillReservation(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
allocation *model.EmployeeCollectionApplicationAllocation,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
|
||||
Where("id = ? AND reserved_amount >= ?", allocation.BillID, allocation.Amount).
|
||||
Updates(map[string]any{
|
||||
"received_amount": gorm.Expr("received_amount + ?", allocation.Amount),
|
||||
"reserved_amount": gorm.Expr("reserved_amount - ?", allocation.Amount),
|
||||
"status": gorm.Expr(
|
||||
"CASE WHEN received_amount + ? >= receivable_amount THEN ?::smallint WHEN received_amount + ? > 0 THEN ?::smallint ELSE ?::smallint END",
|
||||
allocation.Amount, constants.EmployeeCollectionBillStatusSettled,
|
||||
allocation.Amount, constants.EmployeeCollectionBillStatusPartial,
|
||||
constants.EmployeeCollectionBillStatusPending,
|
||||
),
|
||||
"updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "账单预占转入已核销失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "账单预占已变化,核销未入账")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseBillReservation 释放账单预占金额,条件更新并检查 RowsAffected。
|
||||
func releaseBillReservation(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
allocation *model.EmployeeCollectionApplicationAllocation,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
|
||||
Where("id = ? AND reserved_amount >= ?", allocation.BillID, allocation.Amount).
|
||||
Updates(map[string]any{
|
||||
"reserved_amount": gorm.Expr("reserved_amount - ?", allocation.Amount),
|
||||
"updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "释放账单预占失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "账单预占已变化")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadBills 重新读取受影响账单,保证审计快照反映终态金额。
|
||||
// 账单行已在同一事务内持有排他锁,这里只做一次按 ID 升序的普通读取。
|
||||
func reloadBills(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
items []ApplicationAllocationCommand,
|
||||
) ([]*model.EmployeeCollectionBill, error) {
|
||||
billIDs := make([]uint, 0, len(items))
|
||||
for _, item := range items {
|
||||
billIDs = append(billIDs, item.BillID)
|
||||
}
|
||||
var bills []model.EmployeeCollectionBill
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", billIDs).Order("id ASC").Find(&bills).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询受影响员工代收款账单失败")
|
||||
}
|
||||
result := make([]*model.EmployeeCollectionBill, 0, len(bills))
|
||||
for index := range bills {
|
||||
result = append(result, &bills[index])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decisionEventID 生成审批终态审计事件的稳定标识,并约束在审计列宽内。
|
||||
func decisionEventID(applicationID uint, attemptNo int, suffix string) (string, error) {
|
||||
return composeAuditEventID(
|
||||
"employee_collection", "application", uintText(applicationID), "attempt", intText(attemptNo), suffix)
|
||||
}
|
||||
|
||||
// decisionEventSuffix 把渠道决策映射为审计事件后缀。
|
||||
func decisionEventSuffix(decision string) string {
|
||||
switch decision {
|
||||
case constants.ApprovalDecisionRejected:
|
||||
return "rejected"
|
||||
case constants.ApprovalDecisionCancelled:
|
||||
return "cancelled"
|
||||
case constants.ApprovalDecisionDeleted:
|
||||
return "deleted"
|
||||
case constants.ApprovalDecisionSubmissionFailed:
|
||||
return "submission_failed"
|
||||
default:
|
||||
return "closed"
|
||||
}
|
||||
}
|
||||
31
internal/application/employeecollection/audit_event.go
Normal file
31
internal/application/employeecollection/audit_event.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package employeecollection
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// auditEventMaxLength 是统一审计事件标识的列宽上限,与 tb_audit_event.event_id 保持一致。
|
||||
const auditEventMaxLength = 64
|
||||
|
||||
// composeAuditEventID 以冒号连接业务标识片段,生成确定性的审计事件标识。
|
||||
// 超出审计列宽时返回稳定错误,避免写入时分段截断或事务被数据库拒绝。
|
||||
func composeAuditEventID(parts ...string) (string, error) {
|
||||
eventID := strings.Join(parts, ":")
|
||||
if len(eventID) > auditEventMaxLength {
|
||||
return "", errors.New(errors.CodeInternalError, "审计事件标识超出长度限制")
|
||||
}
|
||||
return eventID, nil
|
||||
}
|
||||
|
||||
// uintText 将主键转为审计标识片段。
|
||||
func uintText(value uint) string {
|
||||
return strconv.FormatUint(uint64(value), 10)
|
||||
}
|
||||
|
||||
// intText 将序号转为审计标识片段。
|
||||
func intText(value int) string {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
118
internal/application/employeecollection/bill_close.go
Normal file
118
internal/application/employeecollection/bill_close.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package employeecollection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// BillCloseService 关闭员工代收款账单。
|
||||
// 仅超级管理员可关闭,且只允许关闭仍待核销或部分核销、且不存在审批中分摊的账单。
|
||||
type BillCloseService struct {
|
||||
db *gorm.DB
|
||||
audit BillAuditWriter
|
||||
}
|
||||
|
||||
// NewBillCloseService 创建账单关闭事务脚本。
|
||||
func NewBillCloseService(db *gorm.DB, audit BillAuditWriter) *BillCloseService {
|
||||
return &BillCloseService{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// Close 关闭账单:作废未核销余额、保留已核销金额,并在同一事务内写关闭审计。
|
||||
// 并发关闭通过行锁加 expected-status 条件更新兜底,状态已变化时返回冲突。
|
||||
func (s *BillCloseService) Close(ctx context.Context, id uint, reason string) (*model.EmployeeCollectionBill, error) {
|
||||
operatorID, err := requireSuperAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil || s.db == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "账单关闭能力尚未配置")
|
||||
}
|
||||
if id == 0 {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
|
||||
}
|
||||
closeReason := strings.TrimSpace(reason)
|
||||
var closed *model.EmployeeCollectionBill
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
bill, err := lockBill(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pendingAllocations int64
|
||||
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}).
|
||||
Where("bill_id = ? AND status = ?", id, constants.EmployeeCollectionAllocationStatusPending).
|
||||
Count(&pendingAllocations).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "统计账单审批中分摊失败")
|
||||
}
|
||||
if err := employeecollectiondomain.ValidateBillClose(employeecollectiondomain.BillCloseInput{
|
||||
Status: bill.Status, PendingAllocations: pendingAllocations, Reason: closeReason,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
before := *bill
|
||||
closedAt := time.Now().UTC()
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
|
||||
Where("id = ? AND status = ?", bill.ID, bill.Status).
|
||||
Updates(map[string]any{
|
||||
"status": constants.EmployeeCollectionBillStatusClosed,
|
||||
"closed_reason": closeReason,
|
||||
"closed_at": closedAt,
|
||||
"updater": operatorID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭员工代收款账单失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "账单状态已变化,请刷新后重试")
|
||||
}
|
||||
bill.Status = constants.EmployeeCollectionBillStatusClosed
|
||||
bill.ClosedReason = closeReason
|
||||
bill.ClosedAt = &closedAt
|
||||
bill.Updater = operatorID
|
||||
closeEventID, err := composeAuditEventID("employee_collection", "bill", uintText(bill.ID), "close")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{
|
||||
EventID: closeEventID,
|
||||
ActionCode: constants.AuditActionEmployeeCollectionBillClosed, Summary: "关闭员工代收款账单",
|
||||
Bill: bill,
|
||||
BeforeData: map[string]any{
|
||||
"status": before.Status, "closed_reason": before.ClosedReason,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"status": bill.Status, "closed_reason": bill.ClosedReason,
|
||||
},
|
||||
CorrelationID: bill.SourceNo,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
closed = bill
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return closed, nil
|
||||
}
|
||||
|
||||
// lockBill 以行锁读取账单,未找到返回稳定不存在错误。
|
||||
func lockBill(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionBill, error) {
|
||||
var bill model.EmployeeCollectionBill
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&bill, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定员工代收款账单失败")
|
||||
}
|
||||
return &bill, nil
|
||||
}
|
||||
233
internal/application/employeecollection/bill_creation.go
Normal file
233
internal/application/employeecollection/bill_creation.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package employeecollection
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// BillAudit 描述员工代收款账单事实的实际变化。
|
||||
type BillAudit struct {
|
||||
// EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。
|
||||
EventID string
|
||||
// ActionCode 是已注册的账单审计动作码。
|
||||
ActionCode string
|
||||
// Summary 是给人工阅读的中文摘要。
|
||||
Summary string
|
||||
// Bill 是本次动作后的账单事实。
|
||||
Bill *model.EmployeeCollectionBill
|
||||
// BeforeData 与 AfterData 是脱敏前后的字段快照。
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
// CorrelationID 是来源业务链路标识。
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// BillAuditWriter 在员工代收款业务事务内追加统一 Audit Event。
|
||||
type BillAuditWriter interface {
|
||||
WriteEmployeeCollectionBill(ctx context.Context, tx *gorm.DB, change BillAudit) error
|
||||
}
|
||||
|
||||
// BillCreationService 在来源成功事务内按来源唯一键幂等创建员工代收款账单。
|
||||
// 建账只由来源成功事务携带的来源主键触发,不存在扫描历史订单或充值补建的路径。
|
||||
type BillCreationService struct {
|
||||
audit BillAuditWriter
|
||||
}
|
||||
|
||||
// NewBillCreationService 创建员工代收款建账用例。
|
||||
func NewBillCreationService(audit BillAuditWriter) *BillCreationService {
|
||||
return &BillCreationService{audit: audit}
|
||||
}
|
||||
|
||||
// CreateFromOrderInTx 在后台线下套餐订单激活事务内建账。
|
||||
// 判据见 employeecollectiondomain.ShouldCreateBillForOrder;不满足判据时返回 (nil, nil)。
|
||||
// 重复订单事务、重放或重试都命中 source_key 唯一约束并返回既有账单,不使订单事务失败。
|
||||
func (s *BillCreationService) CreateFromOrderInTx(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
order *model.Order,
|
||||
hasGiftPackage bool,
|
||||
) (*model.EmployeeCollectionBill, error) {
|
||||
if s == nil || tx == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "员工代收款建账用例未完整配置")
|
||||
}
|
||||
if order == nil || order.ID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "员工代收款建账缺少来源订单")
|
||||
}
|
||||
if !employeecollectiondomain.ShouldCreateBillForOrder(
|
||||
employeecollectiondomain.OrderBillSubjectFromOrder(order, hasGiftPackage)) {
|
||||
return nil, nil
|
||||
}
|
||||
if order.OperatorAccountID == nil || *order.OperatorAccountID == 0 {
|
||||
return nil, errors.New(errors.CodeInternalError, "线下套餐订单缺少欠款人账号")
|
||||
}
|
||||
debtorAccountID := *order.OperatorAccountID
|
||||
debtorSnapshot, err := marshalSnapshot(map[string]any{
|
||||
"account_id": debtorAccountID, "account_name": order.OperatorAccountName,
|
||||
"account_type": order.OperatorAccountType,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// shop_id 与 seller_shop_id 同时写入:店铺筛选统一读 shop_id,seller_shop_id 保留兼容口径。
|
||||
customerSnapshot, err := marshalSnapshot(map[string]any{
|
||||
"buyer_type": order.BuyerType, "buyer_id": order.BuyerID,
|
||||
"buyer_nickname": order.BuyerNickname,
|
||||
"shop_id": order.SellerShopID, "seller_shop_id": order.SellerShopID,
|
||||
"asset_identifier": order.AssetIdentifier,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceKey := employeecollectiondomain.OrderSourceKey(order.ID)
|
||||
bill := &model.EmployeeCollectionBill{
|
||||
SourceType: constants.EmployeeCollectionSourceTypeOrder,
|
||||
SourceID: order.ID,
|
||||
SourceKey: sourceKey,
|
||||
SourceNo: order.OrderNo,
|
||||
DebtorAccountID: debtorAccountID,
|
||||
DebtorSnapshot: debtorSnapshot,
|
||||
CustomerSnapshot: customerSnapshot,
|
||||
ReceivableAmount: *order.ActualPaidAmount,
|
||||
Status: constants.EmployeeCollectionBillStatusPending,
|
||||
Creator: debtorAccountID,
|
||||
Updater: debtorAccountID,
|
||||
}
|
||||
return s.persistInTx(ctx, tx, bill,
|
||||
[]string{"employee_collection", "bill", "order", uintText(order.ID), "create"},
|
||||
"后台线下套餐订单创建员工代收款账单", order.OrderNo)
|
||||
}
|
||||
|
||||
// CreateFromRechargeInTx 在代理线下充值入账事务内建账。
|
||||
// 判据见 employeecollectiondomain.ShouldCreateBillForRecharge;欠款人为发起充值的后台账号。
|
||||
// 覆盖企业微信终审通过入账与后台人工确认入账两条入口,重复入账不重复建账。
|
||||
func (s *BillCreationService) CreateFromRechargeInTx(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
record *model.AgentRechargeRecord,
|
||||
) (*model.EmployeeCollectionBill, error) {
|
||||
if s == nil || tx == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "员工代收款建账用例未完整配置")
|
||||
}
|
||||
if record == nil || record.ID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "员工代收款建账缺少来源充值记录")
|
||||
}
|
||||
if !employeecollectiondomain.ShouldCreateBillForRecharge(employeecollectiondomain.RechargeBillSubject{
|
||||
PaymentMethod: record.PaymentMethod, Amount: record.Amount,
|
||||
}) {
|
||||
return nil, nil
|
||||
}
|
||||
if record.UserID == 0 {
|
||||
return nil, errors.New(errors.CodeInternalError, "线下充值记录缺少发起账号")
|
||||
}
|
||||
debtorName, err := rechargeAccountName(ctx, tx, record.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
debtorSnapshot, err := marshalSnapshot(map[string]any{
|
||||
"account_id": record.UserID, "account_name": debtorName,
|
||||
"account_type": model.OperatorAccountTypePlatform,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customerSnapshot, err := marshalSnapshot(map[string]any{
|
||||
"shop_id": record.ShopID, "agent_wallet_id": record.AgentWalletID,
|
||||
"payment_method": record.PaymentMethod, "recharge_no": record.RechargeNo,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceKey := employeecollectiondomain.RechargeSourceKey(record.ID)
|
||||
bill := &model.EmployeeCollectionBill{
|
||||
SourceType: constants.EmployeeCollectionSourceTypeRecharge,
|
||||
SourceID: record.ID,
|
||||
SourceKey: sourceKey,
|
||||
SourceNo: record.RechargeNo,
|
||||
DebtorAccountID: record.UserID,
|
||||
DebtorSnapshot: debtorSnapshot,
|
||||
CustomerSnapshot: customerSnapshot,
|
||||
ReceivableAmount: record.Amount,
|
||||
Status: constants.EmployeeCollectionBillStatusPending,
|
||||
Creator: record.UserID,
|
||||
Updater: record.UserID,
|
||||
}
|
||||
return s.persistInTx(ctx, tx, bill,
|
||||
[]string{"employee_collection", "bill", "recharge", uintText(record.ID), "create"},
|
||||
"代理线下充值入账创建员工代收款账单", record.RechargeNo)
|
||||
}
|
||||
|
||||
// persistInTx 以来源唯一键幂等写入账单:已存在同一来源账单时返回既有事实且不重复审计。
|
||||
func (s *BillCreationService) persistInTx(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
bill *model.EmployeeCollectionBill,
|
||||
eventIDParts []string,
|
||||
summary string,
|
||||
correlationID string,
|
||||
) (*model.EmployeeCollectionBill, error) {
|
||||
eventID, err := composeAuditEventID(eventIDParts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := tx.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "source_key"}},
|
||||
DoNothing: true,
|
||||
}).Create(bill)
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "创建员工代收款账单失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
var existing model.EmployeeCollectionBill
|
||||
if err := tx.WithContext(ctx).Where("source_key = ?", bill.SourceKey).First(&existing).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取既有员工代收款账单失败")
|
||||
}
|
||||
return &existing, nil
|
||||
}
|
||||
if err := s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{
|
||||
EventID: eventID, ActionCode: constants.AuditActionEmployeeCollectionBillCreated, Summary: summary,
|
||||
Bill: bill, AfterData: billAuditSnapshot(bill), CorrelationID: correlationID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bill, nil
|
||||
}
|
||||
|
||||
// marshalSnapshot 将只读业务快照序列化为 jsonb,快照不得包含付款凭证或外部交易敏感内容。
|
||||
func marshalSnapshot(snapshot map[string]any) ([]byte, error) {
|
||||
payload, err := sonic.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化员工代收款账单快照失败")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// rechargeAccountName 读取充值发起账号名称用于欠款人快照;账号已被删除时留空名称。
|
||||
func rechargeAccountName(ctx context.Context, tx *gorm.DB, accountID uint) (string, error) {
|
||||
var account model.Account
|
||||
if err := tx.WithContext(ctx).Unscoped().First(&account, accountID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return "", nil
|
||||
}
|
||||
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询线下充值发起账号失败")
|
||||
}
|
||||
return account.Username, nil
|
||||
}
|
||||
|
||||
// billAuditSnapshot 生成账单审计快照,只包含 ID、来源、金额与状态,不含凭证内容。
|
||||
func billAuditSnapshot(bill *model.EmployeeCollectionBill) map[string]any {
|
||||
return map[string]any{
|
||||
"id": bill.ID, "source_type": bill.SourceType, "source_id": bill.SourceID,
|
||||
"source_key": bill.SourceKey, "source_no": bill.SourceNo,
|
||||
"debtor_account_id": bill.DebtorAccountID, "receivable_amount": bill.ReceivableAmount,
|
||||
"received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount,
|
||||
"status": bill.Status,
|
||||
}
|
||||
}
|
||||
346
internal/application/employeecollection/payment_method.go
Normal file
346
internal/application/employeecollection/payment_method.go
Normal file
@@ -0,0 +1,346 @@
|
||||
// Package employeecollection 收口员工代收款账单、核销申请与线下收款方式字典的写用例。
|
||||
// 写用例在事务内保存业务事实与审计事实,读取由 internal/query 提供。
|
||||
package employeecollection
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdErrors "errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
|
||||
"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"
|
||||
)
|
||||
|
||||
// AuditWriter 接收员工代收款用例在业务事务内产生的配置审计事实。
|
||||
type AuditWriter interface {
|
||||
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit systemconfigapp.ChangeAudit) error
|
||||
}
|
||||
|
||||
// PaymentMethodService 维护线下收款方式字典。
|
||||
// 已启用的字典项由其稳定编码对外,被核销申请引用后只可停用,不允许物理删除或改编码。
|
||||
type PaymentMethodService struct {
|
||||
db *gorm.DB
|
||||
audit AuditWriter
|
||||
}
|
||||
|
||||
// NewPaymentMethodService 创建线下收款方式字典事务脚本。
|
||||
func NewPaymentMethodService(db *gorm.DB, audit AuditWriter) *PaymentMethodService {
|
||||
return &PaymentMethodService{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// Create 创建线下收款方式,并在同一事务内写入配置审计。
|
||||
func (s *PaymentMethodService) Create(
|
||||
ctx context.Context,
|
||||
request dto.CreateEmployeeCollectionPaymentMethodRequest,
|
||||
) (*dto.EmployeeCollectionPaymentMethodResponse, error) {
|
||||
operatorID, err := requireSuperAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil || s.db == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
|
||||
}
|
||||
status := constants.EmployeeCollectionPaymentMethodStatusDisabled
|
||||
if request.Enabled != nil && *request.Enabled {
|
||||
status = constants.EmployeeCollectionPaymentMethodStatusEnabled
|
||||
}
|
||||
var sortOrder int64
|
||||
if request.Sort != nil {
|
||||
sortOrder = *request.Sort
|
||||
}
|
||||
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
|
||||
Code: request.Code, Name: request.Name, SortOrder: sortOrder, Status: status, Remark: request.Remark,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paymentMethod := &model.EmployeeCollectionPaymentMethod{
|
||||
Code: normalized.Code, Name: normalized.Name, SortOrder: normalized.SortOrder,
|
||||
Status: normalized.Status, Remark: normalized.Remark,
|
||||
BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
|
||||
}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(paymentMethod).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建线下收款方式失败")
|
||||
}
|
||||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodCreate,
|
||||
Description: "创建线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
|
||||
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
|
||||
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
|
||||
AfterData: paymentMethodAuditSnapshot(paymentMethod), Result: constants.AuditResultSuccess,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, mapPaymentMethodCodeConflict(err)
|
||||
}
|
||||
return toPaymentMethodResponse(paymentMethod), nil
|
||||
}
|
||||
|
||||
// Update 修改线下收款方式的名称、排序、启停与备注,并在未被引用时允许修改稳定编码。
|
||||
func (s *PaymentMethodService) Update(
|
||||
ctx context.Context,
|
||||
id uint,
|
||||
request dto.UpdateEmployeeCollectionPaymentMethodRequest,
|
||||
) (*dto.EmployeeCollectionPaymentMethodResponse, error) {
|
||||
operatorID, err := requireSuperAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil || s.db == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
|
||||
}
|
||||
if id == 0 {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
|
||||
}
|
||||
var updated *model.EmployeeCollectionPaymentMethod
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
paymentMethod, err := lockPaymentMethod(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := *paymentMethod
|
||||
beforeData := paymentMethodAuditSnapshot(&before)
|
||||
|
||||
if request.Code != nil {
|
||||
code := *request.Code
|
||||
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
|
||||
Code: code, Name: paymentMethod.Name, SortOrder: paymentMethod.SortOrder,
|
||||
Status: paymentMethod.Status, Remark: paymentMethod.Remark,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalized.Code != paymentMethod.Code {
|
||||
referenced, err := countPaymentMethodReferences(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if referenced > 0 {
|
||||
return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced,
|
||||
"线下收款方式已被核销申请或代理充值申请引用,不能修改稳定编码")
|
||||
}
|
||||
if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
paymentMethod.Code = normalized.Code
|
||||
}
|
||||
if request.Name != nil {
|
||||
paymentMethod.Name = *request.Name
|
||||
}
|
||||
if request.Sort != nil {
|
||||
paymentMethod.SortOrder = *request.Sort
|
||||
}
|
||||
if request.Enabled != nil {
|
||||
if *request.Enabled {
|
||||
paymentMethod.Status = constants.EmployeeCollectionPaymentMethodStatusEnabled
|
||||
} else {
|
||||
paymentMethod.Status = constants.EmployeeCollectionPaymentMethodStatusDisabled
|
||||
}
|
||||
}
|
||||
if request.Remark != nil {
|
||||
paymentMethod.Remark = *request.Remark
|
||||
}
|
||||
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
|
||||
Code: paymentMethod.Code, Name: paymentMethod.Name, SortOrder: paymentMethod.SortOrder,
|
||||
Status: paymentMethod.Status, Remark: paymentMethod.Remark,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paymentMethod.Code = normalized.Code
|
||||
paymentMethod.Name = normalized.Name
|
||||
paymentMethod.SortOrder = normalized.SortOrder
|
||||
paymentMethod.Status = normalized.Status
|
||||
paymentMethod.Remark = normalized.Remark
|
||||
paymentMethod.Updater = operatorID
|
||||
|
||||
if err := tx.WithContext(ctx).Save(paymentMethod).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新线下收款方式失败")
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodUpdate,
|
||||
Description: "更新线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
|
||||
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
|
||||
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
|
||||
BeforeData: beforeData, AfterData: paymentMethodAuditSnapshot(paymentMethod),
|
||||
Result: constants.AuditResultSuccess,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
updated = paymentMethod
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, mapPaymentMethodCodeConflict(err)
|
||||
}
|
||||
return toPaymentMethodResponse(updated), nil
|
||||
}
|
||||
|
||||
// Delete 物理删除未被任何核销申请引用的线下收款方式,并写入配置审计。
|
||||
// 已被引用的字典项只允许停用,保证历史申请继续显示冻结名称。
|
||||
func (s *PaymentMethodService) Delete(ctx context.Context, id uint) error {
|
||||
operatorID, err := requireSuperAdmin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.db == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
|
||||
}
|
||||
if id == 0 {
|
||||
return errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
paymentMethod, err := lockPaymentMethod(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
referenced, err := countPaymentMethodReferences(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if referenced > 0 {
|
||||
return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced)
|
||||
}
|
||||
beforeData := paymentMethodAuditSnapshot(paymentMethod)
|
||||
paymentMethod.Updater = operatorID
|
||||
if err := tx.WithContext(ctx).Save(paymentMethod).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新线下收款方式失败")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Delete(paymentMethod).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "删除线下收款方式失败")
|
||||
}
|
||||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodDelete,
|
||||
Description: "删除线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
|
||||
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
|
||||
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
|
||||
BeforeData: beforeData, Result: constants.AuditResultSuccess,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// requireSuperAdmin 校验当前调用者是超级管理员,并返回其账号 ID。
|
||||
// 字典维护不对外开放,未授权一律返回同一禁止访问错误。
|
||||
func requireSuperAdmin(ctx context.Context) (uint, error) {
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||||
return 0, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// lockPaymentMethod 以行锁读取线下收款方式,未找到返回稳定不存在错误。
|
||||
func lockPaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
|
||||
var paymentMethod model.EmployeeCollectionPaymentMethod
|
||||
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&paymentMethod, id).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
|
||||
}
|
||||
return &paymentMethod, nil
|
||||
}
|
||||
|
||||
// ensurePaymentMethodCodeAvailable 校验稳定编码在未删除记录中唯一,excludeID 用于更新自身。
|
||||
func ensurePaymentMethodCodeAvailable(ctx context.Context, tx *gorm.DB, code string, excludeID uint) error {
|
||||
query := tx.WithContext(ctx).Model(&model.EmployeeCollectionPaymentMethod{}).Where("code = ?", code)
|
||||
if excludeID != 0 {
|
||||
query = query.Where("id <> ?", excludeID)
|
||||
}
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验线下收款方式编码失败")
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(errors.CodeEmployeeCollectionPaymentMethodCodeExists)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// countPaymentMethodReferences 统计引用该收款方式的核销申请与代理充值申请数量。
|
||||
// 两类引用任一存在即禁止物理删除与改码,历史快照由各自记录冻结。
|
||||
func countPaymentMethodReferences(ctx context.Context, tx *gorm.DB, id uint) (int64, error) {
|
||||
var applicationCount int64
|
||||
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
|
||||
Where("payment_method_id = ?", id).Count(&applicationCount).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计线下收款方式引用失败")
|
||||
}
|
||||
if applicationCount > 0 {
|
||||
return applicationCount, nil
|
||||
}
|
||||
var rechargeCount int64
|
||||
if err := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
|
||||
Where("offline_payment_method_id = ?", id).Count(&rechargeCount).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计代理充值线下收款方式引用失败")
|
||||
}
|
||||
return rechargeCount, nil
|
||||
}
|
||||
|
||||
// mapPaymentMethodCodeConflict 把稳定编码唯一索引冲突映射为稳定业务错误。
|
||||
// 并发创建或改码时唯一索引是最终裁决,避免把约束冲突暴露成内部错误。
|
||||
func mapPaymentMethodCodeConflict(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if stdErrors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return errors.New(errors.CodeEmployeeCollectionPaymentMethodCodeExists)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// toPaymentMethodResponse 将字典项投影为对外响应。
|
||||
func toPaymentMethodResponse(paymentMethod *model.EmployeeCollectionPaymentMethod) *dto.EmployeeCollectionPaymentMethodResponse {
|
||||
if paymentMethod == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.EmployeeCollectionPaymentMethodResponse{
|
||||
ID: paymentMethod.ID, Code: paymentMethod.Code, Name: paymentMethod.Name,
|
||||
Enabled: paymentMethod.Status == constants.EmployeeCollectionPaymentMethodStatusEnabled,
|
||||
Sort: paymentMethod.SortOrder, Remark: paymentMethod.Remark,
|
||||
CreatedAt: paymentMethod.CreatedAt, UpdatedAt: paymentMethod.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// paymentMethodAuditConfigKey 生成字典项的审计配置键。
|
||||
func paymentMethodAuditConfigKey(id uint) string {
|
||||
return constants.EmployeeCollectionAuditConfigKeyPrefix + "." + strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
// paymentMethodAuditResourceID 生成字典项审计资源标识。
|
||||
func paymentMethodAuditResourceID(id uint) *string {
|
||||
value := strconv.FormatUint(uint64(id), 10)
|
||||
return &value
|
||||
}
|
||||
|
||||
// paymentMethodAuditIdentity 生成字典项审计身份快照,不含任何凭证内容。
|
||||
func paymentMethodAuditIdentity(paymentMethod *model.EmployeeCollectionPaymentMethod) map[string]any {
|
||||
return map[string]any{
|
||||
"id": paymentMethod.ID, "code": paymentMethod.Code, "name": paymentMethod.Name,
|
||||
"status": paymentMethod.Status, "sort": paymentMethod.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
// paymentMethodAuditSnapshot 生成字典项审计前后值快照,不含任何凭证内容。
|
||||
func paymentMethodAuditSnapshot(paymentMethod *model.EmployeeCollectionPaymentMethod) map[string]any {
|
||||
return map[string]any{
|
||||
"id": paymentMethod.ID, "code": paymentMethod.Code, "name": paymentMethod.Name,
|
||||
"status": paymentMethod.Status, "sort": paymentMethod.SortOrder, "remark": paymentMethod.Remark,
|
||||
}
|
||||
}
|
||||
165
internal/application/employeecollection/refund_offset.go
Normal file
165
internal/application/employeecollection/refund_offset.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package employeecollection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RefundOffsetSource 是来源订单退款成功的事实快照。
|
||||
type RefundOffsetSource struct {
|
||||
// RefundID 表示本次退款申请 ID。
|
||||
RefundID uint
|
||||
// OrderID 表示退款关联的来源订单 ID。
|
||||
OrderID uint
|
||||
// RefundAmount 表示本次退款成功金额(分),与退款入账使用的金额为同一实参。
|
||||
RefundAmount int64
|
||||
}
|
||||
|
||||
// RefundOffsetService 在既有退款成功事务内冲销或提示员工代收款账单。
|
||||
// 只处理来源为后台线下套餐订单的账单,其他订单直接跳过,不阻断退款链路。
|
||||
type RefundOffsetService struct {
|
||||
audit BillAuditWriter
|
||||
}
|
||||
|
||||
// NewRefundOffsetService 创建退款冲销用例。
|
||||
func NewRefundOffsetService(audit BillAuditWriter) *RefundOffsetService {
|
||||
return &RefundOffsetService{audit: audit}
|
||||
}
|
||||
|
||||
// ApplyInTx 在既有退款成功事务内按来源唯一键 order:{id} 查找账单并幂等写入冲销事实。
|
||||
// 同一退款对同一账单至多一条关联:重复投递时关联写入影响 0 行,不再冲减、不再写审计、
|
||||
// 也不依赖退款事务的 changed 标志。
|
||||
func (s *RefundOffsetService) ApplyInTx(ctx context.Context, tx *gorm.DB, source RefundOffsetSource) error {
|
||||
if s == nil || tx == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeInternalError, "员工代收款退款冲销能力未配置")
|
||||
}
|
||||
if source.RefundID == 0 || source.OrderID == 0 || source.RefundAmount < 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "员工代收款退款冲销参数无效")
|
||||
}
|
||||
// 0 元退款只作废权益、不退钱:不冲减、不关闭账单、不写冲销记录,也不能让退款事务失败。
|
||||
if source.RefundAmount == 0 {
|
||||
return nil
|
||||
}
|
||||
var bill model.EmployeeCollectionBill
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("source_key = ?", employeecollectiondomain.OrderSourceKey(source.OrderID)).
|
||||
First(&bill).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 来源订单未产生员工代收款账单,跳过而不阻断退款。
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定来源订单员工代收款账单失败")
|
||||
}
|
||||
decision, err := employeecollectiondomain.DecideRefundOffset(billAmounts(&bill), source.RefundAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record := &model.EmployeeCollectionBillRefund{
|
||||
BillID: bill.ID, RefundID: source.RefundID, SourceOrderID: source.OrderID,
|
||||
RefundAmount: source.RefundAmount, BillReceivableAmount: bill.ReceivableAmount,
|
||||
Outcome: decision.Outcome, ReducedAmount: decision.ReducedAmount,
|
||||
}
|
||||
result := tx.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "bill_id"}, {Name: "refund_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(record)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入员工代收款退款冲销关联失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
// 同一退款已冲销过同一账单,保留既有事实。
|
||||
return nil
|
||||
}
|
||||
before := bill
|
||||
if err := applyRefundOutcome(ctx, tx, &bill, decision); err != nil {
|
||||
return err
|
||||
}
|
||||
offsetEventID, err := composeAuditEventID(
|
||||
"employee_collection", "bill", "order", uintText(source.OrderID), "refund", uintText(source.RefundID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{
|
||||
EventID: offsetEventID,
|
||||
ActionCode: constants.AuditActionEmployeeCollectionBillRefundOffseted,
|
||||
Summary: constants.GetEmployeeCollectionRefundOutcomeName(decision.Outcome),
|
||||
Bill: &bill,
|
||||
BeforeData: map[string]any{
|
||||
"receivable_amount": before.ReceivableAmount, "received_amount": before.ReceivedAmount,
|
||||
"reserved_amount": before.ReservedAmount, "status": before.Status,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"receivable_amount": bill.ReceivableAmount, "received_amount": bill.ReceivedAmount,
|
||||
"reserved_amount": bill.ReservedAmount, "status": bill.Status,
|
||||
"refund_id": source.RefundID, "refund_amount": source.RefundAmount, "outcome": decision.Outcome,
|
||||
},
|
||||
CorrelationID: bill.SourceNo,
|
||||
})
|
||||
}
|
||||
|
||||
// applyRefundOutcome 按判定结果修改账单:全额退款关闭、部分冲减应收,提示结果不修改金额与状态。
|
||||
// 关闭与冲减都使用 expected-status 条件更新并检查 RowsAffected,避免并发覆盖。
|
||||
func applyRefundOutcome(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
bill *model.EmployeeCollectionBill,
|
||||
decision employeecollectiondomain.RefundOffsetDecision,
|
||||
) error {
|
||||
expectedStatus := bill.Status
|
||||
switch decision.Outcome {
|
||||
case constants.EmployeeCollectionRefundOutcomeHintOnly:
|
||||
return nil
|
||||
case constants.EmployeeCollectionRefundOutcomeClosedFull:
|
||||
closedAt := time.Now().UTC()
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
|
||||
Where("id = ? AND status = ?", bill.ID, expectedStatus).
|
||||
Updates(map[string]any{
|
||||
"status": constants.EmployeeCollectionBillStatusClosed,
|
||||
"closed_reason": "来源订单全额退款",
|
||||
"closed_at": closedAt,
|
||||
"updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭来源订单全额退款账单失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "员工代收款账单状态已变化,退款冲销未完成")
|
||||
}
|
||||
bill.Status = constants.EmployeeCollectionBillStatusClosed
|
||||
bill.ClosedReason = "来源订单全额退款"
|
||||
bill.ClosedAt = &closedAt
|
||||
return nil
|
||||
case constants.EmployeeCollectionRefundOutcomeReduced:
|
||||
amounts, err := billAmounts(bill).ReduceReceivable(decision.ReducedAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextStatus := amounts.DerivedStatus()
|
||||
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
|
||||
Where("id = ? AND status = ?", bill.ID, expectedStatus).
|
||||
Updates(map[string]any{
|
||||
"receivable_amount": amounts.Receivable,
|
||||
"status": nextStatus,
|
||||
"updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "冲减来源订单退款账单应收失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "员工代收款账单状态已变化,退款冲减未完成")
|
||||
}
|
||||
bill.ReceivableAmount = amounts.Receivable
|
||||
bill.Status = nextStatus
|
||||
return nil
|
||||
default:
|
||||
return errors.New(errors.CodeInternalError, "不支持的退款冲销处理结果")
|
||||
}
|
||||
}
|
||||
171
internal/application/h5popup/asset.go
Normal file
171
internal/application/h5popup/asset.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// Package h5popup 提供 H5 风险换卡与运营弹窗的候选投放、风险地址提交与运营配置维护用例。
|
||||
//
|
||||
// 候选查询会创建或复用个人客户通知并保持未读,即 GET 有副作用,这是产品契约的一部分:
|
||||
// 运营弹窗只在客户请求页面时实时匹配、不预生成通知,而投放事实又必须与「客户确实访问过」对齐。
|
||||
package h5popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// shanghaiLocation 是每日去重键使用的上海自然日时区。
|
||||
// 与 internal/query/packageexpiry 保持同一口径,避免跨自然日重投判定漂移。
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// AssetOwnership 校验当前个人客户是否持有指定资产的有效绑定。
|
||||
// 归属判定必须使用权威实现 customer_binding.OwnsAsset:换货服务内部只查设备绑定虚拟号的判定
|
||||
// 对无虚拟号卡恒为假,直接复用会让无虚拟号的广电卡永远无法自助换卡。
|
||||
type AssetOwnership interface {
|
||||
OwnsAsset(ctx context.Context, customerID uint, assetType string, assetID uint) (bool, error)
|
||||
}
|
||||
|
||||
// assetFacts 是候选匹配与风险资格判定依赖的当前资产事实。
|
||||
type assetFacts struct {
|
||||
AssetType string
|
||||
AssetID uint
|
||||
Identifier string
|
||||
ShopID *uint
|
||||
CarrierType string
|
||||
DeviceType string
|
||||
// RiskStopped 只在卡资产上可能为真:运营商为广电且运营商扩展状态严格等于风险停机常量。
|
||||
// 已销户不参与该判定,两者合并会把已销户卡一并当作风险换卡对象。
|
||||
RiskStopped bool
|
||||
}
|
||||
|
||||
// shanghaiDate 返回上海自然日的 yyyymmdd 文本。
|
||||
func shanghaiDate(now time.Time) string {
|
||||
return now.In(shanghaiLocation).Format("20060102")
|
||||
}
|
||||
|
||||
// invisibleAssetError 统一「资产不存在」与「资产不属于当前客户」的返回,避免形成可枚举差异。
|
||||
func invisibleAssetError() error {
|
||||
return errors.New(errors.CodeAssetNotFound)
|
||||
}
|
||||
|
||||
// isAssetNotFound 判断错误是否表示资产不存在或不可见(归属校验失败与资产不存在同态)。
|
||||
func isAssetNotFound(err error) bool {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr.Code == errors.CodeAssetNotFound
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isRecordNotFound 判断错误是否为 GORM 未命中记录。
|
||||
func isRecordNotFound(err error) bool {
|
||||
return stderrors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// resolveAssetIdentity 按客户端提交的 identifier 定位资产:(资产类型, 资产ID)。
|
||||
// 复用既有解析口径:先查全局标识注册表,再按设备与卡的既有标识回退;
|
||||
// 卡标识由 IotCardStore.GetByIdentifier 统一处理(virtual_no/iccid/msisdn/iccid_19/iccid_20),
|
||||
// 与资产详情解析保持一致,避免自实现查询漏掉 iccid_19/iccid_20 造成静默不投放。
|
||||
// 未命中返回空类型,由调用方按不可见处理。
|
||||
func (s *CandidateService) resolveAssetIdentity(ctx context.Context, identifier string) (string, uint, error) {
|
||||
record, err := s.identifiers.FindByIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产标识失败")
|
||||
}
|
||||
if record != nil {
|
||||
return record.AssetType, record.AssetID, nil
|
||||
}
|
||||
device, err := s.devices.GetByIdentifier(ctx, identifier)
|
||||
if err == nil && device != nil {
|
||||
return constants.AssetTypeDevice, device.ID, nil
|
||||
}
|
||||
if err != nil && !isRecordNotFound(err) {
|
||||
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||
}
|
||||
card, err := s.cards.GetByIdentifier(ctx, identifier)
|
||||
if err == nil && card != nil {
|
||||
return constants.AssetTypeIotCard, card.ID, nil
|
||||
}
|
||||
if err != nil && !isRecordNotFound(err) {
|
||||
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡失败")
|
||||
}
|
||||
return "", 0, nil
|
||||
}
|
||||
|
||||
// loadAssetFacts 读取候选匹配与风险资格判定所需的资产事实。
|
||||
func (s *CandidateService) loadAssetFacts(ctx context.Context, assetType string, assetID uint) (*assetFacts, error) {
|
||||
switch assetType {
|
||||
case constants.AssetTypeIotCard:
|
||||
card, err := s.cards.GetByID(ctx, assetID)
|
||||
if err != nil {
|
||||
if isRecordNotFound(err) {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡资产失败")
|
||||
}
|
||||
facts := &assetFacts{
|
||||
AssetType: constants.AssetTypeIotCard, AssetID: card.ID, Identifier: card.ICCID,
|
||||
ShopID: card.ShopID, CarrierType: card.CarrierType,
|
||||
RiskStopped: card.CarrierType == constants.CarrierTypeCBN &&
|
||||
strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendRiskStop,
|
||||
}
|
||||
deviceType, err := s.boundDeviceType(ctx, card.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
facts.DeviceType = deviceType
|
||||
return facts, nil
|
||||
case constants.AssetTypeDevice:
|
||||
device, err := s.devices.GetByID(ctx, assetID)
|
||||
if err != nil {
|
||||
if isRecordNotFound(err) {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备资产失败")
|
||||
}
|
||||
return &assetFacts{
|
||||
AssetType: constants.AssetTypeDevice, AssetID: device.ID,
|
||||
Identifier: deviceIdentifier(device), ShopID: device.ShopID, DeviceType: device.DeviceType,
|
||||
}, nil
|
||||
default:
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
}
|
||||
|
||||
// boundDeviceType 经卡—设备绑定推导设备类型快照。
|
||||
// 独立卡或未绑定设备时该维度为空;空值不匹配任何已配置范围,只有「未配置范围」表示全量。
|
||||
func (s *CandidateService) boundDeviceType(ctx context.Context, cardID uint) (string, error) {
|
||||
var device model.Device
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("tb_device AS d").
|
||||
Joins("JOIN tb_device_sim_binding AS b ON b.device_id = d.id").
|
||||
Where("b.iot_card_id = ? AND b.bind_status = ? AND b.deleted_at IS NULL AND d.deleted_at IS NULL",
|
||||
cardID, constants.BindStatusBound).
|
||||
Order("b.is_current DESC, b.id DESC").
|
||||
Select("d.*").
|
||||
Take(&device).Error
|
||||
if err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败")
|
||||
}
|
||||
return device.DeviceType, nil
|
||||
}
|
||||
|
||||
// deviceIdentifier 按虚拟号、IMEI、SN 的稳定优先级生成设备标识快照。
|
||||
func deviceIdentifier(device *model.Device) string {
|
||||
if device == nil {
|
||||
return ""
|
||||
}
|
||||
if device.VirtualNo != "" {
|
||||
return device.VirtualNo
|
||||
}
|
||||
if device.IMEI != "" {
|
||||
return device.IMEI
|
||||
}
|
||||
return device.SN
|
||||
}
|
||||
314
internal/application/h5popup/candidate.go
Normal file
314
internal/application/h5popup/candidate.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package h5popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// activeShippingExchangeStatuses 是压制风险候选的物流换货单状态集合。
|
||||
// 含已完成的 4:已完成物流换货单说明风险换卡已走完流程,此时必须停止新投放;
|
||||
// 必须同时限定 flow_type=shipping,直接换货单创建即已完成,不限定会永久压制风险候选。
|
||||
var activeShippingExchangeStatuses = []int{
|
||||
constants.ExchangeStatusPendingInfo,
|
||||
constants.ExchangeStatusPendingShip,
|
||||
constants.ExchangeStatusShipped,
|
||||
constants.ExchangeStatusCompleted,
|
||||
}
|
||||
|
||||
// CandidateService 按当前资产事实投放风险换卡或运营弹窗候选。
|
||||
// 查询会创建或复用通知并保持未读,即 GET 有副作用:运营弹窗只在客户请求页面时实时匹配、不预生成。
|
||||
type CandidateService struct {
|
||||
db *gorm.DB
|
||||
identifiers *postgres.AssetIdentifierStore
|
||||
cards *postgres.IotCardStore
|
||||
devices *postgres.DeviceStore
|
||||
ownership AssetOwnership
|
||||
notifications notificationapp.DirectWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewCandidateService 创建 H5 弹窗候选投放用例。
|
||||
// 资产标识解析复用既有 Store 方法,保证口径与资产详情、换货等入口一致。
|
||||
func NewCandidateService(
|
||||
db *gorm.DB,
|
||||
identifiers *postgres.AssetIdentifierStore,
|
||||
cards *postgres.IotCardStore,
|
||||
devices *postgres.DeviceStore,
|
||||
ownership AssetOwnership,
|
||||
notifications notificationapp.DirectWriter,
|
||||
) *CandidateService {
|
||||
return &CandidateService{
|
||||
db: db, identifiers: identifiers, cards: cards, devices: devices,
|
||||
ownership: ownership, notifications: notifications, now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCandidate 返回当前页面与当前资产的唯一弹窗候选;没有可投放弹窗时 candidate 为空。
|
||||
// 顺序固定:先判风险换卡资格,命中则只处理风险分支;未命中再匹配运营配置。
|
||||
func (s *CandidateService) GetCandidate(ctx context.Context, customerID uint, request dto.PopupCandidateRequest) (*dto.PopupCandidateResponse, error) {
|
||||
if customerID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
identifier := strings.TrimSpace(request.Identifier)
|
||||
if !constants.IsH5PopupPage(request.Page) || identifier == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "弹窗候选参数不合法")
|
||||
}
|
||||
if s == nil || s.db == nil || s.identifiers == nil || s.cards == nil || s.devices == nil ||
|
||||
s.ownership == nil || s.notifications == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "弹窗投放能力尚未配置")
|
||||
}
|
||||
assetType, assetID, err := s.resolveAssetIdentity(ctx, identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if assetType == "" {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
owned, err := s.ownership.OwnsAsset(ctx, customerID, assetType, assetID)
|
||||
if err != nil {
|
||||
if isAssetNotFound(err) {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !owned {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
facts, err := s.loadAssetFacts(ctx, assetType, assetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
|
||||
if facts.RiskStopped {
|
||||
blocked, err := findActiveShippingExchange(ctx, s.db, facts.AssetType, facts.AssetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked == nil {
|
||||
candidate, err := s.deliverRiskCandidate(ctx, customerID, facts, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.PopupCandidateResponse{Candidate: candidate}, nil
|
||||
}
|
||||
}
|
||||
|
||||
candidate, err := s.deliverOperationCandidate(ctx, customerID, request.Page, facts, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.PopupCandidateResponse{Candidate: candidate}, nil
|
||||
}
|
||||
|
||||
// deliverRiskCandidate 创建或复用「客户+资产+上海自然日」的风险换卡通知。
|
||||
// 当日通知已存在且未读时返回同一通知;已被客户关闭(已读)时当日不再返回候选,次日条件成立会创建新通知。
|
||||
func (s *CandidateService) deliverRiskCandidate(ctx context.Context, customerID uint, facts *assetFacts, now time.Time) (*dto.PopupCandidateItem, error) {
|
||||
notification, err := s.notifications.CreateOrGetPersonal(ctx, riskEventKey(customerID, facts, now), customerID, notificationapp.PersonalDirectRequest{
|
||||
NotificationType: constants.NotificationTypeH5PopupRiskExchange,
|
||||
RefType: constants.NotificationRefTypeAsset,
|
||||
RefID: strconv.FormatUint(uint64(facts.AssetID), 10),
|
||||
RefKey: facts.Identifier,
|
||||
ExpiresAt: popupExpiresAt(now),
|
||||
PopupSnapshot: &model.NotificationPopupSnapshot{
|
||||
AssetType: facts.AssetType, AssetID: facts.AssetID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if notification.IsRead {
|
||||
return nil, nil
|
||||
}
|
||||
return toCandidateItem(notification), nil
|
||||
}
|
||||
|
||||
// deliverOperationCandidate 匹配运营配置并按频率创建或复用运营弹窗通知。
|
||||
// 只返回优先级最高一条;同优先级取最近更新时间最新,启停同样刷新该时间。
|
||||
func (s *CandidateService) deliverOperationCandidate(ctx context.Context, customerID uint, page string, facts *assetFacts, now time.Time) (*dto.PopupCandidateItem, error) {
|
||||
config, err := s.matchOperationConfig(ctx, page, facts, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config == nil {
|
||||
return nil, nil
|
||||
}
|
||||
notification, err := s.notifications.CreateOrGetPersonal(ctx, operationEventKey(customerID, config, now), customerID, notificationapp.PersonalDirectRequest{
|
||||
NotificationType: constants.NotificationTypeH5PopupOperation,
|
||||
TemplateData: map[string]string{"title": config.Title, "content": config.Content},
|
||||
RefType: constants.NotificationRefTypeAsset,
|
||||
RefID: strconv.FormatUint(uint64(facts.AssetID), 10),
|
||||
RefKey: facts.Identifier,
|
||||
ExpiresAt: popupExpiresAt(now),
|
||||
PopupSnapshot: &model.NotificationPopupSnapshot{
|
||||
ConfigID: config.ID, ConfigVersion: config.Version,
|
||||
AssetType: facts.AssetType, AssetID: facts.AssetID, ActionType: config.ActionType,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if notification.IsRead {
|
||||
return nil, nil
|
||||
}
|
||||
return toCandidateItem(notification), nil
|
||||
}
|
||||
|
||||
// matchOperationConfig 按时间、启停、页面、店铺、设备类型、卡类型范围匹配运营配置。
|
||||
// 范围同一维度多选取任一命中;未配置该维度即全量;已配置而资产该维度无值时该配置不命中。
|
||||
func (s *CandidateService) matchOperationConfig(ctx context.Context, page string, facts *assetFacts, now time.Time) (*model.H5PopupConfiguration, error) {
|
||||
pageJSON, err := jsonbScalar(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var shopID *string
|
||||
if facts.ShopID != nil {
|
||||
text := strconv.FormatUint(uint64(*facts.ShopID), 10)
|
||||
shopID = &text
|
||||
}
|
||||
shopJSON, err := jsonbScalarPointer(shopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceJSON, err := jsonbScalar(facts.DeviceType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cardJSON, err := jsonbScalar(facts.CarrierType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var config model.H5PopupConfiguration
|
||||
err = s.db.WithContext(ctx).Model(&model.H5PopupConfiguration{}).
|
||||
Where("enabled = ?", constants.H5PopupStatusEnabled).
|
||||
Where("starts_at <= ? AND ends_at >= ?", now, now).
|
||||
Where("?::jsonb <@ pages", pageJSON).
|
||||
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 {
|
||||
if isRecordNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "匹配运营弹窗配置失败")
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// findActiveShippingExchange 查询指定资产是否已存在活动物流换货单。
|
||||
// 取 flow_type=shipping 且状态属于待填写、待发货、已发货待确认、已完成,任一命中即视为已处理。
|
||||
func findActiveShippingExchange(ctx context.Context, db *gorm.DB, assetType string, assetID uint) (*model.ExchangeOrder, error) {
|
||||
var order model.ExchangeOrder
|
||||
err := db.WithContext(ctx).
|
||||
Where("old_asset_type = ? AND old_asset_id = ? AND flow_type = ?", assetType, assetID, constants.ExchangeFlowTypeShipping).
|
||||
Where("status IN ?", activeShippingExchangeStatuses).
|
||||
Order("id DESC").
|
||||
Take(&order).Error
|
||||
if err != nil {
|
||||
if isRecordNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询活动物流换货单失败")
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// riskEventKey 生成风险换卡通知事件键:客户 + 资产 + 上海自然日,复用通知唯一约束保证一天一条。
|
||||
func riskEventKey(customerID uint, facts *assetFacts, now time.Time) string {
|
||||
return popupEventKey(constants.H5PopupRiskEventKeyPrefix+"."+shanghaiDate(now),
|
||||
strconv.FormatUint(uint64(customerID), 10), facts.AssetType, strconv.FormatUint(uint64(facts.AssetID), 10))
|
||||
}
|
||||
|
||||
// operationEventKey 生成运营弹窗通知事件键:客户 + 配置 + 版本,daily 频率再追加上海自然日。
|
||||
// 频率口径按「每客户每配置支持仅一次或每天一次」,因此键内不含资产,客户换资产不会额外获得投放。
|
||||
func operationEventKey(customerID uint, config *model.H5PopupConfiguration, now time.Time) string {
|
||||
prefix := constants.H5PopupOperationOnceEventKeyPrefix
|
||||
if config.Frequency == constants.H5PopupFrequencyDaily {
|
||||
prefix = constants.H5PopupOperationDailyEventKeyPrefix + "." + shanghaiDate(now)
|
||||
}
|
||||
return popupEventKey(prefix,
|
||||
strconv.FormatUint(uint64(customerID), 10), strconv.FormatUint(uint64(config.ID), 10), strconv.FormatInt(config.Version, 10))
|
||||
}
|
||||
|
||||
// popupEventKey 生成固定长度的通知事件键:前缀 + 身份摘要。
|
||||
// tb_notification.event_id 为 varchar(64),身份部分用 sha256 前 12 字节十六进制压缩,
|
||||
// 保证资产与客户 ID 位数增长后仍不超长,同时保持确定性以便复用既有唯一约束去重。
|
||||
func popupEventKey(prefix string, parts ...string) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
|
||||
return prefix + "." + hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
// popupExpiresAt 返回弹窗投放通知的展示截止时间:投放时间 + 90 天。
|
||||
// 弹窗类别沿用 system(展示上限 365 天),90 天在其内,事实物理保留仍按系统类别的 365 天。
|
||||
func popupExpiresAt(now time.Time) *time.Time {
|
||||
expiresAt := now.AddDate(0, 0, constants.H5PopupDisplayDays)
|
||||
return &expiresAt
|
||||
}
|
||||
|
||||
// jsonbScalar 将字符串编码为可直接参与 jsonb 包含判断的 JSON 标量。
|
||||
func jsonbScalar(value string) (string, error) {
|
||||
encoded, err := sonic.Marshal(value)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(errors.CodeInternalError, err, "编码弹窗匹配值失败")
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
// jsonbScalarPointer 将可空字符串编码为 JSON 标量;nil 编码为 JSON null,任何已配置范围都不命中。
|
||||
func jsonbScalarPointer(value *string) (string, error) {
|
||||
if value == nil {
|
||||
return "null", nil
|
||||
}
|
||||
return jsonbScalar(*value)
|
||||
}
|
||||
|
||||
// toCandidateItem 将冻结的通知投影为客户端候选;配置标识与受控动作取通知快照
|
||||
// 而不是当前配置,保证配置修改后旧通知与旧快照不被改写。
|
||||
func toCandidateItem(notification *model.Notification) *dto.PopupCandidateItem {
|
||||
if notification == nil {
|
||||
return nil
|
||||
}
|
||||
item := &dto.PopupCandidateItem{
|
||||
NotificationID: notification.ID, NotificationType: notification.Type,
|
||||
Title: notification.Title, Body: notification.Body,
|
||||
ExpiresAt: notification.ExpiresAt, CreatedAt: notification.CreatedAt,
|
||||
}
|
||||
if notification.Type == constants.NotificationTypeH5PopupRiskExchange {
|
||||
item.PopupType = constants.H5PopupCandidateTypeRiskExchange
|
||||
} else {
|
||||
item.PopupType = constants.H5PopupCandidateTypeOperation
|
||||
}
|
||||
if snapshot := notification.PopupSnapshot; snapshot != nil {
|
||||
item.AssetType = snapshot.AssetType
|
||||
item.AssetID = snapshot.AssetID
|
||||
item.ConfigID = snapshot.ConfigID
|
||||
item.ConfigVersion = snapshot.ConfigVersion
|
||||
item.ActionType = snapshot.ActionType
|
||||
}
|
||||
return item
|
||||
}
|
||||
505
internal/application/h5popup/configuration.go
Normal file
505
internal/application/h5popup/configuration.go
Normal file
@@ -0,0 +1,505 @@
|
||||
package h5popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
var (
|
||||
// popupURLPattern 匹配任意 URL 形态:带协议的绝对地址、www 前缀或站点域名。
|
||||
// 弹窗只允许受控动作,前端按 action_type 白名单映射页面,不接受运营配置下发跳转目标。
|
||||
popupURLPattern = regexp.MustCompile(`(?i)([a-z][a-z0-9+.\-]*://|www\.|\.(com|cn|net|org)(/|$|\s))`)
|
||||
// popupRoutePattern 匹配前端路由形态:以 / 开头的路径片段或 /#/ 哈希路由。
|
||||
popupRoutePattern = regexp.MustCompile(`(^|[\s((])/[A-Za-z#]`)
|
||||
)
|
||||
|
||||
// ConfigurationService 维护 H5 运营弹窗配置。
|
||||
// 配置只决定后续投放:更新在事务内递增版本,启停只改启停位并刷新最近更新时间,两者都记录前后值与版本。
|
||||
type ConfigurationService struct {
|
||||
db *gorm.DB
|
||||
audit *audit.Writer
|
||||
}
|
||||
|
||||
// NewConfigurationService 创建运营弹窗配置事务脚本。
|
||||
func NewConfigurationService(db *gorm.DB, audit *audit.Writer) *ConfigurationService {
|
||||
return &ConfigurationService{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// configurationInput 是校验后的配置值,创建与更新共用同一套归一化规则。
|
||||
type configurationInput struct {
|
||||
Title string
|
||||
Content string
|
||||
PopupType string
|
||||
Pages []string
|
||||
ShopIDs []uint
|
||||
DeviceTypes []string
|
||||
CardTypes []string
|
||||
Priority int
|
||||
Frequency string
|
||||
ActionType string
|
||||
Enabled int
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
}
|
||||
|
||||
// Create 创建运营弹窗配置,初始版本为 1,并在同一事务内写入配置审计。
|
||||
func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5PopupConfigurationRequest) (uint, error) {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
enabled := constants.H5PopupStatusDisabled
|
||||
if request.Enabled != nil && *request.Enabled {
|
||||
enabled = constants.H5PopupStatusEnabled
|
||||
}
|
||||
// 弹窗类型必填:它既参与类别排序,也决定缺省优先级的取值来源。
|
||||
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
|
||||
}
|
||||
actionType := ""
|
||||
if request.ActionType != nil {
|
||||
actionType = *request.ActionType
|
||||
}
|
||||
normalized, err := normalizeConfigurationInput(configurationInput{
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
record := &model.H5PopupConfiguration{
|
||||
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,
|
||||
Enabled: normalized.Enabled, StartsAt: normalized.StartsAt, EndsAt: normalized.EndsAt,
|
||||
Version: 1, BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建运营弹窗配置失败")
|
||||
}
|
||||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationH5PopupConfigurationCreate,
|
||||
Description: "创建运营弹窗配置", ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return record.ID, nil
|
||||
}
|
||||
|
||||
// Update 更新运营弹窗配置:合并入参后整体校验,事务内递增版本并刷新最近更新时间。
|
||||
// 旧版本已投放通知的内容与快照不被改写,新版本可向原命中客户按频率重新投放。
|
||||
func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.UpdateH5PopupConfigurationRequest) error {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
record, err := lockConfiguration(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := *record
|
||||
beforeData := configurationAuditSnapshot(&before)
|
||||
|
||||
merged := configurationInput{
|
||||
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,
|
||||
}
|
||||
if request.Title != nil {
|
||||
merged.Title = *request.Title
|
||||
}
|
||||
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
|
||||
}
|
||||
if request.ShopIDs != nil {
|
||||
merged.ShopIDs = *request.ShopIDs
|
||||
}
|
||||
if request.DeviceTypes != nil {
|
||||
merged.DeviceTypes = *request.DeviceTypes
|
||||
}
|
||||
if request.CardTypes != nil {
|
||||
merged.CardTypes = *request.CardTypes
|
||||
}
|
||||
if request.Priority != nil {
|
||||
merged.Priority = *request.Priority
|
||||
}
|
||||
if request.Frequency != nil {
|
||||
merged.Frequency = *request.Frequency
|
||||
}
|
||||
if request.ActionType != nil {
|
||||
merged.ActionType = *request.ActionType
|
||||
}
|
||||
if request.Enabled != nil {
|
||||
merged.Enabled = enabledStatus(*request.Enabled)
|
||||
}
|
||||
if request.StartsAt != nil {
|
||||
merged.StartsAt = *request.StartsAt
|
||||
}
|
||||
if request.EndsAt != nil {
|
||||
merged.EndsAt = *request.EndsAt
|
||||
}
|
||||
normalized, err := normalizeConfigurationInput(merged)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
record.CardTypes = model.StringJSONBArray(normalized.CardTypes)
|
||||
record.Priority = normalized.Priority
|
||||
record.Frequency = normalized.Frequency
|
||||
record.ActionType = normalized.ActionType
|
||||
record.Enabled = normalized.Enabled
|
||||
record.StartsAt = normalized.StartsAt
|
||||
record.EndsAt = normalized.EndsAt
|
||||
record.Version++
|
||||
record.Updater = operatorID
|
||||
record.UpdatedAt = now
|
||||
if err := tx.WithContext(ctx).Save(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新运营弹窗配置失败")
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationH5PopupConfigurationUpdate,
|
||||
Description: "更新运营弹窗配置", ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
BeforeData: beforeData, AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetEnabled 启停运营弹窗配置,只影响后续候选,并必须刷新最近更新时间。
|
||||
// 启停不递增版本:版本表达配置内容变化,频率去重键因此保持不变,已投放通知不会被再次投放。
|
||||
func (s *ConfigurationService) SetEnabled(ctx context.Context, id uint, enabled bool) error {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
operationType := constants.AuditOperationH5PopupConfigurationDisable
|
||||
description := "停用运营弹窗配置"
|
||||
if enabled {
|
||||
operationType = constants.AuditOperationH5PopupConfigurationEnable
|
||||
description = "启用运营弹窗配置"
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
record, err := lockConfiguration(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeData := configurationAuditSnapshot(record)
|
||||
now := time.Now().UTC()
|
||||
record.Enabled = enabledStatus(enabled)
|
||||
record.Updater = operatorID
|
||||
record.UpdatedAt = now
|
||||
if err := tx.WithContext(ctx).Save(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新运营弹窗配置启停失败")
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: operationType,
|
||||
Description: description, ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
BeforeData: beforeData, AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ensureConfigured() error {
|
||||
if s == nil || s.db == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requirePlatformOperator 校验当前调用者仅限超级管理员与平台账号,并返回其账号 ID。
|
||||
// 非上述身份与资源不存在返回同一禁止访问错误,避免形成可枚举差异。
|
||||
func requirePlatformOperator(ctx context.Context) (uint, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return 0, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// lockConfiguration 以行锁读取运营弹窗配置,未找到返回稳定不存在错误。
|
||||
func lockConfiguration(ctx context.Context, tx *gorm.DB, id uint) (*model.H5PopupConfiguration, error) {
|
||||
var record model.H5PopupConfiguration
|
||||
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).Take(&record).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营弹窗配置失败")
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// normalizeConfigurationInput 归一化并校验配置,创建与更新共用同一套规则。
|
||||
// 拒绝任意 URL 与前端路由是应用层第一道保险,通知渲染的 URL 拦截是第二道。
|
||||
func normalizeConfigurationInput(input configurationInput) (configurationInput, error) {
|
||||
normalized := input
|
||||
normalized.Title = strings.TrimSpace(input.Title)
|
||||
if runes := utf8.RuneCountInString(normalized.Title); runes < 1 || runes > 100 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗标题长度必须在 1~100 字符之间")
|
||||
}
|
||||
// 标题与正文同一口径:两者都会冻结进通知并参与渲染,任一都不接受 URL 或前端路由。
|
||||
if popupURLPattern.MatchString(normalized.Title) || popupRoutePattern.MatchString(normalized.Title) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗标题不接受 URL 或前端路由,只能使用受控动作")
|
||||
}
|
||||
normalized.Content = strings.TrimSpace(input.Content)
|
||||
if runes := utf8.RuneCountInString(normalized.Content); runes < 1 || runes > 2000 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文长度必须在 1~2000 字符之间")
|
||||
}
|
||||
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, "运营弹窗至少需要一个命中页面")
|
||||
}
|
||||
if len(normalized.Pages) > 4 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗命中页面超出受控范围")
|
||||
}
|
||||
for _, page := range normalized.Pages {
|
||||
if !constants.IsH5PopupPage(page) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗命中页面不在受控白名单内")
|
||||
}
|
||||
}
|
||||
normalized.ShopIDs = dedupeShopIDs(input.ShopIDs)
|
||||
if len(normalized.ShopIDs) > 200 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗店铺范围超过 200 项")
|
||||
}
|
||||
normalized.DeviceTypes = dedupeStrings(input.DeviceTypes)
|
||||
if len(normalized.DeviceTypes) > 100 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗设备类型范围超过 100 项")
|
||||
}
|
||||
for _, deviceType := range normalized.DeviceTypes {
|
||||
if utf8.RuneCountInString(deviceType) > 50 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗设备类型超过 50 字符")
|
||||
}
|
||||
}
|
||||
// 卡类型是与 tb_iot_card.carrier_type 直接比较的受控枚举,统一大写后再校验。
|
||||
normalized.CardTypes = dedupeStrings(upperStrings(input.CardTypes))
|
||||
if len(normalized.CardTypes) > 4 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗卡类型范围超过受控取值数量")
|
||||
}
|
||||
for _, cardType := range normalized.CardTypes {
|
||||
if !constants.IsCarrierType(cardType) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗卡类型不在受控白名单内")
|
||||
}
|
||||
}
|
||||
if normalized.Priority < 0 || normalized.Priority > 1000000 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗优先级必须在 0~1000000 之间")
|
||||
}
|
||||
if !constants.IsH5PopupFrequency(normalized.Frequency) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗投放频率不在受控白名单内")
|
||||
}
|
||||
if !constants.IsH5PopupActionType(normalized.ActionType) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗受控动作不在受控白名单内")
|
||||
}
|
||||
if normalized.Enabled != constants.H5PopupStatusEnabled && normalized.Enabled != constants.H5PopupStatusDisabled {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗启停状态不合法")
|
||||
}
|
||||
if normalized.StartsAt.IsZero() || normalized.EndsAt.IsZero() {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗必须同时提供生效开始与结束时间")
|
||||
}
|
||||
normalized.StartsAt = normalized.StartsAt.UTC()
|
||||
normalized.EndsAt = normalized.EndsAt.UTC()
|
||||
if normalized.EndsAt.Before(normalized.StartsAt) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗结束时间不得早于开始时间")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// enabledStatus 把布尔启停转换为 0/1 状态。
|
||||
func enabledStatus(enabled bool) int {
|
||||
if enabled {
|
||||
return constants.H5PopupStatusEnabled
|
||||
}
|
||||
return constants.H5PopupStatusDisabled
|
||||
}
|
||||
|
||||
// dedupeStrings 去空白并按出现顺序去重,保留原始大小写。
|
||||
func dedupeStrings(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[trimmed]; exists {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// upperStrings 去空白并统一大写,供受控枚举范围使用;空白项不保留。
|
||||
func upperStrings(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, strings.ToUpper(trimmed))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// dedupeShopIDs 去重店铺 ID 并丢弃非法值。
|
||||
func dedupeShopIDs(values []uint) []uint {
|
||||
result := make([]uint, 0, len(values))
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// toJSONBStrings 将店铺 ID 编码为 JSONB 文本数组,与范围匹配的文本比较口径一致。
|
||||
func toJSONBStrings(values []uint) model.StringJSONBArray {
|
||||
encoded := make(model.StringJSONBArray, 0, len(values))
|
||||
for _, value := range values {
|
||||
encoded = append(encoded, strconv.FormatUint(uint64(value), 10))
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func storePages(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.Pages...)
|
||||
}
|
||||
|
||||
func storeDeviceTypes(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.DeviceTypes...)
|
||||
}
|
||||
|
||||
func storeCardTypes(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.CardTypes...)
|
||||
}
|
||||
|
||||
// storeShopIDs 将 JSONB 店铺范围还原为 ID 列表用于合并更新。
|
||||
func storeShopIDs(record *model.H5PopupConfiguration) []uint {
|
||||
shopIDs := make([]uint, 0, len(record.ShopIDs))
|
||||
for _, value := range record.ShopIDs {
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil || parsed == 0 {
|
||||
continue
|
||||
}
|
||||
shopIDs = append(shopIDs, uint(parsed))
|
||||
}
|
||||
return shopIDs
|
||||
}
|
||||
|
||||
func configurationAuditKey(id uint) string {
|
||||
return constants.H5PopupAuditConfigKeyPrefix + "." + strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
func configurationAuditResourceID(id uint) *string {
|
||||
value := strconv.FormatUint(uint64(id), 10)
|
||||
return &value
|
||||
}
|
||||
|
||||
// configurationAuditIdentity 生成配置身份快照,不含正文内容。
|
||||
func configurationAuditIdentity(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"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 生成配置审计前后值快照,覆盖类型、范围、优先级、频率、受控动作、启停、有效期与版本。
|
||||
func configurationAuditSnapshot(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"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,
|
||||
"enabled": record.Enabled, "starts_at": record.StartsAt, "ends_at": record.EndsAt,
|
||||
"version": record.Version, "updated_at": record.UpdatedAt,
|
||||
}
|
||||
}
|
||||
170
internal/application/h5popup/risk_exchange.go
Normal file
170
internal/application/h5popup/risk_exchange.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package h5popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RiskExchangeService 处理个人客户自助风险换卡的地址提交。
|
||||
// 幂等靠「锁定旧资产行 + 去重查询既有活动物流换货单」实现,不引入数据库唯一约束:
|
||||
// 资产实例同一时刻只属于一个客户,锁资产行即可覆盖重复提交与并发提交。
|
||||
type RiskExchangeService struct {
|
||||
db *gorm.DB
|
||||
ownership AssetOwnership
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// NewRiskExchangeService 创建风险换卡地址提交事务脚本。
|
||||
func NewRiskExchangeService(db *gorm.DB, ownership AssetOwnership, auditWriter *audit.Writer) *RiskExchangeService {
|
||||
return &RiskExchangeService{db: db, ownership: ownership, auditWriter: auditWriter}
|
||||
}
|
||||
|
||||
// Submit 幂等提交风险换卡收货地址,创建关联旧资产的物流换货单。
|
||||
// 事务内顺序固定为:锁旧资产行 → 复核风险资格 → 去重查询 → 未命中才插入。
|
||||
// 重复提交返回首次创建的换货单与首次地址,不覆盖既有地址。
|
||||
func (s *RiskExchangeService) Submit(ctx context.Context, customerID, assetID uint, request dto.ClientRiskExchangeAddressParams) (*dto.ClientRiskExchangeResponse, error) {
|
||||
if customerID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
if assetID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "风险换卡资产ID不合法")
|
||||
}
|
||||
if s == nil || s.db == nil || s.ownership == nil || s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "风险换卡能力尚未配置")
|
||||
}
|
||||
// 归属校验必须使用权威实现;资产不存在与归属失败返回同态不可见结果。
|
||||
owned, err := s.ownership.OwnsAsset(ctx, customerID, constants.AssetTypeIotCard, assetID)
|
||||
if err != nil {
|
||||
if isAssetNotFound(err) {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !owned {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
|
||||
var result *dto.ClientRiskExchangeResponse
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", assetID).Take(&card).Error; err != nil {
|
||||
if isRecordNotFound(err) {
|
||||
return invisibleAssetError()
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定换卡资产失败")
|
||||
}
|
||||
// 锁内复核风险资格:持锁前的判定可能已被并发状态同步改变。
|
||||
if card.CarrierType != constants.CarrierTypeCBN ||
|
||||
strings.TrimSpace(card.GatewayExtend) != constants.GatewayCardExtendRiskStop {
|
||||
return errors.New(errors.CodeH5PopupRiskNotEligible)
|
||||
}
|
||||
existing, err := findActiveShippingExchange(ctx, tx, constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
result = toRiskExchangeResponse(existing)
|
||||
return nil
|
||||
}
|
||||
order := &model.ExchangeOrder{
|
||||
ExchangeNo: model.GenerateExchangeNo(),
|
||||
FlowType: constants.ExchangeFlowTypeShipping,
|
||||
OldAssetType: constants.AssetTypeIotCard,
|
||||
OldAssetID: card.ID,
|
||||
OldAssetIdentifier: card.ICCID,
|
||||
RecipientName: request.RecipientName,
|
||||
RecipientPhone: request.RecipientPhone,
|
||||
RecipientAddress: request.RecipientAddress,
|
||||
ShopID: card.ShopID,
|
||||
ExchangeReason: constants.H5PopupRiskExchangeReason,
|
||||
// 客户已提交收货信息,因此创建即待发货;不预设业务数据迁移,发货选新资产时仍由后台按既有流程决定。
|
||||
Status: constants.ExchangeStatusPendingShip,
|
||||
MigrateData: false,
|
||||
MigrationStatus: constants.ExchangeMigrationStatusNotMigrated,
|
||||
// H5 客户上下文没有后台账号 ID,置 0 表示由客户自助发起,不冒用任何后台账号身份。
|
||||
BaseModel: model.BaseModel{Creator: 0, Updater: 0},
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建风险换卡单失败")
|
||||
}
|
||||
result = toRiskExchangeResponse(order)
|
||||
return s.appendRiskExchangeAudit(ctx, tx, customerID, order, &card)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// appendRiskExchangeAudit 在同一事务内记录客户自助换卡的状态事实与旧卡引用。
|
||||
func (s *RiskExchangeService) appendRiskExchangeAudit(ctx context.Context, tx *gorm.DB, customerID uint, order *model.ExchangeOrder, card *model.IotCard) error {
|
||||
orderID := strconv.FormatUint(uint64(order.ID), 10)
|
||||
cardID := strconv.FormatUint(uint64(card.ID), 10)
|
||||
customerText := strconv.FormatUint(uint64(customerID), 10)
|
||||
summary := "客户自助提交风险换卡地址"
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionCardRiskExchangeRequested, Summary: summary,
|
||||
Actor: audit.ActorInput{Kind: constants.AuditActorPersonalCustomer, ID: customerText},
|
||||
Source: constants.AuditSourcePersonalAPI,
|
||||
// 个人客户本人业务范围;不使用 platform,避免把客户自助事实记成后台操作。
|
||||
ScopeType: constants.AuditScopePersonalCustomer, ScopeID: customerText,
|
||||
Result: constants.AuditResultSuccess,
|
||||
Metadata: map[string]any{"flow_type": constants.ExchangeFlowTypeShipping, "migrate_data": false},
|
||||
Resources: []audit.ResourceInput{
|
||||
{
|
||||
Type: constants.AuditResourceExchangeOrder, ID: &orderID, Key: order.ExchangeNo, DisplayName: order.ExchangeNo,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCardExchangeOrder,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": order.ID, "exchange_no": order.ExchangeNo, "flow_type": order.FlowType,
|
||||
"old_asset_type": order.OldAssetType, "old_asset_id": order.OldAssetID,
|
||||
"old_asset_identifier": order.OldAssetIdentifier, "shop_id": order.ShopID, "status": order.Status,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"status": order.Status, "migrate_data": order.MigrateData, "migration_status": order.MigrationStatus,
|
||||
},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
},
|
||||
{
|
||||
Type: constants.AuditResourceIotCard, ID: &cardID, Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCardExchangeOldCard,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(card),
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// toRiskExchangeResponse 将换货单投影为地址提交结果。
|
||||
// 地址取记录中的既有值:重复提交返回首次地址,不做任何覆盖。
|
||||
func toRiskExchangeResponse(order *model.ExchangeOrder) *dto.ClientRiskExchangeResponse {
|
||||
if order == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.ClientRiskExchangeResponse{
|
||||
ID: order.ID, ExchangeNo: order.ExchangeNo,
|
||||
Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status),
|
||||
FlowType: order.FlowType,
|
||||
OldAssetType: order.OldAssetType,
|
||||
OldAssetID: order.OldAssetID,
|
||||
OldAssetIdentifier: order.OldAssetIdentifier,
|
||||
RecipientName: order.RecipientName,
|
||||
RecipientPhone: order.RecipientPhone,
|
||||
RecipientAddress: order.RecipientAddress,
|
||||
MigrateData: order.MigrateData,
|
||||
MigrationStatus: order.MigrationStatus,
|
||||
MigrationStatusName: constants.GetExchangeMigrationStatusName(order.MigrationStatus),
|
||||
ExchangeReason: order.ExchangeReason,
|
||||
CreatedAt: order.CreatedAt,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user