Compare commits
33 Commits
7891189712
...
Iteration/
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c4ec7ba4 | |||
| 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 | |||
| 315a7de3e4 | |||
| 5ee8e3cb4a |
@@ -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 索引拆成完整业务能力;拆分后必须同步本表链接。
|
||||
|
||||
19
README.md
19
README.md
@@ -224,7 +224,7 @@ default:
|
||||
- **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式(包含错误码、消息、时间戳);Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx)和日志级别控制;敏感信息自动脱敏保护
|
||||
- **数据持久化**:GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力
|
||||
- **异步任务处理**:Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务
|
||||
- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `scene=device/iot_card`、`format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 功能总结 与 验收记录
|
||||
- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接。当前导出场景共 11 个:`device`、`iot_card`、`order`、`package`、`agent_wallet_transaction`、`agent_recharge`、`refund`、`exchange`、`commission_record`、`package_traffic_alert`、`expiring_asset`;其中 `exchange`、`agent_recharge`、`order`、`commission_record`、`expiring_asset` 的时间筛选固定为 `filters.start_time`/`filters.end_time`(带显式时区的 RFC3339 秒级时间,闭区间含两端),创建时规范化为 UTC 秒级字符串后冻结,执行期只按冻结值严格解析;临期资产导出另有受控入口 `POST /api/admin/expiring-assets/export`
|
||||
- **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 功能总结、接口回放示例 与 SQL 验收脚本
|
||||
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能;完整的权限检查功能支持路由级别的细粒度权限控制,支持平台过滤(web/h5/all)和超级管理员自动跳过(详见 功能总结、使用指南 和 权限检查使用指南)
|
||||
- **商户管理**:完整的商户(Shop)和商户账号管理功能,支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能(详见 使用指南 和 API 文档)
|
||||
@@ -246,14 +246,23 @@ default:
|
||||
### 导出任务接口示例
|
||||
|
||||
```bash
|
||||
# 创建导出任务(scene 支持 device / iot_card)
|
||||
# 创建导出任务(scene 支持 device / iot_card / order / package / agent_wallet_transaction /
|
||||
# agent_recharge / refund / exchange / commission_record / package_traffic_alert / expiring_asset)
|
||||
# exchange / agent_recharge / order / commission_record 的时间筛选固定写在 query.filters 下,
|
||||
# 取值必须为带显式时区的 RFC3339 秒级时间,闭区间含两端,任一端可省略
|
||||
curl -X POST '/api/admin/export-tasks' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-H 'Authorization: Bearer <token>' \\
|
||||
-d '{\"scene\":\"device\",\"format\":\"xlsx\"}'
|
||||
-d '{"scene":"order","format":"xlsx","query":{"filters":{"start_time":"2026-09-01T00:00:00+08:00","end_time":"2026-09-30T23:59:59+08:00"}}}'
|
||||
|
||||
# 查询导出任务列表(支持 scene/status/time 过滤)
|
||||
curl '/api/admin/export-tasks?page=1&page_size=20&scene=device' \\
|
||||
# 创建临期资产导出任务(受控入口,请求体与临期资产列表筛选一致)
|
||||
curl -X POST '/api/admin/expiring-assets/export' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-H 'Authorization: Bearer <token>' \\
|
||||
-d '{"format":"xlsx","days_max":7,"start_time":"2026-09-01T00:00:00+08:00"}'
|
||||
|
||||
# 查询导出任务列表(支持 scene/status/start_time/end_time 过滤)
|
||||
curl '/api/admin/export-tasks?page=1&page_size=20&scene=order&start_time=2026-09-01T00:00:00%2B08:00' \\
|
||||
-H 'Authorization: Bearer <token>'
|
||||
```
|
||||
|
||||
|
||||
@@ -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,10 +28,22 @@ 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.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
|
||||
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
|
||||
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
|
||||
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
|
||||
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
|
||||
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
|
||||
handlers.WeCom = admin.NewWeComHandler(nil, nil)
|
||||
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)
|
||||
handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil)
|
||||
handlers.WithdrawalQualification = admin.NewWithdrawalQualificationHandler(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)
|
||||
|
||||
@@ -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,10 +37,22 @@ 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.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
|
||||
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
|
||||
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
|
||||
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
|
||||
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
|
||||
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
|
||||
handlers.WeCom = admin.NewWeComHandler(nil, nil)
|
||||
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)
|
||||
handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil)
|
||||
handlers.WithdrawalQualification = admin.NewWithdrawalQualificationHandler(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)
|
||||
|
||||
@@ -16,28 +16,36 @@ 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/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/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"
|
||||
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"
|
||||
@@ -84,6 +92,13 @@ type workerRuntime struct {
|
||||
pollingIotCardStore *postgres.IotCardStore
|
||||
pollingBase *task.PollingBase
|
||||
lifecycleSvc *polling.PollingLifecycleService
|
||||
// refundChannelService 是渠道原路退款的唯一用例实例:执行、恢复与退款完成通知共用它,
|
||||
// 使恢复确认的成功与直接调用确认的成功走同一回写路径。
|
||||
refundChannelService *refundchannelApp.Service
|
||||
// carrierThresholdService 是通道流量阈值停复机与两个计划任务的唯一用例实例。
|
||||
carrierThresholdService *carrierThresholdApp.Service
|
||||
// assetAutoRenewalService 是资产钱包自动续费的每日扫描、复机消费者与恢复扫描的唯一用例实例。
|
||||
assetAutoRenewalService *assetAutoRenewalApp.Service
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -155,6 +170,11 @@ func runWorker(cfg *config.Config) {
|
||||
taskHandler.RegisterHandlers()
|
||||
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
|
||||
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, 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)
|
||||
@@ -281,16 +301,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,
|
||||
cfg.Worker.PollingTotalMaxConcurrency,
|
||||
)
|
||||
|
||||
pollingDeviceSimBindingStore := postgres.NewDeviceSimBindingStore(db, redisClient)
|
||||
pollingDeviceStore := postgres.NewDeviceStore(db, redisClient)
|
||||
lifecycleSvc := polling.NewPollingLifecycleService(
|
||||
@@ -301,9 +311,25 @@ 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,
|
||||
)
|
||||
if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
|
||||
stopResumeSvc.SetPollingCallback(lifecycleSvc)
|
||||
}
|
||||
if workerResult.Services.CarrierThreshold == nil {
|
||||
appLogger.Fatal("运营商通道流量阈值用例未配置")
|
||||
}
|
||||
|
||||
runtime := &workerRuntime{
|
||||
redisAddr: redisAddr,
|
||||
@@ -321,14 +347,69 @@ 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,
|
||||
}
|
||||
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)
|
||||
@@ -384,6 +465,17 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
|
||||
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))
|
||||
}
|
||||
@@ -405,6 +497,15 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
|
||||
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,
|
||||
),
|
||||
},
|
||||
owner,
|
||||
appLogger,
|
||||
@@ -473,6 +574,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)
|
||||
@@ -737,6 +906,56 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
|
||||
)); 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,
|
||||
@@ -784,6 +1003,34 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
|
||||
)); 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(
|
||||
@@ -796,6 +1043,21 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
|
||||
); 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
|
||||
}
|
||||
@@ -882,6 +1144,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 卡导入任务。
|
||||
|
||||
@@ -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
|
||||
@@ -313,3 +326,30 @@
|
||||
- **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
|
||||
- **更新触发条件**:触发范围口径变化、认领策略变化、或接缝改为批量探测
|
||||
|
||||
@@ -9,22 +9,37 @@
|
||||
|
||||
## 当前实际使用范围
|
||||
|
||||
系统当前使用微信预下单 `POST <ApiURL>/wxPreCreate` 与支付通知;代理在线充值恢复流程另有本地 `CommonQuery` 调用,用于主动查询支付订单状态。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序);本地 `CommonQuery` 代码保持现有请求格式、签名算法、状态映射和恢复语义不变。该源码事实仅表示本地候选实现及后续双读配置来源改造接缝,不证明真实富友渠道契约,也不证明验签、状态解释或恢复核验已通过。
|
||||
系统使用微信预下单 `POST <ApiURL>/wxPreCreate`、主扫统一下单 `POST <ApiURL>/preCreate` 与支付通知;代理在线充值恢复流程另有本地 `CommonQuery` 调用,用于主动查询支付订单状态。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序),主扫下单的订单类型为 `WECHAT` 或 `ALIPAY`;本地 `CommonQuery` 代码保持现有请求格式、签名算法、状态映射和恢复语义不变。该源码事实仅表示本地候选实现及后续双读配置来源改造接缝,不证明真实富友渠道契约,也不证明验签、状态解释或恢复核验已通过。
|
||||
|
||||
真实核验仍需隔离富友商户、明确接口权限、合法订单样本和允许的网络条件;在这些条件完成前,不得将查单、验签、状态映射或恢复结果作为上线证据。本 Change 不新增富友退款能力。
|
||||
## 原路退款
|
||||
|
||||
退款申请 `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`。
|
||||
|
||||
本文按官方契约记录退款接口,**未做真实渠道实测**:未实测只作记录,不作为阻塞、未完成任务或上线前置;真实渠道可退款性由维护者后续手工验证。真实验收需使用隔离商户验证两种交易类型、签名失败、金额不符、重复通知、退款受理与退款查询;本次不调用真实渠道。
|
||||
|
||||
端点、编码、签名字段、成功码、退款字段或通知语义变化时更新本文。
|
||||
|
||||
端点、编码、签名字段、成功码或通知语义变化时更新本文。
|
||||
|
||||
@@ -196,6 +196,10 @@
|
||||
|
||||
优先轮询是与现有普通轮询并行的高优先级调度队列,而不是一套新的轮询业务逻辑。资产可同时存在于普通轮询和优先轮询,进入优先队列不移除、暂停或改变普通轮询;优先队列仅使该资产额外优先执行同一套既有轮询内容、外部调用及状态同步。一次优先轮询执行成功后任务退出优先队列,资产仍按普通轮询继续运行;外部调用失败或超时按既有失败重试。第一版纳入无有效套餐、套餐过期续购、流量用完购买加油包、人工触发和普通轮询异常补偿五类场景。同一资产有未完成优先任务时,后续触发合并到该任务,追加触发次数、最近时间及来源,不重复调用同一轮普通轮询。优先队列复用普通轮询既有并发上限、失败重试和外部调用保护,仅调度顺序优先,不另建参数配置。
|
||||
|
||||
> **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 已确认的导出、时间筛选与代理自充收款方式基线
|
||||
|
||||
临期列表、佣金明细和套餐流量达量预警均复用现有异步导出任务,创建时冻结筛选条件、操作者及可见店铺范围。临期导出一行对应一项资产,取其当前生效主套餐最终到期时间和剩余天数;加油包不单独成行。流量达量预警导出一行对应一条预警记录,套餐、用量、总量、阈值和到期时间使用触发快照,店铺、业务员和用户组按导出执行时当前归属补充。佣金明细的入账后金额冻结每次佣金钱包变动后的实际余额,回溯记录可为负。
|
||||
@@ -206,7 +210,11 @@
|
||||
|
||||
## 2.17 已确认的报表基线
|
||||
|
||||
激活报表的采购数量以成功导入系统的设备数量计算,不另建采购或入库台账。功能上线后每日生成稳定日报快照;上线前日期不提供报表或明确显示无快照数据,不回填历史。累计激活设备严格采用任一当前关联卡已实名的口径;每日快照中的累计在网设备为已实名且存在有效主套餐的设备,活跃设备为该套餐周期内任一卡真流量大于零的设备,用量为设备当前套餐周期内全部关联卡真流量之和。报表可选择设备名称、型号、制造商、用户组、代理、店铺、业务员中的一个分组维度;未选择时汇总为一行。套餐续费按资产去重,统计期内有主套餐到期的资产计一次到期,至少成功续购一次主套餐计一次续费,续费率不超过 100%。日报快照冻结当天店铺、业务员及用户组归属。后端提供日/月趋势汇总数据和异步导出,图表渲染由前端负责。
|
||||
激活报表的采购数量以成功导入系统的设备数量计算,不另建采购或入库台账。
|
||||
|
||||
> **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 基线
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
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
|
||||
}
|
||||
809
internal/application/assetautorenewal/renew.go
Normal file
809
internal/application/assetautorenewal/renew.go
Normal file
@@ -0,0 +1,809 @@
|
||||
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 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)
|
||||
})
|
||||
}
|
||||
178
internal/application/assetautorenewal/service.go
Normal file
178
internal/application/assetautorenewal/service.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
493
internal/application/businessusergroup/service.go
Normal file
493
internal/application/businessusergroup/service.go
Normal file
@@ -0,0 +1,493 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
// SetMembers 把多个启用平台用户批量设置到指定启用组,直接替换每个账号的原归属。
|
||||
// 任一账号无效则整批不修改,成员前后值审计与业务事实同事务。
|
||||
func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.SetBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, 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.BusinessUserGroupMembersResult{GroupID: groupID, AccountIDs: 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 := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
// 按 id 升序锁账号行:账号行锁保证同一账号串行化,
|
||||
// 同时消除「清空时无成员行导致锁不到行」的幻读与「多账号相反顺序」的死锁。
|
||||
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, "读取平台用户原分组失败")
|
||||
}
|
||||
if err := store.ReplaceMemberGroup(ctx, accountIDs, group.ID, operatorID); err != nil {
|
||||
return mapMemberWriteError(err)
|
||||
}
|
||||
return s.appendMemberAudits(ctx, tx, group, accountIDs, before, operatorID)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ClearMembers 清空指定启用平台用户的业务用户组归属,任一账号无效则整批不修改。
|
||||
func (s *Service) ClearMembers(ctx context.Context, request *dto.ClearBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) {
|
||||
operatorID, err := s.requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountIDs, err := normalizeAccountIDs(request.AccountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &dto.BusinessUserGroupMembersResult{AccountIDs: accountIDs}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.groupStore.WithTx(tx)
|
||||
if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
|
||||
return 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, "读取平台用户原分组失败")
|
||||
}
|
||||
if err := store.ClearMembers(ctx, accountIDs); err != nil {
|
||||
return mapMemberWriteError(err)
|
||||
}
|
||||
return s.appendMemberAudits(ctx, tx, nil, accountIDs, before, operatorID)
|
||||
})
|
||||
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
|
||||
}
|
||||
s.auditWriter.Append(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 nil
|
||||
}
|
||||
|
||||
// appendMemberAudits 在业务事务内为每个账号追加一条成员归属事件。
|
||||
// 账号是实际被替换归属的资源,因此作为主要资源;目标组仅作引用,清空操作没有目标组。
|
||||
func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *model.BusinessUserGroup, accountIDs []uint, before map[uint]model.BusinessUserGroupMember, operatorID uint) 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 group != nil {
|
||||
summary = "设置平台用户业务用户组归属"
|
||||
afterGroupID = group.ID
|
||||
}
|
||||
for _, accountID := range accountIDs {
|
||||
account, exists := accountByID[accountID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
s.auditWriter.Append(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,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionalID(id uint) *string {
|
||||
if id == 0 {
|
||||
return nil
|
||||
}
|
||||
value := strconv.FormatUint(uint64(id), 10)
|
||||
return &value
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -42,6 +43,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 +71,8 @@ type Service struct {
|
||||
eventWriter EventWriter
|
||||
cache CacheInvalidator
|
||||
auditWriter StateAuditWriter
|
||||
// channelThreshold 为可选的通道阈值判定能力;未注入时流量观测不做阈值判定。
|
||||
channelThreshold ChannelThresholdEvaluator
|
||||
}
|
||||
|
||||
// NewService 创建卡实名观测应用服务。
|
||||
@@ -71,6 +80,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
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
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/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -80,6 +81,18 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
|
||||
return err
|
||||
}
|
||||
}
|
||||
if decision.ReadingAccepted && s.channelThreshold != nil {
|
||||
// 达量判定只使用本次已接受的网关累计读数,异常下降保护命中的观测不参与判定;
|
||||
// 判定失败必须回滚整个流量事务,与既有流量事实保持同事务语义。
|
||||
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
|
||||
CardID: card.ID, CarrierID: card.CarrierID,
|
||||
ReadingMB: decision.LastGatewayReadingMB, ObservedAt: observation.Metadata.ObservedAt,
|
||||
// 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。
|
||||
Source: observation.Metadata.Source,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
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,195 @@
|
||||
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)
|
||||
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 {
|
||||
now := time.Now().UTC()
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalQualificationStatusRejected,
|
||||
"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.WithdrawalQualificationStatusRejected
|
||||
version.DecidedAt = &now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":rejected",
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationRejected,
|
||||
Summary: "企业微信未通过提现资料资格",
|
||||
CorrelationID: event.CorrelationID, Qualification: version, Shop: shop,
|
||||
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
|
||||
})
|
||||
}
|
||||
404
internal/application/distributionwithdrawal/registration.go
Normal file
404
internal/application/distributionwithdrawal/registration.go
Normal file
@@ -0,0 +1,404 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// RegistrationResult 返回已落库的待审批注册记录与审批实例引用。
|
||||
type RegistrationResult struct {
|
||||
RegistrationID uint
|
||||
Status int
|
||||
ApprovalInstanceID uint
|
||||
ApprovalStatus int
|
||||
}
|
||||
|
||||
// RegistrationService 受理公开扫码注册。
|
||||
// 只创建待审批注册记录与审批实例,不创建店铺、账号、钱包或上下级归属。
|
||||
type RegistrationService struct {
|
||||
db *gorm.DB
|
||||
verifier VerificationCodeVerifier
|
||||
approval approvalapp.Port
|
||||
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}
|
||||
}
|
||||
|
||||
// Register 创建待审批注册记录。
|
||||
// 分销码无效、上级店铺停用、上级店铺缺少启用的主账号、短信验证码无效分别返回各自的错误码与提示。
|
||||
// 短信验证码只在注册记录与审批实例落库成功后消费:落库前的任何失败都不消费验证码,重试无需重新获取。
|
||||
// 手机号、用户名或店铺编号与既有账号/店铺冲突时返回对应已存在错误码;
|
||||
// 与其它待审批注册记录冲突时返回资源冲突错误并指明冲突字段。
|
||||
// 已通过或已驳回的终态记录不阻塞重新注册;同一关键字段的并发提交由事务级 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,287 @@
|
||||
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)
|
||||
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 {
|
||||
now := time.Now().UTC()
|
||||
reason := rejectionReason(event.Decision)
|
||||
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
|
||||
Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.AgentDistributionRegistrationStatusRejected,
|
||||
"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 = constants.AgentDistributionRegistrationStatusRejected
|
||||
registration.RejectReason = reason
|
||||
registration.DecidedAt = &now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "agent-distribution:" + uintText(registration.ID) + ":rejected",
|
||||
ActionCode: constants.AuditActionAgentDistributionRegistrationRejected,
|
||||
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
|
||||
}
|
||||
896
internal/application/distributionwithdrawal/withdrawal.go
Normal file
896
internal/application/distributionwithdrawal/withdrawal.go
Normal file
@@ -0,0 +1,896 @@
|
||||
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 request.Status != constants.WithdrawalStatusRejected {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 = ?", request.ID, constants.WithdrawalStatusRejected).
|
||||
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,365 @@
|
||||
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)
|
||||
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 {
|
||||
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 := rejectionReason(event.Decision)
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalStatusRejected,
|
||||
"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, frozenBefore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.ReleasedAt = &now
|
||||
before := map[string]any{"status": constants.WithdrawalStatusPending, "frozen_balance": frozenBefore}
|
||||
request.Status = constants.WithdrawalStatusRejected
|
||||
request.ProcessedAt = &now
|
||||
request.RejectReason = reason
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":closed",
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalAttemptClosed,
|
||||
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,
|
||||
frozenBefore int64,
|
||||
) (*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.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, "创建提现释放钱包流水失败")
|
||||
}
|
||||
_ = frozenBefore
|
||||
return transaction, nil
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -193,29 +193,21 @@ func (s *ManagementService) ListPools(ctx context.Context, req dto.PaymentMercha
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计商户池失败")
|
||||
}
|
||||
var pools []model.PaymentMerchantPool
|
||||
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&pools).Error; err != nil {
|
||||
var rows []model.PaymentMerchantPool
|
||||
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
|
||||
}
|
||||
poolIDs := make([]uint, 0, len(pools))
|
||||
for index := range pools {
|
||||
poolIDs = append(poolIDs, pools[index].ID)
|
||||
pools := make([]*model.PaymentMerchantPool, 0, len(rows))
|
||||
for index := range rows {
|
||||
pools = append(pools, &rows[index])
|
||||
}
|
||||
membersByPool := make(map[uint][]uint, len(pools))
|
||||
if len(poolIDs) > 0 {
|
||||
var members []model.PaymentMerchantPoolMember
|
||||
if err := s.db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
|
||||
}
|
||||
for index := range members {
|
||||
member := &members[index]
|
||||
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
|
||||
}
|
||||
projections, err := loadPoolProjections(ctx, s.db, pools, time.Now())
|
||||
if err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
|
||||
}
|
||||
result := make([]*dto.PaymentMerchantPoolResponse, 0, len(pools))
|
||||
for index := range pools {
|
||||
pool := &pools[index]
|
||||
result = append(result, &dto.PaymentMerchantPoolResponse{ID: pool.ID, Name: pool.Name, PaymentMethod: pool.PaymentMethod, Enabled: pool.Status == model.PaymentMerchantStatusEnabled, Strategy: pool.Strategy, ThresholdAmount: pool.ThresholdAmount, ThresholdCount: pool.ThresholdCount, StatisticCycle: pool.StatisticCycle, TimePeriodValue: pool.TimePeriodValue, TimePeriodUnit: pool.TimePeriodUnit, TimePeriodStartedAt: pool.TimePeriodStartedAt, RoutingEpoch: pool.RoutingEpoch, MemberIDs: membersByPool[pool.ID], Remark: pool.Remark})
|
||||
for _, pool := range pools {
|
||||
result = append(result, poolResponse(pool, projections[pool.ID]))
|
||||
}
|
||||
return result, total, nil
|
||||
}
|
||||
@@ -232,7 +224,7 @@ func (s *ManagementService) GetPool(ctx context.Context, id uint) (*dto.PaymentM
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
return s.poolView(ctx, &pool, time.Now())
|
||||
}
|
||||
|
||||
// ListMerchants 分页查询特权商户配置。
|
||||
@@ -429,8 +421,19 @@ func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.Payme
|
||||
if !creating && poolEpochChanged(&pool, &req, previousMemberIDs) {
|
||||
pool.RoutingEpoch++
|
||||
}
|
||||
// 时间轮询池修改周期数值、单位、起始时间或成员顺序并保存成功时,起点由服务端按保存成功时间重算,
|
||||
// 忽略请求携带的旧起点,避免前端时钟或漏传导致时段漂移。
|
||||
restartTimedSlot := !creating && strings.TrimSpace(req.Strategy) == model.PaymentMerchantStrategyTime && timedSlotChanged(&pool, &req, previousMemberIDs)
|
||||
pool.Name, pool.PaymentMethod, pool.Strategy, pool.Remark = strings.TrimSpace(req.Name), strings.TrimSpace(req.PaymentMethod), strings.TrimSpace(req.Strategy), strings.TrimSpace(req.Remark)
|
||||
pool.ThresholdAmount, pool.ThresholdCount, pool.StatisticCycle, pool.TimePeriodValue, pool.TimePeriodUnit, pool.TimePeriodStartedAt = req.ThresholdAmount, req.ThresholdCount, req.StatisticCycle, req.TimePeriodValue, req.TimePeriodUnit, req.TimePeriodStartedAt
|
||||
if creating {
|
||||
// 创建按请求起点;请求缺省起点时取保存时间。
|
||||
restartTimedSlot = pool.Strategy == model.PaymentMerchantStrategyTime && pool.TimePeriodStartedAt == nil
|
||||
}
|
||||
savedAt := time.Now()
|
||||
if restartTimedSlot {
|
||||
pool.TimePeriodStartedAt = &savedAt
|
||||
}
|
||||
if req.Enabled {
|
||||
var others int64
|
||||
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
|
||||
@@ -479,7 +482,7 @@ func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.Payme
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存商户池失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
return s.poolView(ctx, &pool, time.Now())
|
||||
}
|
||||
|
||||
// SetPoolEnabled enables or disables a pool after rechecking the active-pool and member invariants.
|
||||
@@ -537,7 +540,7 @@ func (s *ManagementService) SetPoolEnabled(ctx context.Context, id uint, enabled
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新商户池状态失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
return s.poolView(ctx, &pool, time.Now())
|
||||
}
|
||||
|
||||
func validatePoolMembers(ctx context.Context, tx *gorm.DB, method string, ids []uint) error {
|
||||
@@ -582,6 +585,17 @@ func poolEpochChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerch
|
||||
return pool.StatisticCycle == nil || (*pool.StatisticCycle != "day" && *pool.StatisticCycle != "month") || !sameMemberSet(previousMemberIDs, request.MemberIDs)
|
||||
}
|
||||
|
||||
// timedSlotChanged 判断保存时间轮询池时是否需要按保存成功时间重算时段起点:
|
||||
// 周期数值、单位、起始时间或成员顺序任一变化即视为需要重算。
|
||||
func timedSlotChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerchantPoolRequest, previousMemberIDs []uint) bool {
|
||||
if !sameInt64(pool.TimePeriodValue, request.TimePeriodValue) ||
|
||||
!sameString(pool.TimePeriodUnit, request.TimePeriodUnit) ||
|
||||
!sameTime(pool.TimePeriodStartedAt, request.TimePeriodStartedAt) {
|
||||
return true
|
||||
}
|
||||
return !sameMemberOrder(previousMemberIDs, request.MemberIDs)
|
||||
}
|
||||
|
||||
func sameMemberOrder(left, right []uint) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
@@ -633,16 +647,25 @@ func sameTime(a, b *time.Time) bool {
|
||||
func merchantResponse(m *model.PaymentMerchant) *dto.PaymentMerchantResponse {
|
||||
return &dto.PaymentMerchantResponse{ID: m.ID, Name: m.Name, PaymentMethod: m.PaymentMethod, ProviderType: m.ProviderType, MerchantIdentity: m.MerchantIdentity, Credentials: m.Credentials, CredentialVersion: m.CredentialVersion, Enabled: m.Status == model.PaymentMerchantStatusEnabled, Remark: m.Remark, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}
|
||||
}
|
||||
func poolResponse(ctx context.Context, db *gorm.DB, p *model.PaymentMerchantPool) (*dto.PaymentMerchantPoolResponse, error) {
|
||||
var rows []model.PaymentMerchantPoolMember
|
||||
if err := db.WithContext(ctx).Where("pool_id = ?", p.ID).Order("sort_order ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
// poolResponse 组装商户池响应,成员、成员计数与命中成员由只读投影提供。
|
||||
func poolResponse(p *model.PaymentMerchantPool, projection poolProjection) *dto.PaymentMerchantPoolResponse {
|
||||
response := &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: projection.MemberIDs, EnabledMemberCount: projection.EnabledMembers, MemberCount: len(projection.MemberIDs), UpdatedAt: p.UpdatedAt, Remark: p.Remark}
|
||||
if projection.CurrentMerchant != nil {
|
||||
merchantID := projection.CurrentMerchant.ID
|
||||
response.CurrentMerchantID = &merchantID
|
||||
response.CurrentMerchantName = projection.CurrentMerchant.Name
|
||||
}
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.MerchantID)
|
||||
return response
|
||||
}
|
||||
|
||||
// poolView 读取商户池的只读列表投影并组装响应。
|
||||
func (s *ManagementService) poolView(ctx context.Context, pool *model.PaymentMerchantPool, now time.Time) (*dto.PaymentMerchantPoolResponse, error) {
|
||||
projections, err := loadPoolProjections(ctx, s.db, []*model.PaymentMerchantPool{pool}, now)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
|
||||
}
|
||||
return &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: ids, Remark: p.Remark}, nil
|
||||
return poolResponse(pool, projections[pool.ID]), nil
|
||||
}
|
||||
|
||||
// GetAuthorization 查询特权全局授权配置。
|
||||
|
||||
@@ -249,58 +249,145 @@ func (l *RuntimeLoader) SelectForNewPaymentWithTx(ctx context.Context, tx *gorm.
|
||||
return &RouteSelection{Merchant: chosen, Pool: &pool}, nil
|
||||
}
|
||||
|
||||
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
|
||||
// routingOutcome 是只读选路结果。
|
||||
type routingOutcome struct {
|
||||
// Merchant 是当前命中成员。
|
||||
Merchant *model.PaymentMerchant
|
||||
// OpenNewRound 为真表示每轮累计已全部达标,调用方需以条件更新开启新一轮。
|
||||
OpenNewRound bool
|
||||
}
|
||||
|
||||
// routeStats 是已按统计世代隔离的商户成功累计。
|
||||
type routeStats struct {
|
||||
amounts map[uint]int64
|
||||
counts map[uint]int64
|
||||
}
|
||||
|
||||
// newRouteStats 创建空的成功累计聚合。
|
||||
func newRouteStats() routeStats {
|
||||
return routeStats{amounts: map[uint]int64{}, counts: map[uint]int64{}}
|
||||
}
|
||||
|
||||
// add 累加一条成功事实。
|
||||
func (s routeStats) add(row *model.PaymentMerchantRoutingSuccess) {
|
||||
s.amounts[row.MerchantID] += row.Amount
|
||||
s.counts[row.MerchantID]++
|
||||
}
|
||||
|
||||
// statisticCycle 返回金额/笔数池的统计周期。
|
||||
func statisticCycle(pool *model.PaymentMerchantPool) (string, error) {
|
||||
if pool.StatisticCycle == nil {
|
||||
return "", errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
|
||||
}
|
||||
return *pool.StatisticCycle, nil
|
||||
}
|
||||
|
||||
// thresholdValue 返回金额/笔数池的达标阈值。
|
||||
func thresholdValue(pool *model.PaymentMerchantPool) (int64, error) {
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount {
|
||||
if pool.ThresholdAmount == nil {
|
||||
return 0, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
|
||||
}
|
||||
return *pool.ThresholdAmount, nil
|
||||
}
|
||||
if pool.ThresholdCount == nil {
|
||||
return 0, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
|
||||
}
|
||||
return *pool.ThresholdCount, nil
|
||||
}
|
||||
|
||||
// selectRouteMember 只读计算当前命中成员,与支付创建共用同一套规则:
|
||||
// 金额/笔数取成员顺序中第一个未达阈值的成员;「每轮累计」全部达标时取成员顺序第一项,
|
||||
// 由调用方开启新一轮;自然日与自然月全部达标时视为当期无可用成员;时间方式按当前时段计算。
|
||||
// 池内只有一个启用成员时该成员固定命中,不因达到阈值被跳过、也不因全部达标被拒绝,
|
||||
// 该规则优先于自然周期拒绝语义,短路在全部达标判定之前。
|
||||
// 本函数 MUST NOT 写库:不创建支付单、不推进统计世代、不计入成功累计。
|
||||
func selectRouteMember(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, stats routeStats, now time.Time) (routingOutcome, error) {
|
||||
if len(merchants) == 0 {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
|
||||
return routingOutcome{}, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
|
||||
}
|
||||
if pool.Strategy == model.PaymentMerchantStrategyTime {
|
||||
return chooseTimedMerchant(pool, merchants, now)
|
||||
merchant, err := chooseTimedMerchant(pool, merchants, now)
|
||||
if err != nil {
|
||||
return routingOutcome{}, err
|
||||
}
|
||||
return routingOutcome{Merchant: merchant}, nil
|
||||
}
|
||||
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
|
||||
return routingOutcome{}, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
|
||||
}
|
||||
if pool.StatisticCycle == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
|
||||
cycle, err := statisticCycle(pool)
|
||||
if err != nil {
|
||||
return routingOutcome{}, err
|
||||
}
|
||||
threshold, err := thresholdValue(pool)
|
||||
if err != nil {
|
||||
return routingOutcome{}, err
|
||||
}
|
||||
if len(merchants) == 1 {
|
||||
return routingOutcome{Merchant: merchants[0]}, nil
|
||||
}
|
||||
for _, merchant := range merchants {
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount {
|
||||
if stats.amounts[merchant.ID] < threshold {
|
||||
return routingOutcome{Merchant: merchant}, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if stats.counts[merchant.ID] < threshold {
|
||||
return routingOutcome{Merchant: merchant}, nil
|
||||
}
|
||||
}
|
||||
if cycle != "round" {
|
||||
return routingOutcome{}, errors.New(errors.CodeNoPaymentConfig, "当前周期暂无可用商户")
|
||||
}
|
||||
return routingOutcome{Merchant: merchants[0], OpenNewRound: true}, nil
|
||||
}
|
||||
|
||||
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
|
||||
var stats routeStats
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount || pool.Strategy == model.PaymentMerchantStrategyCount {
|
||||
loaded, err := loadPoolRouteStats(ctx, tx, pool, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats = loaded
|
||||
}
|
||||
outcome, err := selectRouteMember(pool, merchants, stats, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !outcome.OpenNewRound {
|
||||
return outcome.Merchant, nil
|
||||
}
|
||||
// 每轮累计全部达标:当轮结束,以条件更新递增统计世代开启新一轮;
|
||||
// 世代已被并发改变时按并发冲突拒绝本次创建,绝不在旧世代上重复累计。
|
||||
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return outcome.Merchant, nil
|
||||
}
|
||||
|
||||
// loadPoolRouteStats 读取单个商户池当前统计世代的成功累计。
|
||||
// 自然日与自然月周期只统计支付时间不早于当期窗口起点的成功事实。
|
||||
func loadPoolRouteStats(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, now time.Time) (routeStats, error) {
|
||||
cycle, err := statisticCycle(pool)
|
||||
if err != nil {
|
||||
return routeStats{}, err
|
||||
}
|
||||
query := tx.WithContext(ctx).Where("pool_id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch)
|
||||
if start, limited := routingWindowStart(*pool.StatisticCycle, now); limited {
|
||||
if start, limited := routingWindowStart(cycle, now); limited {
|
||||
query = query.Where("paid_at >= ?", start)
|
||||
}
|
||||
var rows []model.PaymentMerchantRoutingSuccess
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
return routeStats{}, err
|
||||
}
|
||||
amounts := make(map[uint]int64, len(merchants))
|
||||
counts := make(map[uint]int64, len(merchants))
|
||||
for _, row := range rows {
|
||||
amounts[row.MerchantID] += row.Amount
|
||||
counts[row.MerchantID]++
|
||||
stats := newRouteStats()
|
||||
for index := range rows {
|
||||
stats.add(&rows[index])
|
||||
}
|
||||
for _, merchant := range merchants {
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount {
|
||||
if pool.ThresholdAmount == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
|
||||
}
|
||||
if amounts[merchant.ID] < *pool.ThresholdAmount {
|
||||
return merchant, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if pool.ThresholdCount == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
|
||||
}
|
||||
if counts[merchant.ID] < *pool.ThresholdCount {
|
||||
return merchant, nil
|
||||
}
|
||||
}
|
||||
if *pool.StatisticCycle != "round" {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig, "当前统计周期内暂无可用商户")
|
||||
}
|
||||
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return merchants[0], nil
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func chooseTimedMerchant(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
|
||||
@@ -353,6 +440,115 @@ func advanceRoutingEpoch(ctx context.Context, tx *gorm.DB, pool *model.PaymentMe
|
||||
return nil
|
||||
}
|
||||
|
||||
// poolProjection 是商户池列表投影的只读计算结果。
|
||||
type poolProjection struct {
|
||||
// MemberIDs 是按顺序排列的成员商户ID。
|
||||
MemberIDs []uint
|
||||
// EnabledMembers 是成员中当前启用且支付方式与池一致的商户数量。
|
||||
EnabledMembers int
|
||||
// CurrentMerchant 是当前命中成员;池停用或无可用成员时为空。
|
||||
CurrentMerchant *model.PaymentMerchant
|
||||
}
|
||||
|
||||
// loadPoolProjections 按页批量计算成员计数与当前命中成员,供列表与详情投影复用。
|
||||
// 只读:不创建支付单、不推进统计世代、不计入成功累计;查询次数为常数,不随池数量线性增长。
|
||||
// 池停用或没有可用成员时当前命中成员为空,不影响其余字段返回。
|
||||
func loadPoolProjections(ctx context.Context, db *gorm.DB, pools []*model.PaymentMerchantPool, now time.Time) (map[uint]poolProjection, error) {
|
||||
projections := make(map[uint]poolProjection, len(pools))
|
||||
if len(pools) == 0 {
|
||||
return projections, nil
|
||||
}
|
||||
poolIDs := make([]uint, 0, len(pools))
|
||||
for _, pool := range pools {
|
||||
poolIDs = append(poolIDs, pool.ID)
|
||||
}
|
||||
var members []model.PaymentMerchantPoolMember
|
||||
if err := db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
membersByPool := make(map[uint][]uint, len(pools))
|
||||
merchantIDs := make([]uint, 0, len(members))
|
||||
for index := range members {
|
||||
member := &members[index]
|
||||
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
|
||||
merchantIDs = append(merchantIDs, member.MerchantID)
|
||||
}
|
||||
merchants := make(map[uint]*model.PaymentMerchant)
|
||||
if len(merchantIDs) > 0 {
|
||||
var rows []model.PaymentMerchant
|
||||
if err := db.WithContext(ctx).Where("id IN ? AND status = ?", merchantIDs, model.PaymentMerchantStatusEnabled).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range rows {
|
||||
merchant := &rows[index]
|
||||
merchants[merchant.ID] = merchant
|
||||
}
|
||||
}
|
||||
// 金额与笔数启用池当前世代的成功累计一次查出:按池拼出「池 + 世代(+ 当期窗口起点)」
|
||||
// 条件,既避免逐池查询,也避免把其他世代与上一周期的成功事实读进内存。
|
||||
terms := make([]string, 0, len(pools))
|
||||
args := make([]any, 0, len(pools)*3)
|
||||
for _, pool := range pools {
|
||||
if pool.Status != model.PaymentMerchantStatusEnabled || len(membersByPool[pool.ID]) == 0 {
|
||||
continue
|
||||
}
|
||||
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
|
||||
continue
|
||||
}
|
||||
cycle, err := statisticCycle(pool)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if start, limited := routingWindowStart(cycle, now); limited {
|
||||
terms = append(terms, "(pool_id = ? AND routing_epoch = ? AND paid_at >= ?)")
|
||||
args = append(args, pool.ID, pool.RoutingEpoch, start)
|
||||
continue
|
||||
}
|
||||
terms = append(terms, "(pool_id = ? AND routing_epoch = ?)")
|
||||
args = append(args, pool.ID, pool.RoutingEpoch)
|
||||
}
|
||||
statsByPool := make(map[uint]routeStats, len(pools))
|
||||
if len(terms) > 0 {
|
||||
var rows []model.PaymentMerchantRoutingSuccess
|
||||
if err := db.WithContext(ctx).Where(strings.Join(terms, " OR "), args...).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range rows {
|
||||
row := &rows[index]
|
||||
stats, ok := statsByPool[row.PoolID]
|
||||
if !ok {
|
||||
stats = newRouteStats()
|
||||
statsByPool[row.PoolID] = stats
|
||||
}
|
||||
stats.add(row)
|
||||
}
|
||||
}
|
||||
for _, pool := range pools {
|
||||
ordered := orderedEnabledMembers(pool, membersByPool[pool.ID], merchants)
|
||||
projection := poolProjection{MemberIDs: membersByPool[pool.ID], EnabledMembers: len(ordered)}
|
||||
if pool.Status == model.PaymentMerchantStatusEnabled {
|
||||
if outcome, err := selectRouteMember(pool, ordered, statsByPool[pool.ID], now); err == nil {
|
||||
projection.CurrentMerchant = outcome.Merchant
|
||||
}
|
||||
}
|
||||
projections[pool.ID] = projection
|
||||
}
|
||||
return projections, nil
|
||||
}
|
||||
|
||||
// orderedEnabledMembers 按成员顺序返回启用且支付方式与池一致的商户。
|
||||
func orderedEnabledMembers(pool *model.PaymentMerchantPool, memberIDs []uint, merchants map[uint]*model.PaymentMerchant) []*model.PaymentMerchant {
|
||||
ordered := make([]*model.PaymentMerchant, 0, len(memberIDs))
|
||||
for _, id := range memberIDs {
|
||||
merchant := merchants[id]
|
||||
if merchant == nil || merchant.Status != model.PaymentMerchantStatusEnabled || merchant.PaymentMethod != pool.PaymentMethod {
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, merchant)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
// FreezeRoute writes only non-sensitive route facts onto the payment.
|
||||
func FreezeRoute(payment *model.Payment, route *RouteSelection) {
|
||||
if payment == nil || route == nil || route.Merchant == nil || route.Pool == nil {
|
||||
|
||||
@@ -51,6 +51,7 @@ type deliveryRequest struct {
|
||||
refID string
|
||||
refKey string
|
||||
expiresAt *time.Time
|
||||
popupSnapshot *model.NotificationPopupSnapshot
|
||||
}
|
||||
|
||||
// DeliveryService 校验接收人并幂等生成站内通知。
|
||||
@@ -123,6 +124,7 @@ func (s *DeliveryService) consumeDynamic(ctx context.Context, envelope outbox.De
|
||||
notificationType: payload.NotificationType, templateData: payload.TemplateData,
|
||||
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
|
||||
}
|
||||
// 载荷校验必须先于接收人解析:无效事件不应触发接收人查询。
|
||||
if err := validateDeliveryRequest(request); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -153,19 +155,45 @@ func validateDeliveryRequest(request deliveryRequest) error {
|
||||
if request.refType != "" && request.refID == "" && request.refKey == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少定位值")
|
||||
}
|
||||
// 投放快照与弹窗类型必须成对出现:非弹窗类型不得写快照,弹窗类型不得缺少快照。
|
||||
isPopup := constants.IsH5PopupNotificationType(request.notificationType)
|
||||
if request.popupSnapshot != nil && !isPopup {
|
||||
return errors.New(errors.CodeInvalidParam, "投放快照只允许用于弹窗通知类型")
|
||||
}
|
||||
if isPopup && request.popupSnapshot == nil {
|
||||
return errors.New(errors.CodeInvalidParam, "弹窗通知缺少投放快照")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
|
||||
// preparedDelivery 是一次事件共享的渲染结果、展示期与审计来源,与接收人数量无关。
|
||||
type preparedDelivery struct {
|
||||
rendered notificationinfra.Rendered
|
||||
now time.Time
|
||||
expiresAt *time.Time
|
||||
origin deliveryOrigin
|
||||
}
|
||||
|
||||
// deliveryOrigin 是投递审计的操作者与入口。
|
||||
// Outbox 消费路径留空,由统一审计从任务上下文补齐(与既有 worker 入口一致);
|
||||
// API 直投路径必须显式提供,因为个人客户请求上下文不携带审计上下文。
|
||||
type deliveryOrigin struct {
|
||||
actor audit.ActorInput
|
||||
source string
|
||||
}
|
||||
|
||||
// prepareDelivery 渲染模板并计算展示期;同一事件只计算一次,不随接收人重复计算。
|
||||
// 审计接缝缺失在此一次性判空:与既有行为一致,渲染之前就失败,而不是按接收人重复判断。
|
||||
func (s *DeliveryService) prepareDelivery(eventID, recipientKind string, request deliveryRequest, origin deliveryOrigin) (*preparedDelivery, error) {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
|
||||
}
|
||||
rendered, err := s.registry.Render(request.notificationType, request.templateData, request.refType, recipientKind)
|
||||
if err != nil {
|
||||
s.logger.Error("站内通知模板校验失败",
|
||||
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
|
||||
zap.String("failure_category", "template"))
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
|
||||
return nil, errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
expiresAt, err := notificationDisplayExpiry(rendered.Category, request.expiresAt, now)
|
||||
@@ -173,46 +201,23 @@ func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind st
|
||||
s.logger.Error("站内通知展示期限校验失败",
|
||||
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
|
||||
zap.String("failure_category", "display_policy"))
|
||||
return nil, err
|
||||
}
|
||||
return &preparedDelivery{rendered: rendered, now: now, expiresAt: expiresAt, origin: origin}, nil
|
||||
}
|
||||
|
||||
// deliver 对每个接收人执行同一套单接收人投放规则;接收人不可用时跳过,不影响其他接收人。
|
||||
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
|
||||
prepared, err := s.prepareDelivery(eventID, recipientKind, request, deliveryOrigin{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipientID := range recipientIDs {
|
||||
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
|
||||
notification, created, err := s.deliverOne(ctx, eventID, recipientKind, recipientID, request, prepared)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
|
||||
return err
|
||||
}
|
||||
if !active {
|
||||
s.logger.Info("站内通知接收人不可用,已跳过",
|
||||
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
|
||||
continue
|
||||
}
|
||||
notification := &model.Notification{
|
||||
EventID: eventID, RecipientKind: recipientKind,
|
||||
RecipientID: recipientID, Category: rendered.Category, Type: rendered.Type,
|
||||
Severity: rendered.Severity, Title: rendered.Title, Body: rendered.Body,
|
||||
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
|
||||
ExpiresAt: expiresAt, CreatedAt: now,
|
||||
}
|
||||
created := false
|
||||
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var createErr error
|
||||
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
|
||||
if createErr != nil || !created {
|
||||
return createErr
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
|
||||
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
Metadata: map[string]any{"outbox_event_id": eventID},
|
||||
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
|
||||
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
|
||||
nil, map[string]any{"created": true, "is_read": false})},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
|
||||
}
|
||||
if !created {
|
||||
if notification != nil && !created {
|
||||
s.logger.Info("站内通知重复事件已幂等忽略",
|
||||
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
|
||||
}
|
||||
@@ -220,6 +225,60 @@ func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind st
|
||||
return nil
|
||||
}
|
||||
|
||||
// deliverOne 校验接收人并在单事务内幂等写入一条通知。
|
||||
// 事件键与接收人已存在时不重复投放,回查并返回既有行(created=false);接收人不可用时返回 (nil, false, nil)。
|
||||
// Outbox 消费与候选查询直投共用本方法,落库规则只有一处。
|
||||
func (s *DeliveryService) deliverOne(ctx context.Context, eventID, recipientKind string, recipientID uint, request deliveryRequest, prepared *preparedDelivery) (*model.Notification, bool, error) {
|
||||
if eventID == "" || recipientID == 0 || prepared == nil {
|
||||
return nil, false, errors.New(errors.CodeInvalidParam, "通知事件或接收人不完整")
|
||||
}
|
||||
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
|
||||
}
|
||||
if !active {
|
||||
s.logger.Info("站内通知接收人不可用,已跳过",
|
||||
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
|
||||
return nil, false, nil
|
||||
}
|
||||
notification := &model.Notification{
|
||||
EventID: eventID, RecipientKind: recipientKind,
|
||||
RecipientID: recipientID, Category: prepared.rendered.Category, Type: prepared.rendered.Type,
|
||||
Severity: prepared.rendered.Severity, Title: prepared.rendered.Title, Body: prepared.rendered.Body,
|
||||
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
|
||||
ExpiresAt: prepared.expiresAt, CreatedAt: prepared.now, PopupSnapshot: request.popupSnapshot,
|
||||
}
|
||||
created := false
|
||||
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var createErr error
|
||||
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
|
||||
if createErr != nil || !created {
|
||||
return createErr
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
|
||||
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
|
||||
Actor: prepared.origin.actor, Source: prepared.origin.source,
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
Metadata: map[string]any{"outbox_event_id": eventID},
|
||||
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
|
||||
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
|
||||
nil, map[string]any{"created": true, "is_read": false})},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
|
||||
}
|
||||
if created {
|
||||
return notification, true, nil
|
||||
}
|
||||
existing, err := s.repository.FindByEventRecipient(ctx, eventID, recipientKind, recipientID)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "回查既有站内通知失败")
|
||||
}
|
||||
return existing, false, nil
|
||||
}
|
||||
|
||||
func notificationDisplayExpiry(category string, requested *time.Time, now time.Time) (*time.Time, error) {
|
||||
switch category {
|
||||
case constants.NotificationCategoryApproval:
|
||||
|
||||
63
internal/application/notification/direct.go
Normal file
63
internal/application/notification/direct.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// PersonalDirectRequest 是当次直投或复用个人客户通知的请求参数。
|
||||
// PopupSnapshot 只允许弹窗投放类型携带,其余类型必须为空。
|
||||
type PersonalDirectRequest struct {
|
||||
NotificationType string
|
||||
TemplateData map[string]string
|
||||
RefType string
|
||||
RefID string
|
||||
RefKey string
|
||||
ExpiresAt *time.Time
|
||||
PopupSnapshot *model.NotificationPopupSnapshot
|
||||
}
|
||||
|
||||
// DirectWriter 是「当次创建或复用个人客户通知」的窄接口。
|
||||
// 候选查询必须当次拿到可用通知标识,不能依赖 Outbox 消费延迟,因此需要这条同步入口。
|
||||
type DirectWriter interface {
|
||||
CreateOrGetPersonal(ctx context.Context, eventID string, customerID uint, request PersonalDirectRequest) (*model.Notification, error)
|
||||
}
|
||||
|
||||
// CreateOrGetPersonal 当次渲染并幂等写入个人客户通知;事件键已存在时不重复投放,回查并返回既有行。
|
||||
// 与 Outbox 消费共用同一渲染、展示期与幂等写入规则,避免两条链路规则漂移。
|
||||
func (s *DeliveryService) CreateOrGetPersonal(ctx context.Context, eventID string, customerID uint, request PersonalDirectRequest) (*model.Notification, error) {
|
||||
if customerID == 0 || eventID == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "个人客户通知参数不完整")
|
||||
}
|
||||
delivery := deliveryRequest{
|
||||
notificationType: request.NotificationType, templateData: request.TemplateData,
|
||||
refType: request.RefType, refID: request.RefID, refKey: request.RefKey,
|
||||
expiresAt: request.ExpiresAt, popupSnapshot: request.PopupSnapshot,
|
||||
}
|
||||
if err := validateDeliveryRequest(delivery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// API 直投不经过 Outbox 消费,自行提供渲染结果与展示期,但仍复用同一落库规则。
|
||||
// 个人客户请求上下文不携带审计上下文,直投必须显式声明操作者与入口,否则投递审计会被入口规则拒绝并静默降级。
|
||||
prepared, err := s.prepareDelivery(eventID, constants.NotificationRecipientKindPersonalCustomer, delivery, deliveryOrigin{
|
||||
actor: audit.ActorInput{Kind: constants.AuditActorPersonalCustomer, ID: strconv.FormatUint(uint64(customerID), 10)},
|
||||
source: constants.AuditSourcePersonalAPI,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notification, _, err := s.deliverOne(ctx, eventID, constants.NotificationRecipientKindPersonalCustomer, customerID, delivery, prepared)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if notification == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "个人客户通知接收人不可用")
|
||||
}
|
||||
return notification, nil
|
||||
}
|
||||
@@ -214,7 +214,13 @@ func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
|
||||
constants.NotificationRecipientKindPersonalCustomer,
|
||||
customerID,
|
||||
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem},
|
||||
[]string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated},
|
||||
[]string{
|
||||
constants.NotificationTypePackageExpiring,
|
||||
constants.NotificationTypeExchangeShippingCreated,
|
||||
constants.NotificationTypeH5PopupRiskExchange,
|
||||
constants.NotificationTypeH5PopupOperation,
|
||||
constants.NotificationTypeAssetAutoRenewalFailed,
|
||||
},
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
246
internal/application/operationsreport/generate.go
Normal file
246
internal/application/operationsreport/generate.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// Package operationsreport 实现运营报表日报快照的生成用例。
|
||||
//
|
||||
// 生成 = 读取生成时刻的只读事实(设备、当前有效关联卡、套餐使用记录、店铺与业务员、用户组、
|
||||
// 套餐与套餐系列)→ 按报表口径域组装三张快照表的行与头行 → 在单事务内整日替换该日全部行并校验不变量。
|
||||
// 用例本身不依赖 GORM、Fiber、Redis 或 Asynq;读写分别由 FactsReader 与 SnapshotWriter 端口提供。
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// DeviceActivationFact 是一台未删除设备在生成时刻的冻结事实。
|
||||
//
|
||||
// 归属(店铺、业务员、用户组与代理的两个取值)是生成时刻取值,之后不再重新解析:
|
||||
// 历史快照行不因后续归属变化被改写。
|
||||
type DeviceActivationFact struct {
|
||||
DeviceID uint
|
||||
VirtualNo string
|
||||
DeviceName string
|
||||
DeviceModel string
|
||||
Manufacturer string
|
||||
ShopID *uint
|
||||
ShopName string
|
||||
RootShopID *uint
|
||||
RootShopName string
|
||||
AgentAccountID *uint
|
||||
AgentAccountName string
|
||||
BusinessOwnerAccountID *uint
|
||||
BusinessOwnerName string
|
||||
BusinessUserGroupID *uint
|
||||
BusinessUserGroupName string
|
||||
Purchased bool
|
||||
Realnamed bool
|
||||
Online bool
|
||||
Active bool
|
||||
RealTrafficMB float64
|
||||
}
|
||||
|
||||
// RenewalFact 是一条到期事实及其续费判定结果在生成时刻的冻结事实。
|
||||
type RenewalFact struct {
|
||||
AssetType string
|
||||
AssetID uint
|
||||
AssetIdentifier string
|
||||
ExpiredUsageID uint
|
||||
PackageID uint
|
||||
PackageName string
|
||||
SeriesID *uint
|
||||
SeriesName string
|
||||
ShopID *uint
|
||||
ShopName string
|
||||
RootShopID *uint
|
||||
RootShopName string
|
||||
AgentAccountID *uint
|
||||
AgentAccountName string
|
||||
BusinessOwnerAccountID *uint
|
||||
BusinessOwnerName string
|
||||
BusinessUserGroupID *uint
|
||||
BusinessUserGroupName string
|
||||
Renewed bool
|
||||
}
|
||||
|
||||
// DayFacts 是一个快照日的全部源事实。
|
||||
type DayFacts struct {
|
||||
Devices []DeviceActivationFact
|
||||
Renewals []RenewalFact
|
||||
}
|
||||
|
||||
// FactsReader 读取生成一份日报快照所需的只读事实。
|
||||
type FactsReader interface {
|
||||
// CountUndeletedDevicesAsOf 是采购数量口径的取值端口,只被 domain.PurchaseCountAsOf 调用。
|
||||
CountUndeletedDevicesAsOf(ctx context.Context, snapshotDate time.Time) (int64, error)
|
||||
// LoadDayFacts 读取该快照日的设备事实与到期事实。
|
||||
LoadDayFacts(ctx context.Context, snapshotDate time.Time) (*DayFacts, error)
|
||||
}
|
||||
|
||||
// SnapshotWriter 整日替换日报快照。
|
||||
type SnapshotWriter interface {
|
||||
// ReplaceDay 在单事务内先删除该日三张快照表的全部行,再整日写入头行与两类明细行,并校验不变量。
|
||||
// 任一步失败必须回滚,使该日不残留部分口径。
|
||||
ReplaceDay(ctx context.Context, snapshotDate time.Time, snapshot model.OperationsReportSnapshot,
|
||||
activations []model.OperationsReportActivationRow, renewals []model.OperationsReportRenewalRow) error
|
||||
}
|
||||
|
||||
// Generator 生成某一天的运营报表日报快照。
|
||||
type Generator struct {
|
||||
reader FactsReader
|
||||
writer SnapshotWriter
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewGenerator 创建日报快照生成用例。
|
||||
func NewGenerator(reader FactsReader, writer SnapshotWriter, logger *zap.Logger) *Generator {
|
||||
return &Generator{reader: reader, writer: writer, logger: logger}
|
||||
}
|
||||
|
||||
// Generate 生成指定上海自然日的日报快照。
|
||||
// 同一日期重复生成的结果等于最后一次执行的结果:整日替换保证不产生重复行或第二套口径。
|
||||
func (g *Generator) Generate(ctx context.Context, snapshotDate time.Time) error {
|
||||
if g == nil || g.reader == nil || g.writer == nil {
|
||||
return errors.New(errors.CodeInternalError, "运营报表快照生成用例未配置")
|
||||
}
|
||||
day := domainreport.SnapshotDay(snapshotDate)
|
||||
|
||||
// 采购数量经唯一的采购数量口径函数取值(设计 D6)。
|
||||
purchasedCount, err := domainreport.PurchaseCountAsOf(ctx, g.reader, day)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
facts, err := g.reader.LoadDayFacts(ctx, day)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if facts == nil {
|
||||
facts = &DayFacts{}
|
||||
}
|
||||
|
||||
generatedAt := time.Now().UTC()
|
||||
snapshot, activations, renewals, err := BuildSnapshotRows(day, generatedAt, purchasedCount, facts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.writer.ReplaceDay(ctx, day, snapshot, activations, renewals); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if g.logger != nil {
|
||||
g.logger.Info("运营报表日报快照生成完成",
|
||||
zap.String("snapshot_date", domainreport.FormatSnapshotDay(day)),
|
||||
zap.Int64("purchased_device_count", snapshot.PurchasedDeviceCount),
|
||||
zap.Int64("activated_device_count", snapshot.ActivatedDeviceCount),
|
||||
zap.Int64("online_device_count", snapshot.OnlineDeviceCount),
|
||||
zap.Int64("active_device_count", snapshot.ActiveDeviceCount),
|
||||
zap.Int64("renewal_due_asset_count", snapshot.RenewalDueAssetCount),
|
||||
zap.Int64("renewal_renewed_asset_count", snapshot.RenewalRenewedAssetCount),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildSnapshotRows 把生成时刻的事实组装为头行与两类明细行。
|
||||
//
|
||||
// 头行值是明细行的汇总值(不是另一套独立读数),因此
|
||||
// 「同一快照日期、任一受支持维度下分组行各指标之和等于头行值」由构造保证;
|
||||
// 到期资产数与续费资产数按资产去重,续费资产恒为到期资产的子集。
|
||||
func BuildSnapshotRows(day time.Time, generatedAt time.Time, purchasedCount int64, facts *DayFacts) (
|
||||
model.OperationsReportSnapshot, []model.OperationsReportActivationRow, []model.OperationsReportRenewalRow, error) {
|
||||
activations := make([]model.OperationsReportActivationRow, 0, len(facts.Devices))
|
||||
snapshot := model.OperationsReportSnapshot{
|
||||
SnapshotDate: day,
|
||||
GeneratedAt: generatedAt,
|
||||
}
|
||||
for _, fact := range facts.Devices {
|
||||
if fact.Purchased {
|
||||
snapshot.PurchasedDeviceCount++
|
||||
}
|
||||
if fact.Realnamed {
|
||||
snapshot.ActivatedDeviceCount++
|
||||
}
|
||||
if fact.Online {
|
||||
snapshot.OnlineDeviceCount++
|
||||
}
|
||||
if fact.Active {
|
||||
snapshot.ActiveDeviceCount++
|
||||
}
|
||||
snapshot.TotalRealTrafficMB = domainreport.Round2(snapshot.TotalRealTrafficMB + fact.RealTrafficMB)
|
||||
activations = append(activations, model.OperationsReportActivationRow{
|
||||
SnapshotDate: day,
|
||||
DeviceID: fact.DeviceID,
|
||||
VirtualNo: fact.VirtualNo,
|
||||
DeviceName: fact.DeviceName,
|
||||
DeviceModel: fact.DeviceModel,
|
||||
Manufacturer: fact.Manufacturer,
|
||||
ShopID: fact.ShopID,
|
||||
ShopName: fact.ShopName,
|
||||
RootShopID: fact.RootShopID,
|
||||
RootShopName: fact.RootShopName,
|
||||
AgentAccountID: fact.AgentAccountID,
|
||||
AgentAccountName: fact.AgentAccountName,
|
||||
BusinessOwnerAccountID: fact.BusinessOwnerAccountID,
|
||||
BusinessOwnerName: fact.BusinessOwnerName,
|
||||
BusinessUserGroupID: fact.BusinessUserGroupID,
|
||||
BusinessUserGroupName: fact.BusinessUserGroupName,
|
||||
Purchased: fact.Purchased,
|
||||
Realnamed: fact.Realnamed,
|
||||
Online: fact.Online,
|
||||
Active: fact.Active,
|
||||
RealTrafficMB: domainreport.Round2(fact.RealTrafficMB),
|
||||
})
|
||||
}
|
||||
if snapshot.PurchasedDeviceCount != purchasedCount {
|
||||
// 采购数量口径函数与明细行必须描述同一总体;不一致说明本次读取跨越了设备增删,
|
||||
// 与其写入一套自相矛盾的快照,不如让该日失败(重试沿用同一目标日期,不产生部分口径)。
|
||||
return model.OperationsReportSnapshot{}, nil, nil, errors.New(errors.CodeDatabaseError,
|
||||
"运营报表快照的采购数量与设备事实不一致,本次生成已终止")
|
||||
}
|
||||
|
||||
renewals := make([]model.OperationsReportRenewalRow, 0, len(facts.Renewals))
|
||||
dueAssets := make(map[assetKey]struct{}, len(facts.Renewals))
|
||||
renewedAssets := make(map[assetKey]struct{}, len(facts.Renewals))
|
||||
for _, fact := range facts.Renewals {
|
||||
key := assetKey{AssetType: fact.AssetType, AssetID: fact.AssetID}
|
||||
dueAssets[key] = struct{}{}
|
||||
if fact.Renewed {
|
||||
renewedAssets[key] = struct{}{}
|
||||
}
|
||||
renewals = append(renewals, model.OperationsReportRenewalRow{
|
||||
SnapshotDate: day,
|
||||
AssetType: fact.AssetType,
|
||||
AssetID: fact.AssetID,
|
||||
AssetIdentifier: fact.AssetIdentifier,
|
||||
ExpiredUsageID: fact.ExpiredUsageID,
|
||||
PackageID: fact.PackageID,
|
||||
PackageName: fact.PackageName,
|
||||
SeriesID: fact.SeriesID,
|
||||
SeriesName: fact.SeriesName,
|
||||
ShopID: fact.ShopID,
|
||||
ShopName: fact.ShopName,
|
||||
RootShopID: fact.RootShopID,
|
||||
RootShopName: fact.RootShopName,
|
||||
AgentAccountID: fact.AgentAccountID,
|
||||
AgentAccountName: fact.AgentAccountName,
|
||||
BusinessOwnerAccountID: fact.BusinessOwnerAccountID,
|
||||
BusinessOwnerName: fact.BusinessOwnerName,
|
||||
BusinessUserGroupID: fact.BusinessUserGroupID,
|
||||
BusinessUserGroupName: fact.BusinessUserGroupName,
|
||||
Renewed: fact.Renewed,
|
||||
})
|
||||
}
|
||||
snapshot.RenewalDueAssetCount = int64(len(dueAssets))
|
||||
snapshot.RenewalRenewedAssetCount = int64(len(renewedAssets))
|
||||
|
||||
return snapshot, activations, renewals, nil
|
||||
}
|
||||
|
||||
// assetKey 是续费指标的资产去重键(沿用既有载体类型取值)。
|
||||
type assetKey struct {
|
||||
AssetType string
|
||||
AssetID uint
|
||||
}
|
||||
299
internal/application/packagetrafficalert/rule.go
Normal file
299
internal/application/packagetrafficalert/rule.go
Normal file
@@ -0,0 +1,299 @@
|
||||
// Package packagetrafficalert 收口套餐真流量预警的规则维护事务脚本与每日扫描用例。
|
||||
//
|
||||
// 规则维护是简单写:Handler → Application 事务脚本 → Persistence,事实与审计同事务。
|
||||
// 每日扫描是复杂写:Application 编排 → Domain 判定 → Port/Infrastructure 原子写入预警事实、
|
||||
// 可靠通知事件与审计;判定只使用套餐使用记录的真流量快照,不读取虚流量、展示量、卡级累计或通道累计。
|
||||
package packagetrafficalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
|
||||
"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"
|
||||
)
|
||||
|
||||
// RuleService 套餐真流量预警规则维护事务脚本。
|
||||
type RuleService struct {
|
||||
db *gorm.DB
|
||||
store *postgres.PackageTrafficAlertStore
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// NewRuleService 创建套餐真流量预警规则事务脚本。
|
||||
func NewRuleService(db *gorm.DB, store *postgres.PackageTrafficAlertStore, auditWriters ...*audit.Writer) *RuleService {
|
||||
service := &RuleService{db: db, store: store}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// Create 为套餐商品创建唯一预警规则。
|
||||
// 创建一律校验套餐存在且商品真流量额度大于零:商品 real_data_mb 只用于配置合法性,
|
||||
// 不作为扫描分母(分母取使用记录的真总量快照)。
|
||||
func (s *RuleService) Create(ctx context.Context, request *dto.CreatePackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleItem, error) {
|
||||
operatorID, err := requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request == nil || request.PackageID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "套餐商品ID不能为空")
|
||||
}
|
||||
if !packagetrafficalert.IsValidThresholdPercent(request.ThresholdPercent) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "真流量预警阈值必须大于等于 1 且小于等于 100,允许两位小数")
|
||||
}
|
||||
enabled := constants.StatusEnabled
|
||||
if request.Enabled != nil && !*request.Enabled {
|
||||
enabled = constants.StatusDisabled
|
||||
}
|
||||
rule := &model.PackageTrafficAlertRule{
|
||||
PackageID: request.PackageID,
|
||||
ThresholdPercent: packagetrafficalert.NormalizeThresholdPercent(request.ThresholdPercent),
|
||||
Enabled: enabled,
|
||||
Remark: request.Remark,
|
||||
BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
|
||||
}
|
||||
var response *dto.PackageTrafficAlertRuleItem
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
pkg, loadErr := s.loadPackage(ctx, tx, request.PackageID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if pkg.RealDataMB <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "该套餐商品真流量额度不大于零,不能启用真流量预警规则")
|
||||
}
|
||||
store := s.store.WithTx(tx)
|
||||
exists, existsErr := store.ExistsRuleByPackageID(ctx, request.PackageID)
|
||||
if existsErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, existsErr, "校验套餐预警规则失败")
|
||||
}
|
||||
if exists {
|
||||
return errors.New(errors.CodeInvalidParam, "套餐已存在真流量预警规则")
|
||||
}
|
||||
if createErr := store.CreateRule(ctx, rule); createErr != nil {
|
||||
if isDuplicateKey(createErr) {
|
||||
return errors.New(errors.CodeInvalidParam, "套餐已存在真流量预警规则")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐真流量预警规则失败")
|
||||
}
|
||||
item := toRuleItem(rule, pkg.PackageName, pkg.RealDataMB)
|
||||
if auditErr := s.appendRuleAudit(ctx, tx, constants.AuditActionPackageTrafficAlertRuleCreated,
|
||||
"创建套餐真流量预警规则", rule, pkg.PackageName, nil, ruleAuditSnapshot(rule, pkg.PackageName)); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
response = item
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Update 修改阈值、启停与备注。
|
||||
// 修改不回填既有预警,也不改写已冻结的预警快照;结果状态为启用时同样校验商品真流量额度大于零。
|
||||
func (s *RuleService) Update(ctx context.Context, ruleID uint, request *dto.UpdatePackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleItem, error) {
|
||||
operatorID, err := requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request == nil || ruleID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "预警规则ID不能为空")
|
||||
}
|
||||
if request.ThresholdPercent != nil && !packagetrafficalert.IsValidThresholdPercent(*request.ThresholdPercent) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "真流量预警阈值必须大于等于 1 且小于等于 100,允许两位小数")
|
||||
}
|
||||
var response *dto.PackageTrafficAlertRuleItem
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.store.WithTx(tx)
|
||||
rule, lockErr := store.LockRuleByID(ctx, ruleID)
|
||||
if lockErr != nil {
|
||||
return ruleLookupError(lockErr)
|
||||
}
|
||||
before := ruleAuditSnapshot(rule, "")
|
||||
if request.ThresholdPercent != nil {
|
||||
rule.ThresholdPercent = packagetrafficalert.NormalizeThresholdPercent(*request.ThresholdPercent)
|
||||
}
|
||||
if request.Enabled != nil {
|
||||
rule.Enabled = constants.StatusEnabled
|
||||
if !*request.Enabled {
|
||||
rule.Enabled = constants.StatusDisabled
|
||||
}
|
||||
}
|
||||
if request.Remark != nil {
|
||||
rule.Remark = *request.Remark
|
||||
}
|
||||
pkg, pkgErr := s.loadPackage(ctx, tx, rule.PackageID)
|
||||
if pkgErr != nil {
|
||||
return pkgErr
|
||||
}
|
||||
if rule.Enabled == constants.StatusEnabled && pkg.RealDataMB <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "该套餐商品真流量额度不大于零,不能启用真流量预警规则")
|
||||
}
|
||||
packageName := pkg.PackageName
|
||||
if updateErr := store.UpdateRule(ctx, rule, operatorID); updateErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐真流量预警规则失败")
|
||||
}
|
||||
after := ruleAuditSnapshot(rule, packageName)
|
||||
if before["enabled"] != after["enabled"] {
|
||||
action, summary := constants.AuditActionPackageTrafficAlertRuleEnabled, "启用套餐真流量预警规则"
|
||||
if rule.Enabled != constants.StatusEnabled {
|
||||
action, summary = constants.AuditActionPackageTrafficAlertRuleDisabled, "停用套餐真流量预警规则"
|
||||
}
|
||||
if auditErr := s.appendRuleAudit(ctx, tx, action, summary, rule, packageName,
|
||||
map[string]any{"enabled": before["enabled"]}, map[string]any{"enabled": after["enabled"]}); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
}
|
||||
if before["threshold_percent"] != after["threshold_percent"] || before["remark"] != after["remark"] {
|
||||
if auditErr := s.appendRuleAudit(ctx, tx, constants.AuditActionPackageTrafficAlertRuleUpdated,
|
||||
"更新套餐真流量预警规则", rule, packageName, before, after); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
}
|
||||
response = toRuleItem(rule, packageName, pkg.RealDataMB)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// loadPackage 查询套餐商品;不存在时按参数错误返回。
|
||||
func (s *RuleService) loadPackage(ctx context.Context, tx *gorm.DB, packageID uint) (*model.Package, error) {
|
||||
var pkg model.Package
|
||||
if err := tx.WithContext(ctx).Where("id = ?", packageID).First(&pkg).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "套餐商品不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐商品失败")
|
||||
}
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
// appendRuleAudit 在业务事务内追加预警规则事件。
|
||||
func (s *RuleService) appendRuleAudit(ctx context.Context, tx *gorm.DB, action, summary string,
|
||||
rule *model.PackageTrafficAlertRule, packageName string, before, after map[string]any) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "套餐真流量预警规则统一审计接缝未配置")
|
||||
}
|
||||
var resourceID *string
|
||||
if rule.ID != 0 {
|
||||
value := strconv.FormatUint(uint64(rule.ID), 10)
|
||||
resourceID = &value
|
||||
}
|
||||
displayName := packageName
|
||||
if displayName == "" {
|
||||
displayName = "套餐 " + strconv.FormatUint(uint64(rule.PackageID), 10)
|
||||
}
|
||||
// 使用 AppendAndGet:预警规则属于关键配置,「要求成功必达」的审计失败必须回滚事务(ENG-TX-001)。
|
||||
if _, 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(middleware.GetUserIDFromContext(ctx)), 10)},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourcePackageTrafficAlertRule, ID: resourceID,
|
||||
Key: strconv.FormatUint(uint64(rule.PackageID), 10), DisplayName: displayName,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePackageTrafficAlertRuleTarget,
|
||||
IdentitySnapshot: ruleAuditIdentity(rule, packageName), BeforeData: before, AfterData: after,
|
||||
}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireOperator 要求调用方已通过后台鉴权,否则拒绝写入。
|
||||
func requireOperator(ctx context.Context) (uint, error) {
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// ruleLookupError 把规则不存在映射为统一资源不可见错误。
|
||||
func ruleLookupError(err error) error {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则失败")
|
||||
}
|
||||
|
||||
// isDuplicateKey 判断数据库错误是否为唯一键冲突。
|
||||
func isDuplicateKey(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := err.Error()
|
||||
return strings.Contains(text, "23505") || strings.Contains(text, "duplicate key") || strings.Contains(text, "SQLSTATE 23505")
|
||||
}
|
||||
|
||||
// ruleAuditSnapshot 返回预警规则可审计的可变字段快照。
|
||||
func ruleAuditSnapshot(rule *model.PackageTrafficAlertRule, packageName string) map[string]any {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
snapshot := map[string]any{
|
||||
"package_id": rule.PackageID,
|
||||
"threshold_percent": rule.ThresholdPercent,
|
||||
"enabled": rule.Enabled,
|
||||
"remark": rule.Remark,
|
||||
}
|
||||
if packageName != "" {
|
||||
snapshot["package_name"] = packageName
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// ruleAuditIdentity 返回预警规则审计身份快照,字段落在注册表白名单内。
|
||||
func ruleAuditIdentity(rule *model.PackageTrafficAlertRule, packageName string) map[string]any {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
identity := map[string]any{
|
||||
"id": rule.ID, "package_id": rule.PackageID, "threshold_percent": rule.ThresholdPercent,
|
||||
"enabled": rule.Enabled, "remark": rule.Remark,
|
||||
}
|
||||
if packageName != "" {
|
||||
identity["package_name"] = packageName
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// toRuleItem 把规则投影为对外响应项。
|
||||
func toRuleItem(rule *model.PackageTrafficAlertRule, packageName string, realDataMB int64) *dto.PackageTrafficAlertRuleItem {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.PackageTrafficAlertRuleItem{
|
||||
ID: rule.ID,
|
||||
PackageID: rule.PackageID,
|
||||
PackageName: packageName,
|
||||
RealDataMB: realDataMB,
|
||||
ThresholdPercent: rule.ThresholdPercent,
|
||||
Enabled: rule.Enabled == constants.StatusEnabled,
|
||||
EnabledName: enabledName(rule.Enabled),
|
||||
Remark: rule.Remark,
|
||||
UpdatedAt: rule.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// enabledName 返回启停状态的中文名称。
|
||||
func enabledName(enabled int) string {
|
||||
if enabled == constants.StatusEnabled {
|
||||
return "启用"
|
||||
}
|
||||
return "停用"
|
||||
}
|
||||
302
internal/application/packagetrafficalert/scan.go
Normal file
302
internal/application/packagetrafficalert/scan.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package packagetrafficalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// AssetKey 是扫描的资产聚合键:卡按 iot_card_id、设备按 device_id,二者互斥非零。
|
||||
type AssetKey struct {
|
||||
AssetType string
|
||||
AssetID uint
|
||||
}
|
||||
|
||||
// AssetAggregate 是同一资产全部当前有效套餐使用记录的真流量汇总。
|
||||
// UsedMB 汇总真已用量,LimitMB 汇总真总量快照;虚流量、展示量、卡级累计与通道累计一律不参与。
|
||||
type AssetAggregate struct {
|
||||
Key AssetKey
|
||||
UsedMB int64
|
||||
LimitMB int64
|
||||
}
|
||||
|
||||
// MainUsage 是资产的主套餐使用记录(阈值来源与预警锚点)。
|
||||
type MainUsage struct {
|
||||
PackageUsageID uint
|
||||
PackageID uint
|
||||
PackageName string
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// EnabledRule 是主套餐对应的当前启用预警规则。
|
||||
type EnabledRule struct {
|
||||
ID uint
|
||||
PackageID uint
|
||||
ThresholdPercent float64
|
||||
}
|
||||
|
||||
// AssetFacts 是触发时必须冻结的资产与归属展示事实。
|
||||
// 资产标识、卡标识、对端标识、设备类型与型号取自触发时的卡与设备绑定;
|
||||
// 归属只包含触发时店铺与「仅业务员」解析出的有效平台业务员。
|
||||
type AssetFacts struct {
|
||||
AssetIdentifier string
|
||||
CardIdentifier string
|
||||
CounterpartIdentifier string
|
||||
DeviceType string
|
||||
DeviceModel string
|
||||
ShopID uint
|
||||
ShopName string
|
||||
BusinessOwnerID *uint
|
||||
BusinessOwnerName string
|
||||
}
|
||||
|
||||
// ScanReader 读取扫描所需的只读事实。
|
||||
type ScanReader interface {
|
||||
// LoadAssetAggregates 按资产汇总当前有效套餐的真已用量与真总量快照。
|
||||
LoadAssetAggregates(ctx context.Context) ([]AssetAggregate, error)
|
||||
// LoadMainUsages 批量读取每个资产的主套餐使用记录(master_usage_id 为空,按优先级/生效时间/编号取第一条)。
|
||||
LoadMainUsages(ctx context.Context, keys []AssetKey) (map[AssetKey]MainUsage, error)
|
||||
// LoadEnabledRules 批量读取套餐商品当前启用的预警规则。
|
||||
LoadEnabledRules(ctx context.Context, packageIDs []uint) (map[uint]EnabledRule, error)
|
||||
// LoadAssetFacts 批量读取资产展示事实与触发时归属。
|
||||
LoadAssetFacts(ctx context.Context, keys []AssetKey) (map[AssetKey]AssetFacts, error)
|
||||
}
|
||||
|
||||
// AlertCandidate 是一次命中要原子落库的完整事实。
|
||||
type AlertCandidate struct {
|
||||
Alert model.PackageTrafficAlert
|
||||
// Notification 为空表示触发时店铺无有效业务员或到期时间不可推算,只保存预警不写通知事件。
|
||||
Notification *NotificationRequest
|
||||
}
|
||||
|
||||
// NotificationRequest 是一次可靠通知事件的最小输入。
|
||||
// 接收人是触发时冻结的业务员账号,投递期不再重新解析店铺业务员,避免向未来业务员补发。
|
||||
type NotificationRequest struct {
|
||||
RecipientAccountID uint
|
||||
ShopID uint
|
||||
TemplateData map[string]string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// AlertWriter 在同一事务内写入预警事实、可靠通知事件与审计。
|
||||
type AlertWriter interface {
|
||||
// SaveAlert 幂等创建预警;返回 false 表示唯一键冲突(视为已处理,不写事件与审计)。
|
||||
SaveAlert(ctx context.Context, candidate AlertCandidate) (bool, error)
|
||||
}
|
||||
|
||||
// ScanService 执行每日套餐真流量达量扫描。
|
||||
type ScanService struct {
|
||||
reader ScanReader
|
||||
writer AlertWriter
|
||||
logger *zap.Logger
|
||||
// now 可在验证时替换,默认使用系统时间。
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewScanService 创建套餐真流量达量扫描用例。
|
||||
func NewScanService(reader ScanReader, writer AlertWriter, logger *zap.Logger) *ScanService {
|
||||
return &ScanService{reader: reader, writer: writer, logger: logger, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// ScanResult 汇总一次扫描的可观察结果。
|
||||
type ScanResult struct {
|
||||
Assets int
|
||||
Hits int
|
||||
Created int
|
||||
Duplicates int
|
||||
Skipped int
|
||||
}
|
||||
|
||||
// Run 执行一次可重跑扫描:按资产汇总真流量,按主套餐规则阈值判定,命中即原子落库。
|
||||
func (s *ScanService) Run(ctx context.Context) error {
|
||||
if s == nil || s.reader == nil || s.writer == nil {
|
||||
return errors.New(errors.CodeInternalError, "套餐真流量达量扫描用例未配置")
|
||||
}
|
||||
aggregates, err := s.reader.LoadAssetAggregates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := &ScanResult{Assets: len(aggregates)}
|
||||
if len(aggregates) == 0 {
|
||||
s.logScan(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]AssetKey, 0, len(aggregates))
|
||||
for _, aggregate := range aggregates {
|
||||
keys = append(keys, aggregate.Key)
|
||||
}
|
||||
mainUsages, err := s.reader.LoadMainUsages(ctx, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err := s.loadRulesForUsages(ctx, mainUsages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
facts, err := s.reader.LoadAssetFacts(ctx, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
triggeredAt := s.now()
|
||||
for _, aggregate := range aggregates {
|
||||
main, hasMain := mainUsages[aggregate.Key]
|
||||
if !hasMain {
|
||||
// 全是加油包、没有主套餐的资产没有阈值来源,直接跳过。
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
rule, hasRule := rules[main.PackageID]
|
||||
if !hasRule {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if aggregate.LimitMB <= 0 {
|
||||
// 汇总分母不是正数的资产不可判定,跳过而不是写入不可用的预警。
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
thresholdBasisPoints := packagetrafficalert.ThresholdBasisPoints(rule.ThresholdPercent)
|
||||
hit, ratioBasisPoints := packagetrafficalert.Decide(aggregate.UsedMB, aggregate.LimitMB, thresholdBasisPoints)
|
||||
if !hit {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
result.Hits++
|
||||
candidate := s.buildCandidate(aggregate, main, rule, ratioBasisPoints, facts[aggregate.Key], triggeredAt)
|
||||
created, saveErr := s.writer.SaveAlert(ctx, candidate)
|
||||
if saveErr != nil {
|
||||
s.logger.Error("套餐真流量达量预警写入失败",
|
||||
zap.String("asset_type", aggregate.Key.AssetType),
|
||||
zap.Uint("asset_id", aggregate.Key.AssetID),
|
||||
zap.Error(saveErr))
|
||||
return saveErr
|
||||
}
|
||||
if created {
|
||||
result.Created++
|
||||
} else {
|
||||
result.Duplicates++
|
||||
}
|
||||
}
|
||||
s.logScan(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRulesForUsages 批量读取主套餐对应的启用规则。
|
||||
func (s *ScanService) loadRulesForUsages(ctx context.Context, usages map[AssetKey]MainUsage) (map[uint]EnabledRule, error) {
|
||||
seen := make(map[uint]struct{}, len(usages))
|
||||
packageIDs := make([]uint, 0, len(usages))
|
||||
for _, usage := range usages {
|
||||
if usage.PackageID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[usage.PackageID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[usage.PackageID] = struct{}{}
|
||||
packageIDs = append(packageIDs, usage.PackageID)
|
||||
}
|
||||
if len(packageIDs) == 0 {
|
||||
return map[uint]EnabledRule{}, nil
|
||||
}
|
||||
sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] })
|
||||
return s.reader.LoadEnabledRules(ctx, packageIDs)
|
||||
}
|
||||
|
||||
// buildCandidate 组装唯一的资产级预警事实与可选通知请求。
|
||||
func (s *ScanService) buildCandidate(aggregate AssetAggregate, main MainUsage, rule EnabledRule,
|
||||
ratioBasisPoints int64, facts AssetFacts, triggeredAt time.Time) AlertCandidate {
|
||||
packageName := main.PackageName
|
||||
if packageName == "" {
|
||||
packageName = "套餐#" + strconv.FormatUint(uint64(main.PackageID), 10)
|
||||
}
|
||||
assetIdentifier := facts.AssetIdentifier
|
||||
if assetIdentifier == "" {
|
||||
// 回落值同步写入快照,保证快照、列表与通知正文一致。
|
||||
assetIdentifier = "资产#" + strconv.FormatUint(uint64(aggregate.Key.AssetID), 10)
|
||||
}
|
||||
alert := model.PackageTrafficAlert{
|
||||
PackageUsageID: main.PackageUsageID,
|
||||
PackageID: main.PackageID,
|
||||
RuleID: rule.ID,
|
||||
AssetType: aggregate.Key.AssetType,
|
||||
AssetID: aggregate.Key.AssetID,
|
||||
AssetIdentifierSnapshot: assetIdentifier,
|
||||
CardIdentifierSnapshot: facts.CardIdentifier,
|
||||
CounterpartIdentifierSnapshot: facts.CounterpartIdentifier,
|
||||
DeviceTypeSnapshot: facts.DeviceType,
|
||||
DeviceModelSnapshot: facts.DeviceModel,
|
||||
PackageNameSnapshot: packageName,
|
||||
UsedMBSnapshot: aggregate.UsedMB,
|
||||
LimitMBSnapshot: aggregate.LimitMB,
|
||||
UsagePercentSnapshot: packagetrafficalert.PercentFromBasisPoints(ratioBasisPoints),
|
||||
ThresholdPercentSnapshot: packagetrafficalert.NormalizeThresholdPercent(rule.ThresholdPercent),
|
||||
ExpiresAtSnapshot: main.ExpiresAt,
|
||||
TriggeredAt: triggeredAt,
|
||||
ShopIDSnapshot: facts.ShopID,
|
||||
ShopNameSnapshot: facts.ShopName,
|
||||
BusinessOwnerAccountIDSnapshot: facts.BusinessOwnerID,
|
||||
BusinessOwnerNameSnapshot: facts.BusinessOwnerName,
|
||||
}
|
||||
candidate := AlertCandidate{Alert: alert}
|
||||
if facts.BusinessOwnerID == nil || *facts.BusinessOwnerID == 0 {
|
||||
// 无有效业务员:只保存预警,不写通知事件,也不在未来补发。
|
||||
return candidate
|
||||
}
|
||||
candidate.Notification = &NotificationRequest{
|
||||
RecipientAccountID: *facts.BusinessOwnerID,
|
||||
ShopID: facts.ShopID,
|
||||
ExpiresAt: notificationExpiresAt(main.ExpiresAt, triggeredAt),
|
||||
TemplateData: map[string]string{
|
||||
"asset_identifier": assetIdentifier,
|
||||
"package_name": packageName,
|
||||
"usage_percent": formatPercent(packagetrafficalert.PercentFromBasisPoints(ratioBasisPoints)),
|
||||
"threshold_percent": formatPercent(alert.ThresholdPercentSnapshot),
|
||||
},
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
// notificationExpiresAt 计算站内通知的展示期结束时间。
|
||||
// 优先使用主套餐到期时间快照;快照为空时沿用既有默认展示期常量兜底,
|
||||
// 预警行的到期时间快照保持为空,不伪造业务到期时间。
|
||||
func notificationExpiresAt(snapshot *time.Time, triggeredAt time.Time) time.Time {
|
||||
if snapshot != nil {
|
||||
return snapshot.UTC()
|
||||
}
|
||||
return triggeredAt.AddDate(0, 0, constants.NotificationSystemDefaultDisplayDays).UTC()
|
||||
}
|
||||
|
||||
// formatPercent 把百分比格式化为最多两位小数、去掉无意义尾零的展示文本。
|
||||
func formatPercent(value float64) string {
|
||||
return strconv.FormatFloat(packagetrafficalert.NormalizeThresholdPercent(value), 'f', -1, 64)
|
||||
}
|
||||
|
||||
// logScan 输出一次扫描的结构化结果,供维护者按日志核对。
|
||||
func (s *ScanService) logScan(result *ScanResult) {
|
||||
if s.logger == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Info("套餐真流量达量扫描完成",
|
||||
zap.Int("assets", result.Assets),
|
||||
zap.Int("hits", result.Hits),
|
||||
zap.Int("created", result.Created),
|
||||
zap.Int("duplicates", result.Duplicates),
|
||||
zap.Int("skipped", result.Skipped))
|
||||
}
|
||||
|
||||
// EventIDFor 返回预警通知事件的稳定ID:内嵌主套餐使用记录与阈值快照(万分比)。
|
||||
func EventIDFor(packageUsageID uint, thresholdPercent float64) string {
|
||||
return fmt.Sprintf("%s:%d:%d", constants.PackageTrafficAlertEventIDPrefix, packageUsageID,
|
||||
packagetrafficalert.ThresholdBasisPoints(thresholdPercent))
|
||||
}
|
||||
62
internal/application/prioritypolling/event.go
Normal file
62
internal/application/prioritypolling/event.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Package prioritypolling 定义卡轮询优先队列的应用层契约。
|
||||
//
|
||||
// 本包只承载业务事实的形状与执行语义,不依赖 Fiber、GORM、Redis、Asynq 或具体外部 SDK;
|
||||
// Outbox 写入、消费者展开与执行接缝适配分别位于基础设施与任务层。
|
||||
package prioritypolling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PriorityRequestedEvent 是业务成功边界可靠请求「为该资源的卡建立优先轮询项」的事实。
|
||||
//
|
||||
// 载荷沿用既有业务观测事件的形状(资源类型 / 资源 ID / 资源 ID 列表),并在业务事务内冻结卡快照:
|
||||
// 独立卡为自身(ResourceIDs 为空),绑定设备的资产为绑定状态有效的全部在用卡(写入 ResourceIDs)。
|
||||
// TriggerType 取值与 pkg/constants.PollingPriorityTrigger* 一致;本轮要逐类型建项的任务类型集合
|
||||
// 由代码常量给出(pkg/constants.PollingPriorityTaskTypes),不由事件载荷决定,避免同一决策出现两个权威。
|
||||
// 来源订单与来源套餐使用记录只保存 ID,由消费者与读侧显式查询。
|
||||
type PriorityRequestedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID uint `json:"resource_id"`
|
||||
ResourceIDs []uint `json:"resource_ids,omitempty"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
SourceOrderID uint `json:"source_order_id,omitempty"`
|
||||
SourcePackageUsageID uint `json:"source_package_usage_id,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// PromptPublisher 把优先执行提示下发到既有轮询提示通道。
|
||||
//
|
||||
// 提示通道不是权威:丢失或缓存服务重启时库内活动优先项仍在,后续普通轮询执行按条件认领仍会执行,
|
||||
// 只退化为延迟一个普通轮询周期;调用方不得以提示的下发成功作为入队事实成立的条件。
|
||||
type PromptPublisher interface {
|
||||
EnqueuePriority(ctx context.Context, cardID uint, taskType string) error
|
||||
}
|
||||
|
||||
// PriorityEventWriter 在原业务事务中追加优先轮询请求事件。
|
||||
type PriorityEventWriter interface {
|
||||
AppendPriorityRequested(ctx context.Context, tx *gorm.DB, event PriorityRequestedEvent) error
|
||||
}
|
||||
|
||||
type pollingPriorityTriggerKey struct{}
|
||||
|
||||
// WithPollingPriorityTrigger 标记本次业务评估来自普通套餐轮询。
|
||||
//
|
||||
// 「资产无有效套餐」这一场景在停复机评估内部判定,而该评估入口被观测消费者、手动实名、保护期轮询、
|
||||
// 既有兼容入口等多条调用链复用;只有普通套餐轮询来源才允许追加优先轮询请求,
|
||||
// 否则会在观测序列、手动实名、保护期与退款等上下文凭空产生加急触发。
|
||||
func WithPollingPriorityTrigger(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, pollingPriorityTriggerKey{}, true)
|
||||
}
|
||||
|
||||
// IsPollingPriorityTrigger 判断当前业务评估是否来自普通套餐轮询。
|
||||
func IsPollingPriorityTrigger(ctx context.Context) bool {
|
||||
value, _ := ctx.Value(pollingPriorityTriggerKey{}).(bool)
|
||||
return value
|
||||
}
|
||||
@@ -4,9 +4,12 @@ package refundapproval
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
@@ -16,11 +19,37 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RefundMaterial 承载本次提交由调用方按既有展示口径解析出的审批材料事实。
|
||||
//
|
||||
// 材料由服务层解析后传入,使审批材料与退款展示字段共用同一份解析规则(ENG-ARCH-002:
|
||||
// 读取口径不复制);材料不可解析时以空值或零值传入,MUST NOT 阻断审批提交。
|
||||
type RefundMaterial struct {
|
||||
// AssetType 是退款关联资产类型:card 单卡、device 设备,沿用退款申请的下单快照推导。
|
||||
AssetType string
|
||||
// DeviceType 与 DeviceModel 取退款申请关联设备,无关联设备时为空串。
|
||||
DeviceType string
|
||||
DeviceModel string
|
||||
// PackageUsedMB 与 PackageTotalMB 是当前退款套餐的已用量与总量(MB,真实流量),解析不到时为 0。
|
||||
PackageUsedMB int64
|
||||
PackageTotalMB int64
|
||||
// OriginalChannelTradeNo 是创建申请时冻结的原支付渠道交易流水号,线下订单为空串。
|
||||
OriginalChannelTradeNo string
|
||||
}
|
||||
|
||||
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
|
||||
//
|
||||
// 本次提交的审批尝试记录由本用例在退款申请落库后于同一事务内构造,调用方只提供
|
||||
// 需要按冻结商户派生、应用层无法自行生成的渠道退款请求号,以及本次提交的申请人备注与审批材料。
|
||||
type CreateCommand struct {
|
||||
Refund *model.RefundRequest
|
||||
Order *model.Order
|
||||
SubmitterAccountID uint
|
||||
// ChannelRefundRequestNo 是原路退款本次尝试冻结的渠道退款请求号;非原路方式为空。
|
||||
ChannelRefundRequestNo string
|
||||
// ApplicantRemark 是本次提交的申请人备注,冻结进当次审批尝试快照;不写入退款主表审批备注。
|
||||
ApplicantRemark string
|
||||
// Material 是本次提交的审批材料事实,冻结进当次尝试快照与审批业务快照。
|
||||
Material RefundMaterial
|
||||
}
|
||||
|
||||
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
|
||||
@@ -29,6 +58,12 @@ type ApplicationAudit struct {
|
||||
Order *model.Order
|
||||
Approval *model.ApprovalInstance
|
||||
Submitter *model.Account
|
||||
// Attempt 非空时表示本次写入新增了一条审批尝试记录。
|
||||
Attempt *model.RefundRequestAttempt
|
||||
// Action 与 EventID 为空时按「首次提交」写入;重提时由调用方显式指定,
|
||||
// 使同一次重提的审计事件在该尝试上保持幂等。
|
||||
Action string
|
||||
EventID string
|
||||
}
|
||||
|
||||
// AuditWriter 接收退款申请事务内审计事实。
|
||||
@@ -39,11 +74,16 @@ type AuditWriter interface {
|
||||
// CreateResult 返回原子保存后的退款申请和初始审批状态。
|
||||
type CreateResult struct {
|
||||
Refund *model.RefundRequest
|
||||
Attempt *model.RefundRequestAttempt
|
||||
SubmitterName string
|
||||
ApprovalStatus int
|
||||
}
|
||||
|
||||
// CreationService 原子创建退款申请、通用审批实例、企微上下文和提交 Outbox。
|
||||
// CreationService 原子创建退款申请、审批尝试记录、通用审批实例和提交 Outbox。
|
||||
//
|
||||
// 每次提交或重提新增一条不可变审批尝试记录,并以尝试记录主键作为通用审批业务标识,
|
||||
// 使同一退款单的每次提交各自持有独立审批实例;退款单只保存最新尝试与最新实例引用用于展示,
|
||||
// 其既有 approval_instance_id 语义与唯一约束保持不变。
|
||||
type CreationService struct {
|
||||
db *gorm.DB
|
||||
approval approvalapp.Port
|
||||
@@ -55,9 +95,12 @@ func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWrite
|
||||
return &CreationService{db: db, approval: approval, audit: audit}
|
||||
}
|
||||
|
||||
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
|
||||
// TriggerHistorical 为历史待审批退款补发一次企业微信审批。
|
||||
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) (*CreateResult, error) {
|
||||
// 历史申请尚未接入尝试模式,因此本次补发同时建立首条尝试记录并把业务标识切换到该记录。
|
||||
//
|
||||
// 补发不为历史申请追溯退款原因必填(该校验只作用于创建与重提用例),但材料仍按既有展示口径
|
||||
// 解析后传入,使新增控件的审批材料在补发路径同样完整,避免模板缺字段导致提交失败。
|
||||
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint, material RefundMaterial) (*CreateResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || refundID == 0 {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
}
|
||||
@@ -91,12 +134,8 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(&refund, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var approvalStatus int
|
||||
var result *CreateResult
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current model.RefundRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, refundID).Error; err != nil {
|
||||
@@ -113,47 +152,66 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
|
||||
if err := tx.WithContext(ctx).First(¤tOrder, current.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
|
||||
}
|
||||
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, ¤tOrder, "", "", material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.SubmittedByAccountID = current.Creator
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||||
}
|
||||
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account, "", material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
|
||||
BusinessID: current.ID, SubmitterAccountID: current.Creator,
|
||||
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
|
||||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||||
CorrelationID: current.RefundNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND approval_instance_id IS NULL", current.ID, model.RefundStatusPending).
|
||||
Update("approval_instance_id", reference.InstanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
|
||||
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
|
||||
if err := attachRefundFirstInstance(ctx, tx, ¤t, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
current.ApprovalInstanceID = &reference.InstanceID
|
||||
if err := updateRefundLatest(ctx, tx, ¤t, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refund = current
|
||||
order = currentOrder
|
||||
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.WriteRefundApplication(ctx, tx, ApplicationAudit{
|
||||
Refund: ¤t, Order: ¤tOrder, Approval: &instance, Submitter: account,
|
||||
})
|
||||
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
|
||||
Refund: ¤t, Order: ¤tOrder, Approval: &instance, Submitter: account, Attempt: attempt,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
result = &CreateResult{Refund: &refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CreateResult{Refund: &refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实、审批尝试事实和审批事实。
|
||||
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
}
|
||||
if command.Refund == nil || command.Order == nil || command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
|
||||
if command.Refund == nil || command.Order == nil ||
|
||||
command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
|
||||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
@@ -168,59 +226,225 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account)
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var approvalStatus int
|
||||
var attempt *model.RefundRequestAttempt
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(command.Refund.OrderID)).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款订单申请边界失败")
|
||||
}
|
||||
var activeCount int64
|
||||
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("order_id = ? AND status IN ?", command.Refund.OrderID, []int{model.RefundStatusPending, model.RefundStatusApproved}).
|
||||
Where("order_id = ? AND status IN ?", command.Refund.OrderID, model.RefundActiveStatuses()).
|
||||
Count(&activeCount).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "复核订单活跃退款申请失败")
|
||||
}
|
||||
if activeCount > 0 {
|
||||
return errors.New(errors.CodeConflict, "该订单已存在退款申请")
|
||||
return errors.New(errors.CodeConflict, "该订单已存在活动退款申请")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
|
||||
}
|
||||
attempt, err = buildAttempt(ctx, tx, command.Refund, command.Order, command.ChannelRefundRequestNo, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||||
}
|
||||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
|
||||
BusinessID: command.Refund.ID, SubmitterAccountID: command.SubmitterAccountID,
|
||||
BusinessID: attempt.ID, SubmitterAccountID: command.SubmitterAccountID,
|
||||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||||
CorrelationID: command.Refund.RefundNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", command.Refund.ID).
|
||||
Update("approval_instance_id", reference.InstanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
|
||||
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
|
||||
if err := attachRefundFirstInstance(ctx, tx, command.Refund, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateRefundLatest(ctx, tx, command.Refund, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
command.Refund.ApprovalInstanceID = &reference.InstanceID
|
||||
approvalStatus = reference.Status
|
||||
var approval model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).First(&approval, reference.InstanceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
|
||||
}
|
||||
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
|
||||
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account,
|
||||
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, Attempt: attempt,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CreateResult{Refund: command.Refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
|
||||
return &CreateResult{Refund: command.Refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
|
||||
}
|
||||
|
||||
// ResubmitCommand 描述重提时的材料变更。
|
||||
// Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收);
|
||||
// 本次新增的不可变审批尝试记录由本用例在同一事务内构造。
|
||||
type ResubmitCommand struct {
|
||||
Refund *model.RefundRequest
|
||||
// ChannelRefundRequestNo 是原路退款本次重提冻结的渠道退款请求号;非原路方式为空。
|
||||
ChannelRefundRequestNo string
|
||||
// ApplicantRemark 是本次重提的申请人备注,冻结进新的审批尝试快照;历史尝试备注不被改写。
|
||||
ApplicantRemark string
|
||||
// Material 是本次重提的审批材料事实,冻结进新的尝试快照与审批业务快照。
|
||||
Material RefundMaterial
|
||||
}
|
||||
|
||||
// Resubmit 修改并重提未成功退款申请,新增审批尝试记录与新的企业微信审批实例。
|
||||
//
|
||||
// 仅已拒绝、已退回或原路退款失败且无审批异常的申请可重提;已成功、待审批、原路处理中或
|
||||
// 存在审批异常的申请返回状态冲突。每次重提新增不可变尝试记录与独立审批实例,
|
||||
// 历史材料与审批结果不被覆盖,退款单只更新为最新尝试引用。
|
||||
func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command ResubmitCommand) (*CreateResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
}
|
||||
if refundID == 0 || command.Refund == nil || command.Refund.Creator == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "重提退款申请参数不完整")
|
||||
}
|
||||
account, err := s.loadSubmitter(ctx, command.Refund.Creator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var created *CreateResult
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(refundID)).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请重提边界失败")
|
||||
}
|
||||
var current model.RefundRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, refundID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
|
||||
}
|
||||
if !isResubmittable(¤t) {
|
||||
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
|
||||
}
|
||||
var order model.Order
|
||||
if err := tx.WithContext(ctx).First(&order, current.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
|
||||
}
|
||||
|
||||
// 材料已在调用方校验,这里把新值并入当前事实后冻结快照。
|
||||
current.Method = command.Refund.Method
|
||||
current.RequestedRefundAmount = command.Refund.RequestedRefundAmount
|
||||
current.FrozenActualReceivedAmount = command.Refund.FrozenActualReceivedAmount
|
||||
current.RefundReason = command.Refund.RefundReason
|
||||
current.RefundVoucherKey = command.Refund.RefundVoucherKey
|
||||
current.CustomerAccountInfo = command.Refund.CustomerAccountInfo
|
||||
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, &order, command.ChannelRefundRequestNo, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.SubmittedByAccountID = current.Creator
|
||||
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: current.Creator,
|
||||
CorrelationID: current.RefundNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 同一事务内回写材料、回到待审批并创建新的审批实例。
|
||||
updates := map[string]any{
|
||||
"status": model.RefundStatusPending,
|
||||
"method": current.Method,
|
||||
"requested_refund_amount": current.RequestedRefundAmount,
|
||||
"frozen_actual_received_amount": current.FrozenActualReceivedAmount,
|
||||
"refund_reason": current.RefundReason,
|
||||
"refund_voucher_key": current.RefundVoucherKey,
|
||||
"customer_account_info": current.CustomerAccountInfo,
|
||||
"source_payment_no": current.SourcePaymentNo,
|
||||
"original_channel_trade_no": current.OriginalChannelTradeNo,
|
||||
"failure_reason": "",
|
||||
"failure_message": "",
|
||||
"channel_refund_status": constants.RefundChannelStatusNone,
|
||||
"reject_reason": "",
|
||||
"processor_id": nil,
|
||||
"processed_at": nil,
|
||||
"updater": current.Creator,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status IN ?", refundID, model.RefundResubmittableStatuses()).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新退款申请重提材料失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款申请状态已变化")
|
||||
}
|
||||
current.Status = model.RefundStatusPending
|
||||
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||||
}
|
||||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
|
||||
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
|
||||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||||
CorrelationID: current.RefundNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateRefundLatest(ctx, tx, ¤t, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
|
||||
}
|
||||
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
|
||||
Refund: ¤t, Order: &order, Approval: &instance, Submitter: account, Attempt: attempt,
|
||||
Action: constants.AuditActionRefundResubmitted,
|
||||
EventID: "refund:" + strconv.FormatUint(uint64(refundID), 10) + ":attempt:" + strconv.FormatUint(uint64(attempt.ID), 10),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
created = &CreateResult{Refund: ¤t, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// isResubmittable 判断退款申请是否处于可重提状态且不存在审批异常。
|
||||
// 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由人工线下处理。
|
||||
func isResubmittable(refund *model.RefundRequest) bool {
|
||||
if refund == nil || refund.AnomalyFlag != 0 {
|
||||
return false
|
||||
}
|
||||
for _, status := range model.RefundResubmittableStatuses() {
|
||||
if refund.Status == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) {
|
||||
@@ -234,7 +458,145 @@ func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*m
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) {
|
||||
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、申请人备注、客户收款信息与套餐使用快照。
|
||||
// attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。
|
||||
// package_usage_snapshot 必须是非空 JSON 对象,因此快照只能在这里按订单事实生成,不能由调用方预置。
|
||||
func buildAttempt(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
refund *model.RefundRequest,
|
||||
order *model.Order,
|
||||
channelRefundRequestNo string,
|
||||
applicantRemark string,
|
||||
material RefundMaterial,
|
||||
) (*model.RefundRequestAttempt, error) {
|
||||
attemptNo, err := nextAttemptNo(ctx, tx, refund.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot, err := packageUsageSnapshot(ctx, tx, refund, order, material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.RefundRequestAttempt{
|
||||
RefundID: refund.ID,
|
||||
AttemptNo: attemptNo,
|
||||
Method: refund.Method,
|
||||
RefundAmount: refund.RequestedRefundAmount,
|
||||
FrozenActualReceivedAmount: refund.FrozenActualReceivedAmount,
|
||||
RefundReason: refund.RefundReason,
|
||||
Remark: strings.TrimSpace(applicantRemark),
|
||||
CustomerAccountInfo: refund.CustomerAccountInfo,
|
||||
CustomerVoucherKeys: refund.RefundVoucherKey,
|
||||
PackageUsageSnapshot: snapshot,
|
||||
ChannelRefundRequestNo: strings.TrimSpace(channelRefundRequestNo),
|
||||
SubmittedByAccountID: refund.Creator,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// nextAttemptNo 返回该退款申请的下一条审批尝试序号;退款申请行已加锁,序号在同一事务内唯一。
|
||||
func nextAttemptNo(ctx context.Context, tx *gorm.DB, refundID uint) (int, error) {
|
||||
var row struct {
|
||||
MaxAttemptNo int
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
|
||||
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
|
||||
Where("refund_id = ?", refundID).Scan(&row).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试序号失败")
|
||||
}
|
||||
return row.MaxAttemptNo + 1, nil
|
||||
}
|
||||
|
||||
// packageUsageSnapshot 冻结本次申请关联的套餐使用情况与本次提交的审批材料,作为企业微信审批判断材料。
|
||||
// 本期退款不按套餐已用流量计算金额,因此该快照只作审批与追溯材料,不参与金额校验。
|
||||
//
|
||||
// 用量、设备维度与原渠道交易流水号由调用方按既有展示口径解析后传入(material):材料不可解析时
|
||||
// 以空值或零值写入,使快照键始终存在而 MUST NOT 阻断审批提交。
|
||||
func packageUsageSnapshot(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, material RefundMaterial) (datatypes.JSON, error) {
|
||||
snapshot := map[string]any{
|
||||
"order_type": order.OrderType,
|
||||
"asset_identifier": order.AssetIdentifier,
|
||||
"asset_type": material.AssetType,
|
||||
"device_type": material.DeviceType,
|
||||
"device_model": material.DeviceModel,
|
||||
"package_used_mb": material.PackageUsedMB,
|
||||
"package_total_mb": material.PackageTotalMB,
|
||||
"channel_trade_no": material.OriginalChannelTradeNo,
|
||||
}
|
||||
if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 {
|
||||
var usage model.PackageUsage
|
||||
if err := tx.WithContext(ctx).First(&usage, *refund.PackageUsageID).Error; err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐使用记录失败")
|
||||
}
|
||||
} else {
|
||||
snapshot["package_usage"] = map[string]any{
|
||||
"id": usage.ID, "package_id": usage.PackageID, "package_name": usage.PackageName,
|
||||
"usage_type": usage.UsageType, "status": usage.Status,
|
||||
"data_limit_mb": usage.DataLimitMB, "data_usage_mb": usage.DataUsageMB,
|
||||
"activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded, err := sonic.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "编码退款套餐使用快照失败")
|
||||
}
|
||||
return datatypes.JSON(encoded), nil
|
||||
}
|
||||
|
||||
// attachAttemptInstance 把审批实例 ID 回写到本次审批尝试记录,写入一次后不可修改。
|
||||
func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.RefundRequestAttempt, instanceID uint) error {
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
|
||||
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
|
||||
}
|
||||
|
||||
// attachRefundFirstInstance 把审批实例回写到退款申请的首次接入引用。
|
||||
// 既有 approval_instance_id 保持「首次接入企业微信审批的实例」语义:条件更新在引用为空时才写入,
|
||||
// 因此重提只新增尝试引用,不会改写首次接入事实,也不会破坏其部分唯一索引。
|
||||
func attachRefundFirstInstance(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, instanceID uint) error {
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", refund.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, "退款申请首次审批实例已变化")
|
||||
}
|
||||
refund.ApprovalInstanceID = &instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateRefundLatest 更新退款申请的最新审批尝试与最新审批实例引用,仅用于展示。
|
||||
// 既有 approval_instance_id 由 attachRefundFirstInstance 单独回写,保持「首次接入企业微信审批的实例」语义不变。
|
||||
func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, instanceID uint) error {
|
||||
updates := map[string]any{
|
||||
"latest_attempt_id": attempt.ID,
|
||||
"latest_approval_instance_id": instanceID,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).Where("id = ?", refund.ID).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新退款申请最新审批引用失败")
|
||||
}
|
||||
refund.LatestAttemptID = attempt.ID
|
||||
refund.LatestApprovalInstanceID = instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// refundSnapshots 冻结提交人与审批业务快照。
|
||||
// 审批业务快照是企微表单构建的数据源,因此本变更新增的审批材料字段必须在此出现,
|
||||
// 且即使不可解析也要以空值或零值写入:控件映射已配置时缺失键会被表单构建明确拒绝。
|
||||
func refundSnapshots(refund *model.RefundRequest, account *model.Account, applicantRemark string, material RefundMaterial) ([]byte, []byte, error) {
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||||
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
|
||||
})
|
||||
@@ -247,11 +609,18 @@ func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byt
|
||||
constants.ApprovalFieldOrderNo: refund.OrderNo,
|
||||
constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier,
|
||||
constants.ApprovalFieldAssetType: refund.OrderType,
|
||||
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.ActualReceivedAmount),
|
||||
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.FrozenActualReceivedAmount),
|
||||
constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount),
|
||||
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
|
||||
constants.ApprovalFieldRefundReason: refund.RefundReason,
|
||||
constants.ApprovalFieldPackageUsageID: refund.PackageUsageID,
|
||||
constants.ApprovalFieldRefundAssetType: material.AssetType,
|
||||
constants.ApprovalFieldRefundDeviceType: material.DeviceType,
|
||||
constants.ApprovalFieldRefundDeviceModel: material.DeviceModel,
|
||||
constants.ApprovalFieldRefundPackageUsedMB: material.PackageUsedMB,
|
||||
constants.ApprovalFieldRefundPackageTotalMB: material.PackageTotalMB,
|
||||
constants.ApprovalFieldRefundChannelTradeNo: material.OriginalChannelTradeNo,
|
||||
constants.ApprovalFieldRefundApplicantRemark: strings.TrimSpace(applicantRemark),
|
||||
constants.ApprovalFieldSubmitterID: account.ID,
|
||||
constants.ApprovalFieldSubmitterName: account.Username,
|
||||
})
|
||||
|
||||
101
internal/application/refundapproval/resolve.go
Normal file
101
internal/application/refundapproval/resolve.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package refundapproval
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ResolveRefundInTx 按审批业务标识解析出退款申请与本次审批尝试记录。
|
||||
//
|
||||
// 退款审批的业务标识在审批尝试模式下取尝试记录主键;本能力上线前的存量申请取退款申请主键。
|
||||
// 尝试记录与退款申请来自两个独立序列,必然存在同值,因此不能只按 businessID 判定归属:
|
||||
// 必须同时匹配 approval_instance_id,才能唯一确定是尝试记录还是退款申请。
|
||||
//
|
||||
// 解析顺序固定为「尝试记录优先、退款申请兜底」:
|
||||
// 1. tb_refund_request_attempt 中 id = businessID 且 approval_instance_id = instanceID;
|
||||
// 2. tb_refund_request 中 id = businessID 且 approval_instance_id = instanceID;
|
||||
// 3. 两者均不匹配返回稳定冲突错误,绝不回落到任一候选业务单。
|
||||
//
|
||||
// attempt 在存量兼容路径下为 nil。
|
||||
func ResolveRefundInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
|
||||
if tx == nil || businessID == 0 || instanceID == 0 {
|
||||
return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效")
|
||||
}
|
||||
|
||||
var attempt model.RefundRequestAttempt
|
||||
err := tx.WithContext(ctx).
|
||||
Where("id = ? AND approval_instance_id = ?", businessID, instanceID).
|
||||
First(&attempt).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
var refund model.RefundRequest
|
||||
if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在")
|
||||
}
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
|
||||
}
|
||||
return &refund, &attempt, nil
|
||||
case err != gorm.ErrRecordNotFound:
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试记录失败")
|
||||
}
|
||||
|
||||
var refund model.RefundRequest
|
||||
err = tx.WithContext(ctx).
|
||||
Where("id = ? AND approval_instance_id = ?", businessID, instanceID).
|
||||
First(&refund).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
return &refund, nil, nil
|
||||
case err == gorm.ErrRecordNotFound:
|
||||
return nil, nil, errors.New(errors.CodeConflict, "退款申请的关联审批实例不一致")
|
||||
default:
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveRefundIDInTx 只解析退款申请标识,供审计资源构造与查询关联使用。
|
||||
func ResolveRefundIDInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (uint, error) {
|
||||
refund, _, err := ResolveRefundInTx(ctx, tx, businessID, instanceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return refund.ID, nil
|
||||
}
|
||||
|
||||
// ResolveRefundForApprovalRequestInTx 解析「审批申请已建立但审批实例尚未回写到业务记录」时刻的业务归属。
|
||||
//
|
||||
// 通用审批创建用例在同一事务内先写审批实例并写审批申请审计,业务侧随后才把实例 ID 回写到
|
||||
// 审批尝试记录。该审计时刻尝试记录已存在但其 approval_instance_id 仍为空,因此按实例一致性
|
||||
// 校验的常规解析必然不命中。本函数只承认这一种在途形态:
|
||||
//
|
||||
// attempt.id = businessID AND attempt.approval_instance_id IS NULL
|
||||
//
|
||||
// 其余情况一律返回不存在,由调用方按常规解析的错误失败关闭,不得放宽为任意未回写记录。
|
||||
func ResolveRefundForApprovalRequestInTx(ctx context.Context, tx *gorm.DB, businessID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
|
||||
if tx == nil || businessID == 0 {
|
||||
return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效")
|
||||
}
|
||||
var attempt model.RefundRequestAttempt
|
||||
err := tx.WithContext(ctx).
|
||||
Where("id = ? AND approval_instance_id IS NULL", businessID).
|
||||
First(&attempt).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, errors.New(errors.CodeNotFound, "退款审批尝试记录未回写审批实例")
|
||||
}
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询在途退款审批尝试记录失败")
|
||||
}
|
||||
var refund model.RefundRequest
|
||||
if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在")
|
||||
}
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
|
||||
}
|
||||
return &refund, &attempt, nil
|
||||
}
|
||||
32
internal/application/refundchannel/audit.go
Normal file
32
internal/application/refundchannel/audit.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// AuditWriter 写退款渠道调用与恢复的可审计事实。
|
||||
// 实现必须与业务更新在同一事务内写入,且摘要不得包含凭证或渠道报文原文。
|
||||
type AuditWriter interface {
|
||||
WriteRefundChannelResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, action string, message string) error
|
||||
}
|
||||
|
||||
// CompletionNotifier 在渠道明确退款成功时补写退款完成通知事实。
|
||||
// 通知载荷由退款能力拥有,本包只负责在正确的时点与事务内触发。
|
||||
type CompletionNotifier interface {
|
||||
AppendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error
|
||||
}
|
||||
|
||||
// appErrorCode 读取应用错误码;非应用错误返回 0。
|
||||
func appErrorCode(err error) int {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr.Code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
75
internal/application/refundchannel/event.go
Normal file
75
internal/application/refundchannel/event.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
||||
)
|
||||
|
||||
// EventRefundChannelRefund 是退款进入渠道原路处理中后的执行事件。
|
||||
const EventRefundChannelRefund = "refund.channel.refund.requested"
|
||||
|
||||
// refundChannelPayloadVersion 是渠道原路退款事件的载荷版本。
|
||||
const refundChannelPayloadVersion = 1
|
||||
|
||||
// Payload 是渠道原路退款事件的载荷。
|
||||
type Payload struct {
|
||||
RefundID uint `json:"refund_id"`
|
||||
OrderID uint `json:"order_id"`
|
||||
}
|
||||
|
||||
// AppendRefundChannelRefund 在企微通过事务内幂等写入渠道原路退款执行事件。
|
||||
// 同一退款申请使用稳定事件 ID,重复投递不会重复创建事实。
|
||||
func AppendRefundChannelRefund(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, refundID, orderID uint) error {
|
||||
if repository == nil {
|
||||
return gorm.ErrInvalidDB
|
||||
}
|
||||
value := strconv.FormatUint(uint64(refundID), 10)
|
||||
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: outboxid.Stable(EventRefundChannelRefund+":", value),
|
||||
EventType: EventRefundChannelRefund,
|
||||
PayloadVersion: refundChannelPayloadVersion,
|
||||
AggregateType: "refund", AggregateID: value,
|
||||
ResourceType: "refund", ResourceID: value,
|
||||
BusinessKey: EventRefundChannelRefund + ":" + value,
|
||||
Payload: Payload{RefundID: refundID, OrderID: orderID},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var payload Payload
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return outbox.Permanent(err)
|
||||
}
|
||||
if envelope.EventType != EventRefundChannelRefund ||
|
||||
envelope.PayloadVersion != refundChannelPayloadVersion || payload.RefundID == 0 {
|
||||
return outbox.Permanent(gorm.ErrInvalidData)
|
||||
}
|
||||
if c == nil || c.service == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款执行能力未配置")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID})
|
||||
return c.service.Execute(ctx, payload.RefundID)
|
||||
}
|
||||
|
||||
// 编译期断言:渠道原路退款消费者满足公共 Outbox 的消费边界。
|
||||
var _ outbox.EventConsumer = (*Consumer)(nil)
|
||||
71
internal/application/refundchannel/number.go
Normal file
71
internal/application/refundchannel/number.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 渠道退款请求号生成规则参数。
|
||||
const (
|
||||
// channelRefundRequestNoAlphabet 随机段字符集:大写字母与数字。
|
||||
channelRefundRequestNoAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
// channelRefundRequestNoRandomLen 随机段长度,取渠道规则上限 18 位。
|
||||
channelRefundRequestNoRandomLen = 18
|
||||
// channelRefundRequestNoLength 请求号总长:前缀 4 + 日期 8 + 随机段 18。
|
||||
channelRefundRequestNoLength = 30
|
||||
// channelRefundRequestNoPrefixLen 前缀固定长度,不足左侧补 0,超过取前 4 位。
|
||||
channelRefundRequestNoPrefixLen = 4
|
||||
)
|
||||
|
||||
// shanghaiLocation 上海时区(东八区),用于按渠道规则生成日期段。
|
||||
var shanghaiLocation = time.FixedZone("CST", 8*3600)
|
||||
|
||||
// BuildChannelRefundRequestNo 按三渠道共性规则生成渠道退款请求号。
|
||||
//
|
||||
// 规则与富友流水号完全一致(本包不引入渠道 SDK,因此在此独立实现同一规则):
|
||||
// 前缀规整为 4 位(不足左侧补 0,超过取前 4 位)+ 上海时区日期 yyyyMMdd + 18 位大写字母
|
||||
// 数字随机段,总长 30。prefix 由调用方按冻结服务商类型传入:富友传机构码,其余渠道传
|
||||
// 商户标识数字段。生成结果一经写入审批尝试记录即不可变,作为渠道幂等标识复用。
|
||||
func BuildChannelRefundRequestNo(prefix string, now time.Time) string {
|
||||
var builder strings.Builder
|
||||
builder.Grow(channelRefundRequestNoLength)
|
||||
builder.WriteString(normalizeChannelRefundPrefix(prefix))
|
||||
builder.WriteString(now.In(shanghaiLocation).Format("20060102"))
|
||||
|
||||
buffer := make([]byte, channelRefundRequestNoRandomLen)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
// 随机源不可用时退回时间派生的同字符集随机段,保证结果仍满足格式与长度约束。
|
||||
builder.WriteString(fallbackRandomSegment(now))
|
||||
return builder.String()
|
||||
}
|
||||
for _, value := range buffer {
|
||||
builder.WriteByte(channelRefundRequestNoAlphabet[int(value)%len(channelRefundRequestNoAlphabet)])
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// normalizeChannelRefundPrefix 将前缀规整为 4 位:不足左侧补 0,超过取前 4 位。
|
||||
func normalizeChannelRefundPrefix(prefix string) string {
|
||||
normalized := strings.TrimSpace(prefix)
|
||||
if len(normalized) >= channelRefundRequestNoPrefixLen {
|
||||
return normalized[:channelRefundRequestNoPrefixLen]
|
||||
}
|
||||
return strings.Repeat("0", channelRefundRequestNoPrefixLen-len(normalized)) + normalized
|
||||
}
|
||||
|
||||
// fallbackRandomSegment 生成 18 位大写字母数字随机段,仅用于随机源不可用时的兜底。
|
||||
func fallbackRandomSegment(now time.Time) string {
|
||||
segment := strings.ToUpper(strconv.FormatInt(now.UnixNano(), 36))
|
||||
segment = strings.Map(func(char rune) rune {
|
||||
if (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') {
|
||||
return char
|
||||
}
|
||||
return 'X'
|
||||
}, segment)
|
||||
if len(segment) >= channelRefundRequestNoRandomLen {
|
||||
return segment[:channelRefundRequestNoRandomLen]
|
||||
}
|
||||
return segment + strings.Repeat("0", channelRefundRequestNoRandomLen-len(segment))
|
||||
}
|
||||
193
internal/application/refundchannel/recover.go
Normal file
193
internal/application/refundchannel/recover.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Stats 是一次恢复扫描的可观察结果。
|
||||
//
|
||||
// Scanned 为本次扫描到的申请数;Confirmed 为回填为渠道明确成功的申请数;
|
||||
// Failed 为回填为渠道失败终态的申请数(含渠道明确失败,以及富友与微信 v2 的本地查询窗口
|
||||
// 超期后终止本次渠道执行);Pending 为结果仍未知、等待下次扫描的申请数(含查询调用失败);
|
||||
// Skipped 为本地事实不可用或已被并发推进而未由本次扫描改动状态的申请数。
|
||||
type Stats struct{ Scanned, Confirmed, Failed, Pending, Skipped int }
|
||||
|
||||
// ProcessBatch 扫描原路处理中的退款并只查询渠道回填结果,绝不重复发起资金动作。
|
||||
func (s *Service) ProcessBatch(ctx context.Context) (Stats, error) {
|
||||
stats := Stats{}
|
||||
if err := s.requireReady(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
var refunds []model.RefundRequest
|
||||
if err := s.db.WithContext(ctx).
|
||||
// 已置异常标记的申请转人工处理,必须退出轮询:否则每次扫描都会重复查询同一笔未知结果。
|
||||
Where("deleted_at IS NULL AND status = ? AND channel_refund_status = ? AND channel_refund_request_no <> ? AND anomaly_flag = ?",
|
||||
model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing, "", 0).
|
||||
Order("id ASC").Limit(recoveryBatchSize).Find(&refunds).Error; err != nil {
|
||||
return stats, errors.Wrap(errors.CodeDatabaseError, err, "扫描原路处理中的退款申请失败")
|
||||
}
|
||||
stats.Scanned = len(refunds)
|
||||
if len(refunds) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
payments, err := s.loadPaidPayments(ctx, refunds)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var firstErr error
|
||||
for index := range refunds {
|
||||
if err := s.recoverOne(ctx, &refunds[index], payments, now, &stats); err != nil {
|
||||
stats.Skipped++
|
||||
s.logger.Warn("渠道原路退款恢复单条处理失败",
|
||||
zap.Uint("refund_id", refunds[index].ID), zap.Error(err))
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats, firstErr
|
||||
}
|
||||
|
||||
// recoverOne 只查询该申请对应的渠道退款状态并按结果回填,不发起任何资金动作。
|
||||
func (s *Service) recoverOne(ctx context.Context, refund *model.RefundRequest, payments map[uint]*model.Payment, now time.Time, stats *Stats) error {
|
||||
target, failureReason, _, err := s.buildTarget(ctx, refund, nil, payments[refund.OrderID])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if failureReason != "" {
|
||||
// 恢复阶段绝不改写为明确失败:渠道可能已受理资金动作,只能留待人工与环境修复。
|
||||
stats.Pending++
|
||||
s.logger.Warn("渠道原路退款恢复缺少本地事实,跳过本次查询",
|
||||
zap.Uint("refund_id", refund.ID), zap.String("failure_reason", failureReason))
|
||||
return nil
|
||||
}
|
||||
if window, reason := queryWindowPolicy(target.ProviderType); window > 0 && now.Sub(refundWindowStart(refund)) > window {
|
||||
return s.flagQueryWindowExpired(ctx, refund, reason, now, stats)
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
|
||||
defer cancel()
|
||||
result, callErr := s.refunder.Query(callCtx, target)
|
||||
if callErr != nil {
|
||||
// 查询失败不能推断渠道结果,保持原路处理中等待下次扫描。
|
||||
stats.Pending++
|
||||
return nil
|
||||
}
|
||||
applied, err := s.writeback(ctx, refund, target, result, constants.AuditActionRefundChannelRecovered, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !applied {
|
||||
stats.Skipped++
|
||||
return nil
|
||||
}
|
||||
switch result.State {
|
||||
case StateSuccess:
|
||||
stats.Confirmed++
|
||||
case StateFailed:
|
||||
stats.Failed++
|
||||
default:
|
||||
stats.Pending++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryWindowPolicy 返回该服务商类型的本地查询窗口与其超期原因。
|
||||
// 返回 0 表示不设本地窗口,持续查询直到渠道给出终态。
|
||||
//
|
||||
// - 富友:退款查询接口只支持 3 日内的退款交易,超期后渠道侧已无法查询,属渠道硬约束;
|
||||
// - 微信 v2:受理响应不含退款状态、渠道侧无查询时限,此处按本地阈值放弃轮询并转人工,
|
||||
// 避免一笔未知结果被无限重试。
|
||||
func queryWindowPolicy(providerType string) (time.Duration, string) {
|
||||
switch providerType {
|
||||
case model.ProviderTypeFuiou:
|
||||
return fuiouQueryWindow, anomalyReasonFuiouQueryWindow
|
||||
case model.ProviderTypeWechatV2:
|
||||
return wechatV2QueryWindow, anomalyReasonWechatV2QueryWindow
|
||||
default:
|
||||
return 0, ""
|
||||
}
|
||||
}
|
||||
|
||||
// flagQueryWindowExpired 在本地查询窗口超期且结果仍未知时终止本次渠道执行并转人工处理。
|
||||
//
|
||||
// 生效后果:退款申请转「原路退款失败」、渠道退款状态转「已失败」、写入稳定的
|
||||
// timeout_unknown 分类与异常标记,并写一次审计;重复扫描不重复写入。
|
||||
// 「结果未确认」这一性质由 failure_reason 承载(它不是明确失败,因此不进入后续回溯判定),
|
||||
// 而 status 只表达该尝试的渠道路径已终止。
|
||||
//
|
||||
// 为何不放行自动重提:本次渠道请求可能已被受理但结果未知,放行重提会以新的请求号再次
|
||||
// 提交资金动作,存在重复退款风险。因此保留异常标记,由人工先向渠道核对再决定处置。
|
||||
func (s *Service) flagQueryWindowExpired(ctx context.Context, refund *model.RefundRequest, reason string, now time.Time, stats *Stats) error {
|
||||
stats.Failed++
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND channel_refund_status = ? AND anomaly_flag = 0",
|
||||
refund.ID, model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing).
|
||||
// UpdateColumns 不隐式推进 updated_at:窗口起算点必须保留在进入原路处理中的时刻,
|
||||
// 否则置标记会把窗口重置,下一轮扫描将重新查询同一笔未知结果。
|
||||
UpdateColumns(map[string]any{
|
||||
"status": model.RefundStatusChannelFailed,
|
||||
"channel_refund_status": constants.RefundChannelStatusFailed,
|
||||
"failure_reason": constants.RefundFailureTimeoutUnknown,
|
||||
"anomaly_flag": 1,
|
||||
"anomaly_reason": reason,
|
||||
})
|
||||
if updated.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "标记退款查询窗口超期失败")
|
||||
}
|
||||
if updated.RowsAffected != 1 {
|
||||
return nil
|
||||
}
|
||||
refund.Status = model.RefundStatusChannelFailed
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
|
||||
refund.FailureReason = constants.RefundFailureTimeoutUnknown
|
||||
refund.AnomalyFlag = 1
|
||||
refund.AnomalyReason = reason
|
||||
return s.audit.WriteRefundChannelResult(ctx, tx, refund,
|
||||
constants.AuditActionRefundAnomalyFlagged, reason+",结果未知,已终止渠道执行并转人工核对")
|
||||
})
|
||||
if err != nil {
|
||||
if appErrorCode(err) != 0 {
|
||||
return err
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "标记退款查询窗口超期失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refundWindowStart 返回查询窗口的起算时点:渠道明确成功时间优先,否则取最后一次实质性状态变更时间。
|
||||
// 结果未知的回写不会推进 updated_at,因此窗口始终从进入原路处理中的时点起算。
|
||||
func refundWindowStart(refund *model.RefundRequest) time.Time {
|
||||
if refund.ChannelRefundedAt != nil {
|
||||
return refund.ChannelRefundedAt.UTC()
|
||||
}
|
||||
return refund.UpdatedAt.UTC()
|
||||
}
|
||||
|
||||
// loadPaidPayments 批量读取该批订单最近一笔已支付的套餐支付单。
|
||||
func (s *Service) loadPaidPayments(ctx context.Context, refunds []model.RefundRequest) (map[uint]*model.Payment, error) {
|
||||
orderIDs := make([]uint, 0, len(refunds))
|
||||
for index := range refunds {
|
||||
orderIDs = append(orderIDs, refunds[index].OrderID)
|
||||
}
|
||||
var payments []model.Payment
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("order_id IN ? AND order_type = ? AND status = ?", orderIDs, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
|
||||
Order("id ASC").Find(&payments).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量读取原支付单失败")
|
||||
}
|
||||
latest := make(map[uint]*model.Payment, len(payments))
|
||||
for index := range payments {
|
||||
latest[payments[index].OrderID] = &payments[index]
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
701
internal/application/refundchannel/service.go
Normal file
701
internal/application/refundchannel/service.go
Normal file
@@ -0,0 +1,701 @@
|
||||
// Package refundchannel 执行与恢复渠道原路退款。
|
||||
//
|
||||
// 本包只编排渠道退款的资金动作与本地状态流转:请求号决定执行幂等、结果按条件更新回写、
|
||||
// 失败按稳定分类终结、未知结果交由恢复扫描查询收敛。具体渠道协议由按服务商类型注入的
|
||||
// Refunder 实现,本包不依赖任何渠道 SDK,也绝不在数据库事务内发起渠道调用。
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
"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/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// State 是渠道调用的稳定结果状态。
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateSuccess State = "success" // 渠道明确成功
|
||||
StateFailed State = "failed" // 渠道明确失败
|
||||
StateUnknown State = "unknown" // 超时或结果未确认,可恢复
|
||||
)
|
||||
|
||||
// 渠道原路退款的固定运行参数。
|
||||
const (
|
||||
// recoveryBatchSize 是恢复扫描的单批上限,与既有批次扫描用例保持一致。
|
||||
recoveryBatchSize = 50
|
||||
// fuiouQueryWindow 是富友退款查询窗口:其退款查询接口只支持 3 日内的退款交易。
|
||||
fuiouQueryWindow = 72 * time.Hour
|
||||
// wechatV2QueryWindow 是微信 v2 退款结果的本地确认上限。
|
||||
// 微信 v2 退款接口的受理响应不含退款状态,终态只能由退款查询确认;渠道侧没有查询时限,
|
||||
// 因此这里只设本地的放弃阈值:超过该期限仍未确认即停止轮询并转人工核对,避免无限查询。
|
||||
wechatV2QueryWindow = 7 * 24 * time.Hour
|
||||
// channelCallTimeout 是单次渠道退款申请或查询调用的最长等待时间。
|
||||
channelCallTimeout = 30 * time.Second
|
||||
// fuiouOrderTypeWechat 是富友原交易的 order_type 当前唯一可达值(富友微信主扫)。
|
||||
// 与 pkg/fuiou.OrderTypeWechat 取值一致;本包不引入渠道 SDK,因此在此固定回传该冻结值。
|
||||
fuiouOrderTypeWechat = "WECHAT"
|
||||
// anomalyReasonFuiouQueryWindow 是富友退款查询窗口超期的异常原因。
|
||||
anomalyReasonFuiouQueryWindow = "富友退款查询窗口已过,需人工核对"
|
||||
// anomalyReasonWechatV2QueryWindow 是微信 v2 退款结果超过本地确认上限的异常原因。
|
||||
anomalyReasonWechatV2QueryWindow = "微信 v2 退款超过 7 天未确认结果,需人工核对"
|
||||
// failureMessageUnknown 是渠道退款调用结果未确认时的安全摘要。
|
||||
failureMessageUnknown = "渠道退款调用结果未确认,等待查询恢复"
|
||||
// failureMessagePaymentFact 是本地原支付事实不可用时的安全摘要。
|
||||
failureMessagePaymentFact = "本地原支付事实不可用,未能发起渠道退款"
|
||||
// failureMessageCredential 是商户退款必需凭证不完整时的安全摘要。
|
||||
failureMessageCredential = "商户退款必需凭证不完整,未发起渠道退款"
|
||||
// failureMessageNoRequestNo 是退款申请缺少渠道退款请求号时的安全摘要。
|
||||
failureMessageNoRequestNo = "退款申请缺少渠道退款请求号,未发起渠道退款"
|
||||
// failureMessageMaxRunes 是失败安全摘要的字符上限,与 failure_message 列宽约束一致。
|
||||
failureMessageMaxRunes = 480
|
||||
// providerTypeAlipay 是支付宝商户的 provider_type 取值;model 未定义该常量,
|
||||
// 取值与商户凭证管理保持的 "alipay" 完全一致。
|
||||
providerTypeAlipay = "alipay"
|
||||
)
|
||||
|
||||
// Target 是执行一次渠道原路退款所需的全部冻结事实。
|
||||
type Target struct {
|
||||
RefundID uint
|
||||
RefundNo string
|
||||
OrderID uint
|
||||
OrderNo string
|
||||
ProviderType string // model.ProviderType*
|
||||
Config *model.WechatConfig // 商户当前凭证,绝不落库或记日志
|
||||
PaymentNo string // 原支付单商户订单号(微信/支付宝 out_trade_no、富友 mchnt_order_no)
|
||||
ChannelTradeNo string // 原支付单渠道交易流水
|
||||
ChannelOrderType string // 富友原交易 order_type
|
||||
PaidAt *time.Time
|
||||
PaidAmount int64 // 原支付单渠道订单总金额(分),渠道退款请求的 total_amt 必须回传该值
|
||||
RefundAmount int64
|
||||
FrozenActualReceivedAmount int64
|
||||
ChannelRefundRequestNo string
|
||||
}
|
||||
|
||||
// Result 是渠道调用或查询的映射结果。
|
||||
type Result struct {
|
||||
State State
|
||||
ChannelRefundNo string // 渠道退款流水号
|
||||
ChannelRefundAmount int64 // 渠道退款金额(分)
|
||||
SettledAt string // 渠道结算日期原文,可空
|
||||
FailureReason string // pkg/constants.RefundFailure* 稳定编码,仅 State!=StateSuccess 时有值
|
||||
FailureMessage string // 安全摘要,不得含凭证或报文原文
|
||||
}
|
||||
|
||||
// Refunder 是渠道原路退款 Port,由基础设施层按服务商类型实现。
|
||||
type Refunder interface {
|
||||
// Refund 至多提交一次可确认的退款请求;请求号由 Target.ChannelRefundRequestNo 提供。
|
||||
Refund(ctx context.Context, target Target) (Result, error)
|
||||
// Query 只查询渠道退款状态,不得发起资金动作。
|
||||
Query(ctx context.Context, target Target) (Result, error)
|
||||
}
|
||||
|
||||
// MerchantLoader 按冻结商户 ID 加载商户当前凭证与渠道所需的全局授权配置。
|
||||
type MerchantLoader interface {
|
||||
LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error)
|
||||
LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error)
|
||||
}
|
||||
|
||||
// Service 执行与恢复原路退款。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
loader MerchantLoader
|
||||
refunder Refunder
|
||||
audit AuditWriter
|
||||
notifier CompletionNotifier
|
||||
logger *zap.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService 创建渠道原路退款用例。
|
||||
func NewService(db *gorm.DB, loader MerchantLoader, refunder Refunder, audit AuditWriter) *Service {
|
||||
return &Service{db: db, loader: loader, refunder: refunder, audit: audit, logger: zap.NewNop(), now: time.Now}
|
||||
}
|
||||
|
||||
// SetCompletionNotifier 注入退款完成通知写入能力;未注入时成功路径不写通知事实。
|
||||
func (s *Service) SetCompletionNotifier(notifier CompletionNotifier) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
s.notifier = notifier
|
||||
return s
|
||||
}
|
||||
|
||||
// SetLogger 注入渠道原路退款运行日志。
|
||||
func (s *Service) SetLogger(logger *zap.Logger) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
s.logger = logger
|
||||
return s
|
||||
}
|
||||
|
||||
// PrepareInTx 在企微通过事务内为原路方式生成请求号并把退款申请置为原路处理中。
|
||||
//
|
||||
// 请求号由提交或重提在不可变审批尝试记录上生成并冻结;尝试记录已带请求号时直接复用,
|
||||
// 仅在缺失时防御性补生成。条件更新要求申请仍处于待审批,否则视为并发冲突。
|
||||
func (s *Service) PrepareInTx(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt) error {
|
||||
if s == nil || tx == nil || refund == nil || refund.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "渠道原路退款准备参数无效")
|
||||
}
|
||||
if refund.Method != constants.RefundMethodOriginalRoute {
|
||||
return nil
|
||||
}
|
||||
requestNo := ""
|
||||
if attempt != nil {
|
||||
requestNo = strings.TrimSpace(attempt.ChannelRefundRequestNo)
|
||||
}
|
||||
if requestNo == "" {
|
||||
// 正常运行不会走到这里:请求号在提交/重提时已冻结到尝试记录上。
|
||||
requestNo = BuildChannelRefundRequestNo(strconv.FormatUint(uint64(refund.ID), 10), s.now())
|
||||
}
|
||||
now := s.now().UTC()
|
||||
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": model.RefundStatusChannelProcessing,
|
||||
"channel_refund_status": constants.RefundChannelStatusProcessing,
|
||||
"channel_refund_request_no": requestNo,
|
||||
"updated_at": now,
|
||||
})
|
||||
if updated.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "进入渠道原路退款处理中失败")
|
||||
}
|
||||
if updated.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款申请状态不允许进入渠道原路退款处理中")
|
||||
}
|
||||
if attempt != nil && attempt.ID != 0 {
|
||||
write := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
|
||||
Where("id = ?", attempt.ID).
|
||||
Update("channel_refund_request_no", requestNo)
|
||||
if write.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, write.Error, "写入退款尝试渠道退款请求号失败")
|
||||
}
|
||||
if write.RowsAffected != 1 {
|
||||
s.logger.Warn("退款尝试渠道退款请求号未写入", zap.Uint("refund_id", refund.ID), zap.Uint("attempt_id", attempt.ID))
|
||||
}
|
||||
attempt.ChannelRefundRequestNo = requestNo
|
||||
}
|
||||
if err := AppendRefundChannelRefund(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款事件失败")
|
||||
}
|
||||
refund.Status = model.RefundStatusChannelProcessing
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
|
||||
refund.ChannelRefundRequestNo = requestNo
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute 幂等执行一次原路退款;已明确成功或已失败终结的申请直接返回 nil。
|
||||
//
|
||||
// 本地事实在只读事务内锁定读取。资金动作「至多提交一次」由提交认领保证:
|
||||
// 提交前先以 channel_submitted_at IS NULL 条件认领,只有认领成功的执行才调用 Refund;
|
||||
// 认领失败表示该尝试已提交过渠道退款请求(例如 Outbox 事件被重复投递或人工重放),
|
||||
// 此时只查询渠道结果并回填,绝不再次提交资金动作。
|
||||
func (s *Service) Execute(ctx context.Context, refundID uint) error {
|
||||
if err := s.requireReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
if refundID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "渠道原路退款缺少退款申请标识")
|
||||
}
|
||||
facts, proceed, err := s.loadExecutionFacts(ctx, refundID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !proceed {
|
||||
return nil
|
||||
}
|
||||
payment, err := s.loadPaidPayment(ctx, facts.refund.OrderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, failureReason, failureMessage, err := s.buildTarget(ctx, facts.refund, facts.attempt, payment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
if failureReason != "" {
|
||||
// 本地事实不可用时绝不调用渠道,按稳定失败分类终结本次原路退款。
|
||||
if _, err := s.writeback(ctx, facts.refund, target, Result{
|
||||
State: StateFailed, FailureReason: failureReason, FailureMessage: failureMessage,
|
||||
}, constants.AuditActionRefundChannelCalled, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 认领本次提交:认领成功才拥有提交权,失败则本次只做查询。
|
||||
claimed, err := s.claimChannelSubmission(ctx, refundID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
|
||||
defer cancel()
|
||||
if !claimed {
|
||||
// 已提交过:只查询渠道结果,绝不再次提交资金动作。
|
||||
result, callErr := s.refunder.Query(callCtx, target)
|
||||
if callErr != nil {
|
||||
// 查询失败不能推断渠道结果,保持原路处理中等待恢复扫描。
|
||||
return nil
|
||||
}
|
||||
s.logger.Info("渠道退款请求已提交过,本次仅查询结果",
|
||||
zap.Uint("refund_id", refundID), zap.String("channel_refund_request_no", target.ChannelRefundRequestNo))
|
||||
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelRecovered, now)
|
||||
return err
|
||||
}
|
||||
result, callErr := s.refunder.Refund(callCtx, target)
|
||||
if callErr != nil {
|
||||
// 传输层错误不能推断渠道未受理,一律按结果未知保持可恢复。
|
||||
result = Result{State: StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: failureMessageUnknown}
|
||||
}
|
||||
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelCalled, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// claimChannelSubmission 以条件更新认领本次渠道退款提交权。
|
||||
//
|
||||
// 返回 true 表示调用方获得提交权、可以调用渠道退款接口;false 表示该尝试在此之前
|
||||
// 已提交过(重复投递或人工重放),调用方只能查询。认领与回写同以 status = 原路处理中
|
||||
// 为谓词,因此并发执行也至多有一次认领成功。
|
||||
func (s *Service) claimChannelSubmission(ctx context.Context, refundID uint, now time.Time) (bool, error) {
|
||||
claimed := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND channel_submitted_at IS NULL",
|
||||
refundID, model.RefundStatusChannelProcessing).
|
||||
UpdateColumn("channel_submitted_at", now)
|
||||
if claimed.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领渠道退款提交权失败")
|
||||
}
|
||||
return claimed.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// executionFacts 是一次渠道执行所需的本地冻结事实。
|
||||
type executionFacts struct {
|
||||
refund *model.RefundRequest
|
||||
attempt *model.RefundRequestAttempt
|
||||
}
|
||||
|
||||
// loadExecutionFacts 在只读事务内锁定退款申请并读取本次执行所需的尝试记录。
|
||||
// proceed 为 false 表示申请已终结、方式不符或已由并发执行推进,调用方必须直接结束本次执行。
|
||||
func (s *Service) loadExecutionFacts(ctx context.Context, refundID uint) (*executionFacts, bool, error) {
|
||||
facts := &executionFacts{}
|
||||
proceed := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var refund model.RefundRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refundID).First(&refund).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
|
||||
}
|
||||
facts.refund = &refund
|
||||
if refund.Method != constants.RefundMethodOriginalRoute {
|
||||
s.logger.Warn("退款方式不是原路,跳过渠道退款", zap.Uint("refund_id", refund.ID), zap.String("method", refund.Method))
|
||||
return nil
|
||||
}
|
||||
// 已通过或已失败终结的申请直接返回;渠道已明确成功的申请也不得再次调用渠道。
|
||||
if refund.Status != model.RefundStatusChannelProcessing ||
|
||||
refund.ChannelRefundStatus == constants.RefundChannelStatusSucceeded {
|
||||
return nil
|
||||
}
|
||||
attempt, err := loadAttempt(ctx, tx, &refund)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
facts.attempt = attempt
|
||||
proceed = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return facts, proceed, nil
|
||||
}
|
||||
|
||||
// loadAttempt 按申请冻结的最新尝试引用读取尝试记录;引用缺失时退回该申请的最大尝试序号。
|
||||
func loadAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) (*model.RefundRequestAttempt, error) {
|
||||
var attempt model.RefundRequestAttempt
|
||||
query := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{})
|
||||
if refund.LatestAttemptID != 0 {
|
||||
if err := query.Where("id = ?", refund.LatestAttemptID).First(&attempt).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
if err := query.Where("refund_id = ?", refund.ID).Order("attempt_no DESC").First(&attempt).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
// loadPaidPayment 读取订单最近一笔已支付的套餐支付单,作为原路退款的原支付事实。
|
||||
func (s *Service) loadPaidPayment(ctx context.Context, orderID uint) (*model.Payment, error) {
|
||||
var payment model.Payment
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
|
||||
Order("id DESC").First(&payment).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取原支付单失败")
|
||||
}
|
||||
return &payment, nil
|
||||
}
|
||||
|
||||
// buildTarget 在事务外组装渠道调用目标。
|
||||
// 返回非空 failureReason 表示本地事实不可用:调用方必须按该分类回写且绝不调用渠道。
|
||||
func (s *Service) buildTarget(ctx context.Context, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, payment *model.Payment) (Target, string, string, error) {
|
||||
target := Target{
|
||||
RefundID: refund.ID, RefundNo: refund.RefundNo, OrderID: refund.OrderID, OrderNo: refund.OrderNo,
|
||||
RefundAmount: resolveRefundAmount(refund, attempt),
|
||||
FrozenActualReceivedAmount: resolveFrozenAmount(refund, attempt),
|
||||
ChannelRefundRequestNo: resolveChannelRefundRequestNo(refund, attempt),
|
||||
}
|
||||
if target.ChannelRefundRequestNo == "" {
|
||||
// 没有请求号就没有渠道幂等标识:本次尝试从未提交过资金动作,可按明确失败终结。
|
||||
return target, constants.RefundFailurePaymentFactInvalid, failureMessageNoRequestNo, nil
|
||||
}
|
||||
if payment == nil {
|
||||
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
|
||||
}
|
||||
target.PaymentNo = strings.TrimSpace(payment.PaymentNo)
|
||||
target.ChannelTradeNo = strings.TrimSpace(payment.ThirdPartyTradeNo)
|
||||
target.PaidAt = payment.PaidAt
|
||||
target.PaidAmount = payment.Amount
|
||||
if target.RefundAmount <= 0 || target.FrozenActualReceivedAmount <= 0 ||
|
||||
target.RefundAmount > target.FrozenActualReceivedAmount {
|
||||
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
|
||||
}
|
||||
config, providerType, err := s.loadChannelConfig(ctx, payment)
|
||||
if err != nil {
|
||||
if !credentialFailure(err) {
|
||||
return target, "", "", err
|
||||
}
|
||||
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
|
||||
}
|
||||
if !credentialComplete(providerType, config) {
|
||||
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
|
||||
}
|
||||
target.ProviderType = providerType
|
||||
target.Config = config
|
||||
if providerType == model.ProviderTypeFuiou {
|
||||
target.ChannelOrderType = fuiouOrderTypeWechat
|
||||
}
|
||||
return target, "", "", nil
|
||||
}
|
||||
|
||||
// loadChannelConfig 加载原支付单实际收款商户的当前凭证。
|
||||
// 新支付按冻结商户标识加载该商户当前凭证与全局微信授权;merchant_id 为空仅表示数据留存期内的
|
||||
// 历史支付,按其原支付配置读取,禁止按当前启用商户池推断历史商户。
|
||||
func (s *Service) loadChannelConfig(ctx context.Context, payment *model.Payment) (*model.WechatConfig, string, error) {
|
||||
if payment.MerchantID != nil {
|
||||
merchant, err := s.loader.LoadMerchant(ctx, *payment.MerchantID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if merchant == nil {
|
||||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "原支付收款商户不存在")
|
||||
}
|
||||
// 仅微信直连(v3/v2)需要全局微信授权配置中的 AppID;其他服务商传 nil 避免无谓失败。
|
||||
var authorization *model.WechatAuthorization
|
||||
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
|
||||
authorization, err = s.loader.LoadAuthorization(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
config, err := merchantpayment.MerchantConfig(merchant, authorization)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return config, merchant.ProviderType, nil
|
||||
}
|
||||
if payment.PaymentConfigID == nil {
|
||||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
|
||||
}
|
||||
var legacy model.WechatConfig
|
||||
if err := s.db.WithContext(ctx).Unscoped().Where("id = ?", *payment.PaymentConfigID).First(&legacy).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
|
||||
}
|
||||
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败")
|
||||
}
|
||||
return &legacy, legacy.ProviderType, nil
|
||||
}
|
||||
|
||||
// credentialComplete 判断该服务商类型发起原路退款所需的凭证是否完整。
|
||||
// 规则与本 Change 冻结的商户退款凭证要求一致,只判断必需字段非空,不新增任何凭证键。
|
||||
// RefundCredentialIssue 返回该服务商类型的退款必需凭证缺失原因;凭证完整时返回空串。
|
||||
//
|
||||
// 这是退款能力的唯一判定入口:退款请求不向渠道传递任何通知地址,因此支付通知地址与
|
||||
// 支付跳转地址都不是退款必需凭证。微信 v2 退款接口(/secapi/pay/refund)请求需要双向
|
||||
// 证书,因此其必需凭证包含 API 客户端证书;缺少该证书的 v2 商户按其凭证完整性判定为
|
||||
// 不可用,补录证书后即可用。判定结果不提供人工开关。
|
||||
func RefundCredentialIssue(providerType string, config *model.WechatConfig) string {
|
||||
if config == nil {
|
||||
return failureMessageCredential
|
||||
}
|
||||
switch providerType {
|
||||
case model.ProviderTypeWechat:
|
||||
if !completeFields(config.WxMchID, config.WxAPIV3Key, config.WxCertContent,
|
||||
config.WxKeyContent, config.WxSerialNo) {
|
||||
return "冻结微信商户退款凭证不完整"
|
||||
}
|
||||
case model.ProviderTypeWechatV2:
|
||||
// v2 退款接口为双向证书接口:缺少 API 客户端证书时按其凭证完整性判定为不可用。
|
||||
if !completeFields(config.WxMchID, config.WxAPIV2Key, config.WxClientCertContent, config.WxClientKeyContent) {
|
||||
return "冻结微信 v2 商户退款凭证不完整(缺少 API 客户端证书)"
|
||||
}
|
||||
case model.ProviderTypeFuiou:
|
||||
if !completeFields(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey,
|
||||
config.FyPublicKey, config.FyAPIURL) {
|
||||
return "冻结富友商户退款凭证不完整"
|
||||
}
|
||||
case providerTypeAlipay:
|
||||
if !completeFields(config.AliAppID, config.AliPrivateKey, config.AliPublicKey) {
|
||||
return "冻结支付宝商户退款凭证不完整"
|
||||
}
|
||||
default:
|
||||
return "冻结商户不支持原路退款"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// credentialComplete 判断该服务商类型的退款必需凭证是否完整。
|
||||
func credentialComplete(providerType string, config *model.WechatConfig) bool {
|
||||
return RefundCredentialIssue(providerType, config) == ""
|
||||
}
|
||||
|
||||
func completeFields(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// credentialFailure 判断凭证加载错误属于渠道侧不可执行的凭证问题,而不是可重试的基础设施错误。
|
||||
func credentialFailure(err error) bool {
|
||||
switch appErrorCode(err) {
|
||||
case errors.CodeNoPaymentConfig, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeWechatConfigUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolveChannelRefundRequestNo 取本次执行的渠道幂等标识。
|
||||
// 尝试记录持有本次提交冻结的请求号,优先级高于退款单上的展示快照:重提会生成新请求号,
|
||||
// 沿用旧快照会让渠道按旧请求号再次受理;两者一致时结果相同。
|
||||
func resolveChannelRefundRequestNo(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) string {
|
||||
if attempt != nil {
|
||||
if requestNo := strings.TrimSpace(attempt.ChannelRefundRequestNo); requestNo != "" {
|
||||
return requestNo
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(refund.ChannelRefundRequestNo)
|
||||
}
|
||||
|
||||
// resolveRefundAmount 取本次原路退款的权威金额:优先审批实际退款金额,其次尝试记录冻结金额。
|
||||
func resolveRefundAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
|
||||
if refund.ApprovedRefundAmount != nil && *refund.ApprovedRefundAmount > 0 {
|
||||
return *refund.ApprovedRefundAmount
|
||||
}
|
||||
if refund.RequestedRefundAmount > 0 {
|
||||
return refund.RequestedRefundAmount
|
||||
}
|
||||
if attempt != nil {
|
||||
return attempt.RefundAmount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveFrozenAmount 取本次原路退款的冻结实收金额。
|
||||
func resolveFrozenAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
|
||||
if refund.FrozenActualReceivedAmount > 0 {
|
||||
return refund.FrozenActualReceivedAmount
|
||||
}
|
||||
if attempt != nil {
|
||||
return attempt.FrozenActualReceivedAmount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// writeback 在独立事务内按渠道结果条件更新退款申请、订单与审计事实。
|
||||
// applied 为 false 表示记录已被并发推进,本次不改动任何状态。
|
||||
func (s *Service) writeback(ctx context.Context, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
|
||||
applied := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
applied, err = s.applyResult(ctx, tx, refund, target, result, action, now)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !applied {
|
||||
s.logger.Warn("渠道原路退款结果未回写,记录已被并发推进",
|
||||
zap.Uint("refund_id", refund.ID), zap.String("action", action), zap.String("state", string(result.State)))
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// applyResult 按结果状态把渠道事实条件回写到退款申请,成功时同步把订单置为已退款。
|
||||
// 所有状态流转都以 status = 原路处理中 为谓词,RowsAffected 为 0 表示并发已推进该记录。
|
||||
func (s *Service) applyResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
|
||||
update := map[string]any{}
|
||||
syncRefund := func() {}
|
||||
orderRefunded := false
|
||||
reason := result.FailureReason
|
||||
message := ""
|
||||
switch result.State {
|
||||
case StateSuccess:
|
||||
amount := result.ChannelRefundAmount
|
||||
if amount <= 0 {
|
||||
amount = target.RefundAmount
|
||||
}
|
||||
update["status"] = model.RefundStatusApproved
|
||||
update["channel_refund_status"] = constants.RefundChannelStatusSucceeded
|
||||
update["channel_refund_no"] = result.ChannelRefundNo
|
||||
update["channel_refund_amount"] = amount
|
||||
update["channel_refunded_at"] = now
|
||||
update["processed_at"] = now
|
||||
update["failure_reason"] = ""
|
||||
update["failure_message"] = ""
|
||||
update["updated_at"] = now
|
||||
orderRefunded = true
|
||||
message = "渠道原路退款明确成功"
|
||||
syncRefund = func() {
|
||||
refund.Status = model.RefundStatusApproved
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusSucceeded
|
||||
refund.ChannelRefundNo = result.ChannelRefundNo
|
||||
refund.ChannelRefundAmount = amount
|
||||
refund.ChannelRefundedAt = &now
|
||||
refund.ProcessedAt = &now
|
||||
refund.FailureReason = ""
|
||||
refund.FailureMessage = ""
|
||||
}
|
||||
case StateFailed:
|
||||
if reason == "" {
|
||||
reason = constants.RefundFailureChannelRejected
|
||||
}
|
||||
message = "渠道原路退款明确失败:" + constants.RefundFailureReasonName(reason)
|
||||
failureMessage := safeMessage(result.FailureMessage, message)
|
||||
update["status"] = model.RefundStatusChannelFailed
|
||||
update["channel_refund_status"] = constants.RefundChannelStatusFailed
|
||||
update["failure_reason"] = reason
|
||||
update["failure_message"] = failureMessage
|
||||
update["updated_at"] = now
|
||||
syncRefund = func() {
|
||||
refund.Status = model.RefundStatusChannelFailed
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
|
||||
refund.FailureReason = reason
|
||||
refund.FailureMessage = failureMessage
|
||||
}
|
||||
default:
|
||||
// 超时或结果未确认:保持原路处理中,等待恢复扫描查询收敛。
|
||||
// 不修改 updated_at,使富友查询窗口从进入原路处理中的时点起算。
|
||||
reason = constants.RefundFailureTimeoutUnknown
|
||||
message = "渠道原路退款结果未确认,保持处理中"
|
||||
failureMessage := safeMessage(result.FailureMessage, failureMessageUnknown)
|
||||
update["channel_refund_status"] = constants.RefundChannelStatusProcessing
|
||||
update["failure_reason"] = reason
|
||||
update["failure_message"] = failureMessage
|
||||
syncRefund = func() {
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
|
||||
refund.FailureReason = reason
|
||||
refund.FailureMessage = failureMessage
|
||||
}
|
||||
}
|
||||
// UpdateColumns 不会隐式推进 updated_at:结果未知时必须保留进入原路处理中的时点,
|
||||
// 富友 72 小时查询窗口正是以该时点起算;需要推进的分支已在 update 中显式写入。
|
||||
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", refund.ID, model.RefundStatusChannelProcessing).
|
||||
UpdateColumns(update)
|
||||
if updated.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, updated.Error, "回写渠道原路退款结果失败")
|
||||
}
|
||||
if updated.RowsAffected != 1 {
|
||||
return false, nil
|
||||
}
|
||||
syncRefund()
|
||||
if orderRefunded {
|
||||
if err := s.markOrderRefunded(ctx, tx, refund, now); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 原路退款的完成时点是渠道明确成功,与客户收款信息退款在企微通过时完成的语义不同:
|
||||
// 退款完成通知必须在同一事务内补写,否则该方式的店铺通知永远不会发出。
|
||||
if s.notifier != nil {
|
||||
if err := s.notifier.AppendCompletedNotification(ctx, tx, refund); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.audit.WriteRefundChannelResult(ctx, tx, refund, action, message); err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款审计失败")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// markOrderRefunded 在渠道明确成功后按方式把订单置为已退款。
|
||||
// 条件更新命中 0 行时容忍订单已是已退款;其他状态只记录告警,不覆盖业务事实。
|
||||
func (s *Service) markOrderRefunded(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, now time.Time) error {
|
||||
updated := tx.WithContext(ctx).Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", refund.OrderID, model.PaymentStatusPaid).
|
||||
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": now})
|
||||
if updated.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "更新订单退款状态失败")
|
||||
}
|
||||
if updated.RowsAffected == 1 {
|
||||
return nil
|
||||
}
|
||||
var order model.Order
|
||||
if err := tx.WithContext(ctx).Select("id", "payment_status").Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取退款关联订单状态失败")
|
||||
}
|
||||
if order.PaymentStatus != model.PaymentStatusRefunded {
|
||||
s.logger.Warn("订单支付状态未置为已退款",
|
||||
zap.Uint("refund_id", refund.ID), zap.Uint("order_id", refund.OrderID), zap.Int("payment_status", order.PaymentStatus))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeMessage 生成失败安全摘要:裁剪空白、限定字符数,空值退回该状态的固定摘要。
|
||||
func safeMessage(message, fallback string) string {
|
||||
text := strings.TrimSpace(message)
|
||||
if text == "" {
|
||||
text = fallback
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) > failureMessageMaxRunes {
|
||||
text = string(runes[:failureMessageMaxRunes])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// requireReady 校验渠道原路退款的全部依赖已配置。
|
||||
func (s *Service) requireReady() error {
|
||||
if s == nil || s.db == nil || s.loader == nil || s.refunder == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款能力未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
252
internal/application/shop/batch_business_owner.go
Normal file
252
internal/application/shop/batch_business_owner.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package shop
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// BusinessOwnerBatchChange 描述一家店铺在批量交接中的负责人前后事实。
|
||||
// 账号快照用于审计引用资源,已软删账号同样保留历史事实。
|
||||
type BusinessOwnerBatchChange struct {
|
||||
Shop *model.Shop
|
||||
BeforeOwnerID *uint
|
||||
AfterOwnerID *uint
|
||||
PreviousOwner *model.Account
|
||||
Owner *model.Account
|
||||
}
|
||||
|
||||
// BusinessOwnerBatchAudit 描述一次批量交接的批次根事实与逐店子事实。
|
||||
// Result 为空表示成功批次,由实现写批次根事件与逐店子事件;
|
||||
// 非空表示业务回滚后的失败或拒绝事实,此时只写批次根事件。
|
||||
type BusinessOwnerBatchAudit struct {
|
||||
BatchKey string
|
||||
Operation string
|
||||
Result string
|
||||
OperatorID uint
|
||||
Total int
|
||||
Owner *model.Account
|
||||
Changes []BusinessOwnerBatchChange
|
||||
}
|
||||
|
||||
// BusinessOwnerBatchAuditWriter 接收店铺负责人批量交接受理事务内的审计事实。
|
||||
// 接口定义在应用层,具体实现由装配注入,避免应用层依赖下游用例包。
|
||||
type BusinessOwnerBatchAuditWriter interface {
|
||||
WriteBusinessOwnerBatch(ctx context.Context, tx *gorm.DB, batch BusinessOwnerBatchAudit) error
|
||||
}
|
||||
|
||||
// SetBatchBusinessOwnerAudit 注入批量交接的批次审计接缝。
|
||||
func (s *BatchBusinessOwnerService) SetBatchBusinessOwnerAudit(writer BusinessOwnerBatchAuditWriter) {
|
||||
s.batchAudit = writer
|
||||
}
|
||||
|
||||
// BatchBusinessOwnerService 收口勾选店铺批量设置或清空平台业务员负责人的事务脚本。
|
||||
// 全量预校验通过后在同一事务内统一更新并逐店写审计;任一项失败整批不修改,
|
||||
// 且失败文案不区分无权、不存在与已删除。
|
||||
type BatchBusinessOwnerService struct {
|
||||
db *gorm.DB
|
||||
batchAudit BusinessOwnerBatchAuditWriter
|
||||
}
|
||||
|
||||
// NewBatchBusinessOwnerService 创建店铺负责人批量交接事务脚本。
|
||||
func NewBatchBusinessOwnerService(db *gorm.DB) *BatchBusinessOwnerService {
|
||||
return &BatchBusinessOwnerService{db: db}
|
||||
}
|
||||
|
||||
// Execute 批量设置或清空店铺负责人。
|
||||
func (s *BatchBusinessOwnerService) Execute(ctx context.Context, request *dto.BatchUpdateShopBusinessOwnerRequest) (*dto.BatchUpdateShopBusinessOwnerResult, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
if !request.BusinessOwnerAccountIDSet {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "必须显式提交业务员归属字段,null 表示清空")
|
||||
}
|
||||
shopIDs, err := normalizeShopIDs(request.ShopIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.batchAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "店铺负责人批量交接统一审计接缝未配置")
|
||||
}
|
||||
operation := "clear"
|
||||
if request.BusinessOwnerAccountID != nil {
|
||||
operation = "assign"
|
||||
}
|
||||
batchKey := batchEventPrefix + uuid.NewString()
|
||||
|
||||
var result *dto.BatchUpdateShopBusinessOwnerResult
|
||||
txErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lockedShops, err := lockManageableShops(ctx, tx, shopIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 命中数不等于请求数即失败,不区分越权、不存在与已删除,避免泄露店铺存在性。
|
||||
if len(lockedShops) != len(shopIDs) {
|
||||
return errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
|
||||
}
|
||||
var owner *uint
|
||||
var ownerAccount *model.Account
|
||||
if request.BusinessOwnerAccountID != nil {
|
||||
account, err := validateBatchBusinessOwner(ctx, tx, *request.BusinessOwnerAccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownerID := account.ID
|
||||
owner, ownerAccount = &ownerID, account
|
||||
}
|
||||
update := tx.WithContext(ctx).Model(&model.Shop{}).Where("id IN ?", shopIDs).
|
||||
Updates(map[string]any{
|
||||
"business_owner_account_id": owner, "updater": operatorID, "updated_at": time.Now(),
|
||||
})
|
||||
if update.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, update.Error, "批量更新店铺负责人失败")
|
||||
}
|
||||
if int(update.RowsAffected) != len(shopIDs) {
|
||||
return errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
|
||||
}
|
||||
if err := s.batchAudit.WriteBusinessOwnerBatch(ctx, tx, BusinessOwnerBatchAudit{
|
||||
BatchKey: batchKey, Operation: operation, OperatorID: operatorID,
|
||||
Total: len(shopIDs), Owner: ownerAccount,
|
||||
Changes: collectBatchChanges(ctx, tx, lockedShops, owner, ownerAccount),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
result = &dto.BatchUpdateShopBusinessOwnerResult{
|
||||
BatchKey: batchKey, ShopCount: len(shopIDs), Cleared: owner == nil, BusinessOwnerAccountID: owner,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
s.recordFailure(ctx, batchKey, operation, operatorID, shopIDs, txErr)
|
||||
return nil, txErr
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// collectBatchChanges 装配逐店审计事实:锁定的店铺携带变更前负责人,
|
||||
// 原负责人账号按一次批量查询载入,目标账号快照由调用方复用,避免 N+1。
|
||||
func collectBatchChanges(ctx context.Context, tx *gorm.DB, shops []*model.Shop, owner *uint, ownerAccount *model.Account) []BusinessOwnerBatchChange {
|
||||
previousIDs := make([]uint, 0, len(shops))
|
||||
seen := make(map[uint]struct{}, len(shops))
|
||||
for _, shop := range shops {
|
||||
if shop.BusinessOwnerAccountID == nil {
|
||||
continue
|
||||
}
|
||||
id := *shop.BusinessOwnerAccountID
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
previousIDs = append(previousIDs, id)
|
||||
}
|
||||
previous := make(map[uint]*model.Account, len(previousIDs))
|
||||
if len(previousIDs) > 0 {
|
||||
var accounts []*model.Account
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", previousIDs).Find(&accounts).Error; err == nil {
|
||||
for _, account := range accounts {
|
||||
previous[account.ID] = account
|
||||
}
|
||||
}
|
||||
}
|
||||
changes := make([]BusinessOwnerBatchChange, 0, len(shops))
|
||||
for _, shop := range shops {
|
||||
change := BusinessOwnerBatchChange{Shop: shop, BeforeOwnerID: shop.BusinessOwnerAccountID, AfterOwnerID: owner, Owner: ownerAccount}
|
||||
if shop.BusinessOwnerAccountID != nil {
|
||||
change.PreviousOwner = previous[*shop.BusinessOwnerAccountID]
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
// recordFailure 在业务回滚后使用独立短事务记录批次失败或拒绝事实。
|
||||
// 二次写入失败不能静默丢弃,按 pkg/auditfailure 既有先例上报为关键级失败。
|
||||
func (s *BatchBusinessOwnerService) recordFailure(ctx context.Context, batchKey, operation string, operatorID uint, shopIDs []uint, originalErr error) {
|
||||
writeErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.batchAudit.WriteBusinessOwnerBatch(ctx, tx, BusinessOwnerBatchAudit{
|
||||
BatchKey: batchKey, Operation: operation, Result: shopAuditFailureResult(originalErr),
|
||||
OperatorID: operatorID, Total: len(shopIDs),
|
||||
})
|
||||
})
|
||||
if writeErr != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionShopBusinessOwnerBatchUpdated,
|
||||
batchKey, "", batchKey, strconv.Itoa(errorCodeOf(originalErr)), writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
// errorCodeOf 返回稳定错误的编码文本,非稳定错误归入内部错误码。
|
||||
func errorCodeOf(err error) int {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr.Code
|
||||
}
|
||||
return errors.CodeInternalError
|
||||
}
|
||||
|
||||
// batchBusinessOwnerFailureMessage 复用平台维护入口的统一失败文案,不区分无权、不存在与已删除。
|
||||
const batchBusinessOwnerFailureMessage = constants.PlatformManagementForbiddenMessage
|
||||
|
||||
// batchEventPrefix 是批次根事件标识前缀,与随机后缀共同保证稳定且不超审计列宽。
|
||||
const batchEventPrefix = "shop-owner-batch:"
|
||||
|
||||
// normalizeShopIDs 去重并保持首次出现顺序,空集合视为非法参数。
|
||||
func normalizeShopIDs(values []uint) ([]uint, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "店铺ID列表不能为空")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// lockManageableShops 在数据范围约束下按主键加行锁读取全部目标店铺。
|
||||
func lockManageableShops(ctx context.Context, tx *gorm.DB, shopIDs []uint) ([]*model.Shop, error) {
|
||||
query := middleware.ApplyShopIDFilter(ctx, tx.WithContext(ctx).Model(&model.Shop{}))
|
||||
var shops []*model.Shop
|
||||
if err := query.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id IN ?", shopIDs).Order("id ASC").Find(&shops).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定批量交接目标店铺失败")
|
||||
}
|
||||
return shops, nil
|
||||
}
|
||||
|
||||
// validateBatchBusinessOwner 校验目标账号是当前启用的平台业务员。
|
||||
func validateBatchBusinessOwner(ctx context.Context, tx *gorm.DB, accountID uint) (*model.Account, error) {
|
||||
if accountID == 0 {
|
||||
return nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
|
||||
}
|
||||
var account model.Account
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "SHARE"}).
|
||||
Where("id = ? AND user_type = ? AND status = ?", accountID, constants.UserTypePlatform, constants.StatusEnabled).
|
||||
First(&account).Error; err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
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/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -112,8 +113,9 @@ func createShop(ctx context.Context, tx *gorm.DB, request *dto.CreateShopRequest
|
||||
}
|
||||
shop.Creator = operatorID
|
||||
shop.Updater = operatorID
|
||||
if err := tx.Create(shop).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建店铺失败")
|
||||
// 分销码在创建时随机生成且唯一;冲突时重新生成并重试,不提供人工指定或编辑入口。
|
||||
if err := CreateShopWithDistributionCode(ctx, tx, shop); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account := &model.Account{
|
||||
@@ -201,6 +203,8 @@ func (s *CreateService) fail(ctx context.Context, request *dto.CreateShopRequest
|
||||
func shopCreationData(shop *model.Shop) map[string]any {
|
||||
data := shopProfileData(shop)
|
||||
data["shop_code"] = shop.ShopCode
|
||||
// 分销码是本 Change 新增的建店事实,按脱敏值记录,口径与审批建店路径一致。
|
||||
data["distribution_code_masked"] = distributiondomain.MaskDistributionCode(shop.DistributionCode)
|
||||
data["parent_id"] = shop.ParentID
|
||||
data["level"] = shop.Level
|
||||
return data
|
||||
@@ -285,7 +289,8 @@ func recordExists(tx *gorm.DB, target any, query string, value any) (bool, error
|
||||
|
||||
func newShopResponse(shop *model.Shop, parentName string) *dto.ShopResponse {
|
||||
return &dto.ShopResponse{
|
||||
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, ParentID: shop.ParentID,
|
||||
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode,
|
||||
DistributionCode: shop.DistributionCode, ParentID: shop.ParentID,
|
||||
BusinessOwnerAccountID: shop.BusinessOwnerAccountID,
|
||||
ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName,
|
||||
ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City,
|
||||
|
||||
95
internal/application/shop/distribution_code.go
Normal file
95
internal/application/shop/distribution_code.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package shop
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// distributionCodeConstraint 是分销码条件唯一索引名,用于识别唯一冲突并重试。
|
||||
const distributionCodeConstraint = "uk_shop_distribution_code"
|
||||
|
||||
// distributionCodeSavepoint 是分销码冲突重试使用的保存点名称。
|
||||
const distributionCodeSavepoint = "shop_distribution_code_retry"
|
||||
|
||||
// CreateShopWithDistributionCode 在事务内为新店铺生成全局唯一随机分销码并创建店铺。
|
||||
//
|
||||
// 每次尝试都重新生成随机码;Create 命中分销码唯一约束时重新生成并重试,
|
||||
// 最多 constants.ShopDistributionCodeMaxAttempts 次。其他唯一冲突(店铺编号等)不重试,
|
||||
// 直接返回数据库错误。
|
||||
//
|
||||
// 重试依赖真实保存点:每条 Create 包在 GORM 的嵌套事务中执行,冲突时 GORM 自动
|
||||
// 回滚到内部保存点,外层事务因此仍可用(PostgreSQL 唯一冲突会中止整个事务,
|
||||
// 不回滚到保存点则后续语句必然 25P02,重试不可能生效)。这里刻意不使用裸
|
||||
// SavePoint/RollbackTo:GORM 的嵌套事务会自行处理 PrepareStmt 下的连接池切换。
|
||||
func CreateShopWithDistributionCode(ctx context.Context, tx *gorm.DB, shop *model.Shop) error {
|
||||
if tx == nil || shop == nil {
|
||||
return errors.New(errors.CodeInvalidParam, "创建店铺参数无效")
|
||||
}
|
||||
// 失败关闭:必须在调用方的事务句柄内执行,否则嵌套事务会自行开启并提交一个新事务,
|
||||
// 破坏调用方的原子性(建店事务与注册审批通过事务均满足该前提)。
|
||||
if !inTransaction(tx) {
|
||||
return errors.New(errors.CodeInvalidStatus, "创建店铺必须传入事务句柄")
|
||||
}
|
||||
for range constants.ShopDistributionCodeMaxAttempts {
|
||||
code, err := distributiondomain.GenerateDistributionCode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if occupied, err := distributionCodeOccupied(ctx, tx, code); err != nil {
|
||||
return err
|
||||
} else if occupied {
|
||||
// 预检命中直接换码,避免把可预期的冲突交给数据库。
|
||||
continue
|
||||
}
|
||||
shop.DistributionCode = code
|
||||
shop.ID = 0
|
||||
createErr := tx.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
|
||||
return inner.Create(shop).Error
|
||||
})
|
||||
if createErr == nil {
|
||||
return nil
|
||||
}
|
||||
if !isDistributionCodeConflict(createErr) {
|
||||
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建店铺失败")
|
||||
}
|
||||
// 分销码冲突:GORM 已回滚到内部保存点,外层事务仍可继续,换码重试。
|
||||
}
|
||||
return errors.New(errors.CodeConflict, "生成分销码冲突,请重试")
|
||||
}
|
||||
|
||||
// inTransaction 判断句柄是否为已开启的事务,与 GORM 自身识别嵌套事务的方式一致。
|
||||
func inTransaction(tx *gorm.DB) bool {
|
||||
if tx == nil || tx.Statement == nil {
|
||||
return false
|
||||
}
|
||||
committer, ok := tx.Statement.ConnPool.(gorm.TxCommitter)
|
||||
return ok && committer != nil && !reflect.ValueOf(committer).IsNil()
|
||||
}
|
||||
|
||||
// distributionCodeOccupied 预检分销码是否已被未删除店铺占用。
|
||||
func distributionCodeOccupied(ctx context.Context, tx *gorm.DB, code string) (bool, error) {
|
||||
var count int64
|
||||
if err := tx.WithContext(ctx).Model(&model.Shop{}).
|
||||
Where("distribution_code = ?", code).Count(&count).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "校验分销码唯一性失败")
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// isDistributionCodeConflict 判断错误是否为分销码条件唯一索引冲突。
|
||||
func isDistributionCodeConflict(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if !stderrors.As(err, &pgErr) {
|
||||
return false
|
||||
}
|
||||
return pgErr.Code == "23505" && pgErr.ConstraintName == distributionCodeConstraint
|
||||
}
|
||||
@@ -16,8 +16,21 @@ import (
|
||||
|
||||
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
|
||||
type UpdateService struct {
|
||||
db *gorm.DB
|
||||
audit accessauditapp.Writer
|
||||
db *gorm.DB
|
||||
audit accessauditapp.Writer
|
||||
qualificationInvalidator WithdrawalQualificationInvalidator
|
||||
}
|
||||
|
||||
// WithdrawalQualificationInvalidator 在店铺停用事务内联动失效提现资料资格。
|
||||
// 接口定义在应用层,具体实现由装配注入,避免应用层依赖下游用例包。
|
||||
type WithdrawalQualificationInvalidator interface {
|
||||
InvalidateByShopDisable(ctx context.Context, tx *gorm.DB, shopID uint, reason string) error
|
||||
}
|
||||
|
||||
// SetWithdrawalQualificationInvalidator 注入店铺停用联动的提现资料资格失效接缝。
|
||||
// 未注入时停用不联动,用于不依赖该能力的旧装配路径。
|
||||
func (s *UpdateService) SetWithdrawalQualificationInvalidator(invalidator WithdrawalQualificationInvalidator) {
|
||||
s.qualificationInvalidator = invalidator
|
||||
}
|
||||
|
||||
// NewUpdateService 创建店铺更新事务脚本。
|
||||
@@ -81,6 +94,8 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
|
||||
shop.Address = request.Address
|
||||
shop.Status = request.Status
|
||||
shop.Updater = operatorID
|
||||
// 分销码创建后不可修改:Save 写全列,这里显式保留加锁读取到的原值。
|
||||
shop.DistributionCode = before.DistributionCode
|
||||
if err := tx.Save(&shop).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新店铺失败")
|
||||
}
|
||||
@@ -110,6 +125,14 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
|
||||
if err := s.writeStateAudits(ctx, tx, &before, &shop, parentShop, operatorID); err != nil {
|
||||
return err
|
||||
}
|
||||
// 店铺停用必须使该店铺全部有效提现资料资格失效,且与停用同事务提交。
|
||||
if before.Status != constants.ShopStatusDisabled && shop.Status == constants.ShopStatusDisabled &&
|
||||
s.qualificationInvalidator != nil {
|
||||
if err := s.qualificationInvalidator.InvalidateByShopDisable(
|
||||
ctx, tx, shop.ID, "代理店铺已停用,提现资料资格自动失效"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -288,9 +288,62 @@ func validateSceneMapping(businessType string, mapping []dto.WeComControlMapping
|
||||
}
|
||||
}
|
||||
}
|
||||
// 本变更新增字段必须配置映射:缺失即明确失败,避免审批材料被静默丢弃后审批人看不到关键依据。
|
||||
// 既有未映射的可选控件不受影响,保持既有静默跳过行为。
|
||||
wanted, ok := sceneBusinessFields(businessType)
|
||||
if !ok {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信审批业务类型无效")
|
||||
}
|
||||
for _, field := range wanted {
|
||||
if !field.RequiredMapping {
|
||||
continue
|
||||
}
|
||||
if _, exists := businessFields[field.Code]; !exists {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信场景缺少必须映射的业务字段: "+field.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequiredSceneField 描述本变更新增、必须配置控件映射的业务字段。
|
||||
type RequiredSceneField struct {
|
||||
// Code 是 control_mapping.business_field 应填写的稳定字段编码。
|
||||
Code string
|
||||
// Name 是字段中文名称,用于在缺失时指出具体缺哪个字段/控件。
|
||||
Name string
|
||||
}
|
||||
|
||||
// RequiredSceneFields 返回指定业务类型下必须配置控件映射的业务字段,顺序与场景白名单一致。
|
||||
//
|
||||
// 这是「必须映射字段」的唯一判定来源:场景映射校验(保存时)与表单构建(提交时)都从
|
||||
// 这里取集合,MUST NOT 再维护第二份清单。既有未标注必须映射的可选字段不在返回结果中,
|
||||
// 因此其缺失映射仍保持既有静默跳过行为。
|
||||
func RequiredSceneFields(businessType string) []RequiredSceneField {
|
||||
fields, ok := sceneBusinessFields(businessType)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]RequiredSceneField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if field.RequiredMapping {
|
||||
result = append(result, RequiredSceneField{Code: field.Code, Name: field.Name})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// approvalBusinessTypes 返回全部已登记审批业务场景,供只读字段汇总复用。
|
||||
func approvalBusinessTypes() []string {
|
||||
return []string{
|
||||
constants.ApprovalBusinessTypeRefund,
|
||||
constants.ApprovalBusinessTypeOfflineRecharge,
|
||||
constants.ApprovalBusinessTypeEmployeeCollection,
|
||||
constants.ApprovalBusinessTypeAgentDistribution,
|
||||
constants.ApprovalBusinessTypeWithdrawalQualification,
|
||||
constants.ApprovalBusinessTypeCommissionWithdrawal,
|
||||
}
|
||||
}
|
||||
|
||||
func allowedSceneBusinessField(businessType, businessField string) bool {
|
||||
fields, ok := sceneBusinessFields(businessType)
|
||||
if !ok {
|
||||
@@ -334,6 +387,13 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
|
||||
{Code: constants.ApprovalFieldRefundVoucherKey, Name: "退款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的退款凭证列表"},
|
||||
{Code: constants.ApprovalFieldRefundReason, Name: "退款原因", ValueType: constants.ApprovalFieldValueTypeString, Description: "业务提交人填写的退款原因"},
|
||||
{Code: constants.ApprovalFieldPackageUsageID, Name: "套餐使用记录 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "退款关联套餐使用记录 ID;无关联记录时可能为空"},
|
||||
{Code: constants.ApprovalFieldRefundAssetType, Name: "资产类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款关联资产类型:card 单卡、device 设备;沿用退款申请的下单快照推导", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundDeviceType, Name: "设备类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款申请关联设备的类型;资产不是设备或无关联设备时为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundDeviceModel, Name: "设备型号", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款申请关联设备的型号;资产不是设备或无关联设备时为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundPackageUsedMB, Name: "当前退款套餐已用量(MB)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "当前退款套餐已用量(MB,真实流量);解析不到套餐时为 0", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundPackageTotalMB, Name: "当前退款套餐总量(MB)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "当前退款套餐总量(MB,真实流量);解析不到套餐时为 0", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundChannelTradeNo, Name: "原支付渠道交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "创建申请时冻结的原成功支付记录渠道交易流水号;线下订单为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundApplicantRemark, Name: "申请人备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提交填写的申请人备注快照;未填写时为空字符串。与审批备注不是同一字段", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
@@ -353,6 +413,56 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
case constants.ApprovalBusinessTypeAgentDistribution:
|
||||
return []dto.WeComBusinessFieldResponse{
|
||||
{Code: constants.ApprovalFieldDistributionCode, Name: "分销码", ValueType: constants.ApprovalFieldValueTypeString, Description: "注册使用的上级店铺分销码脱敏值,仅用于审批人核对来源,不代表新建店铺的码"},
|
||||
{Code: constants.ApprovalFieldDistributionParentShopID, Name: "上级店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "分销码所属上级店铺的系统 ID"},
|
||||
{Code: constants.ApprovalFieldDistributionParentShopName, Name: "上级店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "分销码所属上级店铺名称快照"},
|
||||
{Code: constants.ApprovalFieldDistributionShopName, Name: "申请店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺名称快照"},
|
||||
{Code: constants.ApprovalFieldDistributionShopCode, Name: "申请店铺编号", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺编号快照,通过时按既有唯一约束校验"},
|
||||
{Code: constants.ApprovalFieldDistributionUsername, Name: "代理账号用户名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的代理主账号用户名快照"},
|
||||
{Code: constants.ApprovalFieldDistributionPhoneMasked, Name: "注册手机号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的注册手机号,禁止写入完整手机号"},
|
||||
{Code: constants.ApprovalFieldDistributionContactName, Name: "联系人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册填写的联系人姓名"},
|
||||
{Code: constants.ApprovalFieldDistributionRegion, Name: "注册地址摘要", ValueType: constants.ApprovalFieldValueTypeString, Description: "省市区与详细地址拼接的注册地址摘要"},
|
||||
{Code: constants.ApprovalFieldDistributionBusinessOwner, Name: "业务员", ValueType: constants.ApprovalFieldValueTypeString, Description: "上级代理店铺当前启用业务员的名称快照,用于确认将同步给新代理的初始业务员;上级无业务员时为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldDistributionBusinessOwnerAbsence, Name: "无业务员标记", ValueType: constants.ApprovalFieldValueTypeString, Description: "上级店铺当前无业务员时提交「无」,有业务员时为空字符串,MUST NOT 用提交人或上级主账号填充", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
case constants.ApprovalBusinessTypeWithdrawalQualification:
|
||||
return []dto.WeComBusinessFieldResponse{
|
||||
{Code: constants.ApprovalFieldQualificationShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "提现资料资格所属代理店铺的系统 ID"},
|
||||
{Code: constants.ApprovalFieldQualificationShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "提现资料资格所属代理店铺名称快照"},
|
||||
{Code: constants.ApprovalFieldQualificationSubjectType, Name: "签约主体类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "签约主体类型中文名:企业或个人"},
|
||||
{Code: constants.ApprovalFieldQualificationSubjectCodeMasked, Name: "签约主体代码", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的统一社会信用代码或身份证号,禁止写入完整证件号"},
|
||||
{Code: constants.ApprovalFieldQualificationLegalPersonMasked, Name: "法人身份证号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的法人身份证号,禁止写入完整证件号"},
|
||||
{Code: constants.ApprovalFieldQualificationContractKey, Name: "合同附件", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "合同对象存储 Key 列表(单个对象)"},
|
||||
{Code: constants.ApprovalFieldQualificationIDCardFrontKey, Name: "法人身份证正面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证正面对象存储 Key 列表(单个对象)"},
|
||||
{Code: constants.ApprovalFieldQualificationIDCardBackKey, Name: "法人身份证反面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证反面对象存储 Key 列表(单个对象)"},
|
||||
{Code: constants.ApprovalFieldQualificationBusinessLicenseKey, Name: "营业执照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "营业执照对象存储 Key 列表(单个对象,可选)"},
|
||||
{Code: constants.ApprovalFieldQualificationShopFrontKey, Name: "门头照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "门头照对象存储 Key 列表(单个对象,可选)"},
|
||||
{Code: constants.ApprovalFieldQualificationInvoiceKey, Name: "发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "发票对象存储 Key 列表(单个对象,仅企业可选)"},
|
||||
{Code: constants.ApprovalFieldQualificationInvoiceTitle, Name: "发票抬头", ValueType: constants.ApprovalFieldValueTypeString, Description: "发票抬头,仅企业填写且必须与合同主体一致"},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
case constants.ApprovalBusinessTypeCommissionWithdrawal:
|
||||
return []dto.WeComBusinessFieldResponse{
|
||||
{Code: constants.ApprovalFieldWithdrawalNo, Name: "提现单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提现申请单号"},
|
||||
{Code: constants.ApprovalFieldWithdrawalAttemptNo, Name: "提交次序", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次为第几次提交,重提时递增"},
|
||||
{Code: constants.ApprovalFieldWithdrawalShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "发起提现的代理店铺系统 ID"},
|
||||
{Code: constants.ApprovalFieldWithdrawalShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "发起提现的代理店铺名称快照"},
|
||||
{Code: constants.ApprovalFieldWithdrawalAmount, Name: "提现金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的提现金额"},
|
||||
{Code: constants.ApprovalFieldWithdrawalAmountCent, Name: "提现金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的提现金额整数"},
|
||||
{Code: constants.ApprovalFieldWithdrawalFee, Name: "手续费", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的本次手续费"},
|
||||
{Code: constants.ApprovalFieldWithdrawalActualAmount, Name: "实际到账金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的实际到账金额"},
|
||||
{Code: constants.ApprovalFieldWithdrawalMethod, Name: "收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款方式名称快照"},
|
||||
{Code: constants.ApprovalFieldWithdrawalAccountName, Name: "收款人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款人姓名"},
|
||||
{Code: constants.ApprovalFieldWithdrawalAccountNumber, Name: "收款账号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款账号,供审批人核验打款"},
|
||||
{Code: constants.ApprovalFieldWithdrawalInvoiceKey, Name: "申请级发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "本次申请级发票对象存储 Key 列表,无发票时为空数组"},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
@@ -373,14 +483,12 @@ func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComCon
|
||||
}
|
||||
|
||||
func validApprovalBusinessType(businessType string) bool {
|
||||
switch businessType {
|
||||
case constants.ApprovalBusinessTypeRefund,
|
||||
constants.ApprovalBusinessTypeOfflineRecharge,
|
||||
constants.ApprovalBusinessTypeEmployeeCollection:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
for _, candidate := range approvalBusinessTypes() {
|
||||
if candidate == businessType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func approvalBusinessTypeName(businessType string) string {
|
||||
@@ -391,6 +499,12 @@ func approvalBusinessTypeName(businessType string) string {
|
||||
return "员工线下代充值审批"
|
||||
case constants.ApprovalBusinessTypeEmployeeCollection:
|
||||
return "员工代收款核销审批"
|
||||
case constants.ApprovalBusinessTypeAgentDistribution:
|
||||
return "代理扫码分销注册审批"
|
||||
case constants.ApprovalBusinessTypeWithdrawalQualification:
|
||||
return "提现资料资格审批"
|
||||
case constants.ApprovalBusinessTypeCommissionWithdrawal:
|
||||
return "佣金提现终审"
|
||||
default:
|
||||
return "未知审批业务类型"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package bootstrap
|
||||
|
||||
import (
|
||||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||||
businessUserGroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup"
|
||||
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
|
||||
h5PopupApp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
|
||||
merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
|
||||
@@ -18,17 +20,24 @@ import (
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
|
||||
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
|
||||
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
|
||||
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
|
||||
auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit"
|
||||
businessUserGroupQuery "github.com/break/junhong_cmp_fiber/internal/query/businessusergroup"
|
||||
distributionwithdrawalQuery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
|
||||
employeecollectionQuery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection"
|
||||
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
|
||||
h5PopupQuery "github.com/break/junhong_cmp_fiber/internal/query/h5popup"
|
||||
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
|
||||
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
|
||||
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
|
||||
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert"
|
||||
priorityPollingQuery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling"
|
||||
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
|
||||
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
|
||||
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
||||
@@ -60,7 +69,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(deps.DB)
|
||||
shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(deps.DB)
|
||||
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis)
|
||||
businessUserGroupStore := postgres.NewBusinessUserGroupStore(deps.DB)
|
||||
carrierStore := postgres.NewCarrierStore(deps.DB)
|
||||
pollingPriorityItemStore := postgres.NewPollingPriorityItemStore(deps.DB)
|
||||
rechargeOrderStore := postgres.NewRechargeOrderStore(deps.DB, deps.Redis)
|
||||
paymentStore := postgres.NewPaymentStore(deps.DB, deps.Redis)
|
||||
commissionRecordStore := postgres.NewCommissionRecordStore(deps.DB, deps.Redis)
|
||||
@@ -161,6 +172,26 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
))
|
||||
svc.Account.SetWeComMemberFinder(wecomMembers)
|
||||
|
||||
// H5 弹窗候选必须当次返回可用通知标识,因此 API 进程直接复用 Outbox 消费的同一套渲染、展示期与幂等写入规则。
|
||||
notificationAudit := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
|
||||
notificationDirectWriter := notificationApp.NewDeliveryService(
|
||||
notificationInfra.NewRepository(deps.DB), notificationInfra.NewRegistry(), nil, deps.Logger, notificationAudit,
|
||||
)
|
||||
// 资产标识解析复用既有 Store 方法,保证与资产详情、换货入口同一口径。
|
||||
candidateService := h5PopupApp.NewCandidateService(
|
||||
deps.DB,
|
||||
postgres.NewAssetIdentifierStore(deps.DB),
|
||||
postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||
svc.CustomerBinding,
|
||||
notificationDirectWriter,
|
||||
)
|
||||
riskExchangeService := h5PopupApp.NewRiskExchangeService(deps.DB, svc.CustomerBinding, notificationAudit)
|
||||
popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit)
|
||||
popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB)
|
||||
packageTrafficAlertQuery := packagetrafficalertquery.NewQuery(deps.DB)
|
||||
operationsReportQuery := operationsreportquery.NewQuery(deps.DB)
|
||||
|
||||
return &Handlers{
|
||||
Auth: authHandler.NewHandler(svc.Auth, validate),
|
||||
Account: admin.NewAccountHandler(svc.Account),
|
||||
@@ -199,22 +230,44 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
}(),
|
||||
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
|
||||
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB),
|
||||
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
|
||||
notificationApp.NewReadService(deps.DB, notificationAudit), validate),
|
||||
ClientPopup: app.NewClientPopupHandler(candidateService, riskExchangeService, validate),
|
||||
Shop: func() *admin.ShopHandler {
|
||||
handler := admin.NewShopHandler(svc.Shop, validate)
|
||||
handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit))
|
||||
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB, svc.AccessAudit))
|
||||
updateService := shopApp.NewUpdateService(deps.DB, svc.AccessAudit)
|
||||
// 店铺停用必须联动失效提现资料资格,接入点在同一停用事务内。
|
||||
updateService.SetWithdrawalQualificationInvalidator(svc.WithdrawalQualification)
|
||||
handler.SetUpdateService(updateService)
|
||||
handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB))
|
||||
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB, svc.AccessAudit))
|
||||
return handler
|
||||
}(),
|
||||
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
||||
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
||||
ShopCommission: func() *admin.ShopCommissionHandler {
|
||||
handler := admin.NewShopCommissionHandler(svc.ShopCommission)
|
||||
handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB))
|
||||
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
||||
BusinessUserGroup: func() *admin.BusinessUserGroupHandler {
|
||||
handler := admin.NewBusinessUserGroupHandler(
|
||||
businessUserGroupApp.New(deps.DB, businessUserGroupStore, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)),
|
||||
validate,
|
||||
)
|
||||
handler.SetQuery(businessUserGroupQuery.NewQuery(deps.DB, businessUserGroupStore))
|
||||
batchService := shopApp.NewBatchBusinessOwnerService(deps.DB)
|
||||
batchService.SetBatchBusinessOwnerAudit(auditInfra.NewWriter(auditInfra.NewRegistry(), nil))
|
||||
handler.SetBatchService(batchService)
|
||||
return handler
|
||||
}(),
|
||||
ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(svc.ShopBusinessOwnerImport),
|
||||
PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(svc.PhoneAssetAssociation, validate),
|
||||
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
||||
ShopCommission: func() *admin.ShopCommissionHandler {
|
||||
handler := admin.NewShopCommissionHandler(svc.ShopCommission, validate)
|
||||
handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB))
|
||||
handler.SetWithdrawalQuery(distributionwithdrawalQuery.NewQuery(deps.DB))
|
||||
return handler
|
||||
}(),
|
||||
WithdrawalQualification: admin.NewWithdrawalQualificationHandler(
|
||||
svc.WithdrawalQualification, distributionwithdrawalQuery.NewQuery(deps.DB), validate,
|
||||
),
|
||||
AgentDistribution: app.NewAgentDistributionHandler(svc.DistributionRegistration, validate),
|
||||
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal, validate),
|
||||
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
|
||||
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
|
||||
@@ -225,7 +278,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
|
||||
ExportTask: admin.NewExportTaskHandler(svc.ExportTask),
|
||||
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB),
|
||||
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
|
||||
notificationApp.NewReadService(deps.DB, notificationAudit), validate),
|
||||
H5PopupConfiguration: admin.NewH5PopupConfigurationHandler(popupConfigurationService, popupConfigurationQuery, validate),
|
||||
Device: admin.NewDeviceHandler(svc.Device),
|
||||
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
|
||||
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
|
||||
@@ -234,6 +288,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
||||
Package: admin.NewPackageHandler(svc.Package),
|
||||
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
||||
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate),
|
||||
AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(svc.AssetAutoRenewal, validate),
|
||||
OperationsReport: admin.NewOperationsReportHandler(operationsReportQuery, svc.ExportTask, validate),
|
||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
||||
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
||||
@@ -267,6 +324,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
|
||||
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
||||
PollingPriority: admin.NewPriorityPollingHandler(
|
||||
svc.PriorityPolling, priorityPollingQuery.NewQuery(deps.DB, pollingPriorityItemStore), validate,
|
||||
),
|
||||
Asset: func() *admin.AssetHandler {
|
||||
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
|
||||
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(
|
||||
@@ -283,6 +343,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
h.SetObservationSeriesDispatcher(svc.ObservationSeries)
|
||||
h.SetPackageExpiryQuery(packageExpiry)
|
||||
h.SetPackageExpiryQueue(deps.QueueClient)
|
||||
h.SetExportTaskService(svc.ExportTask)
|
||||
h.SetValidator(validate)
|
||||
return h
|
||||
}(),
|
||||
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
|
||||
|
||||
@@ -7,11 +7,16 @@ 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"
|
||||
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"
|
||||
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
|
||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
packagetrafficalertapp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
|
||||
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
|
||||
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
@@ -20,9 +25,11 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
|
||||
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
|
||||
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
|
||||
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
|
||||
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
|
||||
agentOpenAPISvc "github.com/break/junhong_cmp_fiber/internal/service/agent_open_api"
|
||||
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
|
||||
@@ -67,8 +74,10 @@ import (
|
||||
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
|
||||
operationPasswordSvc "github.com/break/junhong_cmp_fiber/internal/service/operation_password"
|
||||
orderPackageInvalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate"
|
||||
phoneAssetAssociationSvc "github.com/break/junhong_cmp_fiber/internal/service/phone_asset_association"
|
||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
|
||||
shopBusinessOwnerImportSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_business_owner_import"
|
||||
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
|
||||
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
|
||||
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
|
||||
@@ -89,6 +98,9 @@ type services struct {
|
||||
Shop *shopSvc.Service
|
||||
Auth *authSvc.Service
|
||||
ShopCommission *shopCommissionSvc.Service
|
||||
DistributionRegistration *distributionwithdrawalApp.RegistrationService
|
||||
WithdrawalQualification *distributionwithdrawalApp.QualificationService
|
||||
WithdrawalApproval *distributionwithdrawalApp.WithdrawalService
|
||||
CommissionWithdrawal *commissionWithdrawalSvc.Service
|
||||
CommissionWithdrawalSetting *commissionWithdrawalSettingSvc.Service
|
||||
CommissionCalculation *commissionCalculationSvc.Service
|
||||
@@ -107,6 +119,8 @@ type services struct {
|
||||
Package *packageSvc.Service
|
||||
PackageDailyRecord *packageSvc.DailyRecordService
|
||||
PackageCustomerView *packageSvc.CustomerViewService
|
||||
PackageTrafficAlertRule *packagetrafficalertapp.RuleService
|
||||
AssetAutoRenewal *assetAutoRenewalApp.Service
|
||||
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
|
||||
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
|
||||
ShopSeriesGrant *shopSeriesGrantSvc.Service
|
||||
@@ -121,6 +135,7 @@ type services struct {
|
||||
PollingAlert *pollingSvc.AlertService
|
||||
PollingCleanup *pollingSvc.CleanupService
|
||||
PollingManualTrigger *pollingSvc.ManualTriggerService
|
||||
PriorityPolling *pollingSvc.PriorityManualService
|
||||
Asset *assetSvc.Service
|
||||
AssetLifecycle *assetSvc.LifecycleService
|
||||
AssetWallet *assetWalletSvc.Service
|
||||
@@ -138,6 +153,8 @@ type services struct {
|
||||
CustomerBinding *customerBindingSvc.Service
|
||||
OrderPackageInvalidate *orderPackageInvalidateSvc.Service
|
||||
AssetPackageBatchOrder *assetPackageBatchOrderSvc.Service
|
||||
ShopBusinessOwnerImport *shopBusinessOwnerImportSvc.Service
|
||||
PhoneAssetAssociation *phoneAssetAssociationSvc.Service
|
||||
ObservationSeries cardObservationApp.BestEffortSeriesDispatcher
|
||||
CardObservation *cardObservationApp.Service
|
||||
CardObservationSeries *cardObservationApp.SeriesAttemptService
|
||||
@@ -173,12 +190,17 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
iotCard.SetAccessAudit(auditWriter)
|
||||
cardObservationOutbox := outbox.NewRepository()
|
||||
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
|
||||
priorityEvents := prioritypollingInfra.NewPriorityEventWriter(cardObservationOutbox)
|
||||
// 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。
|
||||
carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger)
|
||||
cardObservationService := cardObservationApp.NewService(
|
||||
deps.DB,
|
||||
cardObservationInfra.NewEventWriter(cardObservationOutbox),
|
||||
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
|
||||
)
|
||||
cardObservationService.SetStateAuditWriter(iotCard)
|
||||
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
|
||||
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
|
||||
iotCard.SetCardObservationService(cardObservationService)
|
||||
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
|
||||
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
|
||||
@@ -221,6 +243,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
)
|
||||
packageActivation.SetLifecycleAudit(auditWriter)
|
||||
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
|
||||
packageActivation.SetPriorityEventWriter(priorityEvents)
|
||||
|
||||
stopResumeService := iotCardSvc.NewStopResumeService(
|
||||
deps.Redis,
|
||||
@@ -232,12 +255,16 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
)
|
||||
stopResumeService.SetPollingCallback(pollingLifecycleSvc)
|
||||
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
|
||||
stopResumeService.SetPriorityEventWriter(priorityEvents)
|
||||
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
|
||||
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
|
||||
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
|
||||
iotCard.SetRealnameActivator(packageActivation)
|
||||
iotCard.SetStopResumeService(stopResumeService)
|
||||
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
|
||||
iotCard.SetEnterpriseCardAuthStore(s.EnterpriseCardAuthorization)
|
||||
iotCard.SetEnterpriseStore(s.Enterprise)
|
||||
iotCard.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
|
||||
iotCard.SetRedisClient(deps.Redis)
|
||||
device := deviceSvc.New(
|
||||
deps.DB,
|
||||
@@ -256,6 +283,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
s.Enterprise,
|
||||
)
|
||||
device.SetAccessAudit(auditWriter)
|
||||
device.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
|
||||
device.SetGatewayIntegrationLog(integrationlog.NewRepository(deps.DB))
|
||||
device.SetObservationSeriesEventWriter(observationSeriesEvents)
|
||||
device.SetObservationSeriesDispatcher(observationSeries)
|
||||
@@ -274,6 +302,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
orderService.SetLifecycleAudit(auditWriter)
|
||||
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
|
||||
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
|
||||
orderService.SetPriorityEventWriter(priorityEvents)
|
||||
walletOutbox := outbox.NewRepository()
|
||||
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
|
||||
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
|
||||
@@ -328,10 +357,21 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
refundService.SetNotificationOutbox(walletOutbox)
|
||||
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis))
|
||||
refundService.SetLifecycleAudit(auditWriter)
|
||||
// 渠道原路退款的登记与执行共用同一用例:API 侧只登记待执行事实与可靠事件,
|
||||
// 真正的渠道调用由 Worker 消费该事件执行。
|
||||
refundService.SetChannelRefundService(
|
||||
refundchannelApp.NewService(
|
||||
deps.DB,
|
||||
merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis),
|
||||
paymentInfra.NewRefundAdapter(wechat.NewRedisCache(deps.Redis), deps.Logger),
|
||||
auditWriter,
|
||||
).SetLogger(deps.Logger).SetCompletionNotifier(refundService),
|
||||
)
|
||||
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
|
||||
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
|
||||
exchangeService.SetAccessAudit(auditWriter)
|
||||
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder)
|
||||
assetService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
|
||||
assetService.SetAccessAudit(auditWriter)
|
||||
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
|
||||
wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB)
|
||||
@@ -377,6 +417,18 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
shopService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
|
||||
commissionWithdrawal := commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest)
|
||||
commissionWithdrawal.SetAuditWriter(auditWriter)
|
||||
// 代理分销注册、提现资格与提现终审共用同一审计 Writer 与通用审批创建接缝。
|
||||
distributionRegistration := distributionwithdrawalApp.NewRegistrationService(
|
||||
deps.DB, deps.VerificationService, approvalCreationService, auditWriter,
|
||||
)
|
||||
withdrawalQualification := distributionwithdrawalApp.NewQualificationService(
|
||||
deps.DB, approvalCreationService, auditWriter,
|
||||
)
|
||||
withdrawalApproval := distributionwithdrawalApp.NewWithdrawalService(
|
||||
deps.DB, approvalCreationService, auditWriter,
|
||||
)
|
||||
shopCommission.SetWithdrawalApprovalService(withdrawalApproval)
|
||||
shopService.SetWithdrawalQualificationInvalidator(withdrawalQualification)
|
||||
commissionCalculation := commissionCalculationSvc.New(
|
||||
deps.DB,
|
||||
s.CommissionRecord,
|
||||
@@ -405,6 +457,13 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
pollingManualTriggerService := pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger)
|
||||
pollingManualTriggerService.SetAudit(deps.DB, auditWriter)
|
||||
|
||||
// 人工优先轮询入口:入队、人工关闭与人工重触发;与人工触发共用同包权限判定,
|
||||
// 但使用独立的优先提示通道与合并语义。
|
||||
priorityPollingService := pollingSvc.NewPriorityManualService(
|
||||
deps.DB, s.IotCard, s.PollingPriorityItem, pollingQueueMgr, deps.Logger,
|
||||
)
|
||||
priorityPollingService.SetAudit(auditWriter)
|
||||
|
||||
return &services{
|
||||
AccessAudit: auditWriter,
|
||||
Approval: approvalCreationService,
|
||||
@@ -418,6 +477,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
s.PersonalCustomerOpenID,
|
||||
s.PersonalCustomer,
|
||||
s.PersonalCustomerPhone,
|
||||
s.PhoneAssetAssociation,
|
||||
s.IotCard,
|
||||
s.Device,
|
||||
wechatConfig,
|
||||
@@ -431,6 +491,9 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
Shop: shopService,
|
||||
Auth: authService,
|
||||
ShopCommission: shopCommission,
|
||||
DistributionRegistration: distributionRegistration,
|
||||
WithdrawalQualification: withdrawalQualification,
|
||||
WithdrawalApproval: withdrawalApproval,
|
||||
CommissionWithdrawal: commissionWithdrawal,
|
||||
CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting),
|
||||
CommissionCalculation: commissionCalculation,
|
||||
@@ -449,6 +512,13 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
Package: packageService,
|
||||
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
|
||||
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
|
||||
PackageTrafficAlertRule: packagetrafficalertapp.NewRuleService(deps.DB, s.PackageTrafficAlert, auditWriter),
|
||||
AssetAutoRenewal: assetAutoRenewalApp.NewService(assetAutoRenewalApp.Dependencies{
|
||||
DB: deps.DB, Redis: deps.Redis, Logger: deps.Logger,
|
||||
Outbox: outbox.NewRepository(), AuditWriter: auditWriter,
|
||||
PurchaseValidation: purchaseValidation,
|
||||
Candidates: assetAutoRenewalQuery.NewQuery(deps.DB),
|
||||
}),
|
||||
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, auditWriter),
|
||||
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, auditWriter),
|
||||
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger, auditWriter),
|
||||
@@ -463,6 +533,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
PollingAlert: pollingAlertService,
|
||||
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
|
||||
PollingManualTrigger: pollingManualTriggerService,
|
||||
PriorityPolling: priorityPollingService,
|
||||
Asset: assetService,
|
||||
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, auditWriter),
|
||||
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
|
||||
@@ -480,8 +551,13 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
CustomerBinding: customerBinding,
|
||||
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient, auditWriter),
|
||||
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient, auditWriter),
|
||||
ObservationSeries: observationSeries,
|
||||
CardObservation: cardObservationService,
|
||||
CardObservationSeries: cardObservationSeries,
|
||||
ShopBusinessOwnerImport: shopBusinessOwnerImportSvc.New(s.ShopBusinessOwnerImportTask, deps.QueueClient, auditWriter),
|
||||
PhoneAssetAssociation: phoneAssetAssociationSvc.New(
|
||||
deps.DB, s.PhoneAssetAssociation, s.PhoneAssetUnbindImportTask,
|
||||
s.AssetIdentifier, s.IotCard, s.Device, deps.QueueClient, auditWriter,
|
||||
),
|
||||
ObservationSeries: observationSeries,
|
||||
CardObservation: cardObservationService,
|
||||
CardObservationSeries: cardObservationSeries,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type stores struct {
|
||||
PersonalCustomerOpenID *postgres.PersonalCustomerOpenIDStore
|
||||
PersonalCustomerDevice *postgres.PersonalCustomerDeviceStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
PhoneAssetAssociation *postgres.PhoneAssetAssociationStore
|
||||
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
|
||||
CommissionRecord *postgres.CommissionRecordStore
|
||||
CommissionWithdrawalSetting *postgres.CommissionWithdrawalSettingStore
|
||||
@@ -51,6 +52,7 @@ type stores struct {
|
||||
DataCleanupConfig *postgres.DataCleanupConfigStore
|
||||
DataCleanupLog *postgres.DataCleanupLogStore
|
||||
PollingManualTriggerLog *postgres.PollingManualTriggerLogStore
|
||||
PollingPriorityItem *postgres.PollingPriorityItemStore
|
||||
// 代理钱包系统
|
||||
AgentWallet *postgres.AgentWalletStore
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
@@ -69,8 +71,15 @@ type stores struct {
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
// 资产套餐批量订购任务
|
||||
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
|
||||
// 业务用户组与成员归属
|
||||
BusinessUserGroup *postgres.BusinessUserGroupStore
|
||||
// 店铺负责人 CSV 导入任务
|
||||
ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore
|
||||
PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore
|
||||
// 流量系统
|
||||
CardDailyUsage *postgres.CardDailyUsageStore
|
||||
// 套餐真流量预警规则与达量预警事实
|
||||
PackageTrafficAlert *postgres.PackageTrafficAlertStore
|
||||
// 资产标识符注册表
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
}
|
||||
@@ -89,6 +98,7 @@ func initStores(deps *Dependencies) *stores {
|
||||
PersonalCustomerOpenID: postgres.NewPersonalCustomerOpenIDStore(deps.DB),
|
||||
PersonalCustomerDevice: postgres.NewPersonalCustomerDeviceStore(deps.DB),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
PhoneAssetAssociation: postgres.NewPhoneAssetAssociationStore(deps.DB),
|
||||
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
|
||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||
CommissionWithdrawalSetting: postgres.NewCommissionWithdrawalSettingStore(deps.DB, deps.Redis),
|
||||
@@ -123,20 +133,25 @@ func initStores(deps *Dependencies) *stores {
|
||||
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
|
||||
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
|
||||
PollingManualTriggerLog: postgres.NewPollingManualTriggerLogStore(deps.DB),
|
||||
PollingPriorityItem: postgres.NewPollingPriorityItemStore(deps.DB),
|
||||
// 代理钱包系统
|
||||
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis),
|
||||
// 资产钱包系统
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
|
||||
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
|
||||
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
|
||||
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
|
||||
RefundRequest: postgres.NewRefundStore(deps.DB),
|
||||
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
|
||||
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
|
||||
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
|
||||
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
|
||||
RefundRequest: postgres.NewRefundStore(deps.DB),
|
||||
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
|
||||
BusinessUserGroup: postgres.NewBusinessUserGroupStore(deps.DB),
|
||||
ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB),
|
||||
PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB),
|
||||
PackageTrafficAlert: postgres.NewPackageTrafficAlertStore(deps.DB),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,13 @@ type Handlers struct {
|
||||
ClientDevice *app.ClientDeviceHandler
|
||||
ClientRechargeOrder *app.ClientRechargeOrderHandler
|
||||
ClientNotification *app.ClientNotificationHandler
|
||||
ClientPopup *app.ClientPopupHandler
|
||||
Shop *admin.ShopHandler
|
||||
ShopRole *admin.ShopRoleHandler
|
||||
AdminAuth *admin.AuthHandler
|
||||
ShopCommission *admin.ShopCommissionHandler
|
||||
WithdrawalQualification *admin.WithdrawalQualificationHandler
|
||||
AgentDistribution *app.AgentDistributionHandler
|
||||
CommissionWithdrawal *admin.CommissionWithdrawalHandler
|
||||
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
|
||||
Enterprise *admin.EnterpriseHandler
|
||||
@@ -39,6 +42,7 @@ type Handlers struct {
|
||||
IotCardImport *admin.IotCardImportHandler
|
||||
ExportTask *admin.ExportTaskHandler
|
||||
Notification *admin.NotificationHandler
|
||||
H5PopupConfiguration *admin.H5PopupConfigurationHandler
|
||||
Device *admin.DeviceHandler
|
||||
DeviceImport *admin.DeviceImportHandler
|
||||
AssetAllocationRecord *admin.AssetAllocationRecordHandler
|
||||
@@ -64,6 +68,7 @@ type Handlers struct {
|
||||
PollingAlert *admin.PollingAlertHandler
|
||||
PollingCleanup *admin.PollingCleanupHandler
|
||||
PollingManualTrigger *admin.PollingManualTriggerHandler
|
||||
PollingPriority *admin.PriorityPollingHandler
|
||||
Asset *admin.AssetHandler
|
||||
AssetLifecycle *admin.AssetLifecycleHandler
|
||||
AssetWallet *admin.AssetWalletHandler
|
||||
@@ -74,6 +79,12 @@ type Handlers struct {
|
||||
Refund *admin.RefundHandler
|
||||
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
|
||||
AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler
|
||||
BusinessUserGroup *admin.BusinessUserGroupHandler
|
||||
ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler
|
||||
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
|
||||
PackageTrafficAlert *admin.PackageTrafficAlertHandler
|
||||
AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler
|
||||
OperationsReport *admin.OperationsReportHandler
|
||||
ClientWechat *app.ClientWechatHandler
|
||||
SuperAdmin *admin.SuperAdminHandler
|
||||
SystemConfig *admin.SystemConfigHandler
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal"
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
|
||||
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
|
||||
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
|
||||
assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
@@ -95,6 +101,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
)
|
||||
cardObservationOutbox := outbox.NewRepository()
|
||||
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
|
||||
priorityEvents := prioritypollingInfra.NewPriorityEventWriter(cardObservationOutbox)
|
||||
// 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。
|
||||
carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger)
|
||||
cardObservationService := cardObservationApp.NewService(
|
||||
deps.DB,
|
||||
cardObservationInfra.NewEventWriter(cardObservationOutbox),
|
||||
@@ -106,7 +115,10 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
deps.GatewayClient, deps.Logger,
|
||||
)
|
||||
iotCardAuditService.SetAccessAudit(auditWriter)
|
||||
iotCardAuditService.SetPhoneAssetAssociationStore(stores.PhoneAssetAssociation)
|
||||
cardObservationService.SetStateAuditWriter(iotCardAuditService)
|
||||
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
|
||||
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
|
||||
cardObservationIntegration := integrationlog.NewRepository(deps.DB)
|
||||
cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
|
||||
cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService(
|
||||
@@ -149,6 +161,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox, auditWriter), walletDebitEvents, nil))
|
||||
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
|
||||
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
|
||||
orderService.SetPriorityEventWriter(priorityEvents)
|
||||
|
||||
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
|
||||
stopResumeService := iotCardSvc.NewStopResumeService(
|
||||
@@ -160,8 +173,24 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
deps.Logger,
|
||||
)
|
||||
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
|
||||
stopResumeService.SetPriorityEventWriter(priorityEvents)
|
||||
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
|
||||
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
|
||||
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
|
||||
// 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。
|
||||
carrierThresholdService.SetCommander(carrierThresholdInfra.NewCardCommander(stopResumeService))
|
||||
// 资产钱包自动续费:复机执行同样复用既有停复机单一事实源,通知与复机都走公共 Outbox。
|
||||
assetAutoRenewalService := assetAutoRenewalApp.NewService(assetAutoRenewalApp.Dependencies{
|
||||
DB: deps.DB, Redis: deps.Redis, Logger: deps.Logger,
|
||||
Outbox: cardObservationOutbox, AuditWriter: auditWriter,
|
||||
PurchaseValidation: purchaseValidation,
|
||||
Candidates: assetAutoRenewalQuery.NewQuery(deps.DB),
|
||||
Resume: assetAutoRenewalInfra.NewCardCommander(stopResumeService),
|
||||
ObservationEvents: observationSeriesEvents,
|
||||
PriorityEvents: priorityEvents,
|
||||
})
|
||||
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
|
||||
activationService.SetPriorityEventWriter(priorityEvents)
|
||||
usageService.SetStopResumeCallback(stopResumeService)
|
||||
activationService.SetResumeCallback(stopResumeService)
|
||||
orderService.SetResumeCallback(stopResumeService)
|
||||
@@ -171,6 +200,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
stores.AssetAllocationRecord, stores.ShopPackageAllocation, stores.ShopSeriesAllocation,
|
||||
stores.PackageSeries, deps.GatewayClient, stores.AssetIdentifier, nil, nil,
|
||||
)
|
||||
deviceBatchAllocator.SetPhoneAssetAssociationStore(stores.PhoneAssetAssociation)
|
||||
|
||||
return &queue.WorkerServices{
|
||||
PaymentAudit: auditWriter,
|
||||
@@ -178,6 +208,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
CardObservation: cardObservationService,
|
||||
CardObservationSeries: cardObservationSeriesService,
|
||||
ObservationSeriesEvents: observationSeriesEvents,
|
||||
PriorityEvents: priorityEvents,
|
||||
CommissionCalculation: commissionCalculationService,
|
||||
CommissionStats: commissionStatsService,
|
||||
UsageService: usageService,
|
||||
@@ -186,6 +217,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
AlertService: alertService,
|
||||
CleanupService: cleanupService,
|
||||
StopResumeService: stopResumeService,
|
||||
CarrierThreshold: carrierThresholdService,
|
||||
AssetAutoRenewal: assetAutoRenewalService,
|
||||
OrderExpirer: orderService,
|
||||
AssetPackageOrderCreator: orderService,
|
||||
DeviceBatchAllocator: deviceBatchAllocator,
|
||||
|
||||
@@ -6,105 +6,114 @@ import (
|
||||
)
|
||||
|
||||
type workerStores struct {
|
||||
AssetAllocationRecord *postgres.AssetAllocationRecordStore
|
||||
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||
IotCard *postgres.IotCardStore
|
||||
DeviceImportTask *postgres.DeviceImportTaskStore
|
||||
ExportTask *postgres.ExportTaskStore
|
||||
ExportShardTask *postgres.ExportShardTaskStore
|
||||
Device *postgres.DeviceStore
|
||||
DeviceSimBinding *postgres.DeviceSimBindingStore
|
||||
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
||||
CommissionRecord *postgres.CommissionRecordStore
|
||||
Shop *postgres.ShopStore
|
||||
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
|
||||
PackageSeries *postgres.PackageSeriesStore
|
||||
Order *postgres.OrderStore
|
||||
OrderItem *postgres.OrderItemStore
|
||||
Package *postgres.PackageStore
|
||||
PackageUsage *postgres.PackageUsageStore
|
||||
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
PollingAlertRule *postgres.PollingAlertRuleStore
|
||||
PollingAlertHistory *postgres.PollingAlertHistoryStore
|
||||
DataCleanupConfig *postgres.DataCleanupConfigStore
|
||||
DataCleanupLog *postgres.DataCleanupLogStore
|
||||
AgentWallet *postgres.AgentWalletStore
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
|
||||
AssetAllocationRecord *postgres.AssetAllocationRecordStore
|
||||
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||
IotCard *postgres.IotCardStore
|
||||
DeviceImportTask *postgres.DeviceImportTaskStore
|
||||
ExportTask *postgres.ExportTaskStore
|
||||
ExportShardTask *postgres.ExportShardTaskStore
|
||||
Device *postgres.DeviceStore
|
||||
DeviceSimBinding *postgres.DeviceSimBindingStore
|
||||
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
||||
CommissionRecord *postgres.CommissionRecordStore
|
||||
Shop *postgres.ShopStore
|
||||
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
|
||||
PackageSeries *postgres.PackageSeriesStore
|
||||
Order *postgres.OrderStore
|
||||
OrderItem *postgres.OrderItemStore
|
||||
Package *postgres.PackageStore
|
||||
PackageUsage *postgres.PackageUsageStore
|
||||
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
PollingAlertRule *postgres.PollingAlertRuleStore
|
||||
PollingAlertHistory *postgres.PollingAlertHistoryStore
|
||||
DataCleanupConfig *postgres.DataCleanupConfigStore
|
||||
DataCleanupLog *postgres.DataCleanupLogStore
|
||||
AgentWallet *postgres.AgentWalletStore
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
PhoneAssetAssociation *postgres.PhoneAssetAssociationStore
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
|
||||
ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore
|
||||
PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore
|
||||
}
|
||||
|
||||
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
stores := &workerStores{
|
||||
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
|
||||
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
||||
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
|
||||
ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis),
|
||||
ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis),
|
||||
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
|
||||
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
||||
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
|
||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
|
||||
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
|
||||
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
|
||||
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
||||
Package: postgres.NewPackageStore(deps.DB),
|
||||
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
|
||||
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
|
||||
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
|
||||
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
|
||||
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
|
||||
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
|
||||
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
|
||||
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
|
||||
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
||||
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
|
||||
ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis),
|
||||
ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis),
|
||||
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
|
||||
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
||||
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
|
||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
|
||||
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
|
||||
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
|
||||
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
||||
Package: postgres.NewPackageStore(deps.DB),
|
||||
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
|
||||
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
|
||||
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
|
||||
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
|
||||
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
|
||||
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
|
||||
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
PhoneAssetAssociation: postgres.NewPhoneAssetAssociationStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
|
||||
ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB),
|
||||
PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB),
|
||||
}
|
||||
|
||||
return &queue.WorkerStores{
|
||||
AssetAllocationRecord: stores.AssetAllocationRecord,
|
||||
IotCardImportTask: stores.IotCardImportTask,
|
||||
IotCard: stores.IotCard,
|
||||
DeviceImportTask: stores.DeviceImportTask,
|
||||
ExportTask: stores.ExportTask,
|
||||
ExportShardTask: stores.ExportShardTask,
|
||||
Device: stores.Device,
|
||||
DeviceSimBinding: stores.DeviceSimBinding,
|
||||
ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats,
|
||||
ShopPackageAllocation: stores.ShopPackageAllocation,
|
||||
CommissionRecord: stores.CommissionRecord,
|
||||
Shop: stores.Shop,
|
||||
ShopSeriesAllocation: stores.ShopSeriesAllocation,
|
||||
PackageSeries: stores.PackageSeries,
|
||||
Order: stores.Order,
|
||||
OrderItem: stores.OrderItem,
|
||||
Package: stores.Package,
|
||||
PackageUsage: stores.PackageUsage,
|
||||
PackageUsageDailyRecord: stores.PackageUsageDailyRecord,
|
||||
PollingAlertRule: stores.PollingAlertRule,
|
||||
PollingAlertHistory: stores.PollingAlertHistory,
|
||||
DataCleanupConfig: stores.DataCleanupConfig,
|
||||
DataCleanupLog: stores.DataCleanupLog,
|
||||
AgentWallet: stores.AgentWallet,
|
||||
AgentWalletTransaction: stores.AgentWalletTransaction,
|
||||
AssetWallet: stores.AssetWallet,
|
||||
AssetIdentifier: stores.AssetIdentifier,
|
||||
PersonalCustomer: stores.PersonalCustomer,
|
||||
PersonalCustomerPhone: stores.PersonalCustomerPhone,
|
||||
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
|
||||
AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask,
|
||||
AssetAllocationRecord: stores.AssetAllocationRecord,
|
||||
IotCardImportTask: stores.IotCardImportTask,
|
||||
IotCard: stores.IotCard,
|
||||
DeviceImportTask: stores.DeviceImportTask,
|
||||
ExportTask: stores.ExportTask,
|
||||
ExportShardTask: stores.ExportShardTask,
|
||||
Device: stores.Device,
|
||||
DeviceSimBinding: stores.DeviceSimBinding,
|
||||
ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats,
|
||||
ShopPackageAllocation: stores.ShopPackageAllocation,
|
||||
CommissionRecord: stores.CommissionRecord,
|
||||
Shop: stores.Shop,
|
||||
ShopSeriesAllocation: stores.ShopSeriesAllocation,
|
||||
PackageSeries: stores.PackageSeries,
|
||||
Order: stores.Order,
|
||||
OrderItem: stores.OrderItem,
|
||||
Package: stores.Package,
|
||||
PackageUsage: stores.PackageUsage,
|
||||
PackageUsageDailyRecord: stores.PackageUsageDailyRecord,
|
||||
PollingAlertRule: stores.PollingAlertRule,
|
||||
PollingAlertHistory: stores.PollingAlertHistory,
|
||||
DataCleanupConfig: stores.DataCleanupConfig,
|
||||
DataCleanupLog: stores.DataCleanupLog,
|
||||
AgentWallet: stores.AgentWallet,
|
||||
AgentWalletTransaction: stores.AgentWalletTransaction,
|
||||
AssetWallet: stores.AssetWallet,
|
||||
AssetIdentifier: stores.AssetIdentifier,
|
||||
PersonalCustomer: stores.PersonalCustomer,
|
||||
PersonalCustomerPhone: stores.PersonalCustomerPhone,
|
||||
PhoneAssetAssociation: stores.PhoneAssetAssociation,
|
||||
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
|
||||
AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask,
|
||||
ShopBusinessOwnerImportTask: stores.ShopBusinessOwnerImportTask,
|
||||
PhoneAssetUnbindImportTask: stores.PhoneAssetUnbindImportTask,
|
||||
}
|
||||
}
|
||||
|
||||
45
internal/domain/carrierthreshold/command.go
Normal file
45
internal/domain/carrierthreshold/command.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// SubmissionQueryWindow 是停/复机子任务自提交起允许自动查询确认的窗口。
|
||||
//
|
||||
// 语义:子任务进入 submitted 后,恢复扫描只查询运营商状态回填;超过该窗口仍无法确认结果的
|
||||
// 锁会标记异常并转人工处理,不再每分钟重复查询同一笔无法收敛的结果,也不自动删除锁。
|
||||
const SubmissionQueryWindow = 30 * time.Minute
|
||||
|
||||
// SubmissionExpired 判断子任务自提交起是否已超过自动查询窗口。
|
||||
// 未提交(submittedAt 为空)不算超期。
|
||||
func SubmissionExpired(submittedAt *time.Time, now time.Time) bool {
|
||||
if submittedAt == nil {
|
||||
return false
|
||||
}
|
||||
return now.Sub(*submittedAt) >= SubmissionQueryWindow
|
||||
}
|
||||
|
||||
// CommandOutcome 是一次运营商停复机调用可安全记录的结果摘要。
|
||||
//
|
||||
// 它只承载回填可靠任务状态所需的事实:Integration Log 标识、结果分类与可安全展示的原因,
|
||||
// 不携带渠道报文原文、凭证或内部错误细节。
|
||||
type CommandOutcome struct {
|
||||
// IntegrationID 是本次 Gateway 调用的 Integration Log 标识,失败重试时取最后一次尝试。
|
||||
IntegrationID string
|
||||
// Result 取值 constants.AuditResultSuccess / AuditResultFailed / AuditResultUnknown。
|
||||
Result string
|
||||
// SafeReason 是可安全对外展示的失败原因,成功时为空。
|
||||
SafeReason string
|
||||
}
|
||||
|
||||
// Confirmed 判断本次运营商调用是否已明确成功。
|
||||
func (o CommandOutcome) Confirmed() bool {
|
||||
return o.Result == constants.AuditResultSuccess
|
||||
}
|
||||
|
||||
// Unresolved 判断本次运营商调用结果是否未知(必须由恢复扫描查询收敛)。
|
||||
func (o CommandOutcome) Unresolved() bool {
|
||||
return o.Result == constants.AuditResultUnknown
|
||||
}
|
||||
23
internal/domain/carrierthreshold/evaluation.go
Normal file
23
internal/domain/carrierthreshold/evaluation.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package carrierthreshold
|
||||
|
||||
import "time"
|
||||
|
||||
// Evaluation 是一次通道阈值达量判定所需的卡与读数事实。
|
||||
//
|
||||
// 读数只来自运营商回传的网关累计读数(IoT 卡的 last_gateway_reading_mb),
|
||||
// 不使用本地用量统计、当月用量或套餐真流量;ObservedAt 决定该读数所属的计费周期。
|
||||
type Evaluation struct {
|
||||
// CardID 是触发判定的物联网卡 ID。
|
||||
CardID uint
|
||||
// CarrierID 是卡当前所属运营商 ID,决定阈值配置与周期起点。
|
||||
CarrierID uint
|
||||
// ReadingMB 是本次已接受的运营商网关累计读数。
|
||||
ReadingMB float64
|
||||
// ObservedAt 是本次读数的观测时刻。
|
||||
ObservedAt time.Time
|
||||
// Source 是本次流量观测的来源(constants.CardObservationSource*)。
|
||||
//
|
||||
// 它是判定端获取触发来源的唯一位置:达量命中留痕必须能区分自动判定、人工同步与回调,
|
||||
// 因此调用点把观测来源原样透传,判定端只按列约束归一,不在本处另立来源枚举。
|
||||
Source string
|
||||
}
|
||||
51
internal/domain/carrierthreshold/hit.go
Normal file
51
internal/domain/carrierthreshold/hit.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package carrierthreshold
|
||||
|
||||
import "github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
|
||||
// TriggerSourceUnrecorded 表示命中来源未记录。
|
||||
//
|
||||
// 取值与 tb_carrier_traffic_threshold_lock.trigger_source 的 CHECK 允许的空串一致:
|
||||
// 本次上线前的历史行与无法归类的观测来源都记为未记录,读侧按「无」展示。
|
||||
const TriggerSourceUnrecorded = ""
|
||||
|
||||
// triggerSources 是 trigger_source 列 CHECK 允许的全部非空取值,与
|
||||
// pkg/constants.CardObservationSource* 完全一致,两处必须同时演进。
|
||||
var triggerSources = []string{
|
||||
constants.CardObservationSourcePolling,
|
||||
constants.CardObservationSourceManualSync,
|
||||
constants.CardObservationSourceManualOverride,
|
||||
constants.CardObservationSourceCarrierCallback,
|
||||
constants.CardObservationSourceBusinessEvent,
|
||||
}
|
||||
|
||||
// IsTriggerSource 判断取值是否为列约束允许的命中来源。
|
||||
func IsTriggerSource(source string) bool {
|
||||
for _, allowed := range triggerSources {
|
||||
if source == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SafeTriggerSource 把流量观测来源归一到列约束允许的取值集合。
|
||||
//
|
||||
// 命中事实与停机锁是同一条 INSERT,来源列带 CHECK 约束:若把未归类的来源(上游新增观测来源而
|
||||
// 本处枚举尚未同步)原样写库,INSERT 会因约束失败并回滚整个流量观测事务,等价于因留痕失败而
|
||||
// 延迟停机。因此无法归类的来源一律记为「未记录」,停机事实与留痕写入都不会因此失败。
|
||||
func SafeTriggerSource(source string) string {
|
||||
if IsTriggerSource(source) {
|
||||
return source
|
||||
}
|
||||
return TriggerSourceUnrecorded
|
||||
}
|
||||
|
||||
// 周期恢复的复机结果取值,与 tb_carrier_traffic_threshold_lock.resume_result 的 CHECK 一致。
|
||||
const (
|
||||
// ResumeResultResumed 表示新周期条件满足、已发起复机;最终确认结果由 resume_status 承载。
|
||||
ResumeResultResumed = "resumed"
|
||||
// ResumeResultSkipped 表示新周期仅解锁、未发起复机,跳过原因写入既有失败原因列。
|
||||
ResumeResultSkipped = "skipped"
|
||||
// ResumeResultManual 表示周期归属不可判定,已按跨期语义解锁并转人工核对。
|
||||
ResumeResultManual = "manual"
|
||||
)
|
||||
50
internal/domain/carrierthreshold/lock.go
Normal file
50
internal/domain/carrierthreshold/lock.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package carrierthreshold
|
||||
|
||||
// 周期锁整行生命周期取值,与 tb_carrier_traffic_threshold_lock.status 的 CHECK 一致。
|
||||
const (
|
||||
// LockStatusLocked 表示该卡在该计费周期内持有通道阈值停机锁,周期内拒绝一切复机。
|
||||
LockStatusLocked = "locked"
|
||||
// LockStatusUnlocked 表示已跨期解除通道阈值锁,锁行保留为历史事实。
|
||||
LockStatusUnlocked = "unlocked"
|
||||
)
|
||||
|
||||
// 停复机子任务状态取值,与 stop_status / resume_status 的 CHECK 一致。
|
||||
const (
|
||||
// TaskStatusPending 表示子任务待提交,达量判定写锁时停机子任务处于该状态。
|
||||
TaskStatusPending = "pending"
|
||||
// TaskStatusSubmitted 表示子任务已提交待确认,认领成功后处于该状态。
|
||||
TaskStatusSubmitted = "submitted"
|
||||
// TaskStatusConfirmed 表示运营商调用或状态查询已明确成功。
|
||||
TaskStatusConfirmed = "confirmed"
|
||||
// TaskStatusFailed 表示运营商明确失败。
|
||||
TaskStatusFailed = "failed"
|
||||
// TaskStatusUnknown 表示结果未知,交由恢复扫描查询收敛。
|
||||
TaskStatusUnknown = "unknown"
|
||||
)
|
||||
|
||||
// 异常标记取值,与 anomaly_flag 的 CHECK 一致。
|
||||
const (
|
||||
// AnomalyFlagNone 表示无需人工核对。
|
||||
AnomalyFlagNone = 0
|
||||
// AnomalyFlagManual 表示查询窗口超期或失败无法自动确认,已转人工核对并退出自动扫描。
|
||||
AnomalyFlagManual = 1
|
||||
)
|
||||
|
||||
// UnresolvedTaskStatuses 返回仍需由恢复扫描查询运营商状态收敛的子任务状态集合。
|
||||
//
|
||||
// 语义:submitted 表示已提交待确认,unknown 表示结果未知,failed 表示调用明确失败但运营商侧
|
||||
// 状态仍可能已生效(例如请求已到达而响应超时/异常)——三者都必须继续用只读状态查询确认,
|
||||
// 因此恢复扫描的查询谓词与超期判定共用本集合,避免「失败或结果未知」的锁行退出收敛链路。
|
||||
// pending 表示尚未对运营商发起过调用,confirmed 是终态,都不属于本集合。
|
||||
func UnresolvedTaskStatuses() []string {
|
||||
return []string{TaskStatusSubmitted, TaskStatusUnknown, TaskStatusFailed}
|
||||
}
|
||||
|
||||
// IsUnresolvedTaskStatus 判断子任务状态是否仍需恢复扫描收敛。
|
||||
func IsUnresolvedTaskStatus(status string) bool {
|
||||
switch status {
|
||||
case TaskStatusSubmitted, TaskStatusUnknown, TaskStatusFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
29
internal/domain/carrierthreshold/period.go
Normal file
29
internal/domain/carrierthreshold/period.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// shanghaiLocation 是计费周期边界的固定时区。
|
||||
// 中国自 1991 年起不实行夏令时,使用固定偏移可避免依赖宿主机 tzdata。
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// PeriodStart 计算 now 所属计费周期的起点(上海时区零点)。
|
||||
//
|
||||
// 周期起点 = 本月重置日 0 点,若 now 早于本月重置日 0 点则取上月重置日 0 点。
|
||||
// resetDay 由该卡所属运营商(锁行自身的 carrier)的 data_reset_day 提供,合法范围 1-28,
|
||||
// 因此每个月都有定义。该口径与 isTrafficResetWindow(判断读数回落是否为合法清零的观测窗口)
|
||||
// 是两个独立口径,互不修改。
|
||||
func PeriodStart(now time.Time, resetDay int) (time.Time, error) {
|
||||
if resetDay < MinResetDay || resetDay > MaxResetDay {
|
||||
return time.Time{}, errors.New(errors.CodeInvalidParam, "运营商上游流量重置日必须在 1-28 之间")
|
||||
}
|
||||
local := now.In(shanghaiLocation)
|
||||
current := time.Date(local.Year(), local.Month(), resetDay, 0, 0, 0, 0, shanghaiLocation)
|
||||
if !local.Before(current) {
|
||||
return current, nil
|
||||
}
|
||||
return current.AddDate(0, -1, 0), nil
|
||||
}
|
||||
69
internal/domain/carrierthreshold/threshold.go
Normal file
69
internal/domain/carrierthreshold/threshold.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Package carrierthreshold 定义运营商通道流量阈值的领域规则。
|
||||
//
|
||||
// 本包只表达与传输、持久化无关的纯规则:阈值单位与 GB→MB 换算、按上游流量重置日计算的
|
||||
// 计费周期起点、以及周期锁与停复机子任务的状态取值。运营商通道即既有 Carrier,
|
||||
// 计费周期与网关计数器清零周期是同一事实,因此周期起点只由 carrier.data_reset_day 决定。
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// 阈值单位枚举:只允许 MB 与 GB,换算后统一以 MB 与网关累计读数比较。
|
||||
const (
|
||||
UnitMB = "MB"
|
||||
UnitGB = "GB"
|
||||
)
|
||||
|
||||
// MBPerGB 是 GB→MB 的固定换算系数(1 GB = 1024 MB)。
|
||||
const MBPerGB = 1024
|
||||
|
||||
// 计费周期起点的合法重置日范围:1-28 保证每个月都有定义,不存在 2 月 30 日问题。
|
||||
const (
|
||||
MinResetDay = 1
|
||||
MaxResetDay = 28
|
||||
)
|
||||
|
||||
// Threshold 是通道阈值配置的领域值。
|
||||
type Threshold struct {
|
||||
// Enabled 为 true 表示该通道参与达量停机判定。
|
||||
Enabled bool
|
||||
// Value 是阈值数值,启用时必须为正数。
|
||||
Value float64
|
||||
// Unit 是阈值单位,取值 UnitMB 或 UnitGB。
|
||||
Unit string
|
||||
}
|
||||
|
||||
// Valid 判断阈值配置是否为可用于判定的完整配置。
|
||||
// 未启用、数值非正、单位未知都视为不可判定;不可判定必须跳过判定而不是按 0 停机。
|
||||
func (t Threshold) Valid() bool {
|
||||
if !t.Enabled || t.Value <= 0 {
|
||||
return false
|
||||
}
|
||||
return t.Unit == UnitMB || t.Unit == UnitGB
|
||||
}
|
||||
|
||||
// LimitMB 返回换算为 MB 的阈值上限;单位未知返回稳定参数错误。
|
||||
func (t Threshold) LimitMB() (float64, error) {
|
||||
switch t.Unit {
|
||||
case UnitMB:
|
||||
return t.Value, nil
|
||||
case UnitGB:
|
||||
return t.Value * MBPerGB, nil
|
||||
default:
|
||||
return 0, errors.New(errors.CodeInvalidParam, "通道流量阈值单位仅支持 MB 与 GB")
|
||||
}
|
||||
}
|
||||
|
||||
// Reached 判断运营商回传的当前周期累计读数是否达到或超过阈值。
|
||||
// readingMB 只来自网关累计读数(last_gateway_reading_mb),不使用本地用量或套餐真流量。
|
||||
func (t Threshold) Reached(readingMB float64) (bool, error) {
|
||||
if !t.Valid() {
|
||||
return false, nil
|
||||
}
|
||||
limitMB, err := t.LimitMB()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return readingMB >= limitMB, nil
|
||||
}
|
||||
199
internal/domain/distribution/distribution.go
Normal file
199
internal/domain/distribution/distribution.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// Package distribution 收口代理分销注册、提现资格与提现审批的领域不变量。
|
||||
// 本包不依赖 Fiber、GORM、Redis、Asynq 或具体第三方 SDK。
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
distributionCodeBytes = 16
|
||||
phoneMaskedKeepPrefix = 3
|
||||
phoneMaskedKeepSuffix = 4
|
||||
codeMaskedKeepPrefix = 4
|
||||
codeMaskedKeepSuffix = 4
|
||||
)
|
||||
|
||||
// GenerateDistributionCode 生成 32 位十六进制随机分销码。
|
||||
// 唯一性由数据库条件唯一索引兜底,调用方在冲突时重新生成。
|
||||
func GenerateDistributionCode() (string, error) {
|
||||
buf := make([]byte, distributionCodeBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", errors.Wrap(errors.CodeInternalError, err, "生成分销码失败")
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// ValidateRegistrationInput 规范化并校验扫码注册输入。
|
||||
// 手机号、用户名、店铺编号与店铺名称由公开接口必填;密码长度沿用账号体系既有下限。
|
||||
func ValidateRegistrationInput(input RegistrationInput) (RegistrationInput, error) {
|
||||
input.DistributionCode = strings.TrimSpace(input.DistributionCode)
|
||||
input.Phone = strings.TrimSpace(input.Phone)
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
input.ShopName = strings.TrimSpace(input.ShopName)
|
||||
input.ShopCode = strings.TrimSpace(input.ShopCode)
|
||||
input.ContactName = strings.TrimSpace(input.ContactName)
|
||||
input.Province = strings.TrimSpace(input.Province)
|
||||
input.City = strings.TrimSpace(input.City)
|
||||
input.District = strings.TrimSpace(input.District)
|
||||
input.Address = strings.TrimSpace(input.Address)
|
||||
if input.DistributionCode == "" {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不能为空")
|
||||
}
|
||||
if input.Phone == "" {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号不能为空")
|
||||
}
|
||||
if input.Username == "" {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名不能为空")
|
||||
}
|
||||
if input.ShopName == "" {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "店铺名称不能为空")
|
||||
}
|
||||
if input.ShopCode == "" {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "店铺编号不能为空")
|
||||
}
|
||||
if len(input.Phone) != 11 {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号格式不正确")
|
||||
}
|
||||
if len(input.Username) < 3 || len(input.Username) > 50 {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名长度必须为 3 至 50 个字符")
|
||||
}
|
||||
if len(input.Password) < 6 || len(input.Password) > 64 {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "密码长度必须为 6 至 64 个字符")
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// ValidateQualificationInput 规范化并校验提现资料资格输入。
|
||||
// 企业主体必须填写统一社会信用代码,个人主体必须填写法人身份证号;
|
||||
// 发票仅企业可选,且抬头与统一社会信用代码必须与签约主体一致。
|
||||
func ValidateQualificationInput(input QualificationInput) (QualificationInput, error) {
|
||||
input.SubjectCode = strings.TrimSpace(input.SubjectCode)
|
||||
input.LegalPersonIDCard = strings.TrimSpace(input.LegalPersonIDCard)
|
||||
input.ContractFileKey = strings.TrimSpace(input.ContractFileKey)
|
||||
input.IDCardFrontFileKey = strings.TrimSpace(input.IDCardFrontFileKey)
|
||||
input.IDCardBackFileKey = strings.TrimSpace(input.IDCardBackFileKey)
|
||||
input.BusinessLicenseFileKey = strings.TrimSpace(input.BusinessLicenseFileKey)
|
||||
input.ShopFrontFileKey = strings.TrimSpace(input.ShopFrontFileKey)
|
||||
input.InvoiceFileKey = strings.TrimSpace(input.InvoiceFileKey)
|
||||
input.InvoiceTitle = strings.TrimSpace(input.InvoiceTitle)
|
||||
input.InvoiceSubjectCode = strings.TrimSpace(input.InvoiceSubjectCode)
|
||||
|
||||
switch input.SubjectType {
|
||||
case constants.WithdrawalQualificationSubjectTypeEnterprise:
|
||||
if input.SubjectCode == "" || input.LegalPersonIDCard == "" {
|
||||
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "企业主体必须填写统一社会信用代码与法人身份证号")
|
||||
}
|
||||
case constants.WithdrawalQualificationSubjectTypePersonal:
|
||||
if input.LegalPersonIDCard == "" {
|
||||
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "个人主体必须填写法人身份证号")
|
||||
}
|
||||
if input.SubjectCode == "" {
|
||||
// 个人主体的签约主体代码即法人身份证号。
|
||||
input.SubjectCode = input.LegalPersonIDCard
|
||||
}
|
||||
default:
|
||||
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "签约主体类型无效")
|
||||
}
|
||||
if input.SubjectCode != input.LegalPersonIDCard && input.SubjectType == constants.WithdrawalQualificationSubjectTypePersonal {
|
||||
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "个人主体的签约主体代码必须与法人身份证号一致")
|
||||
}
|
||||
if input.ContractFileKey == "" || input.IDCardFrontFileKey == "" || input.IDCardBackFileKey == "" {
|
||||
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "合同与法人身份证正反面附件必须填写")
|
||||
}
|
||||
if input.SubjectType == constants.WithdrawalQualificationSubjectTypePersonal &&
|
||||
(input.InvoiceFileKey != "" || input.InvoiceTitle != "" || input.InvoiceSubjectCode != "") {
|
||||
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "发票资料仅企业主体可提交")
|
||||
}
|
||||
if input.InvoiceFileKey != "" {
|
||||
if input.InvoiceTitle == "" || input.InvoiceSubjectCode == "" {
|
||||
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "提交发票时必须填写抬头与统一社会信用代码")
|
||||
}
|
||||
if input.InvoiceSubjectCode != input.SubjectCode {
|
||||
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "发票统一社会信用代码必须与合同主体一致")
|
||||
}
|
||||
}
|
||||
// 附件上限由结构保证:资格只有合同、法人身份证正反面、营业执照、门头照、发票共 6 个
|
||||
// 单对象键字段,天然不超过企业微信单张审批单 6 个附件上限,无需运行时计数校验。
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// MaskPhone 生成脱敏手机号,仅保留前 3 位与后 4 位。
|
||||
// 日志与审计不得记录完整手机号。
|
||||
func MaskPhone(phone string) string {
|
||||
phone = strings.TrimSpace(phone)
|
||||
if len(phone) < phoneMaskedKeepPrefix+phoneMaskedKeepSuffix {
|
||||
return ""
|
||||
}
|
||||
return phone[:phoneMaskedKeepPrefix] + "****" + phone[len(phone)-phoneMaskedKeepSuffix:]
|
||||
}
|
||||
|
||||
// MaskSubjectCode 生成脱敏证件号或统一社会信用代码,仅保留前 4 位与后 4 位。
|
||||
// 日志与审计不得记录完整证件号。
|
||||
func MaskSubjectCode(code string) string {
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) < codeMaskedKeepPrefix+codeMaskedKeepSuffix {
|
||||
return ""
|
||||
}
|
||||
return code[:codeMaskedKeepPrefix] + "**********" + code[len(code)-codeMaskedKeepSuffix:]
|
||||
}
|
||||
|
||||
// MaskDistributionCode 生成脱敏分销码,仅保留首尾片段。
|
||||
// 分销码是可枚举的公开入口标识,日志与审计只记录脱敏值。
|
||||
func MaskDistributionCode(code string) string {
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) < codeMaskedKeepPrefix+codeMaskedKeepSuffix {
|
||||
return ""
|
||||
}
|
||||
return code[:codeMaskedKeepPrefix] + "****" + code[len(code)-codeMaskedKeepSuffix:]
|
||||
}
|
||||
|
||||
// FormatCentYuan 将分金额格式化为两位小数的元字符串,仅用于审批表单与展示。
|
||||
func FormatCentYuan(amount int64) string {
|
||||
return strconv.FormatInt(amount/100, 10) + "." +
|
||||
pad2(strconv.FormatInt(amount%100, 10))
|
||||
}
|
||||
|
||||
// pad2 将 0 至 99 的十进制文本左补零到两位。
|
||||
func pad2(value string) string {
|
||||
if len(value) >= 2 {
|
||||
return value
|
||||
}
|
||||
return "0" + value
|
||||
}
|
||||
|
||||
// RegistrationInput 是公开扫码注册的规范化输入。
|
||||
type RegistrationInput struct {
|
||||
DistributionCode string
|
||||
Phone string
|
||||
Username string
|
||||
Password string
|
||||
ShopName string
|
||||
ShopCode string
|
||||
ContactName string
|
||||
Province string
|
||||
City string
|
||||
District string
|
||||
Address string
|
||||
}
|
||||
|
||||
// QualificationInput 是提现资料资格的规范化输入。
|
||||
type QualificationInput struct {
|
||||
SubjectType string
|
||||
SubjectCode string
|
||||
LegalPersonIDCard string
|
||||
ContractFileKey string
|
||||
IDCardFrontFileKey string
|
||||
IDCardBackFileKey string
|
||||
BusinessLicenseFileKey string
|
||||
ShopFrontFileKey string
|
||||
InvoiceFileKey string
|
||||
InvoiceTitle string
|
||||
InvoiceSubjectCode string
|
||||
}
|
||||
137
internal/domain/operationsreport/dimension.go
Normal file
137
internal/domain/operationsreport/dimension.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package operationsreport
|
||||
|
||||
// 报表分组维度:激活情况表七项、套餐续费表六项。
|
||||
// 只支持单一分组维度,不支持同时按多个维度分组;未选择维度时汇总为一行,分组列值为「全部」。
|
||||
const (
|
||||
// DimensionDeviceName 表示设备名称维度。
|
||||
DimensionDeviceName = "device_name"
|
||||
// DimensionDeviceModel 表示设备型号维度。
|
||||
DimensionDeviceModel = "device_model"
|
||||
// DimensionManufacturer 表示制造商维度。
|
||||
DimensionManufacturer = "manufacturer"
|
||||
// DimensionBusinessUserGroup 表示用户组维度。
|
||||
DimensionBusinessUserGroup = "business_user_group"
|
||||
// DimensionAgent 表示代理维度。
|
||||
DimensionAgent = "agent"
|
||||
// DimensionShop 表示店铺维度。
|
||||
DimensionShop = "shop"
|
||||
// DimensionBusinessOwner 表示业务员维度。
|
||||
DimensionBusinessOwner = "business_owner"
|
||||
// DimensionPackageSeries 表示套餐系列维度。
|
||||
DimensionPackageSeries = "package_series"
|
||||
// DimensionPackageName 表示套餐名称维度。
|
||||
DimensionPackageName = "package_name"
|
||||
)
|
||||
|
||||
// DimensionAll 是未选择分组维度时唯一汇总行的分组列值。
|
||||
const DimensionAll = "全部"
|
||||
|
||||
// 趋势粒度取值:只表达粒度,不引入月份参数。
|
||||
const (
|
||||
// GranularityDay 表示按日趋势。
|
||||
GranularityDay = "day"
|
||||
// GranularityMonth 表示按月趋势。
|
||||
GranularityMonth = "month"
|
||||
)
|
||||
|
||||
// NormalizeGranularity 归一趋势粒度,缺省为按日;返回 false 表示取值不受支持。
|
||||
func NormalizeGranularity(value string) (string, bool) {
|
||||
switch value {
|
||||
case "", GranularityDay:
|
||||
return GranularityDay, true
|
||||
case GranularityMonth:
|
||||
return GranularityMonth, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// PlaceholderUnset 是分组取值为空时的固定占位展示。
|
||||
const PlaceholderUnset = "未设置"
|
||||
|
||||
// activationDimensions 是有序的激活情况分组维度与其中文名。
|
||||
var activationDimensions = []struct {
|
||||
Code string
|
||||
Name string
|
||||
}{
|
||||
{DimensionDeviceName, "设备名称"},
|
||||
{DimensionDeviceModel, "设备型号"},
|
||||
{DimensionManufacturer, "制造商"},
|
||||
{DimensionBusinessUserGroup, "用户组"},
|
||||
{DimensionAgent, "代理"},
|
||||
{DimensionShop, "店铺"},
|
||||
{DimensionBusinessOwner, "业务员"},
|
||||
}
|
||||
|
||||
// renewalDimensions 是有序的套餐续费分组维度与其中文名。
|
||||
var renewalDimensions = []struct {
|
||||
Code string
|
||||
Name string
|
||||
}{
|
||||
{DimensionPackageSeries, "套餐系列"},
|
||||
{DimensionPackageName, "套餐名称"},
|
||||
{DimensionBusinessUserGroup, "用户组"},
|
||||
{DimensionAgent, "代理"},
|
||||
{DimensionShop, "店铺"},
|
||||
{DimensionBusinessOwner, "业务员"},
|
||||
}
|
||||
|
||||
// ActivationDimensionCodes 返回激活情况支持的维度编码(按展示顺序)。
|
||||
func ActivationDimensionCodes() []string {
|
||||
codes := make([]string, 0, len(activationDimensions))
|
||||
for _, dimension := range activationDimensions {
|
||||
codes = append(codes, dimension.Code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// RenewalDimensionCodes 返回套餐续费支持的维度编码(按展示顺序)。
|
||||
func RenewalDimensionCodes() []string {
|
||||
codes := make([]string, 0, len(renewalDimensions))
|
||||
for _, dimension := range renewalDimensions {
|
||||
codes = append(codes, dimension.Code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// ActivationDimensionName 返回激活情况维度的中文名;不支持时返回 false。
|
||||
func ActivationDimensionName(code string) (string, bool) {
|
||||
return dimensionName(activationDimensions, code)
|
||||
}
|
||||
|
||||
// RenewalDimensionName 返回套餐续费维度的中文名;不支持时返回 false。
|
||||
func RenewalDimensionName(code string) (string, bool) {
|
||||
return dimensionName(renewalDimensions, code)
|
||||
}
|
||||
|
||||
// IsActivationDimension 判断是否为受支持的激活情况维度。
|
||||
func IsActivationDimension(code string) bool {
|
||||
_, ok := ActivationDimensionName(code)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsRenewalDimension 判断是否为受支持的套餐续费维度。
|
||||
func IsRenewalDimension(code string) bool {
|
||||
_, ok := RenewalDimensionName(code)
|
||||
return ok
|
||||
}
|
||||
|
||||
func dimensionName(dimensions []struct {
|
||||
Code string
|
||||
Name string
|
||||
}, code string) (string, bool) {
|
||||
for _, dimension := range dimensions {
|
||||
if dimension.Code == code {
|
||||
return dimension.Name, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// TextOrPlaceholder 返回非空文本,为空时返回固定占位。
|
||||
func TextOrPlaceholder(value string) string {
|
||||
if value == "" {
|
||||
return PlaceholderUnset
|
||||
}
|
||||
return value
|
||||
}
|
||||
150
internal/domain/operationsreport/metrics.go
Normal file
150
internal/domain/operationsreport/metrics.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Package operationsreport 是运营报表(设备激活与套餐续费)的口径域。
|
||||
//
|
||||
// 本包只表达可复现的口径与纯计算:真流量换算、比率与卡均、分母为零语义、
|
||||
// 预测卡均的当月口径、采购数量口径入口与续费判定规则。
|
||||
// 不依赖 Fiber、GORM、Redis、Asynq 或任何外部 SDK,也不做任何读写。
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MBPerGB 是报表域自持的流量换算常量:1 GB = 1024 MB。
|
||||
// 与 internal/domain/carrierthreshold 的换算同值同源;不为一个换算常数建立跨域依赖。
|
||||
const MBPerGB = 1024
|
||||
|
||||
// shanghaiLocation 是报表口径使用的上海时区(固定 +08:00)。
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// ShanghaiLocation 返回报表口径使用的上海时区。
|
||||
func ShanghaiLocation() *time.Location {
|
||||
return shanghaiLocation
|
||||
}
|
||||
|
||||
// SnapshotDay 把任意时刻归一为它所在的上海自然日零点。
|
||||
// 报表的一切跨日比较都使用上海自然日,不使用服务器本地时区。
|
||||
func SnapshotDay(value time.Time) time.Time {
|
||||
local := value.In(shanghaiLocation)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
|
||||
}
|
||||
|
||||
// PreviousDay 返回给定上海自然日的前一自然日零点。
|
||||
func PreviousDay(day time.Time) time.Time {
|
||||
return SnapshotDay(day).AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
// ParseSnapshotDay 解析 yyyy-MM-dd 形式的上海自然日。
|
||||
func ParseSnapshotDay(value string) (time.Time, error) {
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, shanghaiLocation)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// FormatSnapshotDay 输出上海自然日的 yyyy-MM-dd 文本。
|
||||
func FormatSnapshotDay(day time.Time) string {
|
||||
return SnapshotDay(day).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// FormatMonthPeriod 输出上海自然月的 yyyy-MM 文本。
|
||||
func FormatMonthPeriod(day time.Time) string {
|
||||
return SnapshotDay(day).Format("2006-01")
|
||||
}
|
||||
|
||||
// LowerBoundDay 按落界规则返回区间起点入选的最早快照日期。
|
||||
//
|
||||
// 落界规则(设计 D10):快照日期 D 入选,当且仅当 D 的零点(+08:00)落在请求区间内。
|
||||
// 因此起点恰好落在零点时当日入选,否则从次日起入选。
|
||||
func LowerBoundDay(start time.Time) time.Time {
|
||||
day := SnapshotDay(start)
|
||||
if start.After(day) {
|
||||
return day.AddDate(0, 0, 1)
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
// UpperBoundDay 按落界规则返回区间终点入选的最晚快照日期。
|
||||
func UpperBoundDay(end time.Time) time.Time {
|
||||
return SnapshotDay(end)
|
||||
}
|
||||
|
||||
// PeriodOf 返回给定快照日期所属的趋势期标识:按日为上海自然日,按月为该月首日。
|
||||
func PeriodOf(granularity string, day time.Time) time.Time {
|
||||
normalized := SnapshotDay(day)
|
||||
if granularity == GranularityMonth {
|
||||
return time.Date(normalized.Year(), normalized.Month(), 1, 0, 0, 0, 0, shanghaiLocation)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// FormatPeriod 输出趋势期标识:按日为 yyyy-MM-dd,按月为 yyyy-MM。
|
||||
func FormatPeriod(granularity string, day time.Time) string {
|
||||
if granularity == GranularityMonth {
|
||||
return FormatMonthPeriod(day)
|
||||
}
|
||||
return FormatSnapshotDay(day)
|
||||
}
|
||||
|
||||
// PreviousPeriodStart 返回给定期起始日所属期的前一期起始日(按日减一天,按月减一个月)。
|
||||
func PreviousPeriodStart(granularity string, periodStart time.Time) time.Time {
|
||||
if granularity == GranularityMonth {
|
||||
return periodStart.AddDate(0, -1, 0)
|
||||
}
|
||||
return periodStart.AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
// Ratio 计算比率并按两位小数取整;分母不大于零时不可计算,返回 false(空值语义)。
|
||||
// 比率不设上限:设备删除或迁移可使激活率超过 100%,如实呈现。
|
||||
func Ratio(numerator, denominator int64) (float64, bool) {
|
||||
if denominator <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return Round2(float64(numerator) / float64(denominator)), true
|
||||
}
|
||||
|
||||
// CardAverageGB 计算卡均用量(GB):累计真流量折算 GB 后除以分母设备数。
|
||||
// 分母不大于零时不可计算,返回 false(空值语义)。
|
||||
func CardAverageGB(totalRealTrafficMB float64, denominator int64) (float64, bool) {
|
||||
if denominator <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
average := totalRealTrafficMB / MBPerGB / float64(denominator)
|
||||
return Round2(average), true
|
||||
}
|
||||
|
||||
// ForecastCardAverageGB 计算预测卡均(GB):先按卡均口径得出日均,再按结束日所在上海自然月年化。
|
||||
// 「当月」= 所选结束日所在上海自然月;已过天数 = 结束日日期号;当月总天数 = 该月自然日数。
|
||||
// 分母不大于零时不可计算,返回 false(空值语义)。
|
||||
func ForecastCardAverageGB(totalRealTrafficMB float64, denominator int64, endDate time.Time) (float64, bool) {
|
||||
average, ok := CardAverageGB(totalRealTrafficMB, denominator)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
elapsedDays, totalDays := MonthElapsedAndTotalDays(endDate)
|
||||
if elapsedDays <= 0 || totalDays <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return Round2(average * float64(totalDays) / float64(elapsedDays)), true
|
||||
}
|
||||
|
||||
// MonthElapsedAndTotalDays 返回结束日所在上海自然月的已过天数与当月总天数。
|
||||
// 已过天数按结束日的日期号取值(不区分当月剩余天数),当月总天数取该自然月的实际天数。
|
||||
func MonthElapsedAndTotalDays(endDate time.Time) (int, int) {
|
||||
day := SnapshotDay(endDate)
|
||||
totalDays := time.Date(day.Year(), day.Month()+1, 0, 0, 0, 0, 0, shanghaiLocation).Day()
|
||||
return day.Day(), totalDays
|
||||
}
|
||||
|
||||
// Round2 按两位小数四舍五入。
|
||||
func Round2(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
// RenewalRate 计算续费率:续费资产数除以到期资产数。
|
||||
// 分母为零时不可计算,返回 false(空值语义,导出写「-」)。
|
||||
// 分子为分母子集,因此续费率不超过 100% 由构造保证,不做任何截断或钳制。
|
||||
func RenewalRate(renewed, due int64) (float64, bool) {
|
||||
return Ratio(renewed, due)
|
||||
}
|
||||
37
internal/domain/operationsreport/purchase.go
Normal file
37
internal/domain/operationsreport/purchase.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// PurchaseCountSource 是采购数量口径的取值端口。
|
||||
// 由基础设施层实现为只读查询(截至快照日系统内未删除的设备数)。
|
||||
type PurchaseCountSource interface {
|
||||
CountUndeletedDevicesAsOf(ctx context.Context, snapshotDate time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// PurchaseCountAsOf 返回截至快照日的采购数量,是采购数量口径的**唯一实现点**(设计 D6)。
|
||||
//
|
||||
// 采用口径:采购数量 = 截至快照日(上海自然日)系统内未删除的设备数,
|
||||
// 与 `111.md` §22.4.1「系统录入的设备数量」同读法,不新建采购或入库台账。
|
||||
//
|
||||
// 被拒绝的字面口径:`tb_device_import_task` 中 `operation_type='import'` 且已完成任务的
|
||||
// `success_count` 之和。生产库实测该值为 474,而系统内未删除设备为 18,970;
|
||||
// 差额来自老系统迁移脚本直接写入设备表、绕过导入任务,按字面口径激活率约 1,399%,指标不可用。
|
||||
//
|
||||
// 切换口径只需替换本函数体内的取值方式(一行),调用方与快照表结构都不需要改动。
|
||||
func PurchaseCountAsOf(ctx context.Context, source PurchaseCountSource, snapshotDate time.Time) (int64, error) {
|
||||
return source.CountUndeletedDevicesAsOf(ctx, snapshotDate)
|
||||
}
|
||||
|
||||
// ValidMainPackageStatuses 是「有效主套餐」的状态集合:生效中与已用完。
|
||||
// 「有效」的完整口径为:主套餐(master_usage_id IS NULL)、状态属于本集合、未退款(refund_id IS NULL),
|
||||
// 既有先例见 internal/query/packageexpiry/list.go、internal/query/assetautorenewal/query.go
|
||||
// 与 internal/infrastructure/packagetrafficalert/scanner.go。
|
||||
var ValidMainPackageStatuses = []int{
|
||||
constants.PackageUsageStatusActive,
|
||||
constants.PackageUsageStatusDepleted,
|
||||
}
|
||||
43
internal/domain/operationsreport/renewal.go
Normal file
43
internal/domain/operationsreport/renewal.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// ExpiredMainUsage 是一条到期事实:快照日(上海自然日)等于其到期日的主套餐使用记录。
|
||||
type ExpiredMainUsage struct {
|
||||
UsageID uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// MainUsageCandidate 是同资产上参与续费判定的主套餐记录投影。
|
||||
// 调用方必须只传入未退款(refund_id IS NULL)的主套餐(master_usage_id IS NULL)记录。
|
||||
type MainUsageCandidate struct {
|
||||
UsageID uint
|
||||
Status int
|
||||
ActivatedAt *time.Time
|
||||
}
|
||||
|
||||
// IsRenewed 判定该到期事实是否已续费(设计 D9):
|
||||
// 存在**另一条**未退款主套餐记录,其生效时间晚于本条到期时间,或处于待生效状态。
|
||||
//
|
||||
// 候选中属于本条记录自身的项不参与判定;判定的结果挂在到期行上,
|
||||
// 使续费资产集合恒为到期资产集合的子集,续费率不超过 100% 由构造保证,不做任何截断或钳制。
|
||||
func IsRenewed(expired ExpiredMainUsage, candidates []MainUsageCandidate) bool {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.UsageID == expired.UsageID {
|
||||
continue
|
||||
}
|
||||
if candidate.Status == constants.PackageUsageStatusPending {
|
||||
return true
|
||||
}
|
||||
if candidate.ActivatedAt != nil && candidate.ActivatedAt.After(expired.ExpiresAt) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
52
internal/domain/packagetrafficalert/threshold.go
Normal file
52
internal/domain/packagetrafficalert/threshold.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package packagetrafficalert 提供套餐真流量达量预警的领域判定规则。
|
||||
//
|
||||
// 判定口径固定为「真流量」:分子取套餐使用记录的真已用量,分母取套餐使用记录的真总量快照,
|
||||
// 二者按资产汇总后再与主套餐规则阈值比较;全部使用整数万分比比较,不使用浮点判定,
|
||||
// 避免边界(例如恰好等于阈值)因二进制浮点误差产生错误结论。
|
||||
package packagetrafficalert
|
||||
|
||||
import "math"
|
||||
|
||||
// ratioScale 是万分比刻度:1% = 100,0.01% = 1。
|
||||
const ratioScale = 10000
|
||||
|
||||
// MinThresholdPercent 与 MaxThresholdPercent 是可配置阈值百分比的闭区间端点。
|
||||
const (
|
||||
MinThresholdPercent = 1.0
|
||||
MaxThresholdPercent = 100.0
|
||||
)
|
||||
|
||||
// ThresholdBasisPoints 把百分比阈值换算为整数万分比(0.01% = 1)。
|
||||
// 数据库以 NUMERIC(5,2) 保存两位小数,读取后先四舍五入到两位再换算,保证 1.25% 恒等于 125。
|
||||
func ThresholdBasisPoints(percent float64) int64 {
|
||||
return int64(math.Round(NormalizeThresholdPercent(percent) * 100))
|
||||
}
|
||||
|
||||
// NormalizeThresholdPercent 把百分比四舍五入到两位小数,与 NUMERIC(5,2) 的存储精度一致。
|
||||
func NormalizeThresholdPercent(percent float64) float64 {
|
||||
return math.Round(percent*100) / 100
|
||||
}
|
||||
|
||||
// IsValidThresholdPercent 判断百分比是否落在 1%~100% 闭区间内。
|
||||
func IsValidThresholdPercent(percent float64) bool {
|
||||
normalized := NormalizeThresholdPercent(percent)
|
||||
return normalized >= MinThresholdPercent && normalized <= MaxThresholdPercent
|
||||
}
|
||||
|
||||
// Decide 按资产的汇总真流量判定是否达到阈值,并返回向下取整的汇总比例万分比。
|
||||
//
|
||||
// usedMB 为该资产全部当前有效套餐的真已用量之和,limitMB 为同集合的真总量快照之和。
|
||||
// 分母不大于零属于不可判定资产,调用方必须先跳过;此处返回未命中以避免除零。
|
||||
func Decide(usedMB, limitMB, thresholdBasisPoints int64) (bool, int64) {
|
||||
if limitMB <= 0 || thresholdBasisPoints <= 0 {
|
||||
return false, 0
|
||||
}
|
||||
ratioBasisPoints := usedMB * ratioScale / limitMB
|
||||
hit := usedMB*ratioScale >= thresholdBasisPoints*limitMB
|
||||
return hit, ratioBasisPoints
|
||||
}
|
||||
|
||||
// PercentFromBasisPoints 把万分比换算为保留两位小数的百分比展示值。
|
||||
func PercentFromBasisPoints(basisPoints int64) float64 {
|
||||
return float64(basisPoints) / 100
|
||||
}
|
||||
@@ -27,7 +27,10 @@ func (s *AgentRechargeDataSource) Scene() string {
|
||||
// Count 统计代理充值导出行数。
|
||||
func (s *AgentRechargeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
var total int64
|
||||
query := s.applyFilters(s.baseQuery(ctx), params)
|
||||
query, err := s.applyFilters(s.baseQuery(ctx), params)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -49,7 +52,11 @@ func (s *AgentRechargeDataSource) Fetch(ctx context.Context, params ExportParams
|
||||
}
|
||||
|
||||
var items []agentRechargeExportRow
|
||||
query := s.applyFilters(s.baseQuery(ctx), params).
|
||||
filtered, err := s.applyFilters(s.baseQuery(ctx), params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := filtered.
|
||||
Select(`
|
||||
r.recharge_no,
|
||||
r.amount,
|
||||
@@ -121,7 +128,7 @@ func (s *AgentRechargeDataSource) baseQuery(ctx context.Context) *gorm.DB {
|
||||
return s.db.WithContext(ctx).Table("tb_agent_recharge_record AS r").Where("r.deleted_at IS NULL")
|
||||
}
|
||||
|
||||
func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
|
||||
func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportParams) (*gorm.DB, error) {
|
||||
query = applyExportShopScope(query, params, "r.shop_id")
|
||||
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
|
||||
query = query.Where("r.shop_id = ?", shopID)
|
||||
@@ -129,13 +136,17 @@ func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportPara
|
||||
if status, ok := filterInt(params.Filters, "status"); ok {
|
||||
query = query.Where("r.status = ?", status)
|
||||
}
|
||||
if start, ok := filterTime(params.Filters, "start_date"); ok {
|
||||
query = query.Where("r.created_at >= ?", start)
|
||||
start, end, err := strictTimeRange(params.Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if end, ok := filterEndDate(params.Filters, "end_date"); ok {
|
||||
query = query.Where("r.created_at <= ?", end)
|
||||
if start != nil {
|
||||
query = query.Where("r.created_at >= ?", *start)
|
||||
}
|
||||
return query
|
||||
if end != nil {
|
||||
query = query.Where("r.created_at <= ?", *end)
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
type agentRechargeExportRow struct {
|
||||
|
||||
40
internal/exporter/association_phones.go
Normal file
40
internal/exporter/association_phones.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// loadAssociatedPhonesByAsset 按本批资产集合一次 IN 批量读取当前有效关联手机号。
|
||||
// 两类导出都必须走本方法:逐资产查询会形成 N+1,且导出是分片批处理,批量读是唯一可行口径。
|
||||
// 同一资产存在多项有效关系时按关系创建顺序以「、」连接为单个单元格;无关联时返回空串。
|
||||
func loadAssociatedPhonesByAsset(ctx context.Context, db *gorm.DB, assetType string, assetIDs []uint) (map[uint]string, error) {
|
||||
cells := make(map[uint]string, len(assetIDs))
|
||||
if len(assetIDs) == 0 {
|
||||
return cells, nil
|
||||
}
|
||||
var rows []struct {
|
||||
AssetID uint `gorm:"column:asset_id"`
|
||||
Phone string `gorm:"column:phone"`
|
||||
}
|
||||
if err := db.WithContext(ctx).Table("tb_phone_asset_association").
|
||||
Select("asset_id", "phone").
|
||||
Where("asset_type = ? AND status = ? AND asset_id IN ?",
|
||||
assetType, constants.PhoneAssetAssociationStatusValid, assetIDs).
|
||||
Order("asset_id ASC, id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grouped := make(map[uint][]string, len(rows))
|
||||
for _, row := range rows {
|
||||
grouped[row.AssetID] = append(grouped[row.AssetID], row.Phone)
|
||||
}
|
||||
for assetID, phones := range grouped {
|
||||
cells[assetID] = strings.Join(phones, "、")
|
||||
}
|
||||
return cells, nil
|
||||
}
|
||||
471
internal/exporter/commission_record_scene.go
Normal file
471
internal/exporter/commission_record_scene.go
Normal file
@@ -0,0 +1,471 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// CommissionRecordDataSource 佣金明细导出数据源。
|
||||
// 粒度为佣金记录:原佣金与回溯明细各占一行,金额保持分并在展示层转元,
|
||||
// 负数金额与可为负的余额原样导出,不因符号或余额不足被裁剪。
|
||||
// 归属与设备维度列按导出执行时的当前事实补充,缺值以空值导出,不因个别行缺值丢失整行或整列。
|
||||
type CommissionRecordDataSource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCommissionRecordDataSource 创建佣金明细导出数据源。
|
||||
func NewCommissionRecordDataSource(db *gorm.DB) *CommissionRecordDataSource {
|
||||
return &CommissionRecordDataSource{db: db}
|
||||
}
|
||||
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *CommissionRecordDataSource) Scene() string {
|
||||
return constants.ExportTaskSceneCommissionRecord
|
||||
}
|
||||
|
||||
// Count 统计原佣金与回溯明细的合并行数。
|
||||
func (s *CommissionRecordDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
original, err := s.originalBranch(ctx, params)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var originalTotal int64
|
||||
if err := original.Count(&originalTotal).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
clawback, err := s.clawbackBranch(ctx, params)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var clawbackTotal int64
|
||||
if err := clawback.Count(&clawbackTotal).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(originalTotal + clawbackTotal), nil
|
||||
}
|
||||
|
||||
// Headers 返回佣金明细导出表头。
|
||||
//
|
||||
// 前十五列按 111.md §18.2 与变更 delta 的固定列序输出:店铺、业务员、用户组、资产类型、设备类型、
|
||||
// 设备型号、资产标识、订单号、下单时间、佣金金额、佣金来源、佣金状态、关联佣金明细、入账后金额、创建时间。
|
||||
// 其后的记录来源、记录ID、来源退款单号、是否可提现与佣金入账时间为变更前既有列,予以保留:
|
||||
// 「回溯记录导出」场景要求导出结果仍可辨认行来源、退款单标识与不可提现标识,delta 对导出列的约束为「至少包含」上述十五列。
|
||||
func (s *CommissionRecordDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"店铺", "业务员", "用户组", "资产类型", "设备类型", "设备型号", "资产标识", "订单号", "下单时间",
|
||||
"佣金金额", "佣金来源", "佣金状态", "关联佣金明细", "入账后金额", "创建时间",
|
||||
"记录来源", "记录ID", "来源退款单号", "是否可提现", "佣金入账时间",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询合并后的佣金明细导出数据。
|
||||
func (s *CommissionRecordDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
if limit <= 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
original, err := s.originalBranch(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clawback, err := s.clawbackBranch(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
union := s.db.WithContext(ctx).
|
||||
Raw("SELECT * FROM (?) AS ledger_original UNION ALL SELECT * FROM (?) AS ledger_clawback",
|
||||
original, clawback)
|
||||
var items []commissionRecordExportRow
|
||||
query := s.db.WithContext(ctx).Table("(?) AS ledger", union).
|
||||
Select(`
|
||||
ledger.source,
|
||||
ledger.id,
|
||||
ledger.shop_id,
|
||||
ledger.order_no,
|
||||
ledger.order_created_at,
|
||||
ledger.iot_card_id,
|
||||
ledger.device_id,
|
||||
ledger.commission_source,
|
||||
ledger.amount,
|
||||
ledger.withdrawable,
|
||||
ledger.status,
|
||||
ledger.balance_after,
|
||||
ledger.original_commission_id,
|
||||
ledger.refund_no,
|
||||
ledger.released_at,
|
||||
ledger.created_at
|
||||
`).
|
||||
// 合并后统一排序并分页,保证两类记录落在同一结果集,任一条不缺失也不重复。
|
||||
Order("ledger.created_at DESC").Order("ledger.id DESC").Order("ledger.source ASC").
|
||||
Limit(limit).Offset(offset)
|
||||
if err := query.Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
// 归属与设备维度按导出执行时当前事实批量补充:只读当前店铺、账号、用户组与资产,不回写任何事实。
|
||||
shopIDs := make([]uint, 0, len(items))
|
||||
cardIDs := make([]uint, 0, len(items))
|
||||
deviceIDs := make([]uint, 0, len(items))
|
||||
originalIDs := make([]uint, 0, len(items))
|
||||
for _, item := range items {
|
||||
shopIDs = append(shopIDs, item.ShopID)
|
||||
if item.IotCardID != nil {
|
||||
cardIDs = append(cardIDs, *item.IotCardID)
|
||||
}
|
||||
if item.DeviceID != nil {
|
||||
deviceIDs = append(deviceIDs, *item.DeviceID)
|
||||
}
|
||||
if item.Source == sourceOriginal {
|
||||
originalIDs = append(originalIDs, item.ID)
|
||||
}
|
||||
}
|
||||
shops, err := s.loadShopOwners(ctx, normalizeUintSlice(shopIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownerIDs := make([]uint, 0, len(shops))
|
||||
for _, shop := range shops {
|
||||
if shop.BusinessOwnerAccountID != nil {
|
||||
ownerIDs = append(ownerIDs, *shop.BusinessOwnerAccountID)
|
||||
}
|
||||
}
|
||||
ownerIDs = normalizeUintSlice(ownerIDs)
|
||||
ownerNames, err := loadAccountNames(ctx, s.db, ownerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupNames, err := businessUserGroupNames(ctx, s.db, ownerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assets, err := s.loadAssetDimensions(ctx, normalizeUintSlice(cardIDs), normalizeUintSlice(deviceIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clawbackIDs, err := s.loadClawbackIDs(ctx, normalizeUintSlice(originalIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
shop := shops[item.ShopID]
|
||||
var ownerName, groupName string
|
||||
// 业务员取店铺当前业务员,用户组按该业务员实时推导;店铺无业务员时两列均留空。
|
||||
if shop.BusinessOwnerAccountID != nil {
|
||||
ownerName = ownerNames[*shop.BusinessOwnerAccountID]
|
||||
groupName = currentOwnerGroupName(groupNames, shop.BusinessOwnerAccountID)
|
||||
}
|
||||
assetType, assetIdentifier, deviceType, deviceModel := assets.describe(item.IotCardID, item.DeviceID)
|
||||
// 关联佣金明细:原佣金列其全部回溯明细编号,回溯明细列被回溯的原佣金编号。
|
||||
relatedCommission := formatOptionalUint(item.OriginalCommissionID)
|
||||
if item.Source == sourceOriginal {
|
||||
relatedCommission = formatClawbackIDList(clawbackIDs[item.ID])
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
shop.ShopName,
|
||||
ownerName,
|
||||
groupName,
|
||||
assetType,
|
||||
deviceType,
|
||||
deviceModel,
|
||||
assetIdentifier,
|
||||
item.OrderNo,
|
||||
formatOptionalTime(item.OrderCreatedAt),
|
||||
formatMoneyYuan(item.Amount),
|
||||
formatCommissionSource(item.CommissionSource),
|
||||
constants.GetCommissionRecordStatusName(item.Status),
|
||||
relatedCommission,
|
||||
formatMoneyYuan(item.BalanceAfter),
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
formatCommissionLedgerSource(item.Source),
|
||||
strconv.FormatUint(uint64(item.ID), 10),
|
||||
item.RefundNo,
|
||||
formatCommissionWithdrawable(item.Source, item.Withdrawable),
|
||||
formatOptionalTime(item.ReleasedAt),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// originalBranch 构造原佣金导出分支:自带场景筛选与数据范围。
|
||||
func (s *CommissionRecordDataSource) originalBranch(ctx context.Context, params ExportParams) (*gorm.DB, error) {
|
||||
query := s.db.WithContext(ctx).Table("tb_commission_record AS c").
|
||||
Where("c.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_order o ON c.order_id = o.id AND o.deleted_at IS NULL").
|
||||
Select(`'` + sourceOriginal + `' AS source, c.id, c.shop_id, c.order_id, o.order_no, ` +
|
||||
`o.created_at AS order_created_at, c.iot_card_id, c.device_id, ` +
|
||||
`c.commission_source, c.amount, c.balance_after, c.status, ` +
|
||||
`c.released_at, c.created_at, NULL::bigint AS original_commission_id, ''::varchar AS refund_no, ` +
|
||||
`NULL::boolean AS withdrawable`)
|
||||
query = applyExportShopScope(query, params, "c.shop_id")
|
||||
return applyCommissionExportFilters(query, params, "c.shop_id", "c.commission_source", "c.created_at", "c.status", "o.order_no")
|
||||
}
|
||||
|
||||
// clawbackBranch 构造回溯明细导出分支:资产维度取原佣金关联的卡或设备,保持与原佣金同一口径。
|
||||
func (s *CommissionRecordDataSource) clawbackBranch(ctx context.Context, params ExportParams) (*gorm.DB, error) {
|
||||
query := s.db.WithContext(ctx).Table("tb_commission_clawback_record AS g").
|
||||
Joins("LEFT JOIN tb_commission_record oc ON oc.id = g.original_commission_id").
|
||||
Joins("LEFT JOIN tb_order o ON g.order_id = o.id AND o.deleted_at IS NULL").
|
||||
Select(`'` + sourceClawback + `' AS source, g.id, g.shop_id, g.order_id, ` +
|
||||
`COALESCE(NULLIF(g.order_no, ''), o.order_no) AS order_no, o.created_at AS order_created_at, ` +
|
||||
`oc.iot_card_id, oc.device_id, ` +
|
||||
`g.commission_source, g.amount, g.balance_after, g.status, ` +
|
||||
`NULL::timestamp AS released_at, g.created_at, g.original_commission_id, g.refund_no, g.withdrawable`)
|
||||
query = applyExportShopScope(query, params, "g.shop_id")
|
||||
return applyCommissionExportFilters(query, params, "g.shop_id", "g.commission_source", "g.created_at", "g.status", "g.order_no")
|
||||
}
|
||||
|
||||
// 导出分支来源标识与后台列表保持一致,便于导出结果与列表逐行核对。
|
||||
const (
|
||||
sourceOriginal = "original"
|
||||
sourceClawback = "clawback"
|
||||
)
|
||||
|
||||
// applyCommissionExportFilters 把佣金明细导出的筛选条件应用到单个分支。
|
||||
// 时间范围按各分支自身的创建时间列做闭区间比较,覆盖原佣金与回溯明细两条分支。
|
||||
func applyCommissionExportFilters(query *gorm.DB, params ExportParams, shopColumn, sourceColumn, timeColumn, statusColumn, orderNoColumn string) (*gorm.DB, error) {
|
||||
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
|
||||
query = query.Where(shopColumn+" = ?", shopID)
|
||||
}
|
||||
if status, ok := filterInt(params.Filters, "status"); ok {
|
||||
query = query.Where(statusColumn+" = ?", status)
|
||||
}
|
||||
if source, ok := filterString(params.Filters, "commission_source"); ok {
|
||||
query = query.Where(sourceColumn+" = ?", source)
|
||||
}
|
||||
if orderNo, ok := filterString(params.Filters, "order_no"); ok {
|
||||
query = query.Where(orderNoColumn+" = ?", orderNo)
|
||||
}
|
||||
start, end, err := strictTimeRange(params.Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if start != nil {
|
||||
query = query.Where(timeColumn+" >= ?", *start)
|
||||
}
|
||||
if end != nil {
|
||||
query = query.Where(timeColumn+" <= ?", *end)
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// commissionRecordExportRow 是佣金明细导出的合并行投影,金额一律保持分。
|
||||
type commissionRecordExportRow struct {
|
||||
Source string `gorm:"column:source"`
|
||||
ID uint `gorm:"column:id"`
|
||||
ShopID uint `gorm:"column:shop_id"`
|
||||
OrderNo string `gorm:"column:order_no"`
|
||||
OrderCreatedAt *time.Time `gorm:"column:order_created_at"`
|
||||
IotCardID *uint `gorm:"column:iot_card_id"`
|
||||
DeviceID *uint `gorm:"column:device_id"`
|
||||
CommissionSource string `gorm:"column:commission_source"`
|
||||
Amount int64 `gorm:"column:amount"`
|
||||
Withdrawable *bool `gorm:"column:withdrawable"`
|
||||
Status int `gorm:"column:status"`
|
||||
BalanceAfter int64 `gorm:"column:balance_after"`
|
||||
OriginalCommissionID *uint `gorm:"column:original_commission_id"`
|
||||
RefundNo string `gorm:"column:refund_no"`
|
||||
ReleasedAt *time.Time `gorm:"column:released_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
// commissionExportShop 是执行时当前店铺归属投影。
|
||||
type commissionExportShop struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ShopName string `gorm:"column:shop_name"`
|
||||
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
|
||||
}
|
||||
|
||||
// commissionAssetDevice 是执行时当前设备类型与型号投影。
|
||||
type commissionAssetDevice struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
VirtualNo string `gorm:"column:virtual_no"`
|
||||
DeviceType string `gorm:"column:device_type"`
|
||||
DeviceModel string `gorm:"column:device_model"`
|
||||
}
|
||||
|
||||
// commissionExportAssets 是执行时当前资产维度投影:卡标识、卡当前绑定设备与设备自身的类型型号。
|
||||
type commissionExportAssets struct {
|
||||
cardICCIDs map[uint]string
|
||||
boundDeviceIDs map[uint]uint
|
||||
devices map[uint]commissionAssetDevice
|
||||
}
|
||||
|
||||
// describe 返回单条佣金记录资产在导出执行时的当前维度。
|
||||
// 关联卡时资产类型为卡、资产标识取卡 ICCID、设备维度取该卡当前绑定的设备;
|
||||
// 关联设备时资产类型为设备、资产标识取设备虚拟号、设备维度取设备自身;
|
||||
// 既无卡也无设备、或对应资产已不可读时一律返回空值,不影响该行其余列。
|
||||
func (a commissionExportAssets) describe(cardID, deviceID *uint) (assetType, identifier, deviceType, deviceModel string) {
|
||||
switch {
|
||||
case cardID != nil:
|
||||
assetType = assetTypeName(constants.AssetTypeIotCard)
|
||||
identifier = a.cardICCIDs[*cardID]
|
||||
if bound, ok := a.boundDeviceIDs[*cardID]; ok {
|
||||
device := a.devices[bound]
|
||||
deviceType, deviceModel = device.DeviceType, device.DeviceModel
|
||||
}
|
||||
case deviceID != nil:
|
||||
assetType = assetTypeName(constants.AssetTypeDevice)
|
||||
device := a.devices[*deviceID]
|
||||
identifier = device.VirtualNo
|
||||
deviceType, deviceModel = device.DeviceType, device.DeviceModel
|
||||
default:
|
||||
return "", "", "", ""
|
||||
}
|
||||
return assetType, identifier, deviceType, deviceModel
|
||||
}
|
||||
|
||||
// loadShopOwners 按店铺 ID 批量读取执行时当前店铺名称与业务员账号。
|
||||
func (s *CommissionRecordDataSource) loadShopOwners(ctx context.Context, shopIDs []uint) (map[uint]commissionExportShop, error) {
|
||||
result := make(map[uint]commissionExportShop, len(shopIDs))
|
||||
if len(shopIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []commissionExportShop
|
||||
if err := s.db.WithContext(ctx).Table("tb_shop").
|
||||
Select("id, shop_name, business_owner_account_id").
|
||||
Where("id IN ? AND deleted_at IS NULL", shopIDs).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询佣金导出店铺当前归属失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.ID] = row
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadAssetDimensions 按卡与设备 ID 批量读取执行时当前资产维度。
|
||||
// 卡资产的设备维度取该卡当前绑定的设备(一卡至多一条生效绑定),未绑定或绑定设备已删除时留空。
|
||||
func (s *CommissionRecordDataSource) loadAssetDimensions(ctx context.Context, cardIDs, deviceIDs []uint) (commissionExportAssets, error) {
|
||||
result := commissionExportAssets{
|
||||
cardICCIDs: make(map[uint]string, len(cardIDs)),
|
||||
boundDeviceIDs: make(map[uint]uint, len(cardIDs)),
|
||||
devices: make(map[uint]commissionAssetDevice, len(deviceIDs)),
|
||||
}
|
||||
if len(cardIDs) > 0 {
|
||||
var cards []struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ICCID string `gorm:"column:iccid"`
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Table("tb_iot_card").
|
||||
Select("id, iccid").
|
||||
Where("id IN ? AND deleted_at IS NULL", cardIDs).
|
||||
Scan(&cards).Error; err != nil {
|
||||
return commissionExportAssets{}, errors.Wrap(errors.CodeDatabaseError, err, "查询佣金导出资产卡标识失败")
|
||||
}
|
||||
for _, card := range cards {
|
||||
result.cardICCIDs[card.ID] = card.ICCID
|
||||
}
|
||||
|
||||
var bindings []struct {
|
||||
IotCardID uint `gorm:"column:iot_card_id"`
|
||||
DeviceID uint `gorm:"column:device_id"`
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Table("tb_device_sim_binding AS b").
|
||||
Select("b.iot_card_id, b.device_id").
|
||||
Joins("JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL").
|
||||
Where("b.iot_card_id IN ? AND b.bind_status = ? AND b.deleted_at IS NULL", cardIDs, constants.BindStatusBound).
|
||||
Scan(&bindings).Error; err != nil {
|
||||
return commissionExportAssets{}, errors.Wrap(errors.CodeDatabaseError, err, "查询佣金导出卡当前绑定设备失败")
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
result.boundDeviceIDs[binding.IotCardID] = binding.DeviceID
|
||||
deviceIDs = append(deviceIDs, binding.DeviceID)
|
||||
}
|
||||
}
|
||||
deviceIDs = normalizeUintSlice(deviceIDs)
|
||||
if len(deviceIDs) > 0 {
|
||||
var devices []commissionAssetDevice
|
||||
if err := s.db.WithContext(ctx).Table("tb_device").
|
||||
Select("id, virtual_no, device_type, device_model").
|
||||
Where("id IN ? AND deleted_at IS NULL", deviceIDs).
|
||||
Scan(&devices).Error; err != nil {
|
||||
return commissionExportAssets{}, errors.Wrap(errors.CodeDatabaseError, err, "查询佣金导出设备类型与型号失败")
|
||||
}
|
||||
for _, device := range devices {
|
||||
result.devices[device.ID] = device
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadClawbackIDs 按原佣金批量读取其全部回溯明细编号,按生成顺序稳定排列。
|
||||
func (s *CommissionRecordDataSource) loadClawbackIDs(ctx context.Context, originalIDs []uint) (map[uint][]uint, error) {
|
||||
result := make(map[uint][]uint, len(originalIDs))
|
||||
if len(originalIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
OriginalCommissionID uint `gorm:"column:original_commission_id"`
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Table("tb_commission_clawback_record").
|
||||
Select("id, original_commission_id").
|
||||
Where("original_commission_id IN ?", originalIDs).
|
||||
Order("id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原佣金关联回溯明细失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.OriginalCommissionID] = append(result[row.OriginalCommissionID], row.ID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// formatClawbackIDList 输出原佣金关联的全部回溯明细编号;无回溯关系时为空。
|
||||
func formatClawbackIDList(ids []uint) string {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
parts = append(parts, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
return strings.Join(parts, "、")
|
||||
}
|
||||
|
||||
// formatCommissionLedgerSource 把记录来源转为导出用中文描述。
|
||||
func formatCommissionLedgerSource(source string) string {
|
||||
if source == sourceClawback {
|
||||
return "回溯明细"
|
||||
}
|
||||
return "原佣金"
|
||||
}
|
||||
|
||||
// formatCommissionSource 把佣金来源转为导出用中文描述。
|
||||
func formatCommissionSource(source string) string {
|
||||
switch source {
|
||||
case model.CommissionSourceCostDiff:
|
||||
return "成本价差"
|
||||
case model.CommissionSourceOneTime:
|
||||
return "一次性佣金"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
// formatCommissionWithdrawable 把可提现标识转为导出用中文描述。
|
||||
// 原佣金不参与可提现判定,留空;回溯明细恒为不可提现。
|
||||
func formatCommissionWithdrawable(source string, withdrawable *bool) string {
|
||||
if source != sourceClawback || withdrawable == nil {
|
||||
return ""
|
||||
}
|
||||
if *withdrawable {
|
||||
return "可提现"
|
||||
}
|
||||
return "不可提现"
|
||||
}
|
||||
@@ -14,8 +14,13 @@ import (
|
||||
const (
|
||||
deviceExportBaseHeaderCount = 6
|
||||
deviceExportCardGroupSize = 5
|
||||
deviceExportTailHeaderCount = 5
|
||||
deviceExportMinCardGroups = 1
|
||||
// deviceExportTailHeaderCount 是当前尾部固定列数(含新增的「关联手机号」列)。
|
||||
deviceExportTailHeaderCount = 6
|
||||
// deviceExportLegacyTailHeaderCount 是新增尾部列之前的固定列数,仅供历史任务表头反解回退使用。
|
||||
deviceExportLegacyTailHeaderCount = 5
|
||||
deviceExportMinCardGroups = 1
|
||||
// deviceExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。
|
||||
deviceExportAssociatedPhoneHeader = "关联手机号"
|
||||
)
|
||||
|
||||
// DeviceDataSource 设备导出数据源。
|
||||
@@ -89,9 +94,14 @@ func (s *DeviceDataSource) Fetch(ctx context.Context, params ExportParams, offse
|
||||
return nil, err
|
||||
}
|
||||
|
||||
associatedPhones, err := loadAssociatedPhonesByAsset(ctx, s.db, constants.AssetTypeDevice, deviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(devices))
|
||||
for _, item := range devices {
|
||||
rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], cardGroups))
|
||||
rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], associatedPhones[item.ID], cardGroups))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -375,11 +385,12 @@ func buildDeviceExportHeaders(cardGroups int) []string {
|
||||
"套餐的到期时间",
|
||||
"当前套餐",
|
||||
"钱包余额",
|
||||
deviceExportAssociatedPhoneHeader,
|
||||
)
|
||||
return headers
|
||||
}
|
||||
|
||||
func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, cardGroups int) []string {
|
||||
func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, associatedPhone string, cardGroups int) []string {
|
||||
if cardGroups < deviceExportMinCardGroups {
|
||||
cardGroups = deviceExportMinCardGroups
|
||||
}
|
||||
@@ -420,15 +431,27 @@ func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg
|
||||
formatOptionalTime(pkg.ExpiresAt),
|
||||
pkg.PackageName,
|
||||
formatMoneyYuan(item.WalletBalance),
|
||||
associatedPhone,
|
||||
)
|
||||
return row
|
||||
}
|
||||
|
||||
// cardGroupCountFromHeaders 从已持久化的表头反解卡组列数。
|
||||
// 先按当前尾列数判定;不整除时回退到新增尾部列之前的尾列数再判定,
|
||||
// 否则历史任务凭 ResolvedHeaders 重导出时列组数会被算成 0,缺失全部卡列。
|
||||
func cardGroupCountFromHeaders(headers []string) int {
|
||||
if len(headers) < deviceExportBaseHeaderCount+deviceExportTailHeaderCount {
|
||||
if count := cardGroupCountWithTail(headers, deviceExportTailHeaderCount); count > 0 {
|
||||
return count
|
||||
}
|
||||
return cardGroupCountWithTail(headers, deviceExportLegacyTailHeaderCount)
|
||||
}
|
||||
|
||||
// cardGroupCountWithTail 按指定尾部固定列数反解卡组列数,不整除即无法确定列组。
|
||||
func cardGroupCountWithTail(headers []string, tailHeaderCount int) int {
|
||||
if len(headers) < deviceExportBaseHeaderCount+tailHeaderCount {
|
||||
return 0
|
||||
}
|
||||
cardColumnCount := len(headers) - deviceExportBaseHeaderCount - deviceExportTailHeaderCount
|
||||
cardColumnCount := len(headers) - deviceExportBaseHeaderCount - tailHeaderCount
|
||||
if cardColumnCount <= 0 || cardColumnCount%deviceExportCardGroupSize != 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ func (s *ExchangeDataSource) Scene() string {
|
||||
// Count 统计换货记录导出行数。
|
||||
func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
var total int64
|
||||
query := s.applyFilters(s.baseQuery(ctx), params)
|
||||
query, err := s.applyFilters(s.baseQuery(ctx), params)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -38,7 +41,7 @@ func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (in
|
||||
func (s *ExchangeDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"换货单号", "换货类型", "换货原因", "问题描述/备注", "旧资产类型", "旧资产标识符", "新资产标识符",
|
||||
"收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "创建人", "创建时间",
|
||||
"收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "迁移状态", "创建人", "创建时间",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -49,7 +52,11 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off
|
||||
}
|
||||
|
||||
var items []exchangeExportRow
|
||||
query := s.applyFilters(s.baseQuery(ctx), params).
|
||||
filtered, err := s.applyFilters(s.baseQuery(ctx), params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := filtered.
|
||||
Select(`
|
||||
e.exchange_no,
|
||||
e.flow_type,
|
||||
@@ -64,6 +71,7 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off
|
||||
e.express_company,
|
||||
e.express_no,
|
||||
e.status,
|
||||
e.migration_status,
|
||||
e.created_at,
|
||||
COALESCE(ac.username, '') AS creator_name
|
||||
`).
|
||||
@@ -91,6 +99,7 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off
|
||||
item.ExpressCompany,
|
||||
item.ExpressNo,
|
||||
constants.GetExchangeStatusName(item.Status),
|
||||
constants.GetExchangeMigrationStatusName(item.MigrationStatus),
|
||||
item.CreatorName,
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
})
|
||||
@@ -102,7 +111,7 @@ func (s *ExchangeDataSource) baseQuery(ctx context.Context) *gorm.DB {
|
||||
return s.db.WithContext(ctx).Table("tb_exchange_order AS e").Where("e.deleted_at IS NULL")
|
||||
}
|
||||
|
||||
func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
|
||||
func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) (*gorm.DB, error) {
|
||||
query = applyExportShopScope(query, params, "e.shop_id")
|
||||
if status, ok := filterInt(params.Filters, "status"); ok {
|
||||
query = query.Where("e.status = ?", status)
|
||||
@@ -112,13 +121,17 @@ func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) *
|
||||
}
|
||||
query = applyExchangeAssetKeyword(query, "old", filterValue(params.Filters, "old_asset_keyword"))
|
||||
query = applyExchangeAssetKeyword(query, "new", filterValue(params.Filters, "new_asset_keyword"))
|
||||
if start, ok := filterTime(params.Filters, "created_at_start"); ok {
|
||||
query = query.Where("e.created_at >= ?", start)
|
||||
start, end, err := strictTimeRange(params.Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if end, ok := filterTime(params.Filters, "created_at_end"); ok {
|
||||
query = query.Where("e.created_at <= ?", end)
|
||||
if start != nil {
|
||||
query = query.Where("e.created_at >= ?", *start)
|
||||
}
|
||||
return query
|
||||
if end != nil {
|
||||
query = query.Where("e.created_at <= ?", *end)
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func applyExchangeAssetKeyword(query *gorm.DB, side, keyword string) *gorm.DB {
|
||||
@@ -171,6 +184,7 @@ type exchangeExportRow struct {
|
||||
ExpressCompany string `gorm:"column:express_company"`
|
||||
ExpressNo string `gorm:"column:express_no"`
|
||||
Status int `gorm:"column:status"`
|
||||
MigrationStatus string `gorm:"column:migration_status"`
|
||||
CreatorName string `gorm:"column:creator_name"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
229
internal/exporter/expiring_asset_scene.go
Normal file
229
internal/exporter/expiring_asset_scene.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packageexpiryquery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ExpiringAssetDataSource 临期资产导出数据源。
|
||||
//
|
||||
// 粒度为一行对应一项资产,取该资产当前生效主套餐的最终到期时间与剩余天数,加油包不单独成行。
|
||||
// 候选预筛、最终到期推算与行序全部复用 internal/query/packageexpiry 的列表实现,不另写第二套到期口径。
|
||||
// 店铺、业务员与用户组按导出执行时的当前归属补充,结果仍受任务创建时冻结的可见店铺范围约束。
|
||||
type ExpiringAssetDataSource struct {
|
||||
db *gorm.DB
|
||||
expiryQ *packageexpiryquery.Query
|
||||
}
|
||||
|
||||
// NewExpiringAssetDataSource 创建临期资产导出数据源。
|
||||
func NewExpiringAssetDataSource(db *gorm.DB) *ExpiringAssetDataSource {
|
||||
return &ExpiringAssetDataSource{db: db, expiryQ: packageexpiryquery.NewQuery(db)}
|
||||
}
|
||||
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *ExpiringAssetDataSource) Scene() string {
|
||||
return constants.ExportTaskSceneExpiringAsset
|
||||
}
|
||||
|
||||
// Count 统计临期资产导出行数,与列表同一筛选下的行集合一致。
|
||||
func (s *ExpiringAssetDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
items, err := s.items(ctx, params)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(items), nil
|
||||
}
|
||||
|
||||
// Headers 返回临期资产导出表头,列序与 111.md §18.1 逐列一致。
|
||||
func (s *ExpiringAssetDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"店铺", "业务员", "用户组", "资产类型", "设备类型", "设备型号", "资产标识", "当前套餐", "到期时间", "剩余天数",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询临期资产导出数据。
|
||||
func (s *ExpiringAssetDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
if limit <= 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
items, err := s.items(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if offset >= len(items) {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
page := items[offset:end]
|
||||
|
||||
shopIDs := make([]uint, 0, len(page))
|
||||
deviceIDs := make([]uint, 0, len(page))
|
||||
for _, item := range page {
|
||||
if item.ShopID != nil {
|
||||
shopIDs = append(shopIDs, *item.ShopID)
|
||||
}
|
||||
if item.AssetType == constants.AssetTypeDevice {
|
||||
deviceIDs = append(deviceIDs, item.AssetID)
|
||||
}
|
||||
}
|
||||
shops, err := s.loadShopOwners(ctx, normalizeUintSlice(shopIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownerIDs := make([]uint, 0, len(shops))
|
||||
for _, shop := range shops {
|
||||
if shop.BusinessOwnerAccountID != nil {
|
||||
ownerIDs = append(ownerIDs, *shop.BusinessOwnerAccountID)
|
||||
}
|
||||
}
|
||||
ownerIDs = normalizeUintSlice(ownerIDs)
|
||||
ownerNames, err := loadAccountNames(ctx, s.db, ownerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupNames, err := businessUserGroupNames(ctx, s.db, ownerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices, err := s.loadDeviceAttributes(ctx, normalizeUintSlice(deviceIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(page))
|
||||
for _, item := range page {
|
||||
var shopName, ownerName, groupName string
|
||||
if item.ShopID != nil {
|
||||
shop := shops[*item.ShopID]
|
||||
shopName = shop.ShopName
|
||||
if shop.BusinessOwnerAccountID != nil {
|
||||
ownerName = ownerNames[*shop.BusinessOwnerAccountID]
|
||||
groupName = currentOwnerGroupName(groupNames, shop.BusinessOwnerAccountID)
|
||||
}
|
||||
}
|
||||
var deviceType, deviceModel string
|
||||
if item.AssetType == constants.AssetTypeDevice {
|
||||
device := devices[item.AssetID]
|
||||
deviceType, deviceModel = device.DeviceType, device.DeviceModel
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
shopName,
|
||||
ownerName,
|
||||
groupName,
|
||||
assetTypeName(item.AssetType),
|
||||
deviceType,
|
||||
deviceModel,
|
||||
item.Identifier,
|
||||
item.PackageName,
|
||||
formatOptionalTime(item.EstimatedFinalExpiresAt),
|
||||
formatRemainingDaysValue(item.DaysUntilFinalExpiry),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// items 复用列表同一候选预筛与最终到期推算,数据范围来自任务创建时冻结的可见店铺范围。
|
||||
func (s *ExpiringAssetDataSource) items(ctx context.Context, params ExportParams) ([]dto.ExpiringAssetItem, error) {
|
||||
filter, err := s.listFilter(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope := func(query *gorm.DB) *gorm.DB {
|
||||
return applyExportShopScope(query, params, "shop_id")
|
||||
}
|
||||
return s.expiryQ.ListAllWithScope(ctx, scope, filter)
|
||||
}
|
||||
|
||||
// listFilter 把任务冻结的筛选快照转换为列表同一口径的筛选条件。
|
||||
func (s *ExpiringAssetDataSource) listFilter(params ExportParams) (packageexpiryquery.ListFilter, error) {
|
||||
filter := packageexpiryquery.ListFilter{
|
||||
AssetType: filterValue(params.Filters, "asset_type"),
|
||||
Keyword: filterValue(params.Filters, "keyword"),
|
||||
}
|
||||
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
|
||||
filter.ShopID = &shopID
|
||||
}
|
||||
if packageID, ok := filterUint(params.Filters, "package_id"); ok {
|
||||
filter.PackageID = &packageID
|
||||
}
|
||||
if daysMin, ok := filterInt(params.Filters, "days_min"); ok {
|
||||
filter.DaysMin = &daysMin
|
||||
}
|
||||
if daysMax, ok := filterInt(params.Filters, "days_max"); ok {
|
||||
filter.DaysMax = &daysMax
|
||||
}
|
||||
start, end, err := strictTimeRange(params.Filters)
|
||||
if err != nil {
|
||||
return packageexpiryquery.ListFilter{}, err
|
||||
}
|
||||
filter.StartTime, filter.EndTime = start, end
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func (s *ExpiringAssetDataSource) loadShopOwners(ctx context.Context, shopIDs []uint) (map[uint]expiringAssetShop, error) {
|
||||
result := make(map[uint]expiringAssetShop, len(shopIDs))
|
||||
if len(shopIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []expiringAssetShop
|
||||
if err := s.db.WithContext(ctx).Table("tb_shop").
|
||||
Select("id, shop_name, business_owner_account_id").
|
||||
Where("id IN ? AND deleted_at IS NULL", shopIDs).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期导出店铺当前归属失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.ID] = row
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ExpiringAssetDataSource) loadDeviceAttributes(ctx context.Context, deviceIDs []uint) (map[uint]expiringAssetDevice, error) {
|
||||
result := make(map[uint]expiringAssetDevice, len(deviceIDs))
|
||||
if len(deviceIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []expiringAssetDevice
|
||||
if err := s.db.WithContext(ctx).Table("tb_device").
|
||||
Select("id, device_type, device_model").
|
||||
Where("id IN ?", deviceIDs).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期导出设备类型与型号失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.ID] = row
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// expiringAssetShop 是执行时当前店铺归属投影。
|
||||
type expiringAssetShop struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ShopName string `gorm:"column:shop_name"`
|
||||
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
|
||||
}
|
||||
|
||||
// expiringAssetDevice 是执行时当前设备类型与型号投影。
|
||||
type expiringAssetDevice struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
DeviceType string `gorm:"column:device_type"`
|
||||
DeviceModel string `gorm:"column:device_model"`
|
||||
}
|
||||
|
||||
// formatRemainingDaysValue 输出列表同一最终到期推算给出的剩余上海自然日天数;无法精确推算时为空。
|
||||
func formatRemainingDaysValue(days *int) string {
|
||||
if days == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*days)
|
||||
}
|
||||
@@ -37,7 +37,8 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int
|
||||
|
||||
// Headers 返回 IoT 卡导出表头。
|
||||
func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
|
||||
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)"}, nil
|
||||
// 「关联手机号」固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。
|
||||
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询 IoT 卡导出数据。
|
||||
@@ -49,6 +50,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
|
||||
var items []iotCardExportRow
|
||||
query := s.applyFilters(ctx, s.baseQuery(ctx), params).
|
||||
Select(`
|
||||
c.id,
|
||||
c.iccid,
|
||||
c.msisdn,
|
||||
c.device_virtual_no,
|
||||
@@ -69,6 +71,15 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cardIDs := make([]uint, 0, len(items))
|
||||
for _, item := range items {
|
||||
cardIDs = append(cardIDs, item.ID)
|
||||
}
|
||||
associatedPhones, err := loadAssociatedPhonesByAsset(ctx, s.db, constants.AssetTypeIotCard, cardIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, []string{
|
||||
@@ -84,6 +95,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
|
||||
item.PackageName,
|
||||
strconv.FormatInt(item.DataUsageMB, 10),
|
||||
strconv.FormatInt(remainingPackageDataMB(item.DataLimitMB, item.DataUsageMB), 10),
|
||||
associatedPhones[item.ID],
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
@@ -219,7 +231,11 @@ func (s *IotCardDataSource) applyFilters(ctx context.Context, query *gorm.DB, pa
|
||||
return query
|
||||
}
|
||||
|
||||
// iotCardExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。
|
||||
const iotCardExportAssociatedPhoneHeader = "关联手机号"
|
||||
|
||||
type iotCardExportRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ICCID string `gorm:"column:iccid"`
|
||||
MSISDN string `gorm:"column:msisdn"`
|
||||
DeviceVirtualNo string `gorm:"column:device_virtual_no"`
|
||||
|
||||
345
internal/exporter/operations_report_scene.go
Normal file
345
internal/exporter/operations_report_scene.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
)
|
||||
|
||||
// 导出空值占位:分母为零的比率与卡均、以及无快照时的空指标一律写「-」。
|
||||
const operationsReportEmptyValue = "-"
|
||||
|
||||
// 合计行的分组列值。
|
||||
const operationsReportTotalGroup = "合计"
|
||||
|
||||
// 序号列表头;分组行按导出展示顺序从 1 连续递增,合计行留空不写入任何数字或文字。
|
||||
const operationsReportSerialHeader = "序号"
|
||||
|
||||
// OperationsActivationDataSource 设备激活情况报表导出数据源。
|
||||
//
|
||||
// 导出列与页面展示字段一致并包含合计行,首列为「序号」:分组行按展示顺序从 1 连续递增,
|
||||
// 合计行的序号列留空且其余各列的既有表示不变;导出不含任何文字总结;
|
||||
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
|
||||
// 本场景只对超级管理员与平台账号开放:受控入口已做角色门禁,这里再按任务内冻结的账号类型复核一次,
|
||||
// 阻止通过通用导出入口以代理身份创建本场景任务后读到运营报表数据。
|
||||
type OperationsActivationDataSource struct {
|
||||
query *operationsreportquery.Query
|
||||
}
|
||||
|
||||
// NewOperationsActivationDataSource 创建设备激活情况报表导出数据源。
|
||||
func NewOperationsActivationDataSource(db *gorm.DB) *OperationsActivationDataSource {
|
||||
return &OperationsActivationDataSource{query: operationsreportquery.NewQuery(db)}
|
||||
}
|
||||
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *OperationsActivationDataSource) Scene() string {
|
||||
return constants.ExportTaskSceneOperationsActivation
|
||||
}
|
||||
|
||||
// Count 统计导出行数(分组行 + 合计行)。
|
||||
func (s *OperationsActivationDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
result, err := s.build(ctx, params)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(result.rows), nil
|
||||
}
|
||||
|
||||
// Headers 返回设备激活情况导出表头。
|
||||
func (s *OperationsActivationDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
|
||||
result, err := s.build(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.headers, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 返回设备激活情况导出行。
|
||||
func (s *OperationsActivationDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
result, err := s.build(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sliceOperationsReportRows(result.rows, offset, limit), nil
|
||||
}
|
||||
|
||||
// build 构造导出表头与全部行(分组行 + 合计行)。
|
||||
func (s *OperationsActivationDataSource) build(ctx context.Context, params ExportParams) (*operationsReportResult, error) {
|
||||
if err := ensureOperationsReportExportAllowed(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := activationExportRequest(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := s.query.ActivationSummary(frozenQueryContext(ctx, params), request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headers := []string{
|
||||
operationsReportSerialHeader, response.GroupName, "采购数量", "累计激活数", "激活率", "新增激活数",
|
||||
"累计在网数", "活跃用户数", "累计用量(GB)", "单用户卡均(GB)", "含零预测卡均(GB)", "不含零预测卡均(GB)",
|
||||
}
|
||||
rows := make([][]string, 0, len(response.Items)+1)
|
||||
for index, item := range response.Items {
|
||||
rows = append(rows, []string{
|
||||
strconv.Itoa(index + 1),
|
||||
item.GroupValue,
|
||||
formatOptionalInt64(item.PurchasedDeviceCount),
|
||||
formatOptionalInt64(item.ActivatedDeviceCount),
|
||||
formatOptionalFloat(item.ActivationRate),
|
||||
formatOptionalInt64(item.NewActivatedDeviceCount),
|
||||
formatOptionalInt64(item.OnlineDeviceCount),
|
||||
formatOptionalInt64(item.ActiveDeviceCount),
|
||||
formatOptionalFloat(item.TotalRealTrafficGB),
|
||||
formatOptionalFloat(item.PerUserAverageGB),
|
||||
formatOptionalFloat(item.ForecastAverageIncludingZeroGB),
|
||||
formatOptionalFloat(item.ForecastAverageExcludingZeroGB),
|
||||
})
|
||||
}
|
||||
if response.Totals != nil {
|
||||
total := response.Totals
|
||||
rows = append(rows, []string{
|
||||
// 合计行序号列留空,其余各列沿用既有表示。
|
||||
"",
|
||||
operationsReportTotalGroup,
|
||||
formatOptionalInt64(total.PurchasedDeviceCount),
|
||||
formatOptionalInt64(total.ActivatedDeviceCount),
|
||||
formatOptionalFloat(total.ActivationRate),
|
||||
formatOptionalInt64(total.NewActivatedDeviceCount),
|
||||
formatOptionalInt64(total.OnlineDeviceCount),
|
||||
formatOptionalInt64(total.ActiveDeviceCount),
|
||||
formatOptionalFloat(total.TotalRealTrafficGB),
|
||||
formatOptionalFloat(total.PerUserAverageGB),
|
||||
formatOptionalFloat(total.ForecastAverageIncludingZeroGB),
|
||||
formatOptionalFloat(total.ForecastAverageExcludingZeroGB),
|
||||
})
|
||||
}
|
||||
return &operationsReportResult{headers: headers, rows: rows}, nil
|
||||
}
|
||||
|
||||
// OperationsRenewalDataSource 套餐续费情况报表导出数据源。
|
||||
//
|
||||
// 导出列与页面展示字段一致并包含合计行,首列为「序号」:分组行按展示顺序从 1 连续递增,
|
||||
// 合计行的序号列留空且其余各列的既有表示不变;导出不含任何文字总结;
|
||||
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
|
||||
// 本场景同样按任务内冻结的账号类型复核导出资格。
|
||||
type OperationsRenewalDataSource struct {
|
||||
query *operationsreportquery.Query
|
||||
}
|
||||
|
||||
// NewOperationsRenewalDataSource 创建套餐续费情况报表导出数据源。
|
||||
func NewOperationsRenewalDataSource(db *gorm.DB) *OperationsRenewalDataSource {
|
||||
return &OperationsRenewalDataSource{query: operationsreportquery.NewQuery(db)}
|
||||
}
|
||||
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *OperationsRenewalDataSource) Scene() string {
|
||||
return constants.ExportTaskSceneOperationsRenewal
|
||||
}
|
||||
|
||||
// Count 统计导出行数(分组行 + 合计行)。
|
||||
func (s *OperationsRenewalDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
result, err := s.build(ctx, params)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(result.rows), nil
|
||||
}
|
||||
|
||||
// Headers 返回套餐续费情况导出表头。
|
||||
func (s *OperationsRenewalDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
|
||||
result, err := s.build(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.headers, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 返回套餐续费情况导出行。
|
||||
func (s *OperationsRenewalDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
result, err := s.build(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sliceOperationsReportRows(result.rows, offset, limit), nil
|
||||
}
|
||||
|
||||
// build 构造导出表头与全部行(分组行 + 合计行)。
|
||||
func (s *OperationsRenewalDataSource) build(ctx context.Context, params ExportParams) (*operationsReportResult, error) {
|
||||
if err := ensureOperationsReportExportAllowed(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := renewalExportRequest(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := s.query.RenewalSummary(frozenQueryContext(ctx, params), request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headers := []string{operationsReportSerialHeader, response.GroupName, "到期资产数", "续费资产数", "续费率", "新增未续费数"}
|
||||
rows := make([][]string, 0, len(response.Items)+1)
|
||||
for index, item := range response.Items {
|
||||
rows = append(rows, []string{
|
||||
strconv.Itoa(index + 1),
|
||||
item.GroupValue,
|
||||
formatOptionalInt64(item.DueAssetCount),
|
||||
formatOptionalInt64(item.RenewedAssetCount),
|
||||
formatOptionalFloat(item.RenewalRate),
|
||||
formatOptionalInt64(item.NewUnrenewedAssetCount),
|
||||
})
|
||||
}
|
||||
if response.Totals != nil {
|
||||
total := response.Totals
|
||||
rows = append(rows, []string{
|
||||
// 合计行序号列留空,其余各列沿用既有表示。
|
||||
"",
|
||||
operationsReportTotalGroup,
|
||||
formatOptionalInt64(total.DueAssetCount),
|
||||
formatOptionalInt64(total.RenewedAssetCount),
|
||||
formatOptionalFloat(total.RenewalRate),
|
||||
formatOptionalInt64(total.NewUnrenewedAssetCount),
|
||||
})
|
||||
}
|
||||
return &operationsReportResult{headers: headers, rows: rows}, nil
|
||||
}
|
||||
|
||||
// operationsReportResult 是一次导出构造的表头与全部行。
|
||||
type operationsReportResult struct {
|
||||
headers []string
|
||||
rows [][]string
|
||||
}
|
||||
|
||||
// activationExportRequest 把任务冻结的筛选快照还原为汇总查询请求。
|
||||
// 时间边界只按统一严格解析器解析冻结值,非法值返回错误由调用方落任务失败。
|
||||
func activationExportRequest(params ExportParams) (dto.OperationsActivationSummaryRequest, error) {
|
||||
start, end, err := frozenOperationsReportRange(params.Filters)
|
||||
if err != nil {
|
||||
return dto.OperationsActivationSummaryRequest{}, err
|
||||
}
|
||||
groupBy, err := frozenOperationsReportGroupBy(params.Filters)
|
||||
if err != nil {
|
||||
return dto.OperationsActivationSummaryRequest{}, err
|
||||
}
|
||||
return dto.OperationsActivationSummaryRequest{StartTime: start, EndTime: end, GroupBy: groupBy}, nil
|
||||
}
|
||||
|
||||
// renewalExportRequest 把任务冻结的筛选快照还原为汇总查询请求。
|
||||
func renewalExportRequest(params ExportParams) (dto.OperationsRenewalSummaryRequest, error) {
|
||||
start, end, err := frozenOperationsReportRange(params.Filters)
|
||||
if err != nil {
|
||||
return dto.OperationsRenewalSummaryRequest{}, err
|
||||
}
|
||||
groupBy, err := frozenOperationsReportGroupBy(params.Filters)
|
||||
if err != nil {
|
||||
return dto.OperationsRenewalSummaryRequest{}, err
|
||||
}
|
||||
return dto.OperationsRenewalSummaryRequest{StartTime: start, EndTime: end, GroupBy: groupBy}, nil
|
||||
}
|
||||
|
||||
// operationsReportGroupByKey 是冻结筛选中的分组维度键。
|
||||
// 未选择分组维度时冻结为空串,导出仍然只有唯一一行「全部」,因此空值也按已冻结处理。
|
||||
const operationsReportGroupByKey = "group_by"
|
||||
|
||||
// frozenOperationsReportGroupBy 读取冻结的分组维度;键缺失或空串都表示未选择分组维度。
|
||||
func frozenOperationsReportGroupBy(filters map[string]any) (string, error) {
|
||||
value, exists := filters[operationsReportGroupByKey]
|
||||
if !exists || value == nil {
|
||||
return "", nil
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return "", errors.New(errors.CodeInvalidParam, "导出筛选的分组维度格式不正确")
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// frozenOperationsReportRange 读取冻结的时间边界并复用统一严格解析器校验格式与顺序。
|
||||
func frozenOperationsReportRange(filters map[string]any) (string, string, error) {
|
||||
start, err := frozenOperationsReportTime(filters, exportTimeFilterStartKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
end, err := frozenOperationsReportTime(filters, exportTimeFilterEndKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, _, err := utils.ParseTimeRange(start, end); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// frozenOperationsReportTime 读取单个冻结的时间边界值。
|
||||
func frozenOperationsReportTime(filters map[string]any, key string) (string, error) {
|
||||
value, exists := filters[key]
|
||||
if !exists || value == nil {
|
||||
return "", nil
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return "", utils.TimeFilterFormatError(key)
|
||||
}
|
||||
if text == "" {
|
||||
return "", nil
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// frozenQueryContext 以任务内冻结的账号类型与可见店铺范围构造查询上下文。
|
||||
// 执行期不读取当前请求上下文:创建后的角色、店铺归属或筛选变化都不会扩大或收紧已建任务的数据集。
|
||||
func frozenQueryContext(ctx context.Context, params ExportParams) context.Context {
|
||||
return middleware.SetUserContext(ctx, &middleware.UserContextInfo{
|
||||
UserType: params.UserType,
|
||||
SubordinateShopIDs: params.ScopeShopIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// ensureOperationsReportExportAllowed 只允许超级管理员与平台账号使用运营报表导出场景。
|
||||
// 判定依据是任务内冻结的账号类型,不读取当前请求上下文,因此创建后角色变化不会放宽或收紧已建任务。
|
||||
func ensureOperationsReportExportAllowed(params ExportParams) error {
|
||||
if params.UserType == constants.UserTypeSuperAdmin || params.UserType == constants.UserTypePlatform {
|
||||
return nil
|
||||
}
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
|
||||
// sliceOperationsReportRows 按 offset/limit 切分导出行。
|
||||
func sliceOperationsReportRows(rows [][]string, offset, limit int) [][]string {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if limit <= 0 || offset >= len(rows) {
|
||||
return [][]string{}
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(rows) {
|
||||
end = len(rows)
|
||||
}
|
||||
return rows[offset:end]
|
||||
}
|
||||
|
||||
// formatOptionalInt64 输出可选整数,为空写「-」。
|
||||
func formatOptionalInt64(value *int64) string {
|
||||
if value == nil {
|
||||
return operationsReportEmptyValue
|
||||
}
|
||||
return strconv.FormatInt(*value, 10)
|
||||
}
|
||||
|
||||
// formatOptionalFloat 输出可选小数(保留两位),为空写「-」。
|
||||
func formatOptionalFloat(value *float64) string {
|
||||
if value == nil {
|
||||
return operationsReportEmptyValue
|
||||
}
|
||||
return strconv.FormatFloat(*value, 'f', 2, 64)
|
||||
}
|
||||
@@ -148,11 +148,15 @@ func (s *OrderDataSource) applyFilters(ctx context.Context, query *gorm.DB, para
|
||||
if sellerShopID, ok := filterUint(params.Filters, "seller_shop_id"); ok {
|
||||
query = query.Where("o.seller_shop_id = ?", sellerShopID)
|
||||
}
|
||||
if start, ok := filterTime(params.Filters, "start_time"); ok {
|
||||
query = query.Where("o.created_at >= ?", start)
|
||||
start, end, err := strictTimeRange(params.Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if end, ok := filterTime(params.Filters, "end_time"); ok {
|
||||
query = query.Where("o.created_at <= ?", end)
|
||||
if start != nil {
|
||||
query = query.Where("o.created_at >= ?", *start)
|
||||
}
|
||||
if end != nil {
|
||||
query = query.Where("o.created_at <= ?", *end)
|
||||
}
|
||||
if buyerPhone, ok := filterString(params.Filters, "buyer_phone"); ok {
|
||||
query = query.Where("o.buyer_phone = ?", buyerPhone)
|
||||
|
||||
60
internal/exporter/ownership_columns.go
Normal file
60
internal/exporter/ownership_columns.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// businessUserGroupNames 按执行时当前业务员账号批量推导业务用户组名称。
|
||||
// 用户组不落在店铺库表上,按既有实时推导读取,多个组按排序拼接;达量预警与临期导出共用同一口径。
|
||||
func businessUserGroupNames(ctx context.Context, db *gorm.DB, ownerIDs []uint) (map[uint]string, error) {
|
||||
result := make(map[uint]string, len(ownerIDs))
|
||||
if len(ownerIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []struct {
|
||||
AccountID uint `gorm:"column:account_id"`
|
||||
GroupName string `gorm:"column:group_name"`
|
||||
}
|
||||
if err := db.WithContext(ctx).Table("tb_business_user_group_member AS m").
|
||||
Select("m.account_id, g.name AS group_name").
|
||||
Joins("JOIN tb_business_user_group AS g ON g.id = m.business_user_group_id AND g.deleted_at IS NULL").
|
||||
Where("m.account_id IN ? AND m.deleted_at IS NULL", ownerIDs).
|
||||
Order("m.account_id ASC, g.sort_order ASC, g.id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员业务用户组失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if existing := result[row.AccountID]; existing != "" {
|
||||
result[row.AccountID] = existing + "、" + row.GroupName
|
||||
continue
|
||||
}
|
||||
result[row.AccountID] = row.GroupName
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadAccountNames 按账号 ID 批量读取账号名称,供导出侧执行时当前归属补充。
|
||||
func loadAccountNames(ctx context.Context, db *gorm.DB, accountIDs []uint) (map[uint]string, error) {
|
||||
result := make(map[uint]string, len(accountIDs))
|
||||
if len(accountIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
if err := db.WithContext(ctx).Table("tb_account").
|
||||
Select("id, username").
|
||||
Where("id IN ? AND deleted_at IS NULL", accountIDs).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账号名称失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.ID] = row.Username
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
340
internal/exporter/package_traffic_alert_scene.go
Normal file
340
internal/exporter/package_traffic_alert_scene.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// PackageTrafficAlertDataSource 套餐真流量达量预警导出数据源。
|
||||
//
|
||||
// 粒度为一条预警记录。套餐、用量、总量、阈值、到期时间与资产标识类列一律读预警行冻结的触发快照;
|
||||
// 店铺、业务员与用户组按导出执行时当前归属补充,用户组按既有实时推导,不写入店铺表。
|
||||
// 本场景只对超级管理员与平台账号开放:受控入口已做角色门禁,这里再校验一次,
|
||||
// 阻止通过通用导出入口以代理身份创建本场景任务后读到预警数据。
|
||||
type PackageTrafficAlertDataSource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewPackageTrafficAlertDataSource 创建套餐真流量达量预警导出数据源。
|
||||
func NewPackageTrafficAlertDataSource(db *gorm.DB) *PackageTrafficAlertDataSource {
|
||||
return &PackageTrafficAlertDataSource{db: db}
|
||||
}
|
||||
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *PackageTrafficAlertDataSource) Scene() string {
|
||||
return constants.ExportTaskScenePackageTrafficAlert
|
||||
}
|
||||
|
||||
// Count 统计导出预警行数。
|
||||
func (s *PackageTrafficAlertDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
if err := ensurePackageTrafficAlertExportAllowed(params); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
query, err := s.applyFilters(s.baseQuery(ctx, params), params)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(total), nil
|
||||
}
|
||||
|
||||
// Headers 返回套餐真流量达量预警导出表头。
|
||||
// 表头在 dispatch 阶段冻结,历史任务重导出沿用同一列序;不含任何运营商通道列。
|
||||
func (s *PackageTrafficAlertDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"资产类型", "资产标识", "对应标识符", "卡标识", "设备类型", "设备型号",
|
||||
"套餐名称", "真流量已用量(MB)", "真流量额度(MB)", "比例(%)", "阈值快照(%)",
|
||||
"到期时间", "剩余天数", "触发时间", "店铺", "业务员", "用户组", "通知投递结果",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询预警导出数据。
|
||||
func (s *PackageTrafficAlertDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
if limit <= 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
if err := ensurePackageTrafficAlertExportAllowed(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []packageTrafficAlertExportRow
|
||||
filtered, err := s.applyFilters(s.baseQuery(ctx, params), params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := filtered.
|
||||
Select(`
|
||||
a.asset_type,
|
||||
a.asset_identifier_snapshot,
|
||||
a.counterpart_identifier_snapshot,
|
||||
a.card_identifier_snapshot,
|
||||
a.device_type_snapshot,
|
||||
a.device_model_snapshot,
|
||||
a.package_name_snapshot,
|
||||
a.used_mb_snapshot,
|
||||
a.limit_mb_snapshot,
|
||||
a.usage_percent_snapshot,
|
||||
a.threshold_percent_snapshot,
|
||||
a.expires_at_snapshot,
|
||||
a.triggered_at,
|
||||
a.shop_id_snapshot,
|
||||
a.shop_name_snapshot,
|
||||
a.business_owner_account_id_snapshot,
|
||||
a.business_owner_name_snapshot,
|
||||
a.notification_event_id,
|
||||
sh.id AS current_shop_id,
|
||||
COALESCE(sh.shop_name, '') AS current_shop_name,
|
||||
owner.id AS current_owner_id,
|
||||
COALESCE(owner.username, '') AS current_owner_name,
|
||||
oe.status AS outbox_status,
|
||||
n.id AS notification_id
|
||||
`).
|
||||
Order("a.triggered_at DESC").Order("a.id DESC").
|
||||
Limit(limit).Offset(offset)
|
||||
if err := query.Scan(&items).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警导出数据失败")
|
||||
}
|
||||
groupNames, err := s.loadBusinessUserGroupNames(ctx, items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, []string{
|
||||
assetTypeName(item.AssetType),
|
||||
item.AssetIdentifier,
|
||||
item.CounterpartIdentifier,
|
||||
item.CardIdentifier,
|
||||
item.DeviceType,
|
||||
item.DeviceModel,
|
||||
item.PackageName,
|
||||
strconv.FormatInt(item.UsedMB, 10),
|
||||
strconv.FormatInt(item.LimitMB, 10),
|
||||
formatPercentValue(item.UsagePercent),
|
||||
formatPercentValue(item.ThresholdPercent),
|
||||
formatOptionalTime(item.ExpiresAt),
|
||||
formatRemainingDays(item.ExpiresAt, now),
|
||||
item.TriggeredAt.Format(exportTimeLayout),
|
||||
item.CurrentShopName,
|
||||
item.CurrentOwnerName,
|
||||
currentOwnerGroupName(groupNames, item.CurrentOwnerID),
|
||||
constants.GetPackageTrafficAlertNotifyStatusName(resolveAlertNotifyStatus(item)),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// baseQuery 构造预警导出基础查询。
|
||||
// 归属展示列按执行时当前归属补充:资产 → 当前店铺 → 店铺当前业务员;用户组随后按业务员账号实时推导。
|
||||
func (s *PackageTrafficAlertDataSource) baseQuery(ctx context.Context, params ExportParams) *gorm.DB {
|
||||
query := s.db.WithContext(ctx).Table("tb_package_traffic_alert AS a").
|
||||
Joins("LEFT JOIN tb_iot_card AS c ON a.asset_type = ? AND c.id = a.asset_id AND c.deleted_at IS NULL",
|
||||
constants.AssetTypeIotCard).
|
||||
Joins("LEFT JOIN tb_device AS d ON a.asset_type = ? AND d.id = a.asset_id AND d.deleted_at IS NULL",
|
||||
constants.AssetTypeDevice).
|
||||
Joins("LEFT JOIN tb_shop AS sh ON sh.id = COALESCE(c.shop_id, d.shop_id) AND sh.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_account AS owner ON owner.id = sh.business_owner_account_id AND owner.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_outbox_event AS oe ON oe.event_id = a.notification_event_id").
|
||||
Joins("LEFT JOIN tb_notification AS n ON n.event_id = a.notification_event_id")
|
||||
// 数据范围使用导出侧范围过滤(空范围拒绝),不得使用请求上下文版过滤(空范围语义相反)。
|
||||
return applyExportShopScope(query, params, "a.shop_id_snapshot")
|
||||
}
|
||||
|
||||
// applyFilters 应用导出筛选快照。
|
||||
// 筛选口径与列表一致,都作用在触发快照列上;时间范围按触发时间闭区间解析,
|
||||
// 冻结值一律按统一严格解析器解析,非法值返回错误由调用方落任务失败。
|
||||
func (s *PackageTrafficAlertDataSource) applyFilters(query *gorm.DB, params ExportParams) (*gorm.DB, error) {
|
||||
if packageID, ok := filterUint(params.Filters, "package_id"); ok {
|
||||
query = query.Where("a.package_id = ?", packageID)
|
||||
}
|
||||
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
|
||||
query = query.Where("a.shop_id_snapshot = ?", shopID)
|
||||
}
|
||||
if ownerID, ok := filterUint(params.Filters, "business_owner_account_id"); ok {
|
||||
query = query.Where("a.business_owner_account_id_snapshot = ?", ownerID)
|
||||
}
|
||||
if assetType, ok := filterString(params.Filters, "asset_type"); ok {
|
||||
query = query.Where("a.asset_type = ?", assetType)
|
||||
}
|
||||
if identifier, ok := filterString(params.Filters, "asset_identifier"); ok {
|
||||
pattern := "%" + identifier + "%"
|
||||
query = query.Where("(a.asset_identifier_snapshot ILIKE ? OR a.card_identifier_snapshot ILIKE ? "+
|
||||
"OR a.counterpart_identifier_snapshot ILIKE ?)", pattern, pattern, pattern)
|
||||
}
|
||||
if threshold, ok := alertFilterFloat(params.Filters, "threshold_percent"); ok {
|
||||
query = query.Where("a.threshold_percent_snapshot = ?",
|
||||
packagetrafficalert.NormalizeThresholdPercent(threshold))
|
||||
}
|
||||
startTime, endTime, err := strictTimeRange(params.Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if startTime != nil {
|
||||
query = query.Where("a.triggered_at >= ?", *startTime)
|
||||
}
|
||||
if endTime != nil {
|
||||
query = query.Where("a.triggered_at <= ?", *endTime)
|
||||
}
|
||||
if status, ok := filterInt(params.Filters, "notification_status"); ok {
|
||||
query = applyAlertNotificationStatusFilter(query, status)
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// applyAlertNotificationStatusFilter 按通知投递结果筛选,口径与读侧列表一致。
|
||||
func applyAlertNotificationStatusFilter(query *gorm.DB, status int) *gorm.DB {
|
||||
const hasEvent = "a.notification_event_id <> ''"
|
||||
const hasNotification = "n.id IS NOT NULL"
|
||||
switch status {
|
||||
case constants.PackageTrafficAlertNotifyNoBusinessOwner:
|
||||
return query.Where("a.notification_event_id = ''")
|
||||
case constants.PackageTrafficAlertNotifyNotified:
|
||||
return query.Where(hasEvent).Where(hasNotification)
|
||||
case constants.PackageTrafficAlertNotifyPending:
|
||||
return query.Where(hasEvent).Where("NOT ("+hasNotification+")").
|
||||
Where("oe.status IN ?", []int{constants.OutboxStatusPending, constants.OutboxStatusDelivering})
|
||||
case constants.PackageTrafficAlertNotifyFailed:
|
||||
return query.Where(hasEvent).Where("NOT ("+hasNotification+")").
|
||||
Where("oe.status = ?", constants.OutboxStatusFailed)
|
||||
case constants.PackageTrafficAlertNotifyRecipientGone:
|
||||
return query.Where(hasEvent).Where("NOT ("+hasNotification+")").
|
||||
Where("oe.status = ?", constants.OutboxStatusDelivered)
|
||||
default:
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
// loadBusinessUserGroupNames 按执行时当前业务员账号批量推导业务用户组名称。
|
||||
// 用户组不落在店铺库表上,按既有实时推导读取,多个组按排序拼接。
|
||||
func (s *PackageTrafficAlertDataSource) loadBusinessUserGroupNames(ctx context.Context,
|
||||
items []packageTrafficAlertExportRow) (map[uint]string, error) {
|
||||
ownerIDs := make([]uint, 0, len(items))
|
||||
seen := make(map[uint]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if item.CurrentOwnerID == nil || *item.CurrentOwnerID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[*item.CurrentOwnerID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[*item.CurrentOwnerID] = struct{}{}
|
||||
ownerIDs = append(ownerIDs, *item.CurrentOwnerID)
|
||||
}
|
||||
return businessUserGroupNames(ctx, s.db, ownerIDs)
|
||||
}
|
||||
|
||||
// packageTrafficAlertExportRow 是预警导出的一行原始投影。
|
||||
type packageTrafficAlertExportRow struct {
|
||||
AssetType string `gorm:"column:asset_type"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier_snapshot"`
|
||||
CounterpartIdentifier string `gorm:"column:counterpart_identifier_snapshot"`
|
||||
CardIdentifier string `gorm:"column:card_identifier_snapshot"`
|
||||
DeviceType string `gorm:"column:device_type_snapshot"`
|
||||
DeviceModel string `gorm:"column:device_model_snapshot"`
|
||||
PackageName string `gorm:"column:package_name_snapshot"`
|
||||
UsedMB int64 `gorm:"column:used_mb_snapshot"`
|
||||
LimitMB int64 `gorm:"column:limit_mb_snapshot"`
|
||||
UsagePercent float64 `gorm:"column:usage_percent_snapshot"`
|
||||
ThresholdPercent float64 `gorm:"column:threshold_percent_snapshot"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at_snapshot"`
|
||||
TriggeredAt time.Time `gorm:"column:triggered_at"`
|
||||
ShopIDSnapshot uint `gorm:"column:shop_id_snapshot"`
|
||||
ShopNameSnapshot string `gorm:"column:shop_name_snapshot"`
|
||||
BusinessOwnerID *uint `gorm:"column:business_owner_account_id_snapshot"`
|
||||
BusinessOwnerName string `gorm:"column:business_owner_name_snapshot"`
|
||||
NotificationEventID string `gorm:"column:notification_event_id"`
|
||||
CurrentShopID *uint `gorm:"column:current_shop_id"`
|
||||
CurrentShopName string `gorm:"column:current_shop_name"`
|
||||
CurrentOwnerID *uint `gorm:"column:current_owner_id"`
|
||||
CurrentOwnerName string `gorm:"column:current_owner_name"`
|
||||
OutboxStatus *int `gorm:"column:outbox_status"`
|
||||
NotificationID *uint `gorm:"column:notification_id"`
|
||||
}
|
||||
|
||||
// resolveAlertNotifyStatus 推导导出行的通知投递结果,与列表、详情同口径。
|
||||
func resolveAlertNotifyStatus(item packageTrafficAlertExportRow) int {
|
||||
return constants.ResolvePackageTrafficAlertNotifyStatus(
|
||||
item.NotificationEventID != "", item.OutboxStatus, item.NotificationID != nil)
|
||||
}
|
||||
|
||||
// ensurePackageTrafficAlertExportAllowed 只允许超级管理员与平台账号使用本场景。
|
||||
// 通用导出入口不做场景级角色校验,因此这一层门禁是防止代理越权读取预警数据的必要防线。
|
||||
func ensurePackageTrafficAlertExportAllowed(params ExportParams) error {
|
||||
if params.UserType == constants.UserTypeSuperAdmin || params.UserType == constants.UserTypePlatform {
|
||||
return nil
|
||||
}
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
|
||||
// alertFilterFloat 解析导出筛选中的小数百分比。
|
||||
// 阈值筛选只在预警导出使用,为避免改动既有共享筛选助手文件,这里就地解析。
|
||||
func alertFilterFloat(filters map[string]any, key string) (float64, bool) {
|
||||
value, ok := filters[key]
|
||||
if !ok || value == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case float32:
|
||||
return float64(typed), true
|
||||
case int:
|
||||
return float64(typed), true
|
||||
case int64:
|
||||
return float64(typed), true
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// currentOwnerGroupName 返回执行时当前业务员的用户组名称,无有效业务员时为空。
|
||||
func currentOwnerGroupName(groupNames map[uint]string, ownerID *uint) string {
|
||||
if ownerID == nil {
|
||||
return ""
|
||||
}
|
||||
return groupNames[*ownerID]
|
||||
}
|
||||
|
||||
// assetTypeName 返回资产类型的中文名称。
|
||||
func assetTypeName(assetType string) string {
|
||||
if assetType == constants.AssetTypeDevice {
|
||||
return "设备"
|
||||
}
|
||||
return "物联网卡"
|
||||
}
|
||||
|
||||
// formatPercentValue 输出保留两位小数的百分比。
|
||||
func formatPercentValue(value float64) string {
|
||||
return strconv.FormatFloat(value, 'f', 2, 64)
|
||||
}
|
||||
|
||||
// formatRemainingDays 按上海自然日推算剩余天数;无到期时间时输出空字符串。
|
||||
func formatRemainingDays(expiresAt *time.Time, now time.Time) string {
|
||||
if expiresAt == nil {
|
||||
return ""
|
||||
}
|
||||
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
localExpires := expiresAt.In(location)
|
||||
localNow := now.In(location)
|
||||
expiresDate := time.Date(localExpires.Year(), localExpires.Month(), localExpires.Day(), 0, 0, 0, 0, location)
|
||||
nowDate := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, location)
|
||||
days := int(expiresDate.Sub(nowDate).Hours() / 24)
|
||||
return strconv.Itoa(days)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -38,8 +39,12 @@ func (s *RefundDataSource) Count(ctx context.Context, params ExportParams) (int,
|
||||
// Headers 返回退款记录导出表头。
|
||||
func (s *RefundDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称", "原订单金额(元)",
|
||||
"实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款原因", "审批备注",
|
||||
"退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称",
|
||||
"当前退款套餐已用量(MB)", "当前退款套餐总量(MB)", "原订单金额(元)",
|
||||
"实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款方式",
|
||||
"冻结实收金额(元)", "渠道退款状态", "渠道退款流水号", "渠道退款金额(元)", "失败分类", "异常标记",
|
||||
"来源支付单号", "原支付渠道交易流水号", "线下退款处理流水号",
|
||||
"退款原因", "审批备注",
|
||||
"审批来源", "审批状态", "退款处理状态", "退款申请时间", "退款审批时间", "提交人", "退款凭证",
|
||||
}, nil
|
||||
}
|
||||
@@ -61,6 +66,16 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
|
||||
r.requested_refund_amount,
|
||||
r.approved_refund_amount,
|
||||
r.status,
|
||||
r.method,
|
||||
r.frozen_actual_received_amount,
|
||||
r.channel_refund_status,
|
||||
r.channel_refund_no,
|
||||
r.channel_refund_amount,
|
||||
r.source_payment_no,
|
||||
r.original_channel_trade_no,
|
||||
r.offline_settlement_no,
|
||||
r.failure_reason,
|
||||
r.anomaly_flag,
|
||||
r.refund_reason,
|
||||
r.remark,
|
||||
r.commission_deducted,
|
||||
@@ -71,6 +86,10 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
|
||||
o.total_amount AS original_amount,
|
||||
o.actual_paid_amount AS refundable_amount,
|
||||
COALESCE(pu.package_name, items.package_names, '') AS package_name,
|
||||
-- 当前退款套餐用量:与展示口径一致,按冻结套餐记录 → 订单主套餐 → 订单任一套餐
|
||||
-- 取唯一一条,且不按套餐状态过滤(退款后套餐已失效仍需展示其用量)。
|
||||
COALESCE(usage.data_usage_mb, 0) AS refund_package_used_mb,
|
||||
COALESCE(usage.data_limit_mb, 0) AS refund_package_total_mb,
|
||||
COALESCE(ac.username, '') AS submitter_name,
|
||||
ai.provider AS approval_provider,
|
||||
ai.status AS approval_status,
|
||||
@@ -87,6 +106,21 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
|
||||
FROM tb_order_item AS oi
|
||||
WHERE oi.order_id = r.order_id AND oi.deleted_at IS NULL
|
||||
) AS items ON TRUE`).
|
||||
Joins(`LEFT JOIN LATERAL (
|
||||
SELECT candidate.data_usage_mb, candidate.data_limit_mb
|
||||
FROM tb_package_usage AS candidate
|
||||
WHERE candidate.deleted_at IS NULL
|
||||
AND (
|
||||
(r.package_usage_id IS NOT NULL AND candidate.id = r.package_usage_id AND candidate.order_id = r.order_id)
|
||||
OR (candidate.order_id = r.order_id)
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN r.package_usage_id IS NOT NULL AND candidate.id = r.package_usage_id THEN 0
|
||||
WHEN candidate.master_usage_id IS NULL THEN 1
|
||||
ELSE 2 END,
|
||||
candidate.id ASC
|
||||
LIMIT 1
|
||||
) AS usage ON TRUE`).
|
||||
Order("r.id ASC").
|
||||
Limit(limit).
|
||||
Offset(offset)
|
||||
@@ -103,12 +137,24 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
|
||||
formatRefundAssetType(item.OrderType),
|
||||
item.AssetIdentifier,
|
||||
item.PackageName,
|
||||
strconv.FormatInt(item.RefundPackageUsedMB, 10),
|
||||
strconv.FormatInt(item.RefundPackageTotalMB, 10),
|
||||
formatOptionalMoneyYuan(item.OriginalAmount),
|
||||
formatMoneyYuan(item.ActualReceivedAmount),
|
||||
formatOptionalMoneyYuan(item.RefundableAmount),
|
||||
formatMoneyYuan(item.RequestedRefundAmount),
|
||||
formatOptionalMoneyYuan(item.ApprovedRefundAmount),
|
||||
constants.GetRefundStatusName(item.Status),
|
||||
constants.RefundMethodName(item.Method),
|
||||
formatMoneyYuan(item.FrozenActualReceivedAmount),
|
||||
constants.RefundChannelStatusName(item.ChannelRefundStatus),
|
||||
item.ChannelRefundNo,
|
||||
formatMoneyYuan(item.ChannelRefundAmount),
|
||||
item.SourcePaymentNo,
|
||||
item.OriginalChannelTradeNo,
|
||||
item.OfflineSettlementNo,
|
||||
constants.RefundFailureReasonName(item.FailureReason),
|
||||
formatRefundAnomalyFlag(item.AnomalyFlag),
|
||||
item.RefundReason,
|
||||
item.Remark,
|
||||
formatRefundApprovalSource(item.ApprovalProvider),
|
||||
@@ -145,28 +191,40 @@ func (s *RefundDataSource) applyFilters(query *gorm.DB, params ExportParams) *go
|
||||
}
|
||||
|
||||
type refundExportRow struct {
|
||||
RefundNo string `gorm:"column:refund_no"`
|
||||
ShopName string `gorm:"column:shop_name"`
|
||||
OrderNo string `gorm:"column:order_no"`
|
||||
OrderType string `gorm:"column:order_type"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier"`
|
||||
PackageName string `gorm:"column:package_name"`
|
||||
OriginalAmount *int64 `gorm:"column:original_amount"`
|
||||
ActualReceivedAmount int64 `gorm:"column:actual_received_amount"`
|
||||
RefundableAmount *int64 `gorm:"column:refundable_amount"`
|
||||
RequestedRefundAmount int64 `gorm:"column:requested_refund_amount"`
|
||||
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount"`
|
||||
Status int `gorm:"column:status"`
|
||||
RefundReason string `gorm:"column:refund_reason"`
|
||||
Remark string `gorm:"column:remark"`
|
||||
ApprovalProvider *string `gorm:"column:approval_provider"`
|
||||
ApprovalStatus *int `gorm:"column:approval_status"`
|
||||
CommissionDeducted bool `gorm:"column:commission_deducted"`
|
||||
AssetReset bool `gorm:"column:asset_reset"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
ProcessedAt *time.Time `gorm:"column:processed_at"`
|
||||
SubmitterName string `gorm:"column:submitter_name"`
|
||||
VoucherKeys string `gorm:"column:voucher_keys"`
|
||||
RefundNo string `gorm:"column:refund_no"`
|
||||
ShopName string `gorm:"column:shop_name"`
|
||||
OrderNo string `gorm:"column:order_no"`
|
||||
OrderType string `gorm:"column:order_type"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier"`
|
||||
PackageName string `gorm:"column:package_name"`
|
||||
RefundPackageUsedMB int64 `gorm:"column:refund_package_used_mb"`
|
||||
RefundPackageTotalMB int64 `gorm:"column:refund_package_total_mb"`
|
||||
OriginalAmount *int64 `gorm:"column:original_amount"`
|
||||
ActualReceivedAmount int64 `gorm:"column:actual_received_amount"`
|
||||
RefundableAmount *int64 `gorm:"column:refundable_amount"`
|
||||
RequestedRefundAmount int64 `gorm:"column:requested_refund_amount"`
|
||||
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount"`
|
||||
Status int `gorm:"column:status"`
|
||||
Method string `gorm:"column:method"`
|
||||
FrozenActualReceivedAmount int64 `gorm:"column:frozen_actual_received_amount"`
|
||||
ChannelRefundStatus int `gorm:"column:channel_refund_status"`
|
||||
ChannelRefundNo string `gorm:"column:channel_refund_no"`
|
||||
ChannelRefundAmount int64 `gorm:"column:channel_refund_amount"`
|
||||
SourcePaymentNo string `gorm:"column:source_payment_no"`
|
||||
OriginalChannelTradeNo string `gorm:"column:original_channel_trade_no"`
|
||||
OfflineSettlementNo string `gorm:"column:offline_settlement_no"`
|
||||
FailureReason string `gorm:"column:failure_reason"`
|
||||
AnomalyFlag int `gorm:"column:anomaly_flag"`
|
||||
RefundReason string `gorm:"column:refund_reason"`
|
||||
Remark string `gorm:"column:remark"`
|
||||
ApprovalProvider *string `gorm:"column:approval_provider"`
|
||||
ApprovalStatus *int `gorm:"column:approval_status"`
|
||||
CommissionDeducted bool `gorm:"column:commission_deducted"`
|
||||
AssetReset bool `gorm:"column:asset_reset"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
ProcessedAt *time.Time `gorm:"column:processed_at"`
|
||||
SubmitterName string `gorm:"column:submitter_name"`
|
||||
VoucherKeys string `gorm:"column:voucher_keys"`
|
||||
}
|
||||
|
||||
func formatRefundAssetType(orderType string) string {
|
||||
@@ -200,7 +258,19 @@ func formatRefundProcessingStatus(status int, commissionDeducted, assetReset boo
|
||||
return "已完成"
|
||||
}
|
||||
return "处理中"
|
||||
case model.RefundStatusChannelProcessing:
|
||||
return "原路退款处理中"
|
||||
case model.RefundStatusChannelFailed:
|
||||
return "原路退款失败待人工处理"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
// formatRefundAnomalyFlag 将异常标记转为导出用中文描述。
|
||||
func formatRefundAnomalyFlag(flag int) string {
|
||||
if flag == 0 {
|
||||
return "无异常"
|
||||
}
|
||||
return "有异常"
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
|
||||
NewAgentRechargeDataSource(db),
|
||||
NewRefundDataSource(db),
|
||||
NewExchangeDataSource(db),
|
||||
NewCommissionRecordDataSource(db),
|
||||
NewPackageTrafficAlertDataSource(db),
|
||||
NewExpiringAssetDataSource(db),
|
||||
NewOperationsActivationDataSource(db),
|
||||
NewOperationsRenewalDataSource(db),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -71,7 +76,12 @@ func IsSupportedScene(scene string) bool {
|
||||
constants.ExportTaskSceneAgentWalletTransaction,
|
||||
constants.ExportTaskSceneAgentRecharge,
|
||||
constants.ExportTaskSceneRefund,
|
||||
constants.ExportTaskSceneExchange:
|
||||
constants.ExportTaskSceneExchange,
|
||||
constants.ExportTaskSceneCommissionRecord,
|
||||
constants.ExportTaskScenePackageTrafficAlert,
|
||||
constants.ExportTaskSceneExpiringAsset,
|
||||
constants.ExportTaskSceneOperationsActivation,
|
||||
constants.ExportTaskSceneOperationsRenewal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
125
internal/exporter/time_filters.go
Normal file
125
internal/exporter/time_filters.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
)
|
||||
|
||||
// 受影响导出场景统一使用的冻结时间筛选键。
|
||||
const (
|
||||
exportTimeFilterStartKey = "start_time"
|
||||
exportTimeFilterEndKey = "end_time"
|
||||
)
|
||||
|
||||
// timeFilterScenes 是纳入统一时间筛选契约的导出场景。
|
||||
// 未列入的场景保持既有宽松解析,见设计文档「已知差异登记」。
|
||||
// 达量预警的列表与创建入口仍接受全局宽松时间格式并由创建期归一为 UTC RFC3339 秒级串,
|
||||
// 但执行期同样只按冻结值严格解析,冻结值非法时任务落失败。
|
||||
var timeFilterScenes = map[string]struct{}{
|
||||
constants.ExportTaskSceneExchange: {},
|
||||
constants.ExportTaskSceneAgentRecharge: {},
|
||||
constants.ExportTaskSceneOrder: {},
|
||||
constants.ExportTaskSceneCommissionRecord: {},
|
||||
constants.ExportTaskSceneExpiringAsset: {},
|
||||
constants.ExportTaskScenePackageTrafficAlert: {},
|
||||
// 运营报表的两个导出场景在创建期冻结 start_time/end_time 与分组维度,执行期只按冻结值严格解析。
|
||||
constants.ExportTaskSceneOperationsActivation: {},
|
||||
constants.ExportTaskSceneOperationsRenewal: {},
|
||||
}
|
||||
|
||||
// legacyTimeFilterKeys 是受影响场景必须拒绝的旧时间筛选键。
|
||||
// 键存在且非空时一律拒绝,避免旧前端静默丢条件后导出全量数据。
|
||||
var legacyTimeFilterKeys = map[string][]string{
|
||||
constants.ExportTaskSceneExchange: {"created_at_start", "created_at_end"},
|
||||
constants.ExportTaskSceneAgentRecharge: {"start_date", "end_date"},
|
||||
}
|
||||
|
||||
// NormalizeTaskTimeFilters 在创建导出任务时按场景校验并规范化时间边界。
|
||||
// 规范化结果为 UTC RFC3339 秒级字符串,随筛选快照一并冻结;
|
||||
// 非法格式、旧参数键与开始晚于结束一律在创建期拒绝。
|
||||
func NormalizeTaskTimeFilters(scene string, query map[string]interface{}) error {
|
||||
if !isTimeFilterScene(scene) {
|
||||
return nil
|
||||
}
|
||||
filters, ok := query["filters"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := rejectLegacyTimeFilterKeys(scene, filters); err != nil {
|
||||
return err
|
||||
}
|
||||
start, end, err := parseFrozenTimeRange(filters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if start != nil {
|
||||
filters[exportTimeFilterStartKey] = utils.FormatTimeFilterValue(*start)
|
||||
}
|
||||
if end != nil {
|
||||
filters[exportTimeFilterEndKey] = utils.FormatTimeFilterValue(*end)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTaskTimeFilters 校验导出任务筛选快照中的时间边界,创建期与执行期共用。
|
||||
// 非法值(含变更前遗留任务的旧格式冻结值)返回错误,由调用方把任务落为失败并写安全失败摘要,
|
||||
// 不得忽略该条件后放行全量数据。
|
||||
func ValidateTaskTimeFilters(scene string, filters map[string]any) error {
|
||||
if !isTimeFilterScene(scene) {
|
||||
return nil
|
||||
}
|
||||
if err := rejectLegacyTimeFilterKeys(scene, filters); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := parseFrozenTimeRange(filters)
|
||||
return err
|
||||
}
|
||||
|
||||
// strictTimeRange 解析任务冻结的 start_time/end_time 闭区间,供受影响场景构造执行期筛选。
|
||||
// 冻结值必须是创建期规范化后的带时区 RFC3339 秒级字符串,任一端缺省表示该端不限。
|
||||
func strictTimeRange(filters map[string]any) (*time.Time, *time.Time, error) {
|
||||
return parseFrozenTimeRange(filters)
|
||||
}
|
||||
|
||||
func isTimeFilterScene(scene string) bool {
|
||||
_, ok := timeFilterScenes[scene]
|
||||
return ok
|
||||
}
|
||||
|
||||
func parseFrozenTimeRange(filters map[string]any) (*time.Time, *time.Time, error) {
|
||||
start, err := frozenTimeFilterValue(filters, exportTimeFilterStartKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
end, err := frozenTimeFilterValue(filters, exportTimeFilterEndKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return utils.ParseTimeRange(start, end)
|
||||
}
|
||||
|
||||
func frozenTimeFilterValue(filters map[string]any, key string) (string, error) {
|
||||
value, ok := filters[key]
|
||||
if !ok || value == nil {
|
||||
return "", nil
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return "", utils.TimeFilterFormatError(key)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func rejectLegacyTimeFilterKeys(scene string, filters map[string]any) error {
|
||||
for _, key := range legacyTimeFilterKeys[scene] {
|
||||
text, exists := filters[key].(string)
|
||||
if !exists || text == "" {
|
||||
continue
|
||||
}
|
||||
return errors.New(errors.CodeInvalidParam, key+" 已废弃,请改用 start_time 与 end_time")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
)
|
||||
|
||||
// AgentRechargeHandler 代理预充值 Handler
|
||||
@@ -190,14 +191,21 @@ func (h *AgentRechargeHandler) PaymentVoucherOCR(c *fiber.Ctx) error {
|
||||
// GET /api/admin/agent-recharges
|
||||
func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {
|
||||
var req dto.AgentRechargeListRequest
|
||||
if err := rejectLegacyTimeParams(c, "start_date", "end_date"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
list, total, err := h.service.List(c.UserContext(), &req)
|
||||
list, total, err := h.service.List(c.UserContext(), &req, startTime, endTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"time"
|
||||
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/hibiken/asynq"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
|
||||
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
@@ -23,6 +25,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -36,10 +39,17 @@ type AssetHandler struct {
|
||||
iotCardStopResume *iotCardService.StopResumeService
|
||||
assetPolling *pollingSvc.AssetPollingService
|
||||
assetLifecycleService AssetLifecycleService
|
||||
exchangeTraceQuery AssetExchangeTraceResolver
|
||||
detailProjection AssetDetailProjectionResolver
|
||||
observationSeries cardObservationApp.BestEffortSeriesDispatcher
|
||||
packageExpiryQuery *packageExpiryQuery.Query
|
||||
packageExpiryTrigger func(context.Context) error
|
||||
assetExportService AssetExportTaskCreator
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
// AssetExportTaskCreator 定义创建导出任务的用例,供临期导出受控入口调用。
|
||||
type AssetExportTaskCreator interface {
|
||||
CreateTask(ctx context.Context, req *dto.CreateExportTaskRequest) (*dto.CreateExportTaskResponse, error)
|
||||
}
|
||||
|
||||
// SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。
|
||||
@@ -52,6 +62,16 @@ func (h *AssetHandler) SetPackageExpiryQuery(query *packageExpiryQuery.Query) {
|
||||
h.packageExpiryQuery = query
|
||||
}
|
||||
|
||||
// SetExportTaskService 注入导出任务创建用例,供临期资产导出受控入口使用。
|
||||
func (h *AssetHandler) SetExportTaskService(creator AssetExportTaskCreator) {
|
||||
h.assetExportService = creator
|
||||
}
|
||||
|
||||
// SetValidator 注入请求参数校验器,供新增的受控入口按 DTO 规则校验并返回字段级中文提示。
|
||||
func (h *AssetHandler) SetValidator(v *validator.Validate) {
|
||||
h.validator = v
|
||||
}
|
||||
|
||||
// SetPackageExpiryQueue 注入套餐临期提醒任务队列。
|
||||
func (h *AssetHandler) SetPackageExpiryQueue(client *queue.Client) {
|
||||
if client == nil {
|
||||
@@ -69,9 +89,13 @@ func (h *AssetHandler) SetPackageExpiryQueue(client *queue.Client) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssetExchangeTraceResolver 定义资产详情换货链路读取用例。
|
||||
type AssetExchangeTraceResolver interface {
|
||||
// AssetDetailProjectionResolver 定义资产详情的附加只读投影读取用例:
|
||||
// 换货链路与优先轮询状态。两者都是只读查询,不修改任何业务状态。
|
||||
type AssetDetailProjectionResolver interface {
|
||||
// Resolve 查询资产单节点换货链路。
|
||||
Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error)
|
||||
// ResolvePriorityPolling 查询资产的优先轮询状态投影(只读,不改变队列)。
|
||||
ResolvePriorityPolling(ctx context.Context, assetType string, assetID uint) (*dto.AssetPriorityPollingProjection, error)
|
||||
}
|
||||
|
||||
// NewAssetHandler 创建资产管理处理器
|
||||
@@ -82,16 +106,16 @@ func NewAssetHandler(
|
||||
iotCardSvc *iotCardService.Service,
|
||||
iotCardStopResume *iotCardService.StopResumeService,
|
||||
assetPolling *pollingSvc.AssetPollingService,
|
||||
exchangeTraceQuery AssetExchangeTraceResolver,
|
||||
detailProjection AssetDetailProjectionResolver,
|
||||
) *AssetHandler {
|
||||
return &AssetHandler{
|
||||
assetService: assetSvc,
|
||||
assetAuditService: assetAuditService,
|
||||
deviceService: deviceSvc,
|
||||
iotCardService: iotCardSvc,
|
||||
iotCardStopResume: iotCardStopResume,
|
||||
assetPolling: assetPolling,
|
||||
exchangeTraceQuery: exchangeTraceQuery,
|
||||
assetService: assetSvc,
|
||||
assetAuditService: assetAuditService,
|
||||
deviceService: deviceSvc,
|
||||
iotCardService: iotCardSvc,
|
||||
iotCardStopResume: iotCardStopResume,
|
||||
assetPolling: assetPolling,
|
||||
detailProjection: detailProjection,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +148,14 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||
return err
|
||||
}
|
||||
result.ExchangeTrace = &dto.AssetExchangeTrace{}
|
||||
if h.exchangeTraceQuery != nil {
|
||||
result.ExchangeTrace, err = h.exchangeTraceQuery.Resolve(c.UserContext(), result.AssetType, result.AssetID)
|
||||
result.PriorityPolling = &dto.AssetPriorityPollingProjection{}
|
||||
if h.detailProjection != nil {
|
||||
result.ExchangeTrace, err = h.detailProjection.Resolve(c.UserContext(), result.AssetType, result.AssetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 优先轮询投影只读:读取不改变队列状态,也不触发任何上游调用。
|
||||
result.PriorityPolling, err = h.detailProjection.ResolvePriorityPolling(c.UserContext(), result.AssetType, result.AssetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -138,6 +168,9 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||
// GET /api/admin/expiring-assets
|
||||
func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
|
||||
var request dto.ExpiringAssetListRequest
|
||||
if err := rejectLegacyTimeParams(c, "expires_from", "expires_to"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
logger.GetAppLogger().Warn("临期资产列表参数解析失败",
|
||||
zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err))
|
||||
@@ -146,7 +179,11 @@ func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
|
||||
if h.packageExpiryQuery == nil {
|
||||
return errors.New(errors.CodeInternalError, "套餐临期查询未配置")
|
||||
}
|
||||
result, err := h.packageExpiryQuery.List(c.UserContext(), request)
|
||||
startTime, endTime, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := h.packageExpiryQuery.List(c.UserContext(), request, startTime, endTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -155,6 +192,67 @@ func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ExportExpiring 创建临期资产异步导出任务。
|
||||
// POST /api/admin/expiring-assets/export
|
||||
// 只暴露受控入口;导出任务创建时冻结当页全部筛选、时间边界、操作者与可见店铺范围。
|
||||
func (h *AssetHandler) ExportExpiring(c *fiber.Ctx) error {
|
||||
var req dto.ExportExpiringAssetRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, validation.Message("导出临期资产参数不合法", &req, err))
|
||||
}
|
||||
if h.assetExportService == nil {
|
||||
return errors.New(errors.CodeInternalError, "导出任务服务未配置")
|
||||
}
|
||||
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createRequest := dto.CreateExportTaskRequest{
|
||||
Scene: constants.ExportTaskSceneExpiringAsset,
|
||||
Format: req.Format,
|
||||
Query: map[string]interface{}{"filters": expiringAssetExportFilters(req, startTime, endTime)},
|
||||
}
|
||||
result, err := h.assetExportService.CreateTask(c.UserContext(), &createRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// expiringAssetExportFilters 把临期导出请求转换为导出任务的筛选快照。
|
||||
// 筛选集合与临期列表一致;时间边界在创建时规范化为 UTC RFC3339 秒级字符串后冻结。
|
||||
func expiringAssetExportFilters(req dto.ExportExpiringAssetRequest, startTime, endTime *time.Time) map[string]interface{} {
|
||||
filters := make(map[string]interface{})
|
||||
if req.AssetType != "" {
|
||||
filters["asset_type"] = req.AssetType
|
||||
}
|
||||
if req.Keyword != "" {
|
||||
filters["keyword"] = req.Keyword
|
||||
}
|
||||
if req.ShopID != nil {
|
||||
filters["shop_id"] = *req.ShopID
|
||||
}
|
||||
if req.PackageID != nil {
|
||||
filters["package_id"] = *req.PackageID
|
||||
}
|
||||
if req.DaysMin != nil {
|
||||
filters["days_min"] = *req.DaysMin
|
||||
}
|
||||
if req.DaysMax != nil {
|
||||
filters["days_max"] = *req.DaysMax
|
||||
}
|
||||
if startTime != nil {
|
||||
filters["start_time"] = utils.FormatTimeFilterValue(*startTime)
|
||||
}
|
||||
if endTime != nil {
|
||||
filters["end_time"] = utils.FormatTimeFilterValue(*endTime)
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
// TriggerPackageExpiryReminder 手动提交每日临期提醒扫描任务。
|
||||
// POST /api/admin/expiring-assets/reminder-scan
|
||||
func (h *AssetHandler) TriggerPackageExpiryReminder(c *fiber.Ctx) error {
|
||||
@@ -648,7 +746,7 @@ func (h *AssetHandler) resolveAssetPackageUsagePath(c *fiber.Ctx) (*dto.AssetRes
|
||||
}
|
||||
|
||||
func ensureAssetPackageAdjuster(c *fiber.Ctx) error {
|
||||
if middleware.GetUserIDFromContext(c.UserContext()) != 41 && middleware.GetUserIDFromContext(c.UserContext()) != 127 {
|
||||
if middleware.GetUserIDFromContext(c.UserContext()) != 1124 && middleware.GetUserIDFromContext(c.UserContext()) != 127 {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
)
|
||||
|
||||
type AssetAllocationRecordHandler struct {
|
||||
@@ -21,9 +22,16 @@ func NewAssetAllocationRecordHandler(service *assetAllocationRecordService.Servi
|
||||
|
||||
func (h *AssetAllocationRecordHandler) List(c *fiber.Ctx) error {
|
||||
var req dto.ListAssetAllocationRecordRequest
|
||||
if err := rejectLegacyTimeParams(c, "created_at_start", "created_at_end"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := c.UserContext()
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
@@ -35,7 +43,7 @@ func (h *AssetAllocationRecordHandler) List(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
result, err := h.service.List(ctx, &req, userShopID)
|
||||
result, err := h.service.List(ctx, &req, startTime, endTime, userShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
82
internal/handler/admin/asset_auto_renewal.go
Normal file
82
internal/handler/admin/asset_auto_renewal.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
|
||||
"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/response"
|
||||
)
|
||||
|
||||
// AssetAutoRenewalConfigHandler 资产钱包自动续费配置 Handler。
|
||||
//
|
||||
// 路由组已有「仅超级管理员与平台账号」门禁,应用层仍会复核账号类型(ENG-AUTHZ-001),
|
||||
// 因此代理、企业与个人客户即使绕过路由也无任何入口。
|
||||
type AssetAutoRenewalConfigHandler struct {
|
||||
service *assetAutoRenewalApp.Service
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
// NewAssetAutoRenewalConfigHandler 创建资产钱包自动续费配置 Handler。
|
||||
func NewAssetAutoRenewalConfigHandler(service *assetAutoRenewalApp.Service, validator *validator.Validate) *AssetAutoRenewalConfigHandler {
|
||||
return &AssetAutoRenewalConfigHandler{service: service, validator: validator}
|
||||
}
|
||||
|
||||
// GetConfig 读取资产钱包自动续费配置。
|
||||
// GET /api/admin/asset-auto-renewal-config
|
||||
func (h *AssetAutoRenewalConfigHandler) GetConfig(c *fiber.Ctx) error {
|
||||
config, err := h.service.GetConfig(c.UserContext())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, toAssetAutoRenewalConfigResponse(config))
|
||||
}
|
||||
|
||||
// UpdateConfig 保存资产钱包自动续费配置。
|
||||
// PUT /api/admin/asset-auto-renewal-config
|
||||
func (h *AssetAutoRenewalConfigHandler) UpdateConfig(c *fiber.Ctx) error {
|
||||
var req dto.UpdateAssetAutoRenewalConfigRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, validation.Message("保存自动续费配置参数不合法", &req, err))
|
||||
}
|
||||
config, err := h.service.SaveConfig(c.UserContext(), assetAutoRenewalApp.ConfigRequest{
|
||||
Enabled: req.Enabled,
|
||||
Scope: req.Scope,
|
||||
PackageIDs: req.PackageIDs,
|
||||
DaysBeforeExpiry: req.DaysBeforeExpiry,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, toAssetAutoRenewalConfigResponse(config))
|
||||
}
|
||||
|
||||
// toAssetAutoRenewalConfigResponse 组装配置响应,枚举附加中文名称字段(ENG-DTO-001)。
|
||||
func toAssetAutoRenewalConfigResponse(config *assetAutoRenewalApp.ConfigView) dto.AssetAutoRenewalConfigResponse {
|
||||
packageIDs := config.PackageIDs
|
||||
if packageIDs == nil {
|
||||
packageIDs = []uint{}
|
||||
}
|
||||
enabledName := "关闭"
|
||||
if config.Enabled == constants.AssetAutoRenewalConfigEnabledOn {
|
||||
enabledName = "开启"
|
||||
}
|
||||
return dto.AssetAutoRenewalConfigResponse{
|
||||
Enabled: config.Enabled,
|
||||
Scope: config.Scope,
|
||||
PackageIDs: packageIDs,
|
||||
DaysBeforeExpiry: config.DaysBeforeExpiry,
|
||||
ConfigVersion: config.ConfigVersion,
|
||||
ScopeName: constants.GetAssetAutoRenewalScopeName(config.Scope),
|
||||
EnabledName: enabledName,
|
||||
Updater: config.Updater,
|
||||
UpdatedAt: config.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
enterpriseCardService "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
)
|
||||
|
||||
type AuthorizationHandler struct {
|
||||
@@ -25,14 +26,18 @@ func (h *AuthorizationHandler) List(c *fiber.Ctx) error {
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := h.service.ListRecords(c.UserContext(), enterpriseCardService.ListRecordsRequest{
|
||||
EnterpriseID: req.EnterpriseID,
|
||||
ICCID: req.ICCID,
|
||||
AuthorizerType: req.AuthorizerType,
|
||||
Status: req.Status,
|
||||
StartTime: req.StartTime,
|
||||
EndTime: req.EndTime,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
})
|
||||
|
||||
231
internal/handler/admin/business_user_group.go
Normal file
231
internal/handler/admin/business_user_group.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
businessusergroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup"
|
||||
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
businessusergroupQuery "github.com/break/junhong_cmp_fiber/internal/query/businessusergroup"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// BusinessUserGroupHandler 业务用户组与店铺负责人批量交接处理器。
|
||||
type BusinessUserGroupHandler struct {
|
||||
service *businessusergroupApp.Service
|
||||
query *businessusergroupQuery.Query
|
||||
batchService *shopApp.BatchBusinessOwnerService
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
// NewBusinessUserGroupHandler 创建业务用户组处理器。
|
||||
func NewBusinessUserGroupHandler(service *businessusergroupApp.Service, validator *validator.Validate) *BusinessUserGroupHandler {
|
||||
return &BusinessUserGroupHandler{service: service, validator: validator}
|
||||
}
|
||||
|
||||
// SetQuery 注入业务用户组读取投影。
|
||||
func (h *BusinessUserGroupHandler) SetQuery(query *businessusergroupQuery.Query) {
|
||||
h.query = query
|
||||
}
|
||||
|
||||
// SetBatchService 注入店铺负责人批量交接事务脚本。
|
||||
func (h *BusinessUserGroupHandler) SetBatchService(service *shopApp.BatchBusinessOwnerService) {
|
||||
h.batchService = service
|
||||
}
|
||||
|
||||
// Create 创建业务用户组。
|
||||
// POST /api/admin/business-user-groups
|
||||
func (h *BusinessUserGroupHandler) Create(c *fiber.Ctx) error {
|
||||
if err := requirePlatformManagement(c); err != nil {
|
||||
return err
|
||||
}
|
||||
var request dto.CreateBusinessUserGroupRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validate(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.service.Create(c.UserContext(), &request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// List 查询业务用户组列表。
|
||||
// GET /api/admin/business-user-groups
|
||||
func (h *BusinessUserGroupHandler) List(c *fiber.Ctx) error {
|
||||
if err := requirePlatformManagement(c); err != nil {
|
||||
return err
|
||||
}
|
||||
var request dto.BusinessUserGroupListRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validate(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if h.query == nil {
|
||||
return errors.New(errors.CodeInternalError, "业务用户组查询尚未配置")
|
||||
}
|
||||
result, err := h.query.List(c.UserContext(), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
|
||||
}
|
||||
|
||||
// Detail 查询业务用户组详情。
|
||||
// GET /api/admin/business-user-groups/:id
|
||||
func (h *BusinessUserGroupHandler) Detail(c *fiber.Ctx) error {
|
||||
if err := requirePlatformManagement(c); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if h.query == nil {
|
||||
return errors.New(errors.CodeInternalError, "业务用户组查询尚未配置")
|
||||
}
|
||||
result, err := h.query.Detail(c.UserContext(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Update 更新业务用户组的名称、业务线、排序、启停与备注;编码不可修改。
|
||||
// PUT /api/admin/business-user-groups/:id
|
||||
func (h *BusinessUserGroupHandler) Update(c *fiber.Ctx) error {
|
||||
if err := requirePlatformManagement(c); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var request dto.UpdateBusinessUserGroupRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validate(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.service.Update(c.UserContext(), id, &request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Delete 二次确认删除无成员的业务用户组。
|
||||
// DELETE /api/admin/business-user-groups/:id
|
||||
func (h *BusinessUserGroupHandler) Delete(c *fiber.Ctx) error {
|
||||
if err := requirePlatformManagement(c); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var request dto.BusinessUserGroupDeleteRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if !request.Confirm {
|
||||
return errors.New(errors.CodeInvalidParam, "删除业务用户组必须二次确认")
|
||||
}
|
||||
if err := h.service.Delete(c.UserContext(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
// SetMembers 批量设置平台用户的业务用户组归属,直接替换原归属。
|
||||
// PUT /api/admin/business-user-groups/:id/members
|
||||
func (h *BusinessUserGroupHandler) SetMembers(c *fiber.Ctx) error {
|
||||
if err := requirePlatformManagement(c); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var request dto.SetBusinessUserGroupMembersRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validate(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.service.SetMembers(c.UserContext(), id, &request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// ClearMembers 批量清空平台用户的业务用户组归属。
|
||||
// DELETE /api/admin/business-user-groups/members
|
||||
func (h *BusinessUserGroupHandler) ClearMembers(c *fiber.Ctx) error {
|
||||
if err := requirePlatformManagement(c); err != nil {
|
||||
return err
|
||||
}
|
||||
var request dto.ClearBusinessUserGroupMembersRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validate(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.service.ClearMembers(c.UserContext(), &request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// BatchUpdateShopBusinessOwner 勾选店铺批量设置或清空平台业务员负责人。
|
||||
// PUT /api/admin/shops/business-owner/batch
|
||||
func (h *BusinessUserGroupHandler) BatchUpdateShopBusinessOwner(c *fiber.Ctx) error {
|
||||
if err := requirePlatformManagement(c); err != nil {
|
||||
return err
|
||||
}
|
||||
var request dto.BatchUpdateShopBusinessOwnerRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validate(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if h.batchService == nil {
|
||||
return errors.New(errors.CodeInternalError, "店铺负责人批量交接服务尚未配置")
|
||||
}
|
||||
result, err := h.batchService.Execute(c.UserContext(), &request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *BusinessUserGroupHandler) validate(request any) error {
|
||||
if h.validator == nil {
|
||||
return errors.New(errors.CodeInternalError)
|
||||
}
|
||||
return h.validator.Struct(request)
|
||||
}
|
||||
|
||||
// requirePlatformManagement 校验调用者仅限超级管理员与平台账号,代理与企业统一返回 403。
|
||||
func requirePlatformManagement(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user