From 6893e21f07b2501acabbfccf0dd5a3d214f6bf08 Mon Sep 17 00:00:00 2001 From: MiFaZhan Date: Thu, 29 Oct 2026 14:52:43 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20=E5=90=8C=E6=AD=A5=20v1.0.4=20=E5=BC=80?= =?UTF-8?q?=E6=BA=90=E4=BB=A3=E7=A0=81=E5=B9=B6=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 1 + README.md | 125 +-- build/Dockerfile | 16 +- ...{build_config.yaml => config.example.yaml} | 28 +- config/config_default.yaml | 17 +- docs/角色权限任务切换.md | 24 + go.mod | 4 +- hack/config.example.yaml | 17 + hack/config.yaml | 17 - internal/batch/batch.go | 5 + internal/batch/batch_test.go | 58 ++ internal/batch/runtime.go | 16 +- internal/client/upstream/apn_readiness.go | 154 ++++ .../client/upstream/apn_readiness_test.go | 138 +++ internal/client/upstream/client.go | 176 +++- internal/client/upstream/client_test.go | 394 ++++++++ .../client/upstream/client_timeout_test.go | 49 + internal/cmd/cmd.go | 46 + internal/consts/batch_submit.go | 5 +- internal/consts/lifecycle.go | 14 +- internal/consts/queue.go | 3 + internal/consts/role_sync.go | 11 + .../consumer/rabbitmq/batch_durable_test.go | 247 +++++ .../consumer/rabbitmq/channel_refresh_test.go | 2 +- internal/consumer/rabbitmq/consumer.go | 14 + internal/consumer/rabbitmq/consumer_test.go | 15 +- internal/consumer/rabbitmq/data_review.go | 46 + .../consumer/rabbitmq/traffic_usage_retry.go | 4 +- .../rabbitmq/traffic_usage_retry_test.go | 2 +- internal/dao/internal/sys_config.go | 101 +++ .../dao/internal/sys_permission_revision.go | 83 ++ internal/dao/internal/sys_role.go | 119 +++ internal/dao/internal/sys_role_dept.go | 83 ++ internal/dao/internal/sys_role_menu.go | 85 ++ internal/dao/internal/sys_role_sync_task.go | 111 +++ internal/dao/internal/sys_user_role.go | 83 ++ internal/dao/sys_config.go | 22 + internal/dao/sys_permission_revision.go | 22 + internal/dao/sys_role.go | 22 + internal/dao/sys_role_dept.go | 22 + internal/dao/sys_role_menu.go | 22 + internal/dao/sys_role_sync_task.go | 22 + internal/dao/sys_user_role.go | 22 + internal/fullsync/supply/README.md | 16 +- internal/library/batchfields/limits.go | 50 ++ internal/library/batchfields/limits_test.go | 38 + internal/library/rabbitmq/rabbitmq.go | 30 +- internal/library/rabbitmq/rabbitmq_test.go | 19 + internal/library/rolepolicy/template.go | 151 ++++ internal/library/rolesync/publish.go | 12 + internal/library/tdengine/config_test.go | 61 ++ internal/model/agent_role_sync.go | 22 + internal/model/data_review.go | 8 + internal/model/do/sys_config.go | 27 + internal/model/do/sys_permission_revision.go | 18 + internal/model/do/sys_role.go | 36 + internal/model/do/sys_role_dept.go | 17 + internal/model/do/sys_role_menu.go | 18 + internal/model/do/sys_role_sync_task.go | 32 + internal/model/do/sys_user_role.go | 17 + internal/model/entity/sys_config.go | 25 + .../model/entity/sys_permission_revision.go | 16 + internal/model/entity/sys_role.go | 34 + internal/model/entity/sys_role_dept.go | 12 + internal/model/entity/sys_role_menu.go | 13 + internal/model/entity/sys_role_sync_task.go | 30 + internal/model/entity/sys_user_role.go | 12 + internal/model/upstream_chan.go | 12 +- .../dailyusage/channel_producer_test.go | 2 +- .../dailyusage/channel_scanner_test.go | 2 +- internal/producer/imei/lease_io_test.go | 4 +- .../monthlyusage/channel_producer_test.go | 2 +- .../monthlyusage/channel_scanner_test.go | 2 +- .../monthlyusage/supply_observation.go | 2 +- .../monthlyusage/supply_observation_test.go | 2 +- .../channel_producer_test.go | 2 +- .../channel_scanner_test.go | 2 +- internal/queue/zset/run.go | 2 +- internal/repository/batch/repository.go | 8 +- internal/repository/batch/repository_test.go | 57 ++ internal/repository/cancel/repository.go | 2 +- internal/repository/data/repository.go | 164 +++- .../data/repository_accuracy_test.go | 237 +++++ internal/repository/data/sub_batch.go | 841 ++++++++++++++++++ .../data/sub_batch_concurrency_test.go | 523 +++++++++++ .../repository/data/sub_batch_mysql_test.go | 232 +++++ .../repository/data/sub_batch_order_test.go | 123 +++ internal/repository/data/sub_batch_test.go | 781 ++++++++++++++++ internal/repository/imei/repository_test.go | 22 +- internal/repository/inbound/repository.go | 61 +- .../repository/inbound/repository_test.go | 86 +- internal/repository/lifecycle/failure_text.go | 70 ++ .../repository/lifecycle/failure_text_test.go | 73 ++ internal/repository/lifecycle/repository.go | 6 +- internal/repository/lifecycle/restart.go | 8 +- .../renewchange/change_order_repository.go | 4 +- .../repository/trafficcutoff/repository.go | 18 +- .../repository/trafficusage/routing_test.go | 84 ++ internal/repository/unsub/repository.go | 2 +- internal/service/allocate/atomic_parent.go | 2 +- .../service/allocate/detail_update_test.go | 8 +- internal/service/allocate/executor.go | 87 +- internal/service/allocate/executor_test.go | 61 +- internal/service/allocate/repository.go | 29 +- internal/service/allocate/types.go | 12 + internal/service/batchresult/generator.go | 39 +- .../service/batchresult/generator_test.go | 178 +++- internal/service/cancel/executor.go | 10 +- internal/service/data/async.go | 431 +++++++++ internal/service/data/async_fairness_test.go | 126 +++ .../service/data/async_manual_sync_test.go | 201 +++++ internal/service/data/async_test.go | 650 ++++++++++++++ internal/service/data/executor.go | 112 ++- .../service/data/executor_accuracy_test.go | 253 ++++++ internal/service/data/executor_test.go | 21 +- internal/service/data/types.go | 66 +- internal/service/imei/handler_test.go | 4 +- internal/service/inbound/executor.go | 43 +- internal/service/inbound/executor_test.go | 180 +++- internal/service/inbound/rate_limiter.go | 2 +- internal/service/lifecycle/executor.go | 21 +- internal/service/lifecycle/executor_test.go | 21 +- internal/service/rolesync/role.go | 208 +++++ internal/service/rolesync/role_test.go | 283 ++++++ internal/service/rolesync/runtime.go | 109 +++ internal/service/rolesync/runtime_test.go | 161 ++++ internal/service/rolesync/template.go | 185 ++++ .../rolesync/template_hierarchy_test.go | 97 ++ internal/service/trafficcutoff/executor.go | 26 +- .../service/trafficcutoff/executor_test.go | 2 +- .../trafficcutoffconfig/executor_test.go | 4 +- internal/service/trafficusage/service.go | 2 +- internal/service/unsub/executor.go | 10 +- .../service/warningconfig/executor_test.go | 2 +- internal/service/workorder/notice.go | 6 +- internal/worker/worker.go | 18 + 136 files changed, 9851 insertions(+), 436 deletions(-) rename build/{build_config.yaml => config.example.yaml} (83%) create mode 100644 docs/角色权限任务切换.md create mode 100644 hack/config.example.yaml delete mode 100644 hack/config.yaml create mode 100644 internal/client/upstream/apn_readiness.go create mode 100644 internal/client/upstream/apn_readiness_test.go create mode 100644 internal/client/upstream/client_timeout_test.go create mode 100644 internal/consts/role_sync.go create mode 100644 internal/consumer/rabbitmq/batch_durable_test.go create mode 100644 internal/consumer/rabbitmq/data_review.go create mode 100644 internal/dao/internal/sys_config.go create mode 100644 internal/dao/internal/sys_permission_revision.go create mode 100644 internal/dao/internal/sys_role.go create mode 100644 internal/dao/internal/sys_role_dept.go create mode 100644 internal/dao/internal/sys_role_menu.go create mode 100644 internal/dao/internal/sys_role_sync_task.go create mode 100644 internal/dao/internal/sys_user_role.go create mode 100644 internal/dao/sys_config.go create mode 100644 internal/dao/sys_permission_revision.go create mode 100644 internal/dao/sys_role.go create mode 100644 internal/dao/sys_role_dept.go create mode 100644 internal/dao/sys_role_menu.go create mode 100644 internal/dao/sys_role_sync_task.go create mode 100644 internal/dao/sys_user_role.go create mode 100644 internal/library/batchfields/limits.go create mode 100644 internal/library/batchfields/limits_test.go create mode 100644 internal/library/rolepolicy/template.go create mode 100644 internal/library/rolesync/publish.go create mode 100644 internal/library/tdengine/config_test.go create mode 100644 internal/model/agent_role_sync.go create mode 100644 internal/model/data_review.go create mode 100644 internal/model/do/sys_config.go create mode 100644 internal/model/do/sys_permission_revision.go create mode 100644 internal/model/do/sys_role.go create mode 100644 internal/model/do/sys_role_dept.go create mode 100644 internal/model/do/sys_role_menu.go create mode 100644 internal/model/do/sys_role_sync_task.go create mode 100644 internal/model/do/sys_user_role.go create mode 100644 internal/model/entity/sys_config.go create mode 100644 internal/model/entity/sys_permission_revision.go create mode 100644 internal/model/entity/sys_role.go create mode 100644 internal/model/entity/sys_role_dept.go create mode 100644 internal/model/entity/sys_role_menu.go create mode 100644 internal/model/entity/sys_role_sync_task.go create mode 100644 internal/model/entity/sys_user_role.go create mode 100644 internal/repository/data/repository_accuracy_test.go create mode 100644 internal/repository/data/sub_batch.go create mode 100644 internal/repository/data/sub_batch_concurrency_test.go create mode 100644 internal/repository/data/sub_batch_mysql_test.go create mode 100644 internal/repository/data/sub_batch_order_test.go create mode 100644 internal/repository/data/sub_batch_test.go create mode 100644 internal/repository/lifecycle/failure_text.go create mode 100644 internal/repository/lifecycle/failure_text_test.go create mode 100644 internal/repository/trafficusage/routing_test.go create mode 100644 internal/service/data/async.go create mode 100644 internal/service/data/async_fairness_test.go create mode 100644 internal/service/data/async_manual_sync_test.go create mode 100644 internal/service/data/async_test.go create mode 100644 internal/service/data/executor_accuracy_test.go create mode 100644 internal/service/rolesync/role.go create mode 100644 internal/service/rolesync/role_test.go create mode 100644 internal/service/rolesync/runtime.go create mode 100644 internal/service/rolesync/runtime_test.go create mode 100644 internal/service/rolesync/template.go create mode 100644 internal/service/rolesync/template_hierarchy_test.go diff --git a/.gitattributes b/.gitattributes index 5837541..357a48f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,4 @@ *.ttf binary *.pdf binary *.exe binary +*.go linguist-language=Go diff --git a/README.md b/README.md index c89af50..4ac1051 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,27 @@ # iotlcm-worker-svr -iotLCM 物联网卡全生命周期管理平台的**异步执行中枢** —— RabbitMQ 消费者 + 全量同步编排器。 +Dedicated RabbitMQ consumer service for IotLCM asynchronous work. -平台的大部分耗时操作(批量开停复机、续费变更、流量/状态/IMEI 全量同步、达量断网、工单自动关闭)都在本服务里异步执行。 +## Consumers -## 特性 +The service always registers these business consumers when RabbitMQ is enabled: -- **多队列消费者**:注册业务消费者处理重试队列、批次执行、工单等 -- **全量同步编排**:卡状态 / 本月流量 / 日流量 / 月流量历史 / IMEI 五条全量同步链路,统一的 Producer(扫描 + 供料)→ Consumer(领取 + 执行)模型 -- **通道级并发与限速**:按通道模板配置 QPS,按通道隔离并发度 -- **多轮执行**:失败卡进入下一轮重试,单轮上限与轮次上限可配 -- **运行归档**:每轮执行产出终态 JSON 归档,供通知与排障使用 -- **可观测**:阶段耗时、库存水位、缺料观测等遥测写入归档 +- `iotlcm.traffic_usage.daily.retry` +- `iotlcm.traffic_usage.monthly.retry` +- `iotlcm.work_order.auto_close` -## 技术栈 +For local Admin-to-Worker integration checks, set `workerDiagnostic.enabled: true` to +register `iotlcm.worker.diagnostic`. Diagnostic messages are logged and acknowledged; +they do not access MySQL, TDengine, or Upstream. The diagnostic consumer is disabled +in the default and build configurations. -- Go 1.25 + GoFrame v2 -- RabbitMQ(`github.com/rabbitmq/amqp091-go`) -- MySQL(业务库)、Redis(任务队列与锁)、TDengine(用量时序) -- 支付宝(余额充值)、excelize(Excel 导入导出) +## SIM status synchronization -## 快速开始 +Status sync uses run-scoped queues and durable outcome counters. See +[Run accounting and rollout](SIM_STATUS_RUN_ACCOUNTING.md) for the new +`simStatusQueueSync.redisKeyPrefix` setting and tests. -```bash -go mod tidy -go run . -``` -## 配置 +## 角色权限同步 -配置文件在 `config/config_default.yaml`(默认配置)与 `config/config.yaml`(部署时覆盖)。**公开版里的凭据与地址全部是占位符**: - -| 配置项 | 占位 | 说明 | -| --- | --- | --- | -| `database.default.link` | `${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)` | 业务库 | -| `redis.default.address` | `${REDIS_ADDR}` | 任务队列 | -| `redis.default.pass` | `${REDIS_PASSWORD}` | 缓存口令 | - -上游凭据(AppKey / AppSecret)**不写在本仓**,存放在通道表 `t_sim_channels` 与通道模板表里,由平台写入。 - -## 目录结构 - -```text -iotlcm-worker-svr/ -├── internal/ -│ ├── cmd/ # 启动装配 -│ ├── consumer/ -│ │ ├── rabbitmq/ # RabbitMQ 消费者(批次、重试、工单等) -│ │ └── zset/ # 全量同步消费者(ZSet 任务队列) -│ ├── producer/ -│ │ ├── simstatus/ # 卡状态全量同步 -│ │ ├── monthlyusage/ # 本月流量全量同步 -│ │ ├── dailyusage/ # 日流量全量同步 -│ │ ├── monthlyusagehistory/ # 月流量历史同步 -│ │ └── imei/ # IMEI 全量同步 -│ ├── fullsync/ # 全量同步运行时、归档、契约、HTTP 诊断 -│ ├── service/ # 各业务动作执行器 -│ ├── repository/ # 数据访问 -│ ├── queue/zset/ # 任务队列与锁 -│ └── library/rabbitmq/ # MQ 封装 -├── config/ # 配置 -├── build/ # Dockerfile 与镜像内配置 -├── manifest/deploy/kustomize/ # K8s 部署清单 -└── manifest/docker/ # compose 与启动脚本 -``` - -## 全量同步模型 - -```text -Producer(扫描卡 → 按通道分片入队) - │ - ▼ - ZSet 任务队列(Redis) - │ - ▼ -Consumer(按通道 QPS 领取 → 调上游 → 写库 → 结算) - │ - ▼ - 运行归档(终态 JSON,供 Bot 通知与排障) -``` - -关键设计: - -- **通道隔离**:每个通道独立并发与 QPS,一个慢通道不拖垮其他通道 -- **供料与消费解耦**:Producer 只负责入队,Consumer 按节奏领取,避免上游被瞬时打满 -- **多轮重试**:单卡失败进入下一轮,轮次上限与单轮上限均可配 -- **归档去重**:按 `runId` 标识一次执行,Bot 据此去重推送 - -## 容器化构建 - -```bash -docker build -f build/Dockerfile -t iotlcm-worker-svr:latest . -``` - -镜像内不含任何凭据;`config/config.yaml` 以只读方式挂载覆盖。 - -## 测试 - -```bash -go test ./... -``` - -测试使用本地替身,不访问真实运营商接口。 - -> **已知失败(与上游主分支一致,非本仓引入)**: -> `TestValidateUpstreamBasicInfoAcceptsMatchingIdentifiers`、 -> `TestConfiguredInboundBasicInfoClientUsesDedicatedEndpoint`、 -> `TestAllocateProcessingRetryAfterHasLargeBatchDefault`。 -> 这三项在源仓库同一提交上同样失败,属既有缺陷。 - -## 相关项目 - -- [iotlcm-bot](https://git.5iot.com/iotlcm-public/iotlcm-bot) —— 读取归档 JSON 并推送飞书卡片 -- [iotlcm-upstream-chan](https://git.5iot.com/iotlcm-public/iotlcm-upstream-chan) —— 运营商上游适配 -- [iotlcm-admin-svr](https://git.5iot.com/iotlcm-public/iotlcm-admin-svr) —— 管理后台(发起批次与定时任务) - -## License - -[Apache License 2.0](./LICENSE) +配置 `roleSync.enabled: true` 后,独立接管管理后端的持久化角色权限任务。部署顺序与回滚见 `docs/角色权限任务切换.md`。默认关闭,必须先执行迁移并移除旧管理后端执行入口。 diff --git a/build/Dockerfile b/build/Dockerfile index 9e46d1d..0347244 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,32 +1,32 @@ -FROM golang:1.25-alpine AS builder - +ARG BASE_REGISTRY=docker.io/library +FROM ${BASE_REGISTRY}/golang:1.25-alpine AS builder LABEL stage=gobuilder ENV GOPROXY=https://goproxy.cn,direct ENV TZ=Asia/Shanghai +# RUN apk update --no-cache && apk add --no-cache expect git tzdata WORKDIR /build -COPY go.mod go.sum ./ +COPY go.mod go.sum ./ RUN go mod download -COPY . . +COPY . . ARG BuildVersion ARG BuildTime ARG CommitID -RUN go build -v -o /build/iotlcm-worker-svr -ldflags "-X main.BuildVersion=${BuildVersion} -X main.CommitID=${CommitID} -X main.BuildTime=${BuildTime}" +RUN go build -v -x -o /build/iotlcm-worker-svr -ldflags "-X main.BuildVersion=${BuildVersion} -X main.CommitID=${CommitID} -X main.BuildTime=${BuildTime}" -FROM alpine:3.23 +FROM ${BASE_REGISTRY}/alpine:3.23 AS alpine RUN apk add --no-cache ca-certificates tzdata - ENV TZ=Asia/Shanghai WORKDIR /app COPY --from=builder /build/iotlcm-worker-svr /app/iotlcm-worker-svr -COPY --from=builder /build/build/build_config.yaml /app/config/config.yaml +COPY --from=builder /build/build/config.example.yaml /app/config/config.yaml CMD ["./iotlcm-worker-svr"] diff --git a/build/build_config.yaml b/build/config.example.yaml similarity index 83% rename from build/build_config.yaml rename to build/config.example.yaml index 35a2688..6b32592 100644 --- a/build/build_config.yaml +++ b/build/config.example.yaml @@ -1,7 +1,7 @@ # config.yaml 配置文件 # @Link https://github.com/xiujiecn/xiu-admin # @Copyright Copyright (c) 2025 LiXiujie -# @Author Lxj +# @Author Lxj # @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE # 系统配置 @@ -40,7 +40,7 @@ database: level: "all" stdout: true default: # 分组名称,可自定义,默认为default - link: "mysql:${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai" + link: "${DB_DSN}" debug: true #开启调试模式 charset: "utf8mb4" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。 dryRun: false #ORM空跑(只读不写) @@ -49,7 +49,7 @@ database: maxLifetime: 120 #(单位秒)连接对象可重复使用的时间长度 memorydb: # link: "sqlite::@file(jieiotmem.db)?journal_mode=WAL" - link: "sqlite::@file(:memory:)" + link: "${DB_DSN}" # link: "" debug: true maxIdle: 1 @@ -67,9 +67,9 @@ redis: default: mode: "single" #redis模式,支持:single|cluster|sentinel sentinelMasterName: "iotlcm-master" #哨兵模式主节点名称 - address: 192.0.2.29:6379 + address: 127.0.0.1:6379 db: 0 - pass: "${REDIS_PASSWORD}" + pass: "${SERVICE_PASSWORD}" minIdle: 5 #最小空闲连接数 maxIdle: 30 #允许闲置的最大连接数 maxActive: 5000 #最大活跃连接数 @@ -78,8 +78,8 @@ redis: alipay: - appId: "${MOBILE_APP_ID}" - privateKey: "MIIEowIBAAKCAQEAg+4BpLO2Fw5GMbrihnugyQqiLBp2RhSbSsvjNyzaG10u900N1DcVlh8HNdcus5AGjfbiUwMQwv7Os+NnFrEZA3CtWLXH+uczHIpcMqJJhoVgA//4gf2TX9K4OSQ1pgJeAhTS9WWMEB7DQLb7A6xGjvBdGNbPAzucGhUmtG1tgZSVOcpWqN2h4dQxkZhfR+6m3AqWAPahpQXsNINHewetQ4mIG+b3+z/OknEll0RDRmw7OihOJ24B51Phy7lewl160LgLpLffyaQUUXcFdcNo+HvWqtIT0mH8WWHAgqfN/WVkMz/WVjE0rzbIVgmpQc7+hMbrcafTmeq0wdnVXrKS6QIDAQABAoIBAEagy1ZuMyRiqueCojTWyHv4altsCzd3Xq+LOWfXZihSGYuhP68FklPc5pJHUaQljazAp+dC891AZk5dhlnak4aI8vRT1OyYi39S/wrIYM5YZs+dYF/O7e/VsnQ800+9YrCAj3N6WqJiparmirkrqFZrjZt3nDmh1u8Ltb3P+1jr90MBEdS+BXaEQP/KGxFiKZyoWFn9B4l0aPFkPExF+hgtRpP3gxFcyljQKGVmSUIkT6V08F1oRm72NQOhcizCh9lX+5HYLTkUVQVg6FHwdZs/KeJcZu6Y829pNSNeJmPbR3VPUpTbvwb0qeEoc42T3H/C0xXUeTYizUpHmhJJqnUCgYEA3Y7Xz+MUk98zRJnlr2BDUc+Qnhv3wmSA5LCcRbxTNNBp4TXjnZknY6Ms9D+nJupL4nw6QSYkO6O+tAEmU6gaSDEHKI1uKqU0zOdbGIHNkMqka3rinQcMJNNRZ8T1FLCPFna7S1FNlxulZgWxjqxP/KvWOEpCWLCLzwZNk8fEd+MCgYEAmHBNbaMmT4WvLevY/OMv/5e5csnDFIcsNJKvFLZZMBLuKT/26YN2ryVUivY+TTyHL5zk4VybeauTdQHIvX6sNJoh2Ii6vthKfSufL4rkMob8PHiG33iHBx3VxAwiVXfu3JHVnbCPcXTOzsdRzT17X3rODK9t8319qfVjd0Gxi8MCgYEAqgNc0+ea+MXD35aAzA0AW5z9inZHruHUjqh/onubFeH//ZOOsHa/5RNoAZ1+tLwBszBEu417LFDAm/xt3rAWSuNkbNfqe8r+M2YtXybsCXmWpetMTdcEmJWX2BspPdEmW8SMgIVJFvVd/GXOFH9hebCa1+MT+l6vShO6H/ahaqUCgYAzon6nr4bPkvHWtTfH2VCt2//mIO+iaXMQz8rcUjP9s7vWXyNMz8oI47UyaNyLnwAPoa5po8vKN9L35EXR4jGEk9z5Er9m267HnVoLaZmIf+66sPzGf3YFdBavlIiguMJBFmjsRB0iijVvF1bfFX9PxTI8W8P7oTtKsaMihy8TrwKBgCgjA8DL8HJbRwZ+V/KWOh8rzO5cBUUH3ThIj6u7Wn4tMlscfi+KFKTXghjSORfIVx8v9v2aNZKwGZ547AOPRXhDF09cLAcCfl7uOrwsftziIeBQnrqtpIqcVTgRwGMIMBJEtQEks5N/E/d+dSL1YQKC76mVC8/RMf52WYKH1QCa" + appId: "${APP_ID}" + privateKey: "${PRIVATE_KEY}" alipayPublicKey: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8Df1EPFkA2xy8NgF0UdRYxsshslUi2XSGTG01oQKsrYNB+rhAw9MJdMlunVQxSrBZsw5N7htXGdiTceaLo/n8Wuibp0cRHdErJSp9GhP4pW2IR3FoCCPYlJlyiCUR/NInI3XMai0UdjgiPX03bvYLRAKE8lLiOSgxNagQmOFLmQYRYfFhGbxDe98XMg83YdSWvZKrlM+vzgk8/aPgq7BFLpbRKqLvd8OXXBHDCaWMSSGwCN0whhdEiwbIhSVgwTgkhWXUuNDmT741/+7D46djp8wX/84SDts0fWb63/in9ATTGtNivJX7HPDyqrmcN2iUi5xFHS9XwYBdT4yu/xFQwIDAQAB" notifyUrl: "https://evolution-starless-municipal.ngrok-free.dev/api/v1/pay/alipay/notify" returnUrl: "http://localhost:5666/enterpriseManagement/tBalanceDetail/tAgentBalance" @@ -88,9 +88,9 @@ alipay: # RabbitMQ 配置(worker 核心依赖) rabbitmq: enabled: true - # 示例:amqp://guest:guest@127.0.0.1:5672/ - # dsn: "amqp://admin:admin@127.0.0.1:5672/" - dsn: "amqp://root:sdyz2026@192.0.2.29:5672/" + # 示例:${SERVICE_DSN} + # dsn: "${SERVICE_DSN}" + dsn: "${SERVICE_DSN}" balanceRecharge: retryDelay: "1m" # Alipay 查询失败后的延时重试时间 maxRetry: 100 # 最大重试次数,超过后投递到 iotlcm.balance.recharge.dlq @@ -135,7 +135,7 @@ trafficUsageFullSync: runLockTTL: "5m" consumer: enabled: true - chanClientName: "192.0.2.29:8110" + chanClientName: "127.0.0.1:8110" chanClientTimeout: "90s" lockKey: "flow:consumer:active" lockTTL: "1m" @@ -327,10 +327,10 @@ tsdb: database: "TdEngine" #可选择 TdEngine、Influxdb tdengine: # type: "taosRestful" #http连接方式,端口是6041 - # dsn: "root:taosdata@http(127.0.0.1:6041)/" + # dsn: "${SERVICE_DSN}" type: "taosWS" #websocket连接方式,端口是6041 - # dsn: "root:taosdata@ws(127.0.0.1:6041)/" - dsn: "root:sdyz2026__@ws(192.0.2.29:6041)/" + # dsn: "${SERVICE_DSN}" + dsn: "${SERVICE_DSN}" dbName: "iotlcm" maxOpenConns: 100 maxIdleConns: 10 diff --git a/config/config_default.yaml b/config/config_default.yaml index e8c51d0..8495978 100644 --- a/config/config_default.yaml +++ b/config/config_default.yaml @@ -1,7 +1,10 @@ +roleSync: + enabled: false + # config_default.yaml 配置文件 # @Link https://github.com/xiujiecn/xiu-admin # @Copyright Copyright (c) 2025 LiXiujie -# @Author Lxj +# @Author Lxj # @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE # 系统配置 @@ -28,8 +31,8 @@ fullSyncOps: # Alipay credentials are injected by the deployment configuration. alipay: - appId: "" - privateKey: "" + appId: "${APP_ID}" + privateKey: "${PRIVATE_KEY}" alipayPublicKey: "" isProduction: false @@ -347,7 +350,7 @@ database: level: "all" stdout: true default: # 分组名称,可自定义,默认为default - link: "mysql:${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai" + link: "${DB_DSN}" debug: true #开启调试模式 charset: "utf8mb4" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。 dryRun: false #ORM空跑(只读不写) @@ -365,10 +368,10 @@ redis: # 单实例配置 default: mode: "single" #redis模式,支持:single|cluster|sentinel - sentinelMasterName: "${REDIS_SENTINEL_MASTER}" #哨兵模式主节点名称 - address: 192.0.2.200:6379 + sentinelMasterName: "xiujie-master" #哨兵模式主节点名称 + address: 127.0.0.1:6379 db: 0 - pass: "${REDIS_PASSWORD}" + pass: "${SERVICE_PASSWORD}" minIdle: 5 #最小空闲连接数 maxIdle: 30 #允许闲置的最大连接数 maxActive: 5000 #最大活跃连接数 diff --git a/docs/角色权限任务切换.md b/docs/角色权限任务切换.md new file mode 100644 index 0000000..f5e3b43 --- /dev/null +++ b/docs/角色权限任务切换.md @@ -0,0 +1,24 @@ +# 角色权限任务切换 + +管理后端仅保存角色并事务记录 sys_role_sync_task,Worker 接管执行。任务数据格式和迁移脚本与管理后端一致。 + +## 启用顺序 + +1. 备份并执行 resource/migrations/20261014_role_sync_outbox.sql;已执行时无需重复迁移。 +2. 停止全部旧管理后端执行实例,更新后端。不要与旧消费者混跑。 +3. 核对 Worker 和管理后端使用同环境数据库、RabbitMQ vhost;数据库账号须有角色、菜单、机构、任务及权限版本表的相应读写权限。 +4. 配置 roleSync.enabled: true,更新 Worker;确认 role sync Worker started 日志及 iotlcm-worker-agent-role-sync 消费者。 +5. 核对旧主队列、retry、dlq 积压。完整来源的旧消息转为确定性 ID 的数据库任务;不完整消息拒绝处理,不能盲目清空队列。 +6. 保存成功仅表示来源权限和任务已提交;通过管理后端任务查询与重试接口观察结果。 + +## 验收 + +使用隔离环境验证正常同步、断开 RabbitMQ、消费者断线重连、Worker 重启、重复消息、部分失败、模板切换、多实例撤权和租约丢失。监控 pending/retry 积压、最老任务时间和终态失败。SQLite 测试不能代替 MySQL 行锁及真实消息队列验收。 + +## 回滚 + +关闭 Worker 的 roleSync.enabled 前先记录未完成任务;恢复旧后端消费者与停止新消费者要协调,禁止长期双执行。保留任务表和权限版本表,不删除真实角色绑定。未启用执行器期间任务保留 pending,恢复后处理。 + +## 检查补充 + +无效 JSON 和缺少来源信息的旧消息先确认发布到 iotlcm.agent.role.sync.dlq,再确认原消息;隔离发布失败时原消息重新入队。消息引用不存在的任务只记警告,不无限重投;有效任务载荷必须与任务表的租户及来源字段一致。缺失默认角色直接失败,不自动生成孤立角色,修复绑定须业务确认。权限相关数据库查询使用主库,避免从库延迟将旧权限缓存到新版本。 diff --git a/go.mod b/go.mod index 1ee195b..d5437af 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ require ( github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.0 github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0 github.com/gogf/gf/v2 v2.10.0 + github.com/google/uuid v1.6.0 github.com/rabbitmq/amqp091-go v1.10.0 + github.com/redis/go-redis/v9 v9.18.0 github.com/smartwalle/alipay/v3 v3.2.29 github.com/taosdata/driver-go/v3 v3.8.0 github.com/xuri/excelize/v2 v2.9.0 @@ -27,7 +29,6 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grokify/html-strip-tags-go v0.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -42,7 +43,6 @@ require ( github.com/olekukonko/ll v0.0.9 // indirect github.com/olekukonko/tablewriter v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.4 // indirect diff --git a/hack/config.example.yaml b/hack/config.example.yaml new file mode 100644 index 0000000..445dacf --- /dev/null +++ b/hack/config.example.yaml @@ -0,0 +1,17 @@ +# CLI tool, only in development environment. +# https://goframe.org/docs/cli +gfcli: + gen: + dao: + - link: "${DB_DSN}" + tables: "sys_role,sys_role_menu,sys_role_dept,sys_config,sys_user_role,batch_main,batch_detail,sys_dept,sys_oss,t_resource_batch_download,t_sim_card_cancellation,t_sim_card_info,t_sim_flow_pool,t_warn_config,t_warn_log,t_wo_main,t_wo_record,t_message_log,t_user_message" + descriptionTag: true + noModelComment: true + jsonCase: "CamelLower" + gJsonSupport: true + group: "default" + + docker: + build: "-a amd64 -s linux -p temp -ew" + tagPrefixes: + - iotlcm-worker-svr diff --git a/hack/config.yaml b/hack/config.yaml deleted file mode 100644 index 997d6b3..0000000 --- a/hack/config.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# CLI tool, only in development environment. -# https://goframe.org/docs/cli -gfcli: - gen: - dao: - - link: "mysql:${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai" - tables: "batch_main,batch_detail,sys_dept,sys_oss,t_resource_batch_download,t_sim_card_cancellation,t_sim_card_info,t_sim_flow_pool,t_warn_config,t_warn_log,t_wo_main,t_wo_record,t_message_log,t_user_message" - descriptionTag: true - noModelComment: true - jsonCase: "CamelLower" - gJsonSupport: true - group: "default" - - docker: - build: "-a amd64 -s linux -p temp -ew" - tagPrefixes: - - iotlcm-worker-svr diff --git a/internal/batch/batch.go b/internal/batch/batch.go index 1e647f2..aef81e6 100644 --- a/internal/batch/batch.go +++ b/internal/batch/batch.go @@ -112,6 +112,11 @@ type SelfClaimingProcessingExecutor interface { ClaimsProcessingReentry() bool } +// DurableFailureHandler takes ownership before any generic MarkFailed fallback. +type DurableFailureHandler interface { + ProtectExecutionFailure(ctx context.Context, batchNo string, batchType int, reason string) (bool, error) +} + type ExecutionFailureHandler interface { HandleExecutionFailure(ctx context.Context, batchNo string, batchType int, reason string) error } diff --git a/internal/batch/batch_test.go b/internal/batch/batch_test.go index b3a6c0a..4ed806c 100644 --- a/internal/batch/batch_test.go +++ b/internal/batch/batch_test.go @@ -136,6 +136,64 @@ func (s *repositoryStub) ReclaimProcessing(_ context.Context, batchNo string, ba return s.reclaimed, s.reclaimErr } +type durableExecutorStub struct { + execute func(context.Context) error + protectErr error + protectCalls int +} + +func (s *durableExecutorStub) Execute(ctx context.Context, _ string) error { + if s.execute != nil { + return s.execute(ctx) + } + return nil +} +func (s *durableExecutorStub) ProtectExecutionFailure(ctx context.Context, _ string, _ int, _ string) (bool, error) { + s.protectCalls++ + if ctx.Err() != nil { + return true, ctx.Err() + } + return true, s.protectErr +} + +func TestRuntimeDurableOwnsEveryFailurePath(t *testing.T) { + for _, kind := range []string{"error", "deadline-error", "deadline-nil", "failure-reason", "retry-exhausted", "protect-db-down"} { + t.Run(kind, func(t *testing.T) { + repo := &repositoryStub{mains: []*entity.BatchMain{{BatchNo: "DURABLE", BatchType: Type109, BatchAsyncStatus: AsyncStatusPending}}, claimed: true} + exe := &durableExecutorStub{} + msg := Message{BatchNo: "DURABLE", BatchType: Type109} + switch kind { + case "error": + exe.execute = func(context.Context) error { return errors.New("执行中断") } + case "deadline-error": + exe.execute = func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() } + case "deadline-nil": + exe.execute = func(ctx context.Context) error { <-ctx.Done(); return nil } + case "failure-reason", "retry-exhausted": + msg.FailureReason = "重试耗尽或失败重投" + repo.mains[0].BatchAsyncStatus = AsyncStatusProcessing + case "protect-db-down": + exe.execute = func(context.Context) error { return errors.New("执行中断") } + exe.protectErr = errors.New("数据库不可用") + } + dispatcher := NewDispatcher(&executorStub{}) + dispatcher.Register(Type109, exe) + err := NewRuntime(repo, dispatcher, WithExecutionTimeout(time.Millisecond)).Handle(context.Background(), msg) + if kind == "protect-db-down" { + var failure *FailureMarkError + if !errors.As(err, &failure) { + t.Fatalf("保护失败应可重投: %v", err) + } + } else if err != nil { + t.Fatal(err) + } + if exe.protectCalls != 1 || repo.failCalls != 0 { + t.Fatalf("通用MarkFailed越过durable保护: protect=%d failed=%d", exe.protectCalls, repo.failCalls) + } + }) + } +} + func TestMessageJSONContract(t *testing.T) { body, err := json.Marshal(Message{BatchNo: "BATCH-1", BatchType: Type103}) if err != nil { diff --git a/internal/batch/runtime.go b/internal/batch/runtime.go index cd1707c..4356976 100644 --- a/internal/batch/runtime.go +++ b/internal/batch/runtime.go @@ -8,6 +8,7 @@ import ( "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" ) @@ -84,9 +85,6 @@ func (r *Runtime) Handle(ctx context.Context, message Message) error { } if main.BatchAsyncStatus != AsyncStatusPending { if main.BatchAsyncStatus == AsyncStatusProcessing { - if message.Retryable { - return r.reclaimAndExecute(ctx, message, executor, main) - } if processingExecutor, ok := executor.(ProcessingExecutor); ok { canExecute, checkErr := processingExecutor.CanExecuteProcessing(ctx, main) if checkErr != nil { @@ -98,6 +96,8 @@ func (r *Runtime) Handle(ctx context.Context, message Message) error { } return r.reclaimAndExecute(ctx, message, executor, main) } + } else if message.Retryable { + return r.reclaimAndExecute(ctx, message, executor, main) } } logUnclaimable(ctx, main) @@ -168,9 +168,19 @@ func (r *Runtime) execute(ctx context.Context, message Message, executor Executo } func (r *Runtime) markExecutionFailed(ctx context.Context, message Message, executor Executor, reason string) error { + reason = batchfields.FailureReason(reason) finalizationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.finalizationTimeout) defer cancel() + if handler, ok := executor.(DurableFailureHandler); ok { + handled, err := handler.ProtectExecutionFailure(finalizationCtx, message.BatchNo, message.BatchType, reason) + if err != nil { + return &FailureMarkError{Reason: reason, Err: err} + } + if handled { + return nil + } + } failed, markErr := r.repository.MarkFailed(finalizationCtx, message.BatchNo, message.BatchType, reason) if markErr != nil { return &FailureMarkError{Reason: reason, Err: markErr} diff --git a/internal/client/upstream/apn_readiness.go b/internal/client/upstream/apn_readiness.go new file mode 100644 index 0000000..0cad559 --- /dev/null +++ b/internal/client/upstream/apn_readiness.go @@ -0,0 +1,154 @@ +package upstream + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +// apnRequiredSchema 是异步APN提交依赖的表与关键列,对应 20261015 的四份迁移脚本。 +// 缺表或缺列意味着迁移没有执行完,此时提交必然写不进去,应在发请求前就拒绝。 +var apnRequiredSchema = map[string][]string{ + "t_apn_operation": {"operation_id", "chan_key", "request_hash", "response_json", "revision"}, + "data_operation_parent": {"batch_no", "ready", "finalize_token", "finalize_until"}, + "data_operation_sub_batch": {"operation_id", "batch_no", "chan_key", "request_data", "status", "poll_count", "lease_token"}, + "data_operation_sub_detail": {"detail_id", "operation_id", "batch_no", "iccid"}, + "data_operation_card_claim": {"tenant_id", "iccid", "batch_id", "active_batch_id"}, +} + +// apnReadinessTTL 是依赖检查结果的缓存时间:迁移完成后最迟一分钟内自动放行,无需重启。 +const apnReadinessTTL = time.Minute + +// apnReadinessVerifyTimeout 是单次结构校验的独立超时,与调用方上下文解耦。 +const apnReadinessVerifyTimeout = 5 * time.Second + +type apnReadinessCache struct { + ttl time.Duration + mu sync.Mutex + checkedAt time.Time + err error + // verify 默认指向 verifyAPNSchema;独立出来是为了让缓存行为可测。 + verify func(context.Context) error +} + +var defaultAPNReadiness = &apnReadinessCache{ttl: apnReadinessTTL, verify: verifyAPNSchema} + +// check 返回异步APN提交的依赖状态,结果按 TTL 缓存,避免每次提交都查库。 +func (c *apnReadinessCache) check(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.checkedAt.IsZero() && time.Since(c.checkedAt) < c.ttl { + return c.err + } + verify := c.verify + if verify == nil { + verify = verifyAPNSchema + } + // 校验使用独立超时,不受调用方取消影响;否则一次取消就会把失败结果缓存给全进程。 + verifyCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), apnReadinessVerifyTimeout) + defer cancel() + c.err = verify(verifyCtx) + if c.err != nil && (errors.Is(c.err, context.Canceled) || errors.Is(c.err, context.DeadlineExceeded)) { + // 只是本次校验没跑完,不代表依赖缺失,不写入缓存。 + c.checkedAt = time.Time{} + return c.err + } + c.checkedAt = time.Now() + return c.err +} + +// verifyAPNSchema 直接查询库里的实际表结构,而不是依赖人工开开关确认「迁移已执行」。 +func verifyAPNSchema(ctx context.Context) (err error) { + if !databaseConfigured(ctx) { + // 未配置数据库连接(测试桩、纯传输层用例)时跳过;生产 worker 不存在该情况。 + return nil + } + // g.DB() 在缺配置时会 panic,这里统一转成可读错误,避免拖垮提交链路。 + defer func() { + if recovered := recover(); recovered != nil { + err = gerror.Newf("无法获取数据库连接,不能确认异步APN依赖是否就绪:%v", recovered) + } + }() + db := g.DB() + if db == nil { + return gerror.New("无法获取数据库连接,不能确认异步APN依赖是否就绪") + } + config := db.GetConfig() + if strings.EqualFold(config.Type, "sqlite") { + // 本地/单元测试使用 SQLite,没有 information_schema,跳过结构检查。 + return nil + } + names := make([]string, 0, len(apnRequiredSchema)) + for name := range apnRequiredSchema { + names = append(names, name) + } + sort.Strings(names) + placeholders := make([]string, 0, len(names)) + args := make([]interface{}, 0, len(names)) + for _, name := range names { + placeholders = append(placeholders, "?") + args = append(args, name) + } + records, err := db.GetAll(ctx, fmt.Sprintf( + "SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM information_schema.columns WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (%s)", + strings.Join(placeholders, ",")), args...) + if err != nil { + return gerror.Wrap(err, "查询异步APN依赖表结构失败") + } + existing := make(map[string]map[string]bool, len(records)) + for _, record := range records { + table := record["table_name"].String() + if existing[table] == nil { + existing[table] = make(map[string]bool) + } + existing[table][strings.ToLower(record["column_name"].String())] = true + } + return validateAPNSchema(existing) +} + +// databaseConfigured 判断当前配置里是否声明了数据库,用于区分测试桩与真实运行环境。 +func databaseConfigured(ctx context.Context) bool { + value := g.Cfg().MustGet(ctx, "database") + return value != nil && !value.IsNil() && !value.IsEmpty() +} + +// validateAPNSchema 比对必需的表与列,返回可直接据此执行迁移的错误信息。 +func validateAPNSchema(existing map[string]map[string]bool) error { + tables := make([]string, 0, len(apnRequiredSchema)) + for table := range apnRequiredSchema { + tables = append(tables, table) + } + sort.Strings(tables) + var missingTables []string + var missingColumns []string + for _, table := range tables { + columns, ok := existing[table] + if !ok { + missingTables = append(missingTables, table) + continue + } + required := append([]string(nil), apnRequiredSchema[table]...) + sort.Strings(required) + for _, column := range required { + if !columns[column] { + missingColumns = append(missingColumns, table+"."+column) + } + } + } + switch { + case len(missingTables) > 0: + return gerror.Newf("异步APN依赖未就绪:缺少数据表 %s,请先执行 20261015 迁移脚本", + strings.Join(missingTables, "、")) + case len(missingColumns) > 0: + return gerror.Newf("异步APN依赖未就绪:缺少字段 %s,请先执行 20261015 迁移脚本", + strings.Join(missingColumns, "、")) + } + return nil +} diff --git a/internal/client/upstream/apn_readiness_test.go b/internal/client/upstream/apn_readiness_test.go new file mode 100644 index 0000000..94305a1 --- /dev/null +++ b/internal/client/upstream/apn_readiness_test.go @@ -0,0 +1,138 @@ +package upstream + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" +) + +func fullAPNSchema() map[string]map[string]bool { + schema := make(map[string]map[string]bool, len(apnRequiredSchema)) + for table, columns := range apnRequiredSchema { + schema[table] = make(map[string]bool, len(columns)) + for _, column := range columns { + schema[table][column] = true + } + } + return schema +} + +// TestValidateAPNSchemaRequiresAllTablesAndColumns 固化依赖判据: +// 缺表、缺列都必须给出可据此执行迁移的明确原因,而不是笼统的「未就绪」。 +func TestValidateAPNSchemaRequiresAllTablesAndColumns(t *testing.T) { + if err := validateAPNSchema(fullAPNSchema()); err != nil { + t.Fatalf("完整结构不应报错:%v", err) + } + + missingTable := fullAPNSchema() + delete(missingTable, "data_operation_card_claim") + err := validateAPNSchema(missingTable) + if err == nil || !strings.Contains(err.Error(), "data_operation_card_claim") || !strings.Contains(err.Error(), "缺少数据表") { + t.Fatalf("缺表未被识别:%v", err) + } + + missingColumn := fullAPNSchema() + delete(missingColumn["t_apn_operation"], "revision") + err = validateAPNSchema(missingColumn) + if err == nil || !strings.Contains(err.Error(), "t_apn_operation.revision") || !strings.Contains(err.Error(), "缺少字段") { + t.Fatalf("缺列未被识别:%v", err) + } + + // 表全部缺失时优先报缺表,避免一次报几十个字段干扰排查。 + err = validateAPNSchema(map[string]map[string]bool{}) + if err == nil || !strings.Contains(err.Error(), "缺少数据表") { + t.Fatalf("空库应优先报缺表:%v", err) + } +} + +// TestAsyncApnSubmitBlockedWhenDependencyNotReady 验证依赖未就绪时在发请求前就拒绝, +// 并把错误标记为「未发送」,避免上游误判为已办理。 +func TestAsyncApnSubmitBlockedWhenDependencyNotReady(t *testing.T) { + oldAdapter := g.Cfg().GetAdapter() + defer g.Cfg().SetAdapter(oldAdapter) + oldReady := apnDependencyReady + defer func() { apnDependencyReady = oldReady }() + apnDependencyReady = func(ctx context.Context) error { + return errors.New("缺少数据表 data_operation_card_claim") + } + + probes, submits := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/sim/query/sim-apn-capability" { + probes++ + return + } + submits++ + })) + defer server.Close() + adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s"}}}`, strings.TrimPrefix(server.URL, "http://"))) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + + _, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{ + SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, + ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}, + }) + var notSent interface{ APNRequestNotSent() bool } + if err == nil || !strings.Contains(err.Error(), "依赖未就绪") { + t.Fatalf("依赖未就绪应拒绝提交:%v", err) + } + if !errors.As(err, ¬Sent) || !notSent.APNRequestNotSent() { + t.Fatalf("依赖拒绝必须标记为未发送:%v", err) + } + if probes != 0 || submits != 0 { + t.Fatalf("依赖未就绪时不应发出任何请求:probes=%d submits=%d", probes, submits) + } +} + +// TestReadinessCacheDoesNotCacheCanceledVerification 覆盖审查发现的场景: +// 调用方取消导致的校验失败不能进进程级缓存,否则会误挡后续所有提交。 +func TestReadinessCacheDoesNotCacheCanceledVerification(t *testing.T) { + cache := &apnReadinessCache{ttl: time.Minute} + calls := 0 + cache.verify = func(ctx context.Context) error { + calls++ + if calls == 1 { + return context.Canceled + } + return nil + } + if err := cache.check(context.Background()); !errors.Is(err, context.Canceled) { + t.Fatalf("首次应返回取消错误:%v", err) + } + if err := cache.check(context.Background()); err != nil { + t.Fatalf("取消不应被缓存,第二次应重新校验并放行:%v", err) + } + if calls != 2 { + t.Fatalf("取消后应重新校验,实际调用%d次", calls) + } +} + +// TestReadinessCacheReusesResult 保证依赖检查不会每次提交都查库,同时迁移完成后能自动恢复。 +func TestReadinessCacheReusesResult(t *testing.T) { + cache := &apnReadinessCache{ttl: time.Minute} + calls := 0 + cache.verify = func(ctx context.Context) error { + calls++ + return nil + } + for i := 0; i < 3; i++ { + if err := cache.check(context.Background()); err != nil { + t.Fatalf("第%d次检查报错:%v", i+1, err) + } + } + if calls != 1 { + t.Fatalf("缓存期内应只查一次库,实际%d次", calls) + } +} diff --git a/internal/client/upstream/client.go b/internal/client/upstream/client.go index 81fbc9f..914c783 100644 --- a/internal/client/upstream/client.go +++ b/internal/client/upstream/client.go @@ -19,6 +19,22 @@ import ( const inboundBasicInfoClientNameEnv = "UPSTREAM_INBOUND_BASIC_INFO_CLIENT_NAME" const upstreamProxyMaxTimeoutConfigKey = "upstream.proxy.maxTimeout" +// apnAsyncProtocolVersionConfigKey 是异步APN提交的兼容开关。 +// 默认值改为开启:常规发布不再需要人工逐环境开开关,兼容性由提交前的协议能力探测兜底。 +// 只有需要临时回退到旧协议时,才显式配为 0。 +const apnAsyncProtocolVersionConfigKey = "upstream.proxy.apnAsyncProtocolVersion" + +// defaultApnAsyncProtocolVersion 默认开启异步APN提交。 +const defaultApnAsyncProtocolVersion = 1 + +// defaultUpstreamProxyMaxTimeout 是单次批量转发的兜底上限。 +// 必须大于 upstream-proxy 的 gateway.batchTimeout(默认 20 分钟), +// 让超时由更靠近通道的一层先判定,避免 worker 提前放弃而通道仍在办理,造成结果未知。 +const defaultUpstreamProxyMaxTimeout = 25 * time.Minute + +// defaultUpstreamProxyTimeout 是未配置单卡预算时的默认值。 +const defaultUpstreamProxyTimeout = 10 * time.Second + type Client struct { currentUsageClientName string currentUsageTimeout time.Duration @@ -134,6 +150,106 @@ func (c *Client) ChangeSimApnFunction(ctx context.Context, input *model.SimCardA return requestAll[model.SimCardApnChangeOutputItem](ctx, "/sim/change/sim-apn", input, len(input.SimKeys)) } +// apnRequestNotSentError 仅描述任何APN写请求之前的能力拒绝。 +// 提交开始后的通信错误绝不能使用此类型。 +type apnRequestNotSentError struct{ cause error } + +func (e *apnRequestNotSentError) Error() string { return e.cause.Error() } +func (e *apnRequestNotSentError) Unwrap() error { return e.cause } +func (e *apnRequestNotSentError) APNRequestNotSent() bool { return true } + +func (c *Client) CheckSimApnAsyncCapability(ctx context.Context, input *model.SimCardApnChangeInput) (err error) { + defer func() { + if err != nil { + err = &apnRequestNotSentError{cause: err} + } + }() + if !apnAsyncSubmitEnabled(ctx) { + return gerror.New("异步APN提交已被显式关闭(upstream.proxy.apnAsyncProtocolVersion=0);清除该配置或置为1后重试") + } + if err := apnDependencyReady(ctx); err != nil { + return gerror.Wrap(err, "异步APN依赖未就绪,未发送办理请求") + } + if input == nil || strings.TrimSpace(input.ReqData.OperationID) == "" { + return gerror.New("异步APN提交能力探测缺少operationId") + } + return c.checkSimApnProtocol(ctx, input) +} + +// apnDependencyReady 检查本地真实依赖(迁移表结构);测试可替换以便固定结论。 +var apnDependencyReady = func(ctx context.Context) error { + return defaultAPNReadiness.check(ctx) +} + +// apnAsyncSubmitEnabled 判断应急总闸是否被显式关闭。 +// 默认放行:是否可提交由程序自行检查真实依赖决定—— +// - 本地依赖:apn_readiness 核对迁移表结构是否就绪; +// - 远端兼容:每次提交前的协议能力探测,旧版 chan/proxy 会明确拒绝而不是按同步语义执行。 +// 因此常规发布不再需要人工逐环境开开关;仅回退到旧协议时把配置显式设为 0。 +func apnAsyncSubmitEnabled(ctx context.Context) bool { + return g.Cfg().MustGet(ctx, apnAsyncProtocolVersionConfigKey, defaultApnAsyncProtocolVersion).Int() == 1 +} + +// 仅验证远端协议:已有受理映射的只读补查不受新增提交门禁控制。 +// 旧版代理/通道缺少能力接口时必须拒绝,不能把查询当作普通办理转发。 +func (c *Client) checkSimApnProtocol(ctx context.Context, input *model.SimCardApnChangeInput) error { + if len(input.SimKeys) == 0 || len(input.SimKeys) > 100 || (strings.TrimSpace(input.ReqData.OperationID) == "" && strings.TrimSpace(input.ReqData.QueryJobID) == "") { + return gerror.New("异步APN能力探测需要operationId和1至100张卡快照") + } + type capability struct { + ProtocolVersion int `json:"protocolVersion"` + } + probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + items, err := requestAll[capability](probeCtx, "/sim/query/sim-apn-capability", input, len(input.SimKeys)) + if err != nil { + return gerror.Wrap(err, "异步APN协议能力探测失败,未发送办理或恢复请求") + } + if len(items) != len(input.SimKeys) { + return gerror.New("异步APN协议能力响应卡数不匹配,禁止发送办理请求") + } + seen := make(map[model.SimKey]bool) + for _, item := range items { + if item.SimKey == nil || item.Code != 0 || item.ResData.ProtocolVersion != 1 || seen[*item.SimKey] { + return gerror.New("通道未就绪或不支持异步APN协议,禁止发送办理请求") + } + seen[*item.SimKey] = true + } + for _, key := range input.SimKeys { + if key == nil || !seen[*key] { + return gerror.New("异步APN协议能力响应卡关联不匹配") + } + } + return nil +} + +// SubmitSimApnFunction 仅提交异步批量任务;受理状态不能按办理成功回写。 +func (c *Client) SubmitSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + if err := c.CheckSimApnAsyncCapability(ctx, input); err != nil { + return nil, err + } + request := *input + request.ReqData.SubmitOnly = true + request.ReqData.QueryJobID = "" + request.ReqData.RecoverOnly = false + return c.ChangeSimApnFunction(ctx, &request) +} + +// QuerySimApnFunctionResult 根据已持久化的jobId只查一次,不重新提交。 +func (c *Client) QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + if strings.TrimSpace(input.ReqData.QueryJobID) == "" && strings.TrimSpace(input.ReqData.OperationID) == "" { + return nil, gerror.New("异步APN结果查询缺少jobId和operationId") + } + request := *input + request.ReqData.QueryJobID = strings.TrimSpace(request.ReqData.QueryJobID) + request.ReqData.SubmitOnly = false + request.ReqData.RecoverOnly = request.ReqData.QueryJobID == "" + if err := c.checkSimApnProtocol(ctx, &request); err != nil { + return nil, gerror.Wrap(err, "异步APN只读查询协议未就绪,未发送查询请求") + } + return c.ChangeSimApnFunction(ctx, &request) +} + func request[T any](ctx context.Context, path string, input any, itemCount int) ([]model.SimBaseRes[T], error) { items, err := requestAll[T](ctx, path, input, itemCount) if err != nil { @@ -173,19 +289,13 @@ func requestAllWithAddressTimeout[T any](ctx context.Context, path string, input } client := g.Client() - timeout := timeoutOverride - if timeout <= 0 { - timeout = config.Timeout - } - if timeout <= 0 { - timeout = 10 * time.Second - } - if itemCount > 1 { - timeout *= time.Duration(itemCount) - } - if maxTimeout := g.Cfg().MustGet(ctx, upstreamProxyMaxTimeoutConfigKey, 2*time.Minute).Duration(); maxTimeout > 0 && timeout > maxTimeout { - timeout = maxTimeout + if path == "/sim/change/sim-apn" || path == "/sim/query/sim-apn-capability" { + client.SetContentType("application/json") } + timeout := resolveUpstreamTimeout(path, itemCount, config.Timeout, + timeoutOverride, + configDuration(ctx, upstreamProxyMaxTimeoutConfigKey, defaultUpstreamProxyMaxTimeout), + configDuration(ctx, "upstream.proxy.dataServiceTimeout", 240*time.Second)) setClientTimeout(client, timeout) clientResponse, err := client.Post(ctx, fmt.Sprintf("http://%s%s", address, path), input) if err != nil { @@ -239,3 +349,45 @@ func setClientTimeout(client *gclient.Client, timeout time.Duration) { transport.ResponseHeaderTimeout = timeout } } + +// resolveUpstreamTimeout 计算单次转发预算: +// 普通批量按「单卡预算 × 卡数」放大,并以 maxTimeout 兜底,避免批量操作被默认 2 分钟上限提前判失败; +// 数据开停沿用独立预算,优先于按卡计时。 +func resolveUpstreamTimeout(path string, itemCount int, perCard, timeoutOverride, maxTimeout, dataServiceTimeout time.Duration) time.Duration { + if itemCount < 1 { + itemCount = 1 + } + timeout := timeoutOverride + if timeout <= 0 { + timeout = perCard + } + if timeout <= 0 { + timeout = defaultUpstreamProxyTimeout + } + timeout *= time.Duration(itemCount) + if path == "/sim/change/sim-apn" { + if dataServiceTimeout > 0 { + return dataServiceTimeout + } + return 240 * time.Second + } + if maxTimeout > 0 && timeout > maxTimeout { + timeout = maxTimeout + } + return timeout +} + +// configDuration 读取时长配置,兼容写成 "20m" 字符串或秒数整数两种形式。 +func configDuration(ctx context.Context, key string, fallback time.Duration) time.Duration { + value := g.Cfg().MustGet(ctx, key) + if value == nil || value.IsNil() { + return fallback + } + if duration := value.Duration(); duration >= time.Second { + return duration + } + if seconds := value.Int(); seconds > 0 { + return time.Duration(seconds) * time.Second + } + return fallback +} diff --git a/internal/client/upstream/client_test.go b/internal/client/upstream/client_test.go index a3fa4a0..225f9b3 100644 --- a/internal/client/upstream/client_test.go +++ b/internal/client/upstream/client_test.go @@ -1,16 +1,399 @@ package upstream import ( + "bufio" "context" "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" "os" + "os/exec" + "path/filepath" "strings" + "sync" "testing" "time" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" ) +func startAPNHTTPChainHelper(t *testing.T, module string, extraEnv ...string) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..", "..", module)) + if err != nil { + t.Fatal(err) + } + buildCtx, buildCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer buildCancel() + binary := filepath.Join(t.TempDir(), module+"-http-test.exe") + build := exec.CommandContext(buildCtx, "go", "-C", root, "test", "-work", "-c", "-o", binary, "./internal/cmd") + build.Env = append(os.Environ(), "GOTMPDIR="+t.TempDir()) + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("%s模拟进程编译失败:%v\n%s", module, err, output) + } + ctx, cancel := context.WithTimeout(context.Background(), 170*time.Second) + cmd := exec.CommandContext(ctx, binary, "-test.run=^TestAPNHTTPHarness$", "-test.v", "-test.count=1") + cmd.Dir = filepath.Join(root, "internal", "cmd") + cmd.Env = append(append(os.Environ(), "APN_HTTP_CHAIN_HELPER=1"), extraEnv...) + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + t.Fatal(err) + } + cmd.Stderr = cmd.Stdout + if err := cmd.Start(); err != nil { + cancel() + t.Fatal(err) + } + ready := make(chan string, 1) + var mu sync.Mutex + var log strings.Builder + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + mu.Lock() + log.WriteString(line + "\n") + mu.Unlock() + if strings.HasPrefix(line, "APN_CHAIN_READY=") { + ready <- strings.TrimPrefix(line, "APN_CHAIN_READY=") + } + } + }() + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var address string + select { + case address = <-ready: + case err := <-done: + cancel() + mu.Lock() + text := log.String() + mu.Unlock() + t.Fatalf("%s模拟进程启动即退出:%v\n%s", module, err, text) + case <-ctx.Done(): + cancel() + <-done + mu.Lock() + text := log.String() + mu.Unlock() + t.Fatalf("%s模拟进程启动失败:%s", module, text) + } + t.Cleanup(func() { + requestCtx, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + req, _ := http.NewRequestWithContext(requestCtx, http.MethodPost, address+"/__test/stop", nil) + if res, err := http.DefaultClient.Do(req); err == nil { + _ = res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Errorf("%s停止请求失败:%d", module, res.StatusCode) + } + } else { + t.Errorf("%s停止请求失败:%v", module, err) + } + if err := <-done; err != nil { + mu.Lock() + text := log.String() + mu.Unlock() + t.Errorf("%s模拟进程异常退出:%v\n%s", module, err, text) + } else { + t.Logf("%s helper正常shutdown并退出0", module) + } + cancel() + }) + return address +} + +func TestAPNHTTPChainWorkerClientProxyChanCarrier(t *testing.T) { + if os.Getenv("APN_HTTP_CHAIN_INTEGRATION") != "1" { + t.Skip("未启用本机跨模块HTTP及专用MySQL模拟链") + } + chanURL := startAPNHTTPChainHelper(t, "iotlcm-upstream-chan") + proxyURL := startAPNHTTPChainHelper(t, "iotlcm-upstream-proxy", "APN_HTTP_CHAIN_CHAN="+chanURL) + oldAdapter := g.Cfg().GetAdapter() + defer g.Cfg().SetAdapter(oldAdapter) + adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"2s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(proxyURL, "http://"))) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + client := New() + expectedWrites := map[string]int{} + expectedQueries := map[string]int{} + expectedSubmits := 0 + for _, scenario := range []string{"drop-response", "top-error", "lost-response", "failed-task"} { + t.Run(scenario, func(t *testing.T) { + input := &model.SimCardApnChangeInput{ReqData: model.SimCardApnChangeInputItem{OperationID: fmt.Sprintf("http-chain-%s-%d", scenario, time.Now().UnixNano()), OperType: "1", ApnName: "CMIOT"}} + prefix := scenario + "-" + if scenario == "lost-response" { + prefix = "lost-" + } + if scenario == "failed-task" { + prefix = "failed-" + } + for i := 0; i < 10; i++ { + input.SimKeys = append(input.SimKeys, &model.SimKey{ChanKey: "fake-channel", ICCID: fmt.Sprintf("fake-card-%s-%d", scenario, i), MSISDN: fmt.Sprintf("%s%d", prefix, i)}) + } + out, err := client.SubmitSimApnFunction(context.Background(), input) + if err != nil || len(out) != 10 { + t.Fatalf("首发结果无效:%+v %v", out, err) + } + for _, item := range out { + if scenario != "failed-task" && !item.ResData.Uncertain { + t.Fatalf("丢响应被确认:%+v", item) + } + } + res, err := http.Post(chanURL+"/__test/restart", "application/json", nil) + if err != nil { + t.Fatal(err) + } + _ = res.Body.Close() + recovered, err := client.QuerySimApnFunctionResult(context.Background(), input) + if err != nil || len(recovered) != 10 { + t.Fatalf("恢复失败:%v", err) + } + duplicate, err := client.SubmitSimApnFunction(context.Background(), input) + if err != nil || len(duplicate) != 10 { + t.Fatalf("重复恢复失败:%v", err) + } + if scenario == "lost-response" { + for i, item := range recovered { + if !item.ResData.Uncertain || item.ResData.OrderNum != "" || !duplicate[i].ResData.Uncertain { + t.Fatalf("无jobId必须未知:%+v", item) + } + } + } else { + job := recovered[0].ResData.OrderNum + for i, item := range recovered { + if job == "" || item.ResData.OrderNum != job || !item.ResData.Pending || duplicate[i].ResData.OrderNum != job { + t.Fatalf("恢复丢失已持久化jobId:%+v", item) + } + } + expectedQueries[job] = 3 + input.ReqData.QueryJobID = job + for attempt := 0; attempt < 3; attempt++ { + result, e := client.QuerySimApnFunctionResult(context.Background(), input) + if e != nil || len(result) != 10 { + t.Fatalf("单次查询失败:%v", e) + } + for _, item := range result { + if attempt < 2 && (!item.ResData.Pending || item.Message == "") { + t.Fatalf("14008/处理中不得终态:%+v", item) + } + if attempt == 2 && (item.ResData.Pending || item.ResData.Uncertain || (scenario == "failed-task" && item.Code != 9999) || (scenario != "failed-task" && item.Code != 0)) { + t.Fatalf("终态语义错误:%+v", item) + } + } + } + } + statsResponse, e := http.Get(chanURL + "/__test/stats") + if e != nil { + t.Fatal(e) + } + var stats struct { + Writes map[string]int `json:"writes"` + Queries map[string]int `json:"queries"` + Submits int `json:"submits"` + Unexpected int `json:"unexpected"` + } + e = json.NewDecoder(statsResponse.Body).Decode(&stats) + _ = statsResponse.Body.Close() + expectedSubmits++ + for _, key := range input.SimKeys { + expectedWrites[key.MSISDN] = 1 + } + if e != nil || stats.Submits != expectedSubmits || stats.Unexpected != 0 || len(stats.Writes) != len(expectedWrites) || len(stats.Queries) != len(expectedQueries) { + t.Fatalf("额外或缺失上游请求:stats=%+v err=%v", stats, e) + } + for key, count := range expectedWrites { + if stats.Writes[key] != count { + t.Fatalf("逐卡办理次数错误:%s=%d,预期%d", key, stats.Writes[key], count) + } + } + for job, count := range expectedQueries { + if stats.Queries[job] != count { + t.Fatalf("纯查询次数错误:%s=%d,预期%d", job, stats.Queries[job], count) + } + } + counts := make([]int, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + counts = append(counts, stats.Writes[key.MSISDN]) + } + t.Logf("十卡实际办理计数=%v 累计提交=%d 无额外请求;丢失jobId=%v 明确失败终态=%v", counts, stats.Submits, scenario == "lost-response", scenario == "failed-task") + }) + } +} + +func TestAsyncApnCapabilityGateBlocksOldChanBeforeSubmit(t *testing.T) { + oldAdapter := g.Cfg().GetAdapter() + defer g.Cfg().SetAdapter(oldAdapter) + for _, tc := range []struct { + name string + enabled int + version int + wantError bool + }{{"显式关闭", 0, 1, true}, {"旧版无能力", 1, 0, true}, {"能力就绪", 1, 1, false}} { + t.Run(tc.name, func(t *testing.T) { + probes, submits := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/sim/query/sim-apn-capability" { + probes++ + _, _ = fmt.Fprintf(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":%d}}]}`, tc.version) + return + } + submits++ + _, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"pending":true,"orderNum":"fake-job"}}]}`)) + })) + defer server.Close() + adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":%d}}}`, strings.TrimPrefix(server.URL, "http://"), tc.enabled)) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + _, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}}) + var notSent interface{ APNRequestNotSent() bool } + if (err != nil) != tc.wantError || (tc.wantError && (submits != 0 || !errors.As(err, ¬Sent) || !notSent.APNRequestNotSent())) || (!tc.wantError && (submits != 1 || probes != 1)) { + t.Fatalf("能力门禁失效:probes=%d submits=%d err=%v", probes, submits, err) + } + }) + } +} + +// TestAsyncApnSubmitDefaultsEnabledWithoutGateConfig 固化默认开启: +// 配置里不写 apnAsyncProtocolVersion 时,提交不应再被门禁拦下,兼容性交给协议能力探测。 +func TestAsyncApnSubmitDefaultsEnabledWithoutGateConfig(t *testing.T) { + oldAdapter := g.Cfg().GetAdapter() + defer g.Cfg().SetAdapter(oldAdapter) + probes, submits := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/sim/query/sim-apn-capability" { + probes++ + _, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":1}}]}`)) + return + } + submits++ + _, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"pending":true,"orderNum":"fake-job"}}]}`)) + })) + defer server.Close() + adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s"}}}`, strings.TrimPrefix(server.URL, "http://"))) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + if _, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}}); err != nil { + t.Fatalf("未配置开关时提交应默认放行:%v", err) + } + if probes != 1 || submits != 1 { + t.Fatalf("默认放行后仍应做协议探测:probes=%d submits=%d", probes, submits) + } +} + +func TestAsyncApnReadOnlyQueryBypassesSubmitGateButRequiresProtocol(t *testing.T) { + oldAdapter := g.Cfg().GetAdapter() + defer g.Cfg().SetAdapter(oldAdapter) + for _, tc := range []struct { + name, jobID, operationID string + version int + missingProbe bool + wantQuery bool + }{ + {name: "existing_job", jobID: "existing-job", version: 1, wantQuery: true}, + {name: "existing_operation", operationID: "existing-operation", version: 1, wantQuery: true}, + {name: "legacy_protocol", jobID: "existing-job", version: 0}, + {name: "missing_capability", jobID: "existing-job", missingProbe: true}, + } { + t.Run(tc.name, func(t *testing.T) { + probes, queries := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/sim/query/sim-apn-capability" { + probes++ + if tc.missingProbe { + _ = json.NewEncoder(w).Encode(map[string]any{"code": 404, "message": "missing capability"}) + return + } + _, _ = fmt.Fprintf(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":%d}}]}`, tc.version) + return + } + if r.URL.Path != "/sim/change/sim-apn" { + t.Errorf("unexpected endpoint: %s", r.URL.Path) + } + queries++ + var request model.SimCardApnChangeInput + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + if request.ReqData.SubmitOnly || request.ReqData.QueryJobID != tc.jobID || request.ReqData.RecoverOnly != (tc.jobID == "") { + t.Errorf("query accidentally submitted: %+v", request.ReqData) + } + _, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"orderNum":"existing-job","pending":true}}]}`)) + })) + defer server.Close() + adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":0}}}`, strings.TrimPrefix(server.URL, "http://"))) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + result, err := New().QuerySimApnFunctionResult(context.Background(), &model.SimCardApnChangeInput{ + SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, + ReqData: model.SimCardApnChangeInputItem{OperationID: tc.operationID, QueryJobID: tc.jobID}, + }) + if probes != 1 || (err == nil) != tc.wantQuery || queries != map[bool]int{true: 1, false: 0}[tc.wantQuery] { + t.Fatalf("read-only protocol guard failed: probes=%d queries=%d err=%v result=%+v", probes, queries, err, result) + } + }) + } +} + +func TestAsyncApnSecondProbeRejectionIsTypedAndTransportIsNot(t *testing.T) { + oldAdapter := g.Cfg().GetAdapter() + defer g.Cfg().SetAdapter(oldAdapter) + for _, probeFails := range []bool{true, false} { + probes, submits := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 夹具不共享连接:并发跑包时复用空闲连接会被对端关闭, + // 使失败从"提交阶段"漂移到"只读探测阶段",造成与被测语义无关的抖动。 + w.Header().Set("Connection", "close") + if r.URL.Path == "/sim/query/sim-apn-capability" { + probes++ + if probes == 2 && probeFails { + http.Error(w, "模拟第二次探测失败", http.StatusNotFound) + return + } + fmt.Fprint(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":1}}]}`) + return + } + submits++ + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + _ = conn.Close() + })) + adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(server.URL, "http://"))) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + input := &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}} + client := New() + if err := client.CheckSimApnAsyncCapability(context.Background(), input); err != nil { + t.Fatal(err) + } + _, err = client.SubmitSimApnFunction(context.Background(), input) + server.Close() + var notSent interface{ APNRequestNotSent() bool } + isNotSent := errors.As(err, ¬Sent) && notSent.APNRequestNotSent() + if err == nil || isNotSent != probeFails || (probeFails && submits != 0) || (!probeFails && submits != 1) || probes != 2 { + t.Fatalf("零写类型边界错误:probeFails=%v probes=%d submits=%d err=%v", probeFails, probes, submits, err) + } + } +} + func TestSimCardStatusChangeProtocol(t *testing.T) { input := model.SimCardStatusChangeInput{ SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001"}}, @@ -89,6 +472,17 @@ func TestConfiguredInboundBasicInfoClientUsesDedicatedEndpoint(t *testing.T) { } } +func TestDecodeAsyncApnResponseKeepsUnconfirmedState(t *testing.T) { + body := []byte(`{"code":0,"data":[{"code":0,"resData":{"orderNum":"job-1","pending":true}},{"code":1,"message":"响应未确认","resData":{"orderNum":"job-2","uncertain":true}}]}`) + items, err := decodeResponse[model.SimCardApnChangeOutputItem](body) + if err != nil || len(items) != 2 { + t.Fatalf("解析异步响应失败:%v", err) + } + if !items[0].ResData.Pending || items[0].ResData.OrderNum != "job-1" || !items[1].ResData.Uncertain { + t.Fatalf("异步待查字段丢失:%+v", items) + } +} + func TestSimCardApnChangeProtocol(t *testing.T) { input := model.SimCardApnChangeInput{ SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001"}}, diff --git a/internal/client/upstream/client_timeout_test.go b/internal/client/upstream/client_timeout_test.go new file mode 100644 index 0000000..ee017d9 --- /dev/null +++ b/internal/client/upstream/client_timeout_test.go @@ -0,0 +1,49 @@ +package upstream + +import ( + "testing" + "time" +) + +// TestResolveUpstreamTimeoutScalesWithCardCount 覆盖批量操作预算: +// worker 不能再把批量请求压回 2 分钟,否则通道仍在按卡串行办理时会被提前判失败。 +func TestResolveUpstreamTimeoutScalesWithCardCount(t *testing.T) { + cases := []struct { + name string + path string + itemCount int + perCard time.Duration + override time.Duration + maxTimeout time.Duration + apnTimeout time.Duration + want time.Duration + }{ + {"单卡", "/sim/change/sim-status", 1, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 10 * time.Second}, + {"零卡按单卡处理", "/sim/change/sim-status", 0, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 10 * time.Second}, + {"50卡按单卡预算放大", "/sim/change/sim-status", 50, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 500 * time.Second}, + {"100卡恰好低于上限", "/sim/change/sim-status", 100, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 1000 * time.Second}, + {"超大批量按上限截断", "/sim/query/usage", 1000, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, defaultUpstreamProxyMaxTimeout}, + {"单卡预算缺失用默认值", "/sim/query/usage", 2, 0, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 20 * time.Second}, + {"显式覆盖仍按卡数放大", "/sim/query/usage", 3, 10 * time.Second, 2 * time.Second, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 6 * time.Second}, + {"数据开停使用独立预算", "/sim/change/sim-apn", 50, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 240 * time.Second}, + {"数据开停预算缺失回退", "/sim/change/sim-apn", 1, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 0, 240 * time.Second}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := resolveUpstreamTimeout(c.path, c.itemCount, c.perCard, c.override, c.maxTimeout, c.apnTimeout) + if got != c.want { + t.Fatalf("resolveUpstreamTimeout=%s,期望%s", got, c.want) + } + }) + } +} + +// TestUpstreamMaxTimeoutOutlastsProxyBatchTimeout 固化跨服务预算约束: +// worker 上限必须大于 proxy 的 gateway.batchTimeout(默认 20 分钟), +// 使超时由更靠近通道的一层先判定,避免 worker 提前放弃而通道仍在办理。 +func TestUpstreamMaxTimeoutOutlastsProxyBatchTimeout(t *testing.T) { + const proxyBatchTimeout = 20 * time.Minute + if defaultUpstreamProxyMaxTimeout <= proxyBatchTimeout { + t.Fatalf("worker 上限%s 必须大于 proxy 批量上限%s", defaultUpstreamProxyMaxTimeout, proxyBatchTimeout) + } +} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index d07d1b9..219ec7d 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -2,12 +2,58 @@ package cmd import ( "context" + "strings" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gcmd" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/client/upstream" + datarepository "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/data" + dataservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/worker" ) +var QueryAPNReview = gcmd.Command{ + Name: "query-apn-review", + Usage: "query-apn-review --operation-id= | --batch-no=", + Brief: "只读查询待核查APN子批并同步已确认结果(传 batch-no 时同步该父批下全部待核查子批)", + Arguments: []gcmd.Argument{ + {Name: "operation-id", Brief: "待核查子批operationId"}, + {Name: "batch-no", Brief: "待核查父批批次号,按父批同步全部待核查子批"}, + }, + Func: func(ctx context.Context, parser *gcmd.Parser) error { + operationID := strings.TrimSpace(parser.GetOpt("operation-id").String()) + batchNo := strings.TrimSpace(parser.GetOpt("batch-no").String()) + if operationID == "" && batchNo == "" { + return gerror.New("必须提供 operation-id 或 batch-no") + } + if operationID != "" && batchNo != "" { + return gerror.New("operation-id 与 batch-no 不能同时提供") + } + executor := dataservice.NewExecutor(datarepository.New(), upstream.New()) + if batchNo != "" { + queried, err := executor.QueryReviewBatch(ctx, batchNo) + if err != nil { + return err + } + g.Log().Infof(ctx, "APN待核查批量同步完成 batchNo=%s 已同步=%d", batchNo, queried) + return nil + } + if err := executor.QueryReview(ctx, operationID); err != nil { + return err + } + g.Log().Infof(ctx, "APN待核查查询完成 operationId=%s", operationID) + return nil + }, +} + +func init() { + if err := Main.AddCommand(&QueryAPNReview); err != nil { + panic(err) + } +} + var Main = gcmd.Command{ Name: "main", Usage: "main", diff --git a/internal/consts/batch_submit.go b/internal/consts/batch_submit.go index a9b989b..7cc8d17 100644 --- a/internal/consts/batch_submit.go +++ b/internal/consts/batch_submit.go @@ -1,8 +1,9 @@ package consts // BatchSubBatchSize 是批量开停数据 / 停复机执行时,单个上游通道每次请求携带的卡数量上限。 -// 原来一个通道整组一次性发给 upstream-chan,大批量会触发 worker 侧 HTTP 超时(默认 2 分钟上限)导致整批失败。 -// 按此值拆分为多个小请求后,每个请求都能在超时上限内返回。 +// upstream-chan 对同一通道按卡串行调用运营商接口,单个请求耗时随卡数线性增长; +// 因此超时预算必须按「单卡预算 × 卡数」计算并配置足够上限(见 upstream.proxy.maxTimeout), +// 不能依赖固定 2 分钟上限,否则请求仍在上游办理就会被判失败。 const BatchSubBatchSize = 100 // BatchSubBatchConcurrency 是同一通道内子批次的并发度,受信号量限制: diff --git a/internal/consts/lifecycle.go b/internal/consts/lifecycle.go index 03efb56..cece036 100644 --- a/internal/consts/lifecycle.go +++ b/internal/consts/lifecycle.go @@ -71,17 +71,23 @@ const ( BatchAsyncStatusSuccess = 3 BatchAsyncStatusFailed = 4 BatchAsyncStatusPartialSuccess = 5 + BatchAsyncStatusUnknown = 6 + BatchAsyncStatusReview = 6 BatchRunResultPending = 0 BatchRunResultRunning = 1 BatchRunResultSuccess = 2 BatchRunResultFailed = 3 BatchRunResultPartialSuccess = 4 + BatchRunResultUnknown = 5 + BatchRunResultReview = 5 - BatchDetailStatusPending = 0 - BatchDetailStatusRunning = 1 - BatchDetailStatusSuccess = 2 - BatchDetailStatusFailed = 3 + BatchDetailStatusPending = 0 + BatchDetailStatusRunning = 1 + BatchDetailStatusSuccess = 2 + BatchDetailStatusFailed = 3 + BatchDetailStatusUnknown = 4 + BatchDetailStatusNotExecuted = 5 ResourceTypeExcel = 1 DownloadStatusGenerated = 1 diff --git a/internal/consts/queue.go b/internal/consts/queue.go index 79cd451..ae31870 100644 --- a/internal/consts/queue.go +++ b/internal/consts/queue.go @@ -25,6 +25,9 @@ const ( QueueBatchRestartResumeDelay = "iotlcm.batch.restart.resume.delay" QueuePackageCustomerSync = "iotlcm.package_customer.sync" QueueChannelRefresh = "iotlcm.channel.refresh" + // QueueDataReviewQuery 承载"手动同步上游结果"命令:Admin 发布父批号, + // Worker 只对既有 operationId 做只读补查并回写,绝不重发办理。 + QueueDataReviewQuery = "iotlcm.data.review.query" // QueueBalanceRechargeDeadLetter 保存重试次数耗尽后仍失败的充值超时消息, // 供人工排查(例如支付宝凭据未配置导致的永久失败),不再自动重投。 QueueBalanceRechargeDeadLetter = "iotlcm.balance.recharge.dlq" diff --git a/internal/consts/role_sync.go b/internal/consts/role_sync.go new file mode 100644 index 0000000..1f2d6e4 --- /dev/null +++ b/internal/consts/role_sync.go @@ -0,0 +1,11 @@ +package consts + +const ( + DefaultSystemTenantCode = "000000" + SuperAdminRoleId = int64(1) + SysRoleMenuDataScopeRole = "0" + SysRoleDataScopeAll = "1" + SysRoleDataScopeCustom = "2" + SysRoleDataScopeDeptAndBelow = "4" + QueueAgentRoleSync = "iotlcm.agent.role.sync" +) diff --git a/internal/consumer/rabbitmq/batch_durable_test.go b/internal/consumer/rabbitmq/batch_durable_test.go new file mode 100644 index 0000000..624297f --- /dev/null +++ b/internal/consumer/rabbitmq/batch_durable_test.go @@ -0,0 +1,247 @@ +package rabbitmq + +import ( + "context" + "errors" + "path/filepath" + "testing" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch" + mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" + batchrepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/batch" + datarepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/data" + dataservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data" +) + +type durableBatchRepo struct{ failed int } + +func (*durableBatchRepo) FindMain(context.Context, string) (*entity.BatchMain, error) { + return &entity.BatchMain{BatchNo: "APN-RECOVER", BatchType: 109, BatchAsyncStatus: batch.AsyncStatusPending}, nil +} +func (*durableBatchRepo) MarkProcessing(context.Context, string, int) (bool, error) { return true, nil } +func (r *durableBatchRepo) MarkFailed(context.Context, string, int, string) (bool, error) { + r.failed++ + return true, nil +} +func (*durableBatchRepo) MarkSuccess(context.Context, string, int, int, int, string) (bool, error) { + return true, nil +} + +type durableBatchExecutor struct { + calls, protections int + retryable bool + protectionErr error +} + +func (e *durableBatchExecutor) Execute(context.Context, string) error { + e.calls++ + if e.retryable { + return batch.Retryable(errors.New("准备中断")) + } + return errors.New("本地执行中断") +} +func (e *durableBatchExecutor) ProtectExecutionFailure(ctx context.Context, _ string, _ int, _ string) (bool, error) { + e.protections++ + if ctx.Err() != nil { + return true, ctx.Err() + } + return true, e.protectionErr +} + +func TestDurableAPNConsumerNeverFallsThroughToGenericFailure(t *testing.T) { + repo := &durableBatchRepo{} + executor := &durableBatchExecutor{} + dispatcher := batch.NewDispatcher(nil) + dispatcher.Register(109, executor) + consumers := &Consumers{batch: batch.NewRuntime(repo, dispatcher)} + if err := consumers.handleBatchExecute(context.Background(), batch.Message{BatchNo: "APN-RECOVER", BatchType: 109}); err != nil { + t.Fatal(err) + } + if executor.calls != 1 || executor.protections != 1 || repo.failed != 0 { + t.Fatalf("消费者普通错误越过durable保护: calls=%d protections=%d failed=%d", executor.calls, executor.protections, repo.failed) + } +} + +func TestDurableAPNConsumerRetryExhaustionProtectsBeforePublish(t *testing.T) { + repo := &durableBatchRepo{} + dbDown := errors.New("模拟保护数据库不可用") + executor := &durableBatchExecutor{retryable: true, protectionErr: dbDown} + dispatcher := batch.NewDispatcher(nil) + dispatcher.Register(109, executor) + consumers := &Consumers{batch: batch.NewRuntime(repo, dispatcher), batchExecuteMaxRetries: 0} + ctx := mq.WithDeliveryMetadata(context.Background(), mq.DeliveryMetadata{Headers: map[string]any{batchRetryCountHeader: 3}}) + err := consumers.handleBatchExecute(ctx, batch.Message{BatchNo: "APN-RECOVER", BatchType: 109}) + // 保护失败必须在消息发布前返回,测试不会连接真实RabbitMQ。 + if !errors.Is(err, dbDown) || executor.calls != 1 || executor.protections != 1 || repo.failed != 0 { + t.Fatalf("重试耗尽错误终结或重发: err=%v calls=%d protections=%d failed=%d", err, executor.calls, executor.protections, repo.failed) + } + executor.protectionErr = nil + if err := consumers.handleBatchExecute(context.Background(), batch.Message{BatchNo: "APN-RECOVER", BatchType: 109, FailureReason: "重试耗尽"}); err != nil { + t.Fatal(err) + } + if executor.calls != 1 || executor.protections != 2 || repo.failed != 0 { + t.Fatal("失败重投重新执行或MarkFailed") + } +} + +// reentryWriteCounter 同时覆盖同步写、异步提交与结果查询,重入准备期间均不得调用。 +type reentryWriteCounter struct{ syncWrites, asyncWrites, queries int } + +func (u *reentryWriteCounter) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + u.syncWrites++ + return nil, nil +} +func (u *reentryWriteCounter) SubmitSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + u.asyncWrites++ + return nil, nil +} +func (u *reentryWriteCounter) QuerySimApnFunctionResult(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + u.queries++ + return nil, nil +} + +func TestAPNProcessingRabbitMQReentryRequiresDurableParent(t *testing.T) { + ctx := context.Background() + if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(" + filepath.ToSlash(filepath.Join(t.TempDir(), "apn-reentry.db")) + ")"}}}); err != nil { + t.Fatal(err) + } + db := g.DB() + t.Cleanup(func() { + if err := db.Close(ctx); err != nil { + t.Error(err) + } + }) + for _, sql := range []string{ + "CREATE TABLE batch_main (id INTEGER PRIMARY KEY,batch_no TEXT UNIQUE,batch_type INTEGER,batch_async_status INTEGER,tenant_id TEXT,start_time DATETIME,batch_info TEXT,run_result INTEGER DEFAULT 0,valid_count INTEGER DEFAULT 0,success_count INTEGER DEFAULT 0,fail_count INTEGER DEFAULT 0,fail_reason TEXT,end_time DATETIME,updated_at DATETIME,created_by INTEGER,created_dept INTEGER,updated_by INTEGER)", + "CREATE TABLE batch_detail (id INTEGER PRIMARY KEY AUTOINCREMENT,batch_no TEXT,batch_type INTEGER,row_no INTEGER,item_id TEXT,detail_status INTEGER,error_reason TEXT,snapshot TEXT,updated_by INTEGER)", + "CREATE TABLE t_sim_card_info (id INTEGER PRIMARY KEY AUTOINCREMENT,tenant_id TEXT,iccid TEXT,msisdn TEXT,imsi TEXT,chan_key TEXT,data_service_status INTEGER)", + "CREATE TABLE data_operation_parent (batch_no TEXT PRIMARY KEY,ready INTEGER DEFAULT 0,finalize_token TEXT,finalize_until DATETIME,recovery_attempt_at DATETIME,last_reason TEXT)", + "CREATE TABLE data_operation_sub_batch (id INTEGER PRIMARY KEY AUTOINCREMENT,batch_no TEXT,batch_type INTEGER,chan_key TEXT,operation_id TEXT UNIQUE,detail_ids TEXT,request_data TEXT,job_id TEXT,status TEXT,poll_count INTEGER DEFAULT 0,next_check_at DATETIME,submitted_at DATETIME,lease_token TEXT,lease_until DATETIME,created_at DATETIME DEFAULT CURRENT_TIMESTAMP,last_reason TEXT)", + "CREATE TABLE data_operation_sub_detail (detail_id INTEGER PRIMARY KEY,operation_id TEXT,batch_no TEXT,iccid TEXT,UNIQUE(batch_no,iccid))", + "CREATE TABLE data_operation_card_claim (tenant_id TEXT,iccid TEXT,batch_id INTEGER,operation_id TEXT,active_batch_id INTEGER DEFAULT 0,active_operation_id TEXT DEFAULT '',PRIMARY KEY(tenant_id,iccid))", + } { + if _, err := db.Exec(ctx, sql); err != nil { + t.Fatalf("初始化本机 SQLite fixture: %v", err) + } + } + for _, tc := range []struct { + name string + old, retryable bool + }{ + {name: "legacy-ordinary", old: true}, + {name: "legacy-retryable", old: true, retryable: true}, + {name: "durable-ordinary"}, + {name: "durable-retryable", retryable: true}, + } { + t.Run(tc.name, func(t *testing.T) { + status := batch.AsyncStatusPending + if tc.old { + status = batch.AsyncStatusProcessing + } + // 使用 NULL 旧首发时间以确保 ReclaimProcessing 的 WHERE start_time IS NULL 可以命中。 + if _, err := db.Exec(ctx, "INSERT INTO batch_main(batch_no,batch_type,batch_async_status,tenant_id) VALUES (?,109,?,'tenant')", tc.name, status); err != nil { + t.Fatal(err) + } + for i := 0; i < 10; i++ { + iccid := tc.name + "-card-" + string(rune('0'+i)) + detailStatus := batch.DetailStatusPending + if tc.old { + detailStatus = batch.DetailStatusRunning + } + if _, err := db.Exec(ctx, "INSERT INTO batch_detail(batch_no,batch_type,row_no,item_id,detail_status,snapshot) VALUES (?,109,?,?,?,?)", tc.name, i+1, iccid, detailStatus, `{"authorized":true}`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(ctx, "INSERT INTO t_sim_card_info(tenant_id,iccid,msisdn,imsi,chan_key,data_service_status) VALUES ('tenant',?,?,?,'mock-apn',0)", iccid, "130000000"+string(rune('0'+i)), "460000000"+string(rune('0'+i))); err != nil { + t.Fatal(err) + } + } + mainRepo := batchrepo.New() + if tc.old && tc.retryable { + // 先证实同一 fixture 的旧批确实可被重领,再复位空首发时间。 + claimed, err := mainRepo.ReclaimProcessing(ctx, tc.name, 109, nil) + if err != nil || !claimed { + t.Fatalf("重领 CAS 必须可命中,避免假阳性: claimed=%t err=%v", claimed, err) + } + if _, err := db.Exec(ctx, "UPDATE batch_main SET start_time=NULL WHERE batch_no=?", tc.name); err != nil { + t.Fatal(err) + } + } + if !tc.old { + claimed, err := mainRepo.MarkProcessing(ctx, tc.name, 109) + if err != nil || !claimed { + t.Fatalf("新批需同事务领取 Processing 与 ready=0 父凭据: claimed=%t err=%v", claimed, err) + } + } + parent, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_parent WHERE batch_no=? AND ready=0", tc.name) + if err != nil || parent.Int() != 1 && !tc.old || parent.Int() != 0 && tc.old { + t.Fatalf("前置凭据不符: old=%t ready0=%d err=%v", tc.old, parent.Int(), err) + } + before, err := db.GetValue(ctx, "SELECT start_time FROM batch_main WHERE batch_no=?", tc.name) + if err != nil { + t.Fatal(err) + } + upstream := &reentryWriteCounter{} + dispatcher := batch.NewDispatcher(nil) + dispatcher.Register(109, dataservice.NewExecutor(datarepo.New(), upstream)) + consumer := &Consumers{batch: batch.NewRuntime(mainRepo, dispatcher)} + err = consumer.handleBatchExecute(ctx, batch.Message{BatchNo: tc.name, BatchType: 109, Retryable: tc.retryable}) + if err != nil { + t.Errorf("重入应安全跳过旧批或准备新批,不能进入消息重发路径: %v", err) + } + if upstream.syncWrites != 0 || upstream.asyncWrites != 0 || upstream.queries != 0 { + t.Errorf("重入意外调用上游: sync=%d submit=%d query=%d", upstream.syncWrites, upstream.asyncWrites, upstream.queries) + } + main, err := mainRepo.FindMain(ctx, tc.name) + if err != nil || main == nil || main.BatchAsyncStatus != batch.AsyncStatusProcessing { + t.Fatalf("批状态被意外终结: main=%+v err=%v", main, err) + } + remaining, err := db.GetValue(ctx, "SELECT COUNT(*) FROM batch_detail WHERE batch_no=? AND detail_status=1", tc.name) + if err != nil || remaining.Int() != 10 { + t.Fatalf("运行中明细不能被更改: count=%d err=%v", remaining.Int(), err) + } + intent, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_sub_batch WHERE batch_no=? AND status='prepared'", tc.name) + if err != nil { + t.Fatal(err) + } + if tc.old { + if intent.Int() != 0 { + t.Errorf("无凭据历史批不应产生新提交意图: %d", intent.Int()) + } + assigned, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_sub_detail WHERE batch_no=?", tc.name) + if err != nil || assigned.Int() != 0 { + t.Errorf("历史批不可冻结明细: count=%d err=%v", assigned.Int(), err) + } + claims, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_card_claim WHERE operation_id!='' AND iccid LIKE ?", tc.name+"-%") + if err != nil || claims.Int() != 0 { + t.Errorf("历史批不可建立卡级提交占位: count=%d err=%v", claims.Int(), err) + } + parent, err = db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_parent WHERE batch_no=?", tc.name) + if err != nil || parent.Int() != 0 { + t.Errorf("历史批不可补造父凭据: count=%d err=%v", parent.Int(), err) + } + after, err := db.GetValue(ctx, "SELECT start_time FROM batch_main WHERE batch_no=?", tc.name) + if err != nil || after.String() != before.String() { + t.Errorf("旧批重入不可重领取: before=%s after=%s err=%v", before.String(), after.String(), err) + } + } else { + if intent.Int() != 1 { + t.Errorf("新批 ready=0 未正确恢复成 10 卡一个冻结意图: %d", intent.Int()) + } + parent, err = db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_parent WHERE batch_no=? AND ready=1", tc.name) + if err != nil || parent.Int() != 1 { + t.Errorf("新批冻结意图与 ready=1 未原子提交: count=%d err=%v", parent.Int(), err) + } + assigned, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_sub_detail WHERE batch_no=?", tc.name) + if err != nil || assigned.Int() != 10 { + t.Errorf("新批冻结明细必须覆盖全部 10 卡: count=%d err=%v", assigned.Int(), err) + } + } + }) + } +} diff --git a/internal/consumer/rabbitmq/channel_refresh_test.go b/internal/consumer/rabbitmq/channel_refresh_test.go index dd3e905..207c753 100644 --- a/internal/consumer/rabbitmq/channel_refresh_test.go +++ b/internal/consumer/rabbitmq/channel_refresh_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gcfg" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" ) func setChannelRefreshTestConfig(t *testing.T, gateway, proxy string) { diff --git a/internal/consumer/rabbitmq/consumer.go b/internal/consumer/rabbitmq/consumer.go index 4612193..fee2421 100644 --- a/internal/consumer/rabbitmq/consumer.go +++ b/internal/consumer/rabbitmq/consumer.go @@ -56,6 +56,11 @@ type ImeiQueueStartHandler interface { Produce(context.Context, model.ImeiQueueStartMessage) error } +// DataReviewQueryHandler 执行"手动同步上游结果":只读补查既有 operationId。 +type DataReviewQueryHandler interface { + QueryReviewBatch(ctx context.Context, batchNo string) (int, error) +} + type Consumers struct { trafficUsage TrafficUsageHandler workOrder WorkOrderHandler @@ -72,6 +77,14 @@ type Consumers struct { simStatusQueueStart SimStatusQueueStartHandler simStatusQueueTask SimStatusQueueTaskHandler imeiQueueStart ImeiQueueStartHandler + dataReview DataReviewQueryHandler +} + +// WithDataReviewQuery adds the manual "sync upstream result" consumer without +// changing any existing RabbitMQ consumer wiring. +func (c *Consumers) WithDataReviewQuery(handler DataReviewQueryHandler) *Consumers { + c.dataReview = handler + return c } // WithDailyUsageStart adds the previous-day usage producer without changing any @@ -145,6 +158,7 @@ func (c *Consumers) initializers(workerDiagnosticEnabled bool) []func(context.Co c.initBalanceRechargeTimeout, c.initBatchExecute, c.initChannelRefresh, + c.initDataReviewQuery, } if c.packageCustomerSync != nil { initializers = append(initializers, c.initPackageCustomerSync) diff --git a/internal/consumer/rabbitmq/consumer_test.go b/internal/consumer/rabbitmq/consumer_test.go index 58b25bd..3109c8d 100644 --- a/internal/consumer/rabbitmq/consumer_test.go +++ b/internal/consumer/rabbitmq/consumer_test.go @@ -300,7 +300,7 @@ func TestHandleWorkerDiagnostic(t *testing.T) { message := model.WorkerDiagnosticMessage{ TraceID: "worker-test-1", - SentAt: 1780000000000, + SentAt: 1784030000000, Message: "worker ping", } if err := consumers.handleWorkerDiagnostic(context.Background(), message); err != nil { @@ -311,14 +311,14 @@ func TestHandleWorkerDiagnostic(t *testing.T) { func TestWorkerDiagnosticMessageJSONContract(t *testing.T) { message := model.WorkerDiagnosticMessage{ TraceID: "worker-test-1", - SentAt: 1780000000000, + SentAt: 1784030000000, Message: "worker ping", } body, err := json.Marshal(message) if err != nil { t.Fatalf("marshal diagnostic message: %v", err) } - const expected = `{"traceId":"worker-test-1","sentAt":1780000000000,"message":"worker ping"}` + const expected = `{"traceId":"worker-test-1","sentAt":1784030000000,"message":"worker ping"}` if string(body) != expected { t.Fatalf("unexpected diagnostic JSON: %s", body) } @@ -517,10 +517,11 @@ func TestMonthlyHistoryStartConsumerIsOptIn(t *testing.T) { func TestConsumerInitializersRespectWorkerDiagnosticSwitch(t *testing.T) { consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}) - if got := len(consumers.initializers(false)); got != 6 { - t.Fatalf("diagnostic disabled: expected 6 business consumers, got %d", got) + // 手动同步上游结果(initDataReviewQuery)随业务消费者常驻,无处理器时自跳过。 + if got := len(consumers.initializers(false)); got != 7 { + t.Fatalf("diagnostic disabled: expected 7 business consumers, got %d", got) } - if got := len(consumers.initializers(true)); got != 7 { - t.Fatalf("diagnostic enabled: expected 7 consumers, got %d", got) + if got := len(consumers.initializers(true)); got != 8 { + t.Fatalf("diagnostic enabled: expected 8 consumers, got %d", got) } } diff --git a/internal/consumer/rabbitmq/data_review.go b/internal/consumer/rabbitmq/data_review.go new file mode 100644 index 0000000..813babc --- /dev/null +++ b/internal/consumer/rabbitmq/data_review.go @@ -0,0 +1,46 @@ +package rabbitmq + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" +) + +func (c *Consumers) initDataReviewQuery(ctx context.Context) error { + if c.dataReview == nil { + return nil + } + if !mq.Enabled(ctx) { + g.Log().Info(ctx, "data review query consumer skipped, RabbitMQ disabled") + return nil + } + if err := mq.ConsumeJSON[model.DataReviewQueryMessage]( + ctx, + consts.QueueDataReviewQuery, + "iotlcm-data-review-query", + c.handleDataReviewQuery, + ); err != nil { + return err + } + g.Log().Infof(ctx, "data review query consumer started, queue:%s", consts.QueueDataReviewQuery) + return nil +} + +func (c *Consumers) handleDataReviewQuery(ctx context.Context, message model.DataReviewQueryMessage) error { + if c.dataReview == nil || message.BatchNo == "" { + g.Log().Errorf(ctx, "data review query message dropped, invalid message:%+v", message) + return nil + } + queried, err := c.dataReview.QueryReviewBatch(ctx, message.BatchNo) + if err != nil { + // 手动同步失败只记日志,不重投:重投也不会改变上游结果,且可能放大查询压力。 + g.Log().Errorf(ctx, "手动同步APN上游结果失败 batchNo=%s 已同步=%d error=%v", message.BatchNo, queried, err) + return nil + } + g.Log().Infof(ctx, "手动同步APN上游结果完成 batchNo=%s 已同步=%d", message.BatchNo, queried) + return nil +} diff --git a/internal/consumer/rabbitmq/traffic_usage_retry.go b/internal/consumer/rabbitmq/traffic_usage_retry.go index e34cce4..04f22d8 100644 --- a/internal/consumer/rabbitmq/traffic_usage_retry.go +++ b/internal/consumer/rabbitmq/traffic_usage_retry.go @@ -6,11 +6,11 @@ import ( "errors" "time" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/trafficusage" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" ) var trafficUsagePublish = mq.PublishBytesWithHeaders diff --git a/internal/consumer/rabbitmq/traffic_usage_retry_test.go b/internal/consumer/rabbitmq/traffic_usage_retry_test.go index ed109db..70e52db 100644 --- a/internal/consumer/rabbitmq/traffic_usage_retry_test.go +++ b/internal/consumer/rabbitmq/traffic_usage_retry_test.go @@ -4,10 +4,10 @@ import ( "context" "encoding/json" "errors" + amqp "github.com/rabbitmq/amqp091-go" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/trafficusage" - amqp "github.com/rabbitmq/amqp091-go" "testing" "time" ) diff --git a/internal/dao/internal/sys_config.go b/internal/dao/internal/sys_config.go new file mode 100644 index 0000000..1aa23f8 --- /dev/null +++ b/internal/dao/internal/sys_config.go @@ -0,0 +1,101 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// SysConfigDao is the data access object for the table sys_config. +type SysConfigDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns SysConfigColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysConfigColumns defines and stores column names for the table sys_config. +type SysConfigColumns struct { + ConfigId string // + TenantId string // + ConfigName string // + ConfigKey string // + ConfigValue string // + ConfigType string // + CreatedDept string // + CreatedBy string // + CreatedAt string // + UpdatedBy string // + UpdatedAt string // + Remark string // +} + +// sysConfigColumns holds the columns for the table sys_config. +var sysConfigColumns = SysConfigColumns{ + ConfigId: "config_id", + TenantId: "tenant_id", + ConfigName: "config_name", + ConfigKey: "config_key", + ConfigValue: "config_value", + ConfigType: "config_type", + CreatedDept: "created_dept", + CreatedBy: "created_by", + CreatedAt: "created_at", + UpdatedBy: "updated_by", + UpdatedAt: "updated_at", + Remark: "remark", +} + +// NewSysConfigDao creates and returns a new DAO object for table data access. +func NewSysConfigDao(handlers ...gdb.ModelHandler) *SysConfigDao { + return &SysConfigDao{ + group: "default", + table: "sys_config", + columns: sysConfigColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysConfigDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysConfigDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysConfigDao) Columns() SysConfigColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysConfigDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *SysConfigDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *SysConfigDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/sys_permission_revision.go b/internal/dao/internal/sys_permission_revision.go new file mode 100644 index 0000000..66c22f3 --- /dev/null +++ b/internal/dao/internal/sys_permission_revision.go @@ -0,0 +1,83 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// SysPermissionRevisionDao is the data access object for the table sys_permission_revision. +type SysPermissionRevisionDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns SysPermissionRevisionColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysPermissionRevisionColumns defines and stores column names for the table sys_permission_revision. +type SysPermissionRevisionColumns struct { + Id string // + Revision string // + UpdatedAt string // +} + +// sysPermissionRevisionColumns holds the columns for the table sys_permission_revision. +var sysPermissionRevisionColumns = SysPermissionRevisionColumns{ + Id: "id", + Revision: "revision", + UpdatedAt: "updated_at", +} + +// NewSysPermissionRevisionDao creates and returns a new DAO object for table data access. +func NewSysPermissionRevisionDao(handlers ...gdb.ModelHandler) *SysPermissionRevisionDao { + return &SysPermissionRevisionDao{ + group: "default", + table: "sys_permission_revision", + columns: sysPermissionRevisionColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysPermissionRevisionDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysPermissionRevisionDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysPermissionRevisionDao) Columns() SysPermissionRevisionColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysPermissionRevisionDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *SysPermissionRevisionDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *SysPermissionRevisionDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/sys_role.go b/internal/dao/internal/sys_role.go new file mode 100644 index 0000000..cdc588e --- /dev/null +++ b/internal/dao/internal/sys_role.go @@ -0,0 +1,119 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// SysRoleDao is the data access object for the table sys_role. +type SysRoleDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns SysRoleColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysRoleColumns defines and stores column names for the table sys_role. +type SysRoleColumns struct { + RoleId string // + TenantId string // + DeptId string // + RoleName string // + RoleKey string // + RoleSort string // + DataScope string // + MenuCheckStrictly string // + DeptCheckStrictly string // + Status string // + IsPass string // + SourceRoleId string // + SourceDeptId string // + CreatedDept string // + CreatedBy string // + CreatedAt string // + UpdatedBy string // + UpdatedAt string // + DeletedBy string // + DeletedAt string // + Remark string // +} + +// sysRoleColumns holds the columns for the table sys_role. +var sysRoleColumns = SysRoleColumns{ + RoleId: "role_id", + TenantId: "tenant_id", + DeptId: "dept_id", + RoleName: "role_name", + RoleKey: "role_key", + RoleSort: "role_sort", + DataScope: "data_scope", + MenuCheckStrictly: "menu_check_strictly", + DeptCheckStrictly: "dept_check_strictly", + Status: "status", + IsPass: "is_pass", + SourceRoleId: "source_role_id", + SourceDeptId: "source_dept_id", + CreatedDept: "created_dept", + CreatedBy: "created_by", + CreatedAt: "created_at", + UpdatedBy: "updated_by", + UpdatedAt: "updated_at", + DeletedBy: "deleted_by", + DeletedAt: "deleted_at", + Remark: "remark", +} + +// NewSysRoleDao creates and returns a new DAO object for table data access. +func NewSysRoleDao(handlers ...gdb.ModelHandler) *SysRoleDao { + return &SysRoleDao{ + group: "default", + table: "sys_role", + columns: sysRoleColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysRoleDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysRoleDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysRoleDao) Columns() SysRoleColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysRoleDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *SysRoleDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *SysRoleDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/sys_role_dept.go b/internal/dao/internal/sys_role_dept.go new file mode 100644 index 0000000..f39c574 --- /dev/null +++ b/internal/dao/internal/sys_role_dept.go @@ -0,0 +1,83 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// SysRoleDeptDao is the data access object for the table sys_role_dept. +type SysRoleDeptDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns SysRoleDeptColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysRoleDeptColumns defines and stores column names for the table sys_role_dept. +type SysRoleDeptColumns struct { + TenantId string // + RoleId string // + DeptId string // +} + +// sysRoleDeptColumns holds the columns for the table sys_role_dept. +var sysRoleDeptColumns = SysRoleDeptColumns{ + TenantId: "tenant_id", + RoleId: "role_id", + DeptId: "dept_id", +} + +// NewSysRoleDeptDao creates and returns a new DAO object for table data access. +func NewSysRoleDeptDao(handlers ...gdb.ModelHandler) *SysRoleDeptDao { + return &SysRoleDeptDao{ + group: "default", + table: "sys_role_dept", + columns: sysRoleDeptColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysRoleDeptDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysRoleDeptDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysRoleDeptDao) Columns() SysRoleDeptColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysRoleDeptDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *SysRoleDeptDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *SysRoleDeptDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/sys_role_menu.go b/internal/dao/internal/sys_role_menu.go new file mode 100644 index 0000000..cdeac0c --- /dev/null +++ b/internal/dao/internal/sys_role_menu.go @@ -0,0 +1,85 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// SysRoleMenuDao is the data access object for the table sys_role_menu. +type SysRoleMenuDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns SysRoleMenuColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysRoleMenuColumns defines and stores column names for the table sys_role_menu. +type SysRoleMenuColumns struct { + TenantId string // + RoleId string // + MenuId string // + DataScope string // +} + +// sysRoleMenuColumns holds the columns for the table sys_role_menu. +var sysRoleMenuColumns = SysRoleMenuColumns{ + TenantId: "tenant_id", + RoleId: "role_id", + MenuId: "menu_id", + DataScope: "data_scope", +} + +// NewSysRoleMenuDao creates and returns a new DAO object for table data access. +func NewSysRoleMenuDao(handlers ...gdb.ModelHandler) *SysRoleMenuDao { + return &SysRoleMenuDao{ + group: "default", + table: "sys_role_menu", + columns: sysRoleMenuColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysRoleMenuDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysRoleMenuDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysRoleMenuDao) Columns() SysRoleMenuColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysRoleMenuDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *SysRoleMenuDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *SysRoleMenuDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/sys_role_sync_task.go b/internal/dao/internal/sys_role_sync_task.go new file mode 100644 index 0000000..ebb51a4 --- /dev/null +++ b/internal/dao/internal/sys_role_sync_task.go @@ -0,0 +1,111 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// SysRoleSyncTaskDao is the data access object for the table sys_role_sync_task. +type SysRoleSyncTaskDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns SysRoleSyncTaskColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysRoleSyncTaskColumns defines and stores column names for the table sys_role_sync_task. +type SysRoleSyncTaskColumns struct { + TaskId string // + TenantId string // + SourceRoleId string // + SourceDeptId string // + ActorId string // + Status string // + Attempts string // + NextAttemptAt string // + LeaseUntil string // + LeaseToken string // + Payload string // + LastError string // + TotalCount string // + SuccessCount string // + FailedCount string // + CreatedAt string // + UpdatedAt string // +} + +// sysRoleSyncTaskColumns holds the columns for the table sys_role_sync_task. +var sysRoleSyncTaskColumns = SysRoleSyncTaskColumns{ + TaskId: "task_id", + TenantId: "tenant_id", + SourceRoleId: "source_role_id", + SourceDeptId: "source_dept_id", + ActorId: "actor_id", + Status: "status", + Attempts: "attempts", + NextAttemptAt: "next_attempt_at", + LeaseUntil: "lease_until", + LeaseToken: "lease_token", + Payload: "payload", + LastError: "last_error", + TotalCount: "total_count", + SuccessCount: "success_count", + FailedCount: "failed_count", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +// NewSysRoleSyncTaskDao creates and returns a new DAO object for table data access. +func NewSysRoleSyncTaskDao(handlers ...gdb.ModelHandler) *SysRoleSyncTaskDao { + return &SysRoleSyncTaskDao{ + group: "default", + table: "sys_role_sync_task", + columns: sysRoleSyncTaskColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysRoleSyncTaskDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysRoleSyncTaskDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysRoleSyncTaskDao) Columns() SysRoleSyncTaskColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysRoleSyncTaskDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *SysRoleSyncTaskDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *SysRoleSyncTaskDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/sys_user_role.go b/internal/dao/internal/sys_user_role.go new file mode 100644 index 0000000..6b8fae4 --- /dev/null +++ b/internal/dao/internal/sys_user_role.go @@ -0,0 +1,83 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// SysUserRoleDao is the data access object for the table sys_user_role. +type SysUserRoleDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns SysUserRoleColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysUserRoleColumns defines and stores column names for the table sys_user_role. +type SysUserRoleColumns struct { + TenantId string // + UserId string // + RoleId string // +} + +// sysUserRoleColumns holds the columns for the table sys_user_role. +var sysUserRoleColumns = SysUserRoleColumns{ + TenantId: "tenant_id", + UserId: "user_id", + RoleId: "role_id", +} + +// NewSysUserRoleDao creates and returns a new DAO object for table data access. +func NewSysUserRoleDao(handlers ...gdb.ModelHandler) *SysUserRoleDao { + return &SysUserRoleDao{ + group: "default", + table: "sys_user_role", + columns: sysUserRoleColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysUserRoleDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysUserRoleDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysUserRoleDao) Columns() SysUserRoleColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysUserRoleDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *SysUserRoleDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *SysUserRoleDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/sys_config.go b/internal/dao/sys_config.go new file mode 100644 index 0000000..d6651df --- /dev/null +++ b/internal/dao/sys_config.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal" +) + +// sysConfigDao is the data access object for the table sys_config. +// You can define custom methods on it to extend its functionality as needed. +type sysConfigDao struct { + *internal.SysConfigDao +} + +var ( + // SysConfig is a globally accessible object for table sys_config operations. + SysConfig = sysConfigDao{internal.NewSysConfigDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/sys_permission_revision.go b/internal/dao/sys_permission_revision.go new file mode 100644 index 0000000..7f0b227 --- /dev/null +++ b/internal/dao/sys_permission_revision.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal" +) + +// sysPermissionRevisionDao is the data access object for the table sys_permission_revision. +// You can define custom methods on it to extend its functionality as needed. +type sysPermissionRevisionDao struct { + *internal.SysPermissionRevisionDao +} + +var ( + // SysPermissionRevision is a globally accessible object for table sys_permission_revision operations. + SysPermissionRevision = sysPermissionRevisionDao{internal.NewSysPermissionRevisionDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/sys_role.go b/internal/dao/sys_role.go new file mode 100644 index 0000000..ae2730e --- /dev/null +++ b/internal/dao/sys_role.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal" +) + +// sysRoleDao is the data access object for the table sys_role. +// You can define custom methods on it to extend its functionality as needed. +type sysRoleDao struct { + *internal.SysRoleDao +} + +var ( + // SysRole is a globally accessible object for table sys_role operations. + SysRole = sysRoleDao{internal.NewSysRoleDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/sys_role_dept.go b/internal/dao/sys_role_dept.go new file mode 100644 index 0000000..082ed6f --- /dev/null +++ b/internal/dao/sys_role_dept.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal" +) + +// sysRoleDeptDao is the data access object for the table sys_role_dept. +// You can define custom methods on it to extend its functionality as needed. +type sysRoleDeptDao struct { + *internal.SysRoleDeptDao +} + +var ( + // SysRoleDept is a globally accessible object for table sys_role_dept operations. + SysRoleDept = sysRoleDeptDao{internal.NewSysRoleDeptDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/sys_role_menu.go b/internal/dao/sys_role_menu.go new file mode 100644 index 0000000..1414abb --- /dev/null +++ b/internal/dao/sys_role_menu.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal" +) + +// sysRoleMenuDao is the data access object for the table sys_role_menu. +// You can define custom methods on it to extend its functionality as needed. +type sysRoleMenuDao struct { + *internal.SysRoleMenuDao +} + +var ( + // SysRoleMenu is a globally accessible object for table sys_role_menu operations. + SysRoleMenu = sysRoleMenuDao{internal.NewSysRoleMenuDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/sys_role_sync_task.go b/internal/dao/sys_role_sync_task.go new file mode 100644 index 0000000..711d8a9 --- /dev/null +++ b/internal/dao/sys_role_sync_task.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal" +) + +// sysRoleSyncTaskDao is the data access object for the table sys_role_sync_task. +// You can define custom methods on it to extend its functionality as needed. +type sysRoleSyncTaskDao struct { + *internal.SysRoleSyncTaskDao +} + +var ( + // SysRoleSyncTask is a globally accessible object for table sys_role_sync_task operations. + SysRoleSyncTask = sysRoleSyncTaskDao{internal.NewSysRoleSyncTaskDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/sys_user_role.go b/internal/dao/sys_user_role.go new file mode 100644 index 0000000..42fcc9f --- /dev/null +++ b/internal/dao/sys_user_role.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal" +) + +// sysUserRoleDao is the data access object for the table sys_user_role. +// You can define custom methods on it to extend its functionality as needed. +type sysUserRoleDao struct { + *internal.SysUserRoleDao +} + +var ( + // SysUserRole is a globally accessible object for table sys_user_role operations. + SysUserRole = sysUserRoleDao{internal.NewSysUserRoleDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/fullsync/supply/README.md b/internal/fullsync/supply/README.md index e80340b..cdc0692 100644 --- a/internal/fullsync/supply/README.md +++ b/internal/fullsync/supply/README.md @@ -14,4 +14,18 @@ - 可控时间:阶段累计、处理与库存排除、unknown、采样缺口、并发事件、256 KiB 排名与恢复总量。 - `go build ./...` 已通过。 - 本机 CGO 关闭且未发现 C 编译器,race 尚未执行成功。 -- 全仓测试非全绿; +- 全仓测试非全绿;未改动 origin/main 4369472 上复现 TestConfiguredInboundBasicInfoClientUsesDedicatedEndpoint、TestValidateUpstreamBasicInfoAcceptsMatchingIdentifiers、TestAllocateProcessingRetryAfterHasLargeBatchDefault 失败,后两项出现配置搜索路径 panic。 + +## 预演门禁与回退记录(仅只读核对,尚未部署) + +环境仅 127.0.0.1。 + +- worker 原镜像:sha256:136196e9b05cbf926d4cf51451de99c22c5aa35479f5b60a31d7b0d65c4d9fd3 +- bot 原镜像:sha256:671ebc8f8777b72ca168e24631a5fbb4a1d1ca499b81b3a83415a39c801b170f +- Compose 目录:/home/xjzl/iotlcm-deploy/iotlcmpreview +- worker Compose 组合:docker-compose.yml + docker-compose.channel-scan-30a8a34.yml;bot 来自 docker-compose.yml。 +- worker /app/logs/full-sync 与 bot /data/full-sync 均挂载 /data/logs/iotlcm-worker-svr/full-sync;bot state 挂载 /data/app/iotlcm-bot/state。 +- 递归归档基础校验:1 份,未发现基础格式/终态/汇总计数错误;尚需使用 Bot 完整 Validate 及其实际配置再次核验。 +- 本机 Docker daemon 查询超时;Jenkins list_jobs 返回 401。尚未构建独立镜像、运行 race、保存完整部署配置、检查活跃 Run/Replay 或白名单、重建服务或触发管理端。 + +发布前仍需补齐以上门禁。仅服务级重建 worker/bot,显式 enabled=true,不替换归档/state 目录。先关闭 enabled 并重启 worker 可回退观测;必要时恢复原两服务 digest 和原 Compose/config。不清 Run、归档或发送状态。本轮没有进行规模压测,也不证明全程无缺料或八百万卡容量。 diff --git a/internal/library/batchfields/limits.go b/internal/library/batchfields/limits.go new file mode 100644 index 0000000..11c3922 --- /dev/null +++ b/internal/library/batchfields/limits.go @@ -0,0 +1,50 @@ +package batchfields + +import ( + "regexp" + "strings" + "unicode/utf8" +) + +const ( + ItemIDLimit = 64 + DetailReasonLimit = 512 + FailureReasonByteLimit = 4096 +) + +var mysqlErrorAfterSQL = regexp.MustCompile(`(?i): Error [0-9]{3,5} \([0-9A-Z]{5}\): [^\r\n]{0,160}`) + +func DetailReason(value string) string { + runes := []rune(strings.TrimSpace(value)) + if len(runes) > DetailReasonLimit { + return string(runes[:DetailReasonLimit]) + } + return string(runes) +} + +func ItemID(value string) string { + if utf8.RuneCountInString(value) > ItemIDLimit { + return "" + } + return value +} + +func FailureReason(value string) string { + value = strings.ToValidUTF8(strings.TrimSpace(value), "") + upper := strings.ToUpper(value) + for _, marker := range []string{": INSERT INTO ", ": UPDATE ", ": SELECT ", ": DELETE FROM "} { + if index := strings.Index(upper, marker); index >= 0 { + databaseError := mysqlErrorAfterSQL.FindString(value[index+len(marker):]) + value = value[:index] + " (SQL omitted)" + databaseError + break + } + } + if len(value) <= FailureReasonByteLimit { + return value + } + end := FailureReasonByteLimit - 3 + for end > 0 && !utf8.RuneStart(value[end]) { + end-- + } + return value[:end] + "..." +} diff --git a/internal/library/batchfields/limits_test.go b/internal/library/batchfields/limits_test.go new file mode 100644 index 0000000..3bdfb90 --- /dev/null +++ b/internal/library/batchfields/limits_test.go @@ -0,0 +1,38 @@ +package batchfields + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestItemIDRejectsOversizedCardIdentifier(t *testing.T) { + if got := ItemID(strings.Repeat("9", 65)); got != "" { + t.Fatalf("oversized identifier = %q, want empty", got) + } + if got := ItemID(strings.Repeat("9", 64)); len(got) != 64 { + t.Fatalf("64-character identifier = %q", got) + } +} + +func TestDetailReasonFitsColumn(t *testing.T) { + if got := DetailReason(strings.Repeat("错", 513)); utf8.RuneCountInString(got) != 512 { + t.Fatalf("detail reason length = %d", utf8.RuneCountInString(got)) + } +} + +func TestFailureReasonOmitsStatementAndFitsColumn(t *testing.T) { + value := "insert failed: INSERT INTO batch_detail VALUES (" + strings.Repeat("错", 30000) + ")" + got := FailureReason(value) + if strings.Contains(got, "VALUES") || len(got) > FailureReasonByteLimit || !utf8.ValidString(got) { + t.Fatalf("unsafe failure reason: %q", got) + } + got = FailureReason(strings.Repeat("错", 30000)) + if len(got) > FailureReasonByteLimit || !utf8.ValidString(got) { + t.Fatalf("invalid truncated UTF-8 reason") + } + got = FailureReason("insert detail failed: INSERT INTO batch_detail VALUES ('" + strings.Repeat("9", 1000) + "'): Error 1406 (22001): Data too long for column 'item_id' at row 1") + if strings.Contains(got, "VALUES") || !strings.Contains(got, "Error 1406 (22001)") { + t.Fatalf("SQL should be removed but database error preserved: %q", got) + } +} diff --git a/internal/library/rabbitmq/rabbitmq.go b/internal/library/rabbitmq/rabbitmq.go index 076a8b1..3499760 100644 --- a/internal/library/rabbitmq/rabbitmq.go +++ b/internal/library/rabbitmq/rabbitmq.go @@ -241,24 +241,29 @@ func publishWithConfirm(ctx context.Context, ch *amqp.Channel, queue string, mes // Consume 启动消费者,使用手动 Ack,handler 成功才确认消息。 func Consume(ctx context.Context, queue, consumer string, handler Handler) error { + _, err := ConsumeWithDone(ctx, queue, consumer, handler) + return err +} + +func ConsumeWithDone(ctx context.Context, queue, consumer string, handler Handler) (<-chan struct{}, error) { ch, err := Channel(ctx) if err != nil { - return err + return nil, err } if err = declareQueue(ch, queue); err != nil { _ = ch.Close() - return err + return nil, err } if err = ch.Qos(1, 0, false); err != nil { _ = ch.Close() - return gerror.Wrap(err, "rabbitmq set qos failed") + return nil, gerror.Wrap(err, "rabbitmq set qos failed") } // autoAck=false,业务处理成功后手动 Ack,处理失败时 Nack 并重新入队。 deliveries, err := ch.Consume(queue, consumer, false, false, false, false, nil) if err != nil { _ = ch.Close() - return gerror.Wrap(err, "rabbitmq consume failed") + return nil, gerror.Wrap(err, "rabbitmq consume failed") } consumerState := &managedConsumer{ @@ -269,6 +274,8 @@ func Consume(ctx context.Context, queue, consumer string, handler Handler) error registerConsumer(consumerState) go func() { defer close(consumerState.done) + defer ch.Close() + defer unregisterConsumer(consumerState) for { if ctx.Err() != nil { return @@ -302,7 +309,7 @@ func Consume(ctx context.Context, queue, consumer string, handler Handler) error } } }() - return nil + return consumerState.done, nil } // ConsumeJSON 启动 JSON 消费者,统一完成反序列化和 Ack/Nack 处理。 @@ -423,3 +430,16 @@ func cloneHeaders(headers amqp.Table) map[string]any { func isClosedError(err error) bool { return err == nil || errors.Is(err, amqp.ErrClosed) } + +func unregisterConsumer(target *managedConsumer) { + consumerMu.Lock() + defer consumerMu.Unlock() + for index, consumer := range consumers { + if consumer == target { + remaining := make([]*managedConsumer, 0, len(consumers)-1) + remaining = append(remaining, consumers[:index]...) + consumers = append(remaining, consumers[index+1:]...) + return + } + } +} diff --git a/internal/library/rabbitmq/rabbitmq_test.go b/internal/library/rabbitmq/rabbitmq_test.go index 8747e87..39956c4 100644 --- a/internal/library/rabbitmq/rabbitmq_test.go +++ b/internal/library/rabbitmq/rabbitmq_test.go @@ -149,3 +149,22 @@ func resetRabbitMQState(t *testing.T) { connMu.Unlock() }) } + +func TestUnregisterConsumerPreservesShutdownSnapshot(t *testing.T) { + consumerMu.Lock() + original := consumers + first, second, third := &managedConsumer{}, &managedConsumer{}, &managedConsumer{} + consumers = []*managedConsumer{first, second, third} + snapshot := consumers + consumerMu.Unlock() + defer func() { consumerMu.Lock(); consumers = original; consumerMu.Unlock() }() + unregisterConsumer(second) + consumerMu.Lock() + defer consumerMu.Unlock() + if len(consumers) != 2 || consumers[0] != first || consumers[1] != third { + t.Fatal("consumer not removed") + } + if snapshot[1] != second || snapshot[2] != third { + t.Fatal("shutdown snapshot mutated") + } +} diff --git a/internal/library/rolepolicy/template.go b/internal/library/rolepolicy/template.go new file mode 100644 index 0000000..60a9b09 --- /dev/null +++ b/internal/library/rolepolicy/template.go @@ -0,0 +1,151 @@ +package rolepolicy + +import ( + "context" + "github.com/gogf/gf/v2/errors/gerror" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" + "strconv" + "strings" +) + +const TemplateConfigKey = "agent.role.template.role_id" + +func Template(ctx context.Context) (*entity.SysRole, error) { + var configs []*entity.SysConfig + if err := dao.SysConfig.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("config_key", TemplateConfigKey).Scan(&configs); err != nil { + return nil, err + } + if len(configs) == 0 { + return nil, nil + } + if len(configs) != 1 { + return nil, gerror.New("代理模板角色系统参数重复") + } + return ValidateTemplate(ctx, configs[0].ConfigValue) +} + +func ValidateTemplate(ctx context.Context, value string) (*entity.SysRole, error) { + roleID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || roleID <= 0 || roleID == consts.SuperAdminRoleId { + return nil, gerror.New("代理模板参数必须填写非超级管理员的有效角色ID") + } + var role *entity.SysRole + if err = dao.SysRole.Ctx(ctx).Master().Where("role_id", roleID).Where("tenant_id", consts.DefaultSystemTenantCode).Scan(&role); err != nil { + return nil, err + } + if role == nil || role.Status != "0" || (role.CreatedDept > 0 && role.CreatedDept != role.DeptId) || role.DeptId <= 0 { + return nil, gerror.New("代理模板参数对应角色须正常启用、绑定本级机构且非上级继承角色") + } + if role.DataScope != consts.SysRoleDataScopeDeptAndBelow { + return nil, gerror.New("代理模板数据范围必须为本机构及下级") + } + var dept *entity.SysDept + if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", role.TenantId).Where("dept_id", role.DeptId).Where("status", "0").Scan(&dept); err != nil { + return nil, err + } + if dept == nil { + return nil, gerror.New("代理模板所属机构不存在或已停用") + } + invalidScopes, err := dao.SysRoleMenu.Ctx(ctx).Master().Where("tenant_id", role.TenantId).Where("role_id", role.RoleId).WhereIn("data_scope", []string{consts.SysRoleDataScopeAll, consts.SysRoleDataScopeCustom}).Count() + if err != nil { + return nil, err + } + if invalidScopes > 0 { + return nil, gerror.New("代理模板按钮不允许全部或自定义数据范围") + } + return role, nil +} + +func MenuIDs(ctx context.Context, roleID int64) ([]int64, error) { + var menus []*entity.SysRoleMenu + if err := dao.SysRoleMenu.Ctx(ctx).Master().Where("role_id", roleID).Scan(&menus); err != nil { + return nil, err + } + ids := make([]int64, 0, len(menus)) + for _, menu := range menus { + ids = append(ids, menu.MenuId) + } + return ids, nil +} + +func Intersect(left, right []int64) []int64 { + allowed := make(map[int64]bool, len(right)) + for _, id := range right { + allowed[id] = true + } + result := make([]int64, 0, len(left)) + for _, id := range left { + if allowed[id] { + result = append(result, id) + } + } + return result +} + +func CurrentSuperAdmin(ctx context.Context, userID int64) (bool, error) { + if userID <= 0 { + return false, nil + } + var relations []*entity.SysUserRole + if err := dao.SysUserRole.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("user_id", userID).Where("role_id", consts.SuperAdminRoleId).Scan(&relations); err != nil { + return false, err + } + if len(relations) == 0 { + return false, nil + } + var role *entity.SysRole + if err := dao.SysRole.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("role_id", consts.SuperAdminRoleId).Where("status", "0").Scan(&role); err != nil { + return false, err + } + return role != nil, nil +} + +// RestrictMenus validates organization membership and role bindings. Templates are +// overwrite sources, not runtime permission ceilings: platform role edits must +// remain effective until the next explicit template distribution. +func RestrictMenus(ctx context.Context, tenantID string, deptID int64, ids []int64) ([]int64, error) { + template, err := Template(ctx) + if err != nil { + return nil, err + } + if template == nil { + return ids, nil + } + seen := make(map[int64]bool) + for deptID > 0 { + if seen[deptID] { + return nil, gerror.New("机构上下级存在循环") + } + seen[deptID] = true + var dept *entity.SysDept + if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("dept_id", deptID).Scan(&dept); err != nil { + return nil, err + } + if dept == nil || dept.Status != "0" { + return []int64{}, nil + } + if tenantID == template.TenantId && deptID == template.DeptId { + break + } + if dept.DeptRole == consts.SuperAdminRoleId { + return nil, gerror.Newf("机构%d不能使用超级管理员角色作为代理权限上限,请核对机构角色绑定", dept.DeptId) + } + if dept.DeptRole <= 0 { + return nil, gerror.Newf("机构%d未关联默认角色,请联系平台管理员核对机构角色绑定", dept.DeptId) + } + var role *entity.SysRole + if err := dao.SysRole.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("dept_id", deptID).Where("role_id", dept.DeptRole).Where("status", "0").Scan(&role); err != nil { + return nil, err + } + if role == nil { + return nil, gerror.Newf("机构%d默认角色不存在、已停用或归属不匹配,请联系平台管理员核对", dept.DeptId) + } + deptID = dept.ParentId + } + if tenantID == template.TenantId && !seen[template.DeptId] { + return []int64{}, nil + } + return ids, nil +} diff --git a/internal/library/rolesync/publish.go b/internal/library/rolesync/publish.go new file mode 100644 index 0000000..b398321 --- /dev/null +++ b/internal/library/rolesync/publish.go @@ -0,0 +1,12 @@ +package rolesync + +import ( + "context" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" +) + +var publishRoleSync = func(ctx context.Context, message *model.AgentRoleSyncMessage) error { + return rabbitmq.Publish(ctx, consts.QueueAgentRoleSync, message) +} diff --git a/internal/library/tdengine/config_test.go b/internal/library/tdengine/config_test.go new file mode 100644 index 0000000..4475a7f --- /dev/null +++ b/internal/library/tdengine/config_test.go @@ -0,0 +1,61 @@ +package tdengine + +import ( + "context" + "testing" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" +) + +func TestGetConfigDatabaseRouting(t *testing.T) { + previous := g.Cfg().GetAdapter() + t.Cleanup(func() { g.Cfg().SetAdapter(previous) }) + for _, test := range []struct { + name string + fields string + fallback string + daily string + monthly string + }{ + {"defaults", "", "iotlcm_daily", "iotlcm_daily", "iotlcm_daily"}, + {"legacy", " dbName: legacy\n", "legacy", "legacy", "legacy"}, + {"split", " dbName: legacy\n dailyDbName: iotlcm_daily\n monthlyDbName: iotlcm_monthly\n", "legacy", "iotlcm_daily", "iotlcm_monthly"}, + {"daily_only", " dbName: legacy\n dailyDbName: iotlcm_daily\n", "legacy", "iotlcm_daily", "legacy"}, + {"monthly_only", " dbName: legacy\n monthlyDbName: iotlcm_monthly\n", "legacy", "legacy", "iotlcm_monthly"}, + } { + t.Run(test.name, func(t *testing.T) { + adapter, err := gcfg.NewAdapterContent("tsdb:\n database: TdEngine\n tdengine:\n batchSize: 20\n" + test.fields) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + config := GetConfig(context.Background()) + if config.DbName != test.fallback { + t.Fatalf("fallback=%q want=%q", config.DbName, test.fallback) + } + for _, stable := range []string{config.DailyStable, config.PoolDailyStable, config.PoolCardCountDailyStable} { + if got := config.DbNameFor(stable); got != test.daily { + t.Errorf("%s database=%q want=%q", stable, got, test.daily) + } + } + for _, stable := range []string{config.MonthlyStable, config.PoolMonthlyStable, config.PoolCardCountMonthlyStable} { + if got := config.DbNameFor(stable); got != test.monthly { + t.Errorf("%s database=%q want=%q", stable, got, test.monthly) + } + } + if got := config.DbNameFor("other_stable"); got != test.fallback { + t.Errorf("unknown stable database=%q", got) + } + }) + } +} + +func TestDatabaseRoutingEmptySplitNames(t *testing.T) { + config := Config{DbName: "legacy", DailyStable: "daily", MonthlyStable: "monthly"} + for _, stable := range []string{"daily", "monthly", "other"} { + if got := config.DbNameFor(stable); got != "legacy" { + t.Errorf("%s database=%q", stable, got) + } + } +} diff --git a/internal/model/agent_role_sync.go b/internal/model/agent_role_sync.go new file mode 100644 index 0000000..87b33ff --- /dev/null +++ b/internal/model/agent_role_sync.go @@ -0,0 +1,22 @@ +package model + +type AgentRoleSyncMessage struct { + TemplateRole bool `json:"templateRole,omitempty"` + Superseded bool `json:"superseded,omitempty"` + TaskId string `json:"taskId,omitempty"` + TotalCount int `json:"totalCount,omitempty"` + SuccessCount int `json:"successCount,omitempty"` + FailedCount int `json:"failedCount,omitempty"` + TenantId string `json:"tenantId"` + SourceRoleId int64 `json:"sourceRoleId"` + SourceDeptId int64 `json:"sourceDeptId"` + ActorId int64 `json:"actorId"` + RetryCount int `json:"retryCount"` + ChildDeptIds []int64 `json:"childDeptIds,omitempty"` +} + +type AgentRoleSyncDeadLetterMessage struct { + Message AgentRoleSyncMessage `json:"message"` + Error string `json:"error"` + FailedAt string `json:"failedAt"` +} diff --git a/internal/model/data_review.go b/internal/model/data_review.go new file mode 100644 index 0000000..6831254 --- /dev/null +++ b/internal/model/data_review.go @@ -0,0 +1,8 @@ +package model + +// DataReviewQueryMessage 是"手动同步上游结果"命令。 +// 只按既有 operationId/jobId 查询一次,不重发办理、不清零自动补查计数。 +type DataReviewQueryMessage struct { + BatchNo string `json:"batchNo"` + RequestedBy string `json:"requestedBy,omitempty"` +} diff --git a/internal/model/do/sys_config.go b/internal/model/do/sys_config.go new file mode 100644 index 0000000..1b3e8a6 --- /dev/null +++ b/internal/model/do/sys_config.go @@ -0,0 +1,27 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// SysConfig is the golang structure of table sys_config for DAO operations like Where/Data. +type SysConfig struct { + g.Meta `orm:"table:sys_config, do:true"` + ConfigId any // + TenantId any // + ConfigName any // + ConfigKey any // + ConfigValue any // + ConfigType any // + CreatedDept any // + CreatedBy any // + CreatedAt *gtime.Time // + UpdatedBy any // + UpdatedAt *gtime.Time // + Remark any // +} diff --git a/internal/model/do/sys_permission_revision.go b/internal/model/do/sys_permission_revision.go new file mode 100644 index 0000000..fc9e2bc --- /dev/null +++ b/internal/model/do/sys_permission_revision.go @@ -0,0 +1,18 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// SysPermissionRevision is the golang structure of table sys_permission_revision for DAO operations like Where/Data. +type SysPermissionRevision struct { + g.Meta `orm:"table:sys_permission_revision, do:true"` + Id any // + Revision any // + UpdatedAt *gtime.Time // +} diff --git a/internal/model/do/sys_role.go b/internal/model/do/sys_role.go new file mode 100644 index 0000000..5f62f86 --- /dev/null +++ b/internal/model/do/sys_role.go @@ -0,0 +1,36 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// SysRole is the golang structure of table sys_role for DAO operations like Where/Data. +type SysRole struct { + g.Meta `orm:"table:sys_role, do:true"` + RoleId any // + TenantId any // + DeptId any // + RoleName any // + RoleKey any // + RoleSort any // + DataScope any // + MenuCheckStrictly any // + DeptCheckStrictly any // + Status any // + IsPass any // + SourceRoleId any // + SourceDeptId any // + CreatedDept any // + CreatedBy any // + CreatedAt *gtime.Time // + UpdatedBy any // + UpdatedAt *gtime.Time // + DeletedBy any // + DeletedAt *gtime.Time // + Remark any // +} diff --git a/internal/model/do/sys_role_dept.go b/internal/model/do/sys_role_dept.go new file mode 100644 index 0000000..4cda2c3 --- /dev/null +++ b/internal/model/do/sys_role_dept.go @@ -0,0 +1,17 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +// SysRoleDept is the golang structure of table sys_role_dept for DAO operations like Where/Data. +type SysRoleDept struct { + g.Meta `orm:"table:sys_role_dept, do:true"` + TenantId any // + RoleId any // + DeptId any // +} diff --git a/internal/model/do/sys_role_menu.go b/internal/model/do/sys_role_menu.go new file mode 100644 index 0000000..9d90a99 --- /dev/null +++ b/internal/model/do/sys_role_menu.go @@ -0,0 +1,18 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +// SysRoleMenu is the golang structure of table sys_role_menu for DAO operations like Where/Data. +type SysRoleMenu struct { + g.Meta `orm:"table:sys_role_menu, do:true"` + TenantId any // + RoleId any // + MenuId any // + DataScope any // +} diff --git a/internal/model/do/sys_role_sync_task.go b/internal/model/do/sys_role_sync_task.go new file mode 100644 index 0000000..28d4f58 --- /dev/null +++ b/internal/model/do/sys_role_sync_task.go @@ -0,0 +1,32 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// SysRoleSyncTask is the golang structure of table sys_role_sync_task for DAO operations like Where/Data. +type SysRoleSyncTask struct { + g.Meta `orm:"table:sys_role_sync_task, do:true"` + TaskId any // + TenantId any // + SourceRoleId any // + SourceDeptId any // + ActorId any // + Status any // + Attempts any // + NextAttemptAt any // + LeaseUntil any // + LeaseToken any // + Payload any // + LastError any // + TotalCount any // + SuccessCount any // + FailedCount any // + CreatedAt *gtime.Time // + UpdatedAt *gtime.Time // +} diff --git a/internal/model/do/sys_user_role.go b/internal/model/do/sys_user_role.go new file mode 100644 index 0000000..622cda9 --- /dev/null +++ b/internal/model/do/sys_user_role.go @@ -0,0 +1,17 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +// SysUserRole is the golang structure of table sys_user_role for DAO operations like Where/Data. +type SysUserRole struct { + g.Meta `orm:"table:sys_user_role, do:true"` + TenantId any // + UserId any // + RoleId any // +} diff --git a/internal/model/entity/sys_config.go b/internal/model/entity/sys_config.go new file mode 100644 index 0000000..de37a8b --- /dev/null +++ b/internal/model/entity/sys_config.go @@ -0,0 +1,25 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysConfig is the golang structure for table sys_config. +type SysConfig struct { + ConfigId int64 `json:"configId" orm:"config_id" ` // + TenantId string `json:"tenantId" orm:"tenant_id" ` // + ConfigName string `json:"configName" orm:"config_name" ` // + ConfigKey string `json:"configKey" orm:"config_key" ` // + ConfigValue string `json:"configValue" orm:"config_value" ` // + ConfigType string `json:"configType" orm:"config_type" ` // + CreatedDept int64 `json:"createdDept" orm:"created_dept" ` // + CreatedBy int64 `json:"createdBy" orm:"created_by" ` // + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" ` // + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" ` // + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" ` // + Remark string `json:"remark" orm:"remark" ` // +} diff --git a/internal/model/entity/sys_permission_revision.go b/internal/model/entity/sys_permission_revision.go new file mode 100644 index 0000000..e4a6511 --- /dev/null +++ b/internal/model/entity/sys_permission_revision.go @@ -0,0 +1,16 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysPermissionRevision is the golang structure for table sys_permission_revision. +type SysPermissionRevision struct { + Id int `json:"id" orm:"id" ` // + Revision int64 `json:"revision" orm:"revision" ` // + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" ` // +} diff --git a/internal/model/entity/sys_role.go b/internal/model/entity/sys_role.go new file mode 100644 index 0000000..dad3588 --- /dev/null +++ b/internal/model/entity/sys_role.go @@ -0,0 +1,34 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysRole is the golang structure for table sys_role. +type SysRole struct { + RoleId int64 `json:"roleId" orm:"role_id" ` // + TenantId string `json:"tenantId" orm:"tenant_id" ` // + DeptId int64 `json:"deptId" orm:"dept_id" ` // + RoleName string `json:"roleName" orm:"role_name" ` // + RoleKey string `json:"roleKey" orm:"role_key" ` // + RoleSort int64 `json:"roleSort" orm:"role_sort" ` // + DataScope string `json:"dataScope" orm:"data_scope" ` // + MenuCheckStrictly int64 `json:"menuCheckStrictly" orm:"menu_check_strictly" ` // + DeptCheckStrictly int64 `json:"deptCheckStrictly" orm:"dept_check_strictly" ` // + Status string `json:"status" orm:"status" ` // + IsPass int64 `json:"isPass" orm:"is_pass" ` // + SourceRoleId int64 `json:"sourceRoleId" orm:"source_role_id" ` // + SourceDeptId int64 `json:"sourceDeptId" orm:"source_dept_id" ` // + CreatedDept int64 `json:"createdDept" orm:"created_dept" ` // + CreatedBy int64 `json:"createdBy" orm:"created_by" ` // + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" ` // + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" ` // + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" ` // + DeletedBy int64 `json:"deletedBy" orm:"deleted_by" ` // + DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" ` // + Remark string `json:"remark" orm:"remark" ` // +} diff --git a/internal/model/entity/sys_role_dept.go b/internal/model/entity/sys_role_dept.go new file mode 100644 index 0000000..9fffd24 --- /dev/null +++ b/internal/model/entity/sys_role_dept.go @@ -0,0 +1,12 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +// SysRoleDept is the golang structure for table sys_role_dept. +type SysRoleDept struct { + TenantId string `json:"tenantId" orm:"tenant_id" ` // + RoleId int64 `json:"roleId" orm:"role_id" ` // + DeptId int64 `json:"deptId" orm:"dept_id" ` // +} diff --git a/internal/model/entity/sys_role_menu.go b/internal/model/entity/sys_role_menu.go new file mode 100644 index 0000000..e960ad0 --- /dev/null +++ b/internal/model/entity/sys_role_menu.go @@ -0,0 +1,13 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +// SysRoleMenu is the golang structure for table sys_role_menu. +type SysRoleMenu struct { + TenantId string `json:"tenantId" orm:"tenant_id" ` // + RoleId int64 `json:"roleId" orm:"role_id" ` // + MenuId int64 `json:"menuId" orm:"menu_id" ` // + DataScope string `json:"dataScope" orm:"data_scope" ` // +} diff --git a/internal/model/entity/sys_role_sync_task.go b/internal/model/entity/sys_role_sync_task.go new file mode 100644 index 0000000..d7047ca --- /dev/null +++ b/internal/model/entity/sys_role_sync_task.go @@ -0,0 +1,30 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysRoleSyncTask is the golang structure for table sys_role_sync_task. +type SysRoleSyncTask struct { + TaskId string `json:"taskId" orm:"task_id" ` // + TenantId string `json:"tenantId" orm:"tenant_id" ` // + SourceRoleId int64 `json:"sourceRoleId" orm:"source_role_id" ` // + SourceDeptId int64 `json:"sourceDeptId" orm:"source_dept_id" ` // + ActorId int64 `json:"actorId" orm:"actor_id" ` // + Status string `json:"status" orm:"status" ` // + Attempts int `json:"attempts" orm:"attempts" ` // + NextAttemptAt int64 `json:"nextAttemptAt" orm:"next_attempt_at" ` // + LeaseUntil int64 `json:"leaseUntil" orm:"lease_until" ` // + LeaseToken string `json:"leaseToken" orm:"lease_token" ` // + Payload string `json:"payload" orm:"payload" ` // + LastError string `json:"lastError" orm:"last_error" ` // + TotalCount int `json:"totalCount" orm:"total_count" ` // + SuccessCount int `json:"successCount" orm:"success_count" ` // + FailedCount int `json:"failedCount" orm:"failed_count" ` // + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" ` // + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" ` // +} diff --git a/internal/model/entity/sys_user_role.go b/internal/model/entity/sys_user_role.go new file mode 100644 index 0000000..0624e48 --- /dev/null +++ b/internal/model/entity/sys_user_role.go @@ -0,0 +1,12 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +// SysUserRole is the golang structure for table sys_user_role. +type SysUserRole struct { + TenantId string `json:"tenantId" orm:"tenant_id" ` // + UserId int64 `json:"userId" orm:"user_id" ` // + RoleId int64 `json:"roleId" orm:"role_id" ` // +} diff --git a/internal/model/upstream_chan.go b/internal/model/upstream_chan.go index b9c788f..75dc012 100644 --- a/internal/model/upstream_chan.go +++ b/internal/model/upstream_chan.go @@ -101,10 +101,16 @@ type SimCardStatusChangeOutputItem struct { type SimCardApnChangeInput = SimBaseReq[SimCardApnChangeInputItem] type SimCardApnChangeInputItem struct { - OperType string `json:"operType"` - ApnName string `json:"apnName"` + OperType string `json:"operType"` + ApnName string `json:"apnName"` + SubmitOnly bool `json:"submitOnly,omitempty"` + QueryJobID string `json:"queryJobId,omitempty"` + OperationID string `json:"operationId,omitempty"` + RecoverOnly bool `json:"recoverOnly,omitempty"` } type SimCardApnChangeOutputItem struct { - OrderNum string `json:"orderNum"` + OrderNum string `json:"orderNum"` + Pending bool `json:"pending,omitempty"` + Uncertain bool `json:"uncertain,omitempty"` } diff --git a/internal/producer/dailyusage/channel_producer_test.go b/internal/producer/dailyusage/channel_producer_test.go index 0f52318..c5026ca 100644 --- a/internal/producer/dailyusage/channel_producer_test.go +++ b/internal/producer/dailyusage/channel_producer_test.go @@ -12,10 +12,10 @@ import ( "testing" "time" + "github.com/gogf/gf/v2/container/gvar" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil" - "github.com/gogf/gf/v2/container/gvar" ) // datasetScanner applies the SQL eligibility predicate before LIMIT; metadata diff --git a/internal/producer/dailyusage/channel_scanner_test.go b/internal/producer/dailyusage/channel_scanner_test.go index a71b6f7..f4d1f18 100644 --- a/internal/producer/dailyusage/channel_scanner_test.go +++ b/internal/producer/dailyusage/channel_scanner_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" "github.com/gogf/gf/v2/database/gdb" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" ) // SQLite runs the SAME predicate and ordering with only the two MySQL hints diff --git a/internal/producer/imei/lease_io_test.go b/internal/producer/imei/lease_io_test.go index ba4f9c9..3264932 100644 --- a/internal/producer/imei/lease_io_test.go +++ b/internal/producer/imei/lease_io_test.go @@ -16,11 +16,11 @@ import ( "testing" "time" + "github.com/gogf/gf/v2/database/gredis" + goredis "github.com/redis/go-redis/v9" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil" - "github.com/gogf/gf/v2/database/gredis" - goredis "github.com/redis/go-redis/v9" ) // This proxy withholds a real Redis request/reply without closing its TCP socket. diff --git a/internal/producer/monthlyusage/channel_producer_test.go b/internal/producer/monthlyusage/channel_producer_test.go index 68cfe38..36162b0 100644 --- a/internal/producer/monthlyusage/channel_producer_test.go +++ b/internal/producer/monthlyusage/channel_producer_test.go @@ -12,9 +12,9 @@ import ( "testing" "time" + "github.com/gogf/gf/v2/container/gvar" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil" - "github.com/gogf/gf/v2/container/gvar" ) // datasetScanner applies the SQL eligibility predicate before LIMIT; metadata diff --git a/internal/producer/monthlyusage/channel_scanner_test.go b/internal/producer/monthlyusage/channel_scanner_test.go index 82678f9..b47759a 100644 --- a/internal/producer/monthlyusage/channel_scanner_test.go +++ b/internal/producer/monthlyusage/channel_scanner_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" "github.com/gogf/gf/v2/database/gdb" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" ) // SQLite runs the SAME predicate and ordering with only the two MySQL hints diff --git a/internal/producer/monthlyusage/supply_observation.go b/internal/producer/monthlyusage/supply_observation.go index 82645f8..3728084 100644 --- a/internal/producer/monthlyusage/supply_observation.go +++ b/internal/producer/monthlyusage/supply_observation.go @@ -2,8 +2,8 @@ package monthlyusage import ( "context" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply" "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply" "time" ) diff --git a/internal/producer/monthlyusage/supply_observation_test.go b/internal/producer/monthlyusage/supply_observation_test.go index fd812fe..c691bf6 100644 --- a/internal/producer/monthlyusage/supply_observation_test.go +++ b/internal/producer/monthlyusage/supply_observation_test.go @@ -3,11 +3,11 @@ package monthlyusage import ( "context" "fmt" + "github.com/gogf/gf/v2/container/gvar" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil" - "github.com/gogf/gf/v2/container/gvar" "strings" "testing" "time" diff --git a/internal/producer/monthlyusagehistory/channel_producer_test.go b/internal/producer/monthlyusagehistory/channel_producer_test.go index 54349c8..b9e5d00 100644 --- a/internal/producer/monthlyusagehistory/channel_producer_test.go +++ b/internal/producer/monthlyusagehistory/channel_producer_test.go @@ -12,10 +12,10 @@ import ( "testing" "time" + "github.com/gogf/gf/v2/container/gvar" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil" - "github.com/gogf/gf/v2/container/gvar" ) // datasetScanner applies the SQL eligibility predicate before LIMIT; metadata diff --git a/internal/producer/monthlyusagehistory/channel_scanner_test.go b/internal/producer/monthlyusagehistory/channel_scanner_test.go index fc31a6a..d6c2634 100644 --- a/internal/producer/monthlyusagehistory/channel_scanner_test.go +++ b/internal/producer/monthlyusagehistory/channel_scanner_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" "github.com/gogf/gf/v2/database/gdb" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" ) // SQLite runs the SAME predicate and ordering with only the two MySQL hints diff --git a/internal/queue/zset/run.go b/internal/queue/zset/run.go index 8d827ba..0e65da3 100644 --- a/internal/queue/zset/run.go +++ b/internal/queue/zset/run.go @@ -15,9 +15,9 @@ import ( "strings" "time" + "github.com/gogf/gf/v2/errors/gerror" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" - "github.com/gogf/gf/v2/errors/gerror" ) // ErrRunNotFound reports that a run's metadata hash does not exist. Callers use diff --git a/internal/repository/batch/repository.go b/internal/repository/batch/repository.go index 60c9721..036748a 100644 --- a/internal/repository/batch/repository.go +++ b/internal/repository/batch/repository.go @@ -10,6 +10,7 @@ import ( batchdomain "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" ) @@ -71,6 +72,11 @@ func (r *Repository) MarkProcessing(ctx context.Context, batchNo string, batchTy Update(); updateErr != nil { return gerror.Wrap(updateErr, "mark pending batch details running failed") } + if batchType == 107 || batchType == 109 || batchType == 110 { + if _, insertErr := tx.Exec("INSERT INTO data_operation_parent (batch_no,ready) VALUES (?,0)", batchNo); insertErr != nil { + return gerror.Wrap(insertErr, "create durable APN parent marker failed") + } + } claimed = true return nil }) @@ -114,7 +120,7 @@ func (r *Repository) MarkFailed(ctx context.Context, batchNo string, batchType i Data(do.BatchMain{ BatchAsyncStatus: batchdomain.AsyncStatusFailed, RunResult: batchdomain.RunResultFailed, - FailReason: reason, + FailReason: batchfields.FailureReason(reason), EndTime: gtime.Now(), }). Update() diff --git a/internal/repository/batch/repository_test.go b/internal/repository/batch/repository_test.go index f1278ca..f741f05 100644 --- a/internal/repository/batch/repository_test.go +++ b/internal/repository/batch/repository_test.go @@ -10,6 +10,7 @@ import ( batchdomain "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" ) @@ -150,6 +151,26 @@ func TestMarkFailedTruncatesDetailReason(t *testing.T) { } } +func TestMarkFailedKeepsMainReasonWithinTextColumn(t *testing.T) { + ctx := context.Background() + repository := newTestRepository(t, ctx) + insertBatch(t, ctx, repository.db, "B-LONG", batchdomain.Type103) + if claimed, err := repository.MarkProcessing(ctx, "B-LONG", batchdomain.Type103); err != nil || !claimed { + t.Fatalf("claim batch = (%t, %v)", claimed, err) + } + reason := "database error: INSERT INTO batch_detail VALUES (" + strings.Repeat("错", 30000) + ")" + if marked, err := repository.MarkFailed(ctx, "B-LONG", batchdomain.Type103, reason); err != nil || !marked { + t.Fatalf("mark failed = (%t, %v)", marked, err) + } + var main entity.BatchMain + if err := repository.db.Model(dao.BatchMain.Table()).Ctx(ctx).Where(dao.BatchMain.Columns().BatchNo, "B-LONG").Scan(&main); err != nil { + t.Fatal(err) + } + if len(main.FailReason) > batchfields.FailureReasonByteLimit || strings.Contains(main.FailReason, "VALUES") { + t.Fatalf("unsafe failure reason: %q", main.FailReason) + } +} + func newTestRepository(t *testing.T, ctx context.Context) *Repository { t.Helper() db, err := gdb.New(gdb.ConfigNode{ @@ -192,6 +213,42 @@ func insertBatch(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, b } } +func TestMarkProcessingAPNMarkerIsAtomic(t *testing.T) { + for _, batchType := range []int{107, 109, 110} { + t.Run(string(rune('A'+batchType-107)), func(t *testing.T) { + ctx := context.Background() + repo := newTestRepository(t, ctx) + insertBatch(t, ctx, repo.db, "APN", batchType) + claimed, err := repo.MarkProcessing(ctx, "APN", batchType) + if err == nil || claimed { + t.Fatalf("缺少迁移必须安全失败: claimed=%t err=%v", claimed, err) + } + assertStatus(t, ctx, repo.db, "batch_main", "batch_async_status", batchdomain.AsyncStatusPending) + main, err := repo.FindMain(ctx, "APN") + if err != nil || main.StartTime != nil { + t.Fatalf("失败未回滚首发时间: %+v %v", main, err) + } + pending, err := repo.db.Model("batch_detail").Ctx(ctx).Where("detail_status", batchdomain.DetailStatusPending).Count() + if err != nil || pending != 1 { + t.Fatalf("明细没有安全回滚: %d %v", pending, err) + } + if _, err := repo.db.Exec(ctx, "CREATE TABLE data_operation_parent(batch_no TEXT PRIMARY KEY,ready INTEGER DEFAULT 0)"); err != nil { + t.Fatal(err) + } + if claimed, err := repo.MarkProcessing(ctx, "APN", batchType); err != nil || !claimed { + t.Fatalf("新批领取: %t %v", claimed, err) + } + n, err := repo.db.Model("data_operation_parent").Ctx(ctx).Where("batch_no", "APN").Where("ready", 0).Count() + if err != nil || n != 1 { + t.Fatalf("新批缺少原子凭据: %d %v", n, err) + } + if claimed, err := repo.MarkProcessing(ctx, "APN", batchType); err != nil || claimed { + t.Fatalf("重投不应重复创建凭据: %t %v", claimed, err) + } + }) + } +} + func assertStatus(t *testing.T, ctx context.Context, db gdb.DB, table, column string, expected int) { t.Helper() actual, err := db.Model(table).Ctx(ctx).Value(column) diff --git a/internal/repository/cancel/repository.go b/internal/repository/cancel/repository.go index 376bbc8..4c5ad76 100644 --- a/internal/repository/cancel/repository.go +++ b/internal/repository/cancel/repository.go @@ -230,7 +230,7 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *cancelserv } reason := task.FailureReason if reason == "" { - reason = "batch validation failed; cancellation was skipped" + reason = "批次校验未通过,已跳过取消操作" } now := gtime.Now() return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { diff --git a/internal/repository/data/repository.go b/internal/repository/data/repository.go index 89886da..34250d3 100644 --- a/internal/repository/data/repository.go +++ b/internal/repository/data/repository.go @@ -4,6 +4,7 @@ import ( "context" "strconv" "strings" + "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" @@ -13,6 +14,7 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult" @@ -36,14 +38,30 @@ func (r *Repository) database() gdb.DB { } func (r *Repository) Load(ctx context.Context, batchNo string) (*data.Task, error) { + return r.loadTask(ctx, batchNo, false) +} + +func (r *Repository) loadTask(ctx context.Context, batchNo string, allDetails bool) (*data.Task, error) { + return r.loadTaskFiltered(ctx, batchNo, allDetails, nil) +} + +func (r *Repository) loadTaskDetails(ctx context.Context, batchNo string, ids []int64) (*data.Task, error) { + return r.loadTaskFiltered(ctx, batchNo, true, ids) +} + +func (r *Repository) loadTaskFiltered(ctx context.Context, batchNo string, allDetails bool, ids []int64) (*data.Task, error) { if batchNo == "" { return nil, gerror.New("data service batch number is empty") } batch := entity.BatchMain{} + statuses := []int{consts.BatchAsyncStatusProcessing} + if allDetails { + statuses = append(statuses, consts.BatchAsyncStatusReview) + } if err := r.database().Model(dao.BatchMain.Table()).Safe().Ctx(ctx). Where(dao.BatchMain.Columns().BatchNo, batchNo). WhereIn(dao.BatchMain.Columns().BatchType, []int{consts.BatchTypeDataCut, consts.BatchTypeDataStart, consts.BatchTypeDataStop}). - Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing). + WhereIn(dao.BatchMain.Columns().BatchAsyncStatus, statuses). Scan(&batch); err != nil { return nil, gerror.Wrap(err, "query data service batch") } @@ -56,16 +74,18 @@ func (r *Repository) Load(ctx context.Context, batchNo string) (*data.Task, erro } var batchDetails []*entity.BatchDetail - if err = r.database().Model(dao.BatchDetail.Table()).Safe().Ctx(ctx). + detailQuery := r.database().Model(dao.BatchDetail.Table()).Safe().Ctx(ctx). Where(dao.BatchDetail.Columns().BatchNo, batchNo). - Where(dao.BatchDetail.Columns().BatchType, batch.BatchType). - Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning). - OrderAsc(dao.BatchDetail.Columns().RowNo). - Scan(&batchDetails); err != nil { - return nil, gerror.Wrap(err, "query running data service details") + Where(dao.BatchDetail.Columns().BatchType, batch.BatchType) + if len(ids) > 0 { + detailQuery = detailQuery.WhereIn("id", ids) } - if len(batchDetails) == 0 { - return nil, gerror.New("data service batch has no running details") + if !allDetails { + detailQuery = detailQuery.Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning). + Where("NOT EXISTS (SELECT 1 FROM data_operation_sub_detail assigned WHERE assigned.detail_id = batch_detail.id)") + } + if err = detailQuery.OrderAsc(dao.BatchDetail.Columns().RowNo).Scan(&batchDetails); err != nil { + return nil, gerror.Wrap(err, "query running data service details") } task := &data.Task{ @@ -88,10 +108,11 @@ func (r *Repository) Load(ctx context.Context, batchNo string) (*data.Task, erro continue } detail := &data.Detail{ - ID: batchDetail.Id, - RowNo: batchDetail.RowNo, - ICCID: strings.TrimSpace(batchDetail.ItemId), - Reason: strings.TrimSpace(batchDetail.ErrorReason), + ID: batchDetail.Id, + DetailStatus: batchDetail.DetailStatus, + RowNo: batchDetail.RowNo, + ICCID: strings.TrimSpace(batchDetail.ItemId), + Reason: strings.TrimSpace(batchDetail.ErrorReason), } if batchDetail.Snapshot != nil { if value := strings.TrimSpace(batchDetail.Snapshot.Get("ICCID").String()); value != "" { @@ -102,14 +123,17 @@ func (r *Repository) Load(ctx context.Context, batchNo string) (*data.Task, erro detail.MSISDN = detail.InputMSISDN detail.IMSI = detail.InputIMSI detail.Authorized = batchDetail.Snapshot.Get("authorized").Bool() + detail.ValidationPassed = batchDetail.Snapshot.Get("validationPassed").Bool() detail.ChanKey = strings.TrimSpace(batchDetail.Snapshot.Get("chanKey").String()) detail.TrafficCutoffCrossMonthHold = batchDetail.Snapshot.Get("trafficCutoffCrossMonthHold").Int() + detail.OrderNum = batchDetail.Snapshot.Get("orderNum").String() + detail.UpstreamCode = batchDetail.Snapshot.Get("upstreamCode").Int() + detail.UpstreamReason = batchDetail.Snapshot.Get("upstreamMessage").String() + detail.StatusBefore = batchDetail.Snapshot.Get("statusBefore").Int() + detail.StatusAfter = batchDetail.Snapshot.Get("statusAfter").Int() } task.Details = append(task.Details, detail) } - if len(task.Details) == 0 { - return nil, gerror.New("data service batch has no readable running details") - } if err = r.loadCards(ctx, task); err != nil { return nil, err } @@ -229,6 +253,7 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *data.Task) if detail == nil { continue } + detail.DetailStatus = consts.BatchDetailStatusFailed result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). Where(dao.BatchDetail.Columns().Id, detail.ID). Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo). @@ -263,7 +288,7 @@ func (r *Repository) SaveResult(ctx context.Context, task *data.Task, detail *da if resolved.Success { if detail.Card == nil { resolved.Success = false - resolved.Message = "SIM card record is missing" + resolved.Message = "卡记录不存在,未办理" } else { result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()). Where(dao.TSimCardInfo.Columns().Id, detail.Card.ID). @@ -332,16 +357,37 @@ func dataServiceUpdateData(task *data.Task, detail *data.Detail) do.TSimCardTraf } func (r *Repository) Finalize(ctx context.Context, task *data.Task) error { - successCount := 0 + successCount, unknownCount := 0, 0 for _, detail := range task.Details { - if detail != nil && detail.Reason == "" { + if detail != nil && detail.DetailStatus == consts.BatchDetailStatusUnknown { + unknownCount++ + } else if detail != nil && detail.DetailStatus == consts.BatchDetailStatusSuccess { successCount++ } } - failCount := len(task.Details) - successCount + failCount := len(task.Details) - successCount - unknownCount batchAsyncStatus, runResult := batchResult(successCount, failCount) - if task.FailureReason == "" && failCount > 0 { - task.FailureReason = strconv.Itoa(failCount) + " SIM cards failed" + if unknownCount > 0 { + batchAsyncStatus, runResult = consts.BatchAsyncStatusReview, consts.BatchRunResultReview + } + reasons := make([]string, 0, 3) + seenReasons := map[string]bool{} + for _, detail := range task.Details { + if detail != nil && detail.Reason != "" && !seenReasons[detail.Reason] && len(reasons) < 3 { + reasons = append(reasons, detail.Reason) + seenReasons[detail.Reason] = true + } + } + if len(task.Details) == 0 { + if task.FailureReason == "" { + task.FailureReason = "数据开停批次没有有效明细" + } + task.FailureReason = batchfields.FailureReason(task.FailureReason) + } else { + task.FailureReason = "" + if failCount > 0 || unknownCount > 0 { + task.FailureReason = batchfields.FailureReason("成功=" + strconv.Itoa(successCount) + ",明确失败=" + strconv.Itoa(failCount) + ",待核查=" + strconv.Itoa(unknownCount) + ";" + strings.Join(reasons, ";")) + } } validCount := 0 if task.ValidationOK { @@ -353,6 +399,26 @@ func (r *Repository) Finalize(ctx context.Context, task *data.Task) error { } now := gtime.Now() err = r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + var main struct { + Status int `orm:"batch_async_status"` + } + if err := r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("batch_async_status").Where("batch_no", task.Batch.BatchNo)).Scan(&main); err != nil { + return err + } + if main.Status != consts.BatchAsyncStatusProcessing && !(ctx.Value(finalizeTokenKey{}) != nil && main.Status == consts.BatchAsyncStatusReview) { + return gerror.New("data service batch is no longer processing") + } + if token, ok := ctx.Value(finalizeTokenKey{}).(string); ok { + var lease struct { + Token string `orm:"finalize_token"` + } + if err := r.subLock(tx.Ctx(ctx).Model(parentTable).Fields("finalize_token").Where("batch_no", task.Batch.BatchNo).WhereGT("finalize_until", time.Now())).Scan(&lease); err != nil { + return err + } + if lease.Token != token { + return gerror.New("APN最终汇总租约已过期") + } + } ossID, err := tx.Ctx(ctx).Model(dao.SysOss.Table()).Data(do.SysOss{ TenantId: task.Batch.TenantID, FileName: artifact.FileName, @@ -399,9 +465,13 @@ func (r *Repository) Finalize(ctx context.Context, task *data.Task) error { if err != nil { return gerror.Wrap(err, "insert data service result download") } + allowedStatuses := []int{consts.BatchAsyncStatusProcessing} + if ctx.Value(finalizeTokenKey{}) != nil { + allowedStatuses = append(allowedStatuses, consts.BatchAsyncStatusReview) + } result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()). Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo). - Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing). + WhereIn(dao.BatchMain.Columns().BatchAsyncStatus, allowedStatuses). Data(do.BatchMain{ BatchAsyncStatus: batchAsyncStatus, ValidCount: validCount, @@ -423,6 +493,10 @@ func (r *Repository) Finalize(ctx context.Context, task *data.Task) error { if affected != 1 { return gerror.New("data service batch is no longer processing") } + if token, ok := ctx.Value(finalizeTokenKey{}).(string); ok { + _, err = tx.Ctx(ctx).Model(parentTable).Where("batch_no", task.Batch.BatchNo).Where("finalize_token", token).Data(g.Map{"finalize_token": gdb.Raw("NULL"), "finalize_until": gdb.Raw("NULL")}).Update() + return err + } return nil }) if err != nil { @@ -432,6 +506,36 @@ func (r *Repository) Finalize(ctx context.Context, task *data.Task) error { return nil } +const subDetailOwnershipTable = "data_operation_sub_detail" + +// markFrozenValidation 以“是否已被冻结进持久化子批”判定明细是否通过提交前校验。 +// 只有通过校验的明细才会被冻结提交,因此冻结归属才是有效数的可信依据; +// authorized 只表示运营商授权范围,不能当作校验通过的证据。 +func (r *Repository) markFrozenValidation(ctx context.Context, task *data.Task) error { + if task == nil || len(task.Details) == 0 { + return nil + } + var rows []struct { + DetailID int64 `orm:"detail_id"` + } + if err := r.database().Model(subDetailOwnershipTable).Ctx(ctx). + Fields("detail_id").Where("batch_no", task.Batch.BatchNo).Scan(&rows); err != nil { + return gerror.Wrap(err, "query frozen data service detail ownership") + } + frozen := make(map[int64]struct{}, len(rows)) + for _, row := range rows { + frozen[row.DetailID] = struct{}{} + } + for _, detail := range task.Details { + if detail == nil { + continue + } + _, owned := frozen[detail.ID] + detail.ValidationPassed = owned + } + return nil +} + func validDetailCount(task *data.Task) int { if task == nil { return 0 @@ -460,12 +564,13 @@ func artifactRequest(task *data.Task) batchresult.Request { } rows = append(rows, batchresult.Row{ RowNo: detail.RowNo, + DetailStatus: detail.DetailStatus, ICCID: detail.ICCID, MSISDN: detail.MSISDN, IMSI: detail.IMSI, Operation: operationLabel(task.Operation), ApnName: task.ApnName, - Success: detail.Reason == "", + Success: detail.DetailStatus == consts.BatchDetailStatusSuccess, Reason: detail.Reason, UpstreamCode: detail.UpstreamCode, OrderNum: detail.OrderNum, @@ -497,6 +602,13 @@ func buildBatchInfo(task *data.Task, ossID, downloadID int64) *gjson.Json { info["operation"] = task.Operation.Name info["operType"] = task.Operation.ApnOperType info["apnName"] = task.ApnName + unknownCount := 0 + for _, detail := range task.Details { + if detail != nil && detail.DetailStatus == consts.BatchDetailStatusUnknown { + unknownCount++ + } + } + info["apnUnknownCount"] = unknownCount info["resultFileOssId"] = ossID info["resultDownloadId"] = downloadID return gjson.New(info) @@ -504,10 +616,10 @@ func buildBatchInfo(task *data.Task, ossID, downloadID int64) *gjson.Json { func batchResult(successCount, failCount int) (int, int) { switch { - case failCount == 0: - return consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess case successCount == 0: return consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed + case failCount == 0: + return consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess default: return consts.BatchAsyncStatusPartialSuccess, consts.BatchRunResultPartialSuccess } diff --git a/internal/repository/data/repository_accuracy_test.go b/internal/repository/data/repository_accuracy_test.go new file mode 100644 index 0000000..6ac28c3 --- /dev/null +++ b/internal/repository/data/repository_accuracy_test.go @@ -0,0 +1,237 @@ +package data + +import ( + "context" + "fmt" + "path/filepath" + "testing" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult" + service "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data" +) + +func TestAccuracyEmptyValidationBatchFinalizesFailed(t *testing.T) { + for _, batchType := range []int{107, 109, 110} { + t.Run(fmt.Sprint(batchType), func(t *testing.T) { + repo, generator, ctx := accuracyTestRepository(t, batchType) + if err := service.NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(ctx, "ACCURACY"); err != nil { + t.Fatal(err) + } + row, err := repo.database().GetOne(ctx, "SELECT batch_async_status,run_result,valid_count,success_count,fail_count,fail_reason FROM batch_main WHERE batch_no='ACCURACY'") + if err != nil { + t.Fatal(err) + } + if row["batch_async_status"].Int() != consts.BatchAsyncStatusFailed || row["run_result"].Int() != consts.BatchRunResultFailed { + t.Fatalf("零明细校验失败被误报成功: %+v", row) + } + if row["valid_count"].Int() != 0 || row["success_count"].Int() != 0 || row["fail_count"].Int() != 0 || row["fail_reason"].String() == "" { + t.Fatalf("空批次不得虚构行数或丢失失败原因: %+v", row) + } + if len(generator.requests) != 1 || len(generator.requests[0].Rows) != 0 { + t.Fatalf("空批次结果文件行数错误: %+v", generator.requests) + } + download, err := repo.database().GetOne(ctx, "SELECT total_count,success_count,fail_count FROM t_resource_batch_download WHERE batch_no='ACCURACY'") + if err != nil || download["total_count"].Int() != 0 || download["success_count"].Int() != 0 || download["fail_count"].Int() != 0 { + t.Fatalf("空批次下载统计错误: %+v err=%v", download, err) + } + }) + } +} + +func TestAccuracyAuthorizedSnapshotDoesNotPassValidation(t *testing.T) { + repo, _, ctx := accuracyTestRepository(t, 110) + if _, err := repo.database().Exec(ctx, `INSERT INTO batch_detail (id,batch_no,batch_type,row_no,item_id,detail_status,snapshot) VALUES (1,'ACCURACY',110,1,'',1,'{"authorized":true}')`); err != nil { + t.Fatal(err) + } + task, err := repo.Load(ctx, "ACCURACY") + if err != nil { + t.Fatal(err) + } + if len(task.Details) != 1 || !task.Details[0].Authorized || task.Details[0].ValidationPassed { + t.Fatalf("授权标志不能被当成校验通过: %+v", task.Details) + } +} + +func TestAccuracyFrozenOwnershipDrivesValidCount(t *testing.T) { + repo, _, ctx := accuracyTestRepository(t, 110) + // 冻结归属才代表已通过提交前校验:授权标记不等于校验通过, + // 未被冻结进子批的明细(校验被拒)也不得计入有效数。 + if _, err := repo.database().Exec(ctx, "INSERT INTO data_operation_sub_detail (detail_id,operation_id,batch_no,iccid) VALUES (1,'op-1','ACCURACY','frozen-card')"); err != nil { + t.Fatal(err) + } + task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: 110}, Details: []*service.Detail{ + {ID: 1, Authorized: true, DetailStatus: consts.BatchDetailStatusUnknown, Reason: "补查窗口耗尽,结果未知,禁止重发"}, + {ID: 2, Authorized: true, DetailStatus: consts.BatchDetailStatusFailed, Reason: "ICCID does not exist in SIM card records"}, + }} + if err := repo.markFrozenValidation(ctx, task); err != nil { + t.Fatal(err) + } + if !task.Details[0].ValidationPassed || task.Details[1].ValidationPassed { + t.Fatalf("冻结归属判定错误: %+v", task.Details) + } + task.ValidationOK = true + if count := validDetailCount(task); count != 1 { + t.Fatalf("恢复时有效数错误: got=%d want=1", count) + } +} + +func TestAccuracyFinalizeEmptyBatchPreservesValidationReason(t *testing.T) { + repo, _, ctx := accuracyTestRepository(t, 109) + operation, err := service.OperationByBatchType(109) + if err != nil { + t.Fatal(err) + } + task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: 109}, Operation: operation, FailureReason: "导入文件没有可执行行"} + if err := repo.Finalize(ctx, task); err != nil { + t.Fatal(err) + } + row, err := repo.database().GetOne(ctx, "SELECT batch_async_status,run_result,fail_reason FROM batch_main WHERE batch_no='ACCURACY'") + if err != nil { + t.Fatal(err) + } + if row["batch_async_status"].Int() != consts.BatchAsyncStatusFailed || row["run_result"].Int() != consts.BatchRunResultFailed || row["fail_reason"].String() != "导入文件没有可执行行" || task.FailureReason != "导入文件没有可执行行" { + t.Fatalf("空批次原始校验原因被覆盖: %+v task=%+v", row, task) + } +} + +func TestAccuracyValidationFailureKeepsPerRowReasons(t *testing.T) { + for _, batchType := range []int{107, 109, 110} { + t.Run(fmt.Sprint(batchType), func(t *testing.T) { + repo, generator, ctx := accuracyTestRepository(t, batchType) + operation, err := service.OperationByBatchType(batchType) + if err != nil { + t.Fatal(err) + } + task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: batchType}, Operation: operation, Details: []*service.Detail{ + {ID: 1, RowNo: 1, ICCID: "same-card", Reason: "MSISDN does not match the SIM card record"}, + {ID: 2, RowNo: 2, ICCID: "same-card", Reason: "IMSI does not match the SIM card record"}, + }} + for _, detail := range task.Details { + if _, err := repo.database().Exec(ctx, "INSERT INTO batch_detail (id,batch_no,batch_type,row_no,item_id,detail_status) VALUES (?,'ACCURACY',?,?,?,1)", detail.ID, batchType, detail.RowNo, detail.ICCID); err != nil { + t.Fatal(err) + } + } + if err := repo.SaveValidationFailure(ctx, task); err != nil { + t.Fatal(err) + } + if err := repo.Finalize(ctx, task); err != nil { + t.Fatal(err) + } + if len(generator.requests) != 1 || len(generator.requests[0].Rows) != len(task.Details) { + t.Fatalf("逐行导出缺失: %+v", generator.requests) + } + for i, detail := range task.Details { + row, err := repo.database().GetOne(ctx, "SELECT detail_status,error_reason FROM batch_detail WHERE id=?", detail.ID) + if err != nil || row["detail_status"].Int() != consts.BatchDetailStatusFailed || row["error_reason"].String() != detail.Reason { + t.Fatalf("逐行原因落库错误: row=%+v detail=%+v err=%v", row, detail, err) + } + exported := generator.requests[0].Rows[i] + if exported.Success || exported.DetailStatus != consts.BatchDetailStatusFailed || exported.Reason != detail.Reason { + t.Fatalf("逐行原因导出被改写: %+v", exported) + } + } + }) + } +} + +func TestAccuracyFinalizeUnknownStillRequiresReview(t *testing.T) { + for _, statuses := range [][]int{{consts.BatchDetailStatusUnknown}, {consts.BatchDetailStatusSuccess, consts.BatchDetailStatusUnknown}, {consts.BatchDetailStatusFailed, consts.BatchDetailStatusUnknown}} { + t.Run(fmt.Sprint(statuses), func(t *testing.T) { + repo, _, ctx := accuracyTestRepository(t, 110) + task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: 110}} + for i, status := range statuses { + task.Details = append(task.Details, &service.Detail{ID: int64(i + 1), DetailStatus: status, Reason: "该卡结果待核查"}) + } + if err := repo.Finalize(ctx, task); err != nil { + t.Fatal(err) + } + row, err := repo.database().GetOne(ctx, "SELECT batch_async_status,run_result,fail_count,batch_info FROM batch_main WHERE batch_no='ACCURACY'") + if err != nil { + t.Fatal(err) + } + wantFailed := 0 + if statuses[0] == consts.BatchDetailStatusFailed { + wantFailed = 1 + } + if row["batch_async_status"].Int() != consts.BatchAsyncStatusReview || row["run_result"].Int() != consts.BatchRunResultReview || row["fail_count"].Int() != wantFailed { + t.Fatalf("未知被误计成功或明确失败: %+v", row) + } + }) + } +} + +func TestAccuracyBatchResultCounts(t *testing.T) { + for _, tc := range []struct { + success, failed int + status, result int + }{{0, 0, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed}, {0, 2, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed}, {2, 0, consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess}, {1, 1, consts.BatchAsyncStatusPartialSuccess, consts.BatchRunResultPartialSuccess}} { + t.Run(fmt.Sprintf("success=%d/failed=%d", tc.success, tc.failed), func(t *testing.T) { + status, result := batchResult(tc.success, tc.failed) + if status != tc.status || result != tc.result { + t.Fatalf("汇总结果=(%d,%d),期望=(%d,%d)", status, result, tc.status, tc.result) + } + }) + } +} + +func accuracyTestRepository(t *testing.T, batchType int) (*Repository, *accuracyArtifactGenerator, context.Context) { + t.Helper() + ctx := context.Background() + db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(" + filepath.ToSlash(filepath.Join(t.TempDir(), "accuracy.db")) + ")"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(ctx); err != nil { + t.Error(err) + } + }) + for _, statement := range []string{ + "CREATE TABLE batch_main (id INTEGER PRIMARY KEY,batch_no TEXT,batch_type INTEGER,batch_async_status INTEGER,tenant_id TEXT,start_time DATETIME,created_by INTEGER,created_dept INTEGER,valid_count INTEGER,success_count INTEGER,fail_count INTEGER,run_result INTEGER,fail_reason TEXT,batch_info TEXT,end_time DATETIME,updated_by INTEGER)", + "CREATE TABLE batch_detail (id INTEGER PRIMARY KEY,batch_no TEXT,batch_type INTEGER,row_no INTEGER,item_id TEXT,detail_status INTEGER,error_code TEXT,error_reason TEXT,snapshot TEXT,updated_by INTEGER)", + "CREATE TABLE data_operation_parent (batch_no TEXT PRIMARY KEY,ready INTEGER DEFAULT 0,finalize_token TEXT,finalize_until DATETIME,last_reason TEXT)", + "CREATE TABLE data_operation_sub_batch (id INTEGER PRIMARY KEY,batch_no TEXT,status TEXT)", + "CREATE TABLE data_operation_sub_detail (detail_id INTEGER PRIMARY KEY, operation_id TEXT, batch_no TEXT, iccid TEXT, UNIQUE(batch_no,iccid))", + "CREATE TABLE sys_oss (id INTEGER PRIMARY KEY AUTOINCREMENT,tenant_id TEXT,file_name TEXT,original_name TEXT,file_suffix TEXT,path TEXT,url TEXT,created_dept INTEGER,created_by INTEGER,updated_by INTEGER,service TEXT,md5 TEXT,file_size INTEGER)", + "CREATE TABLE t_resource_batch_download (id INTEGER PRIMARY KEY AUTOINCREMENT,batch_no TEXT,task_name TEXT,resource_type INTEGER,export_scene INTEGER,total_count INTEGER,success_count INTEGER,fail_count INTEGER,download_status INTEGER,oss_id INTEGER,file_name TEXT,file_ext TEXT,file_url TEXT,file_path TEXT,file_size INTEGER,file_md5 TEXT,download_count INTEGER,max_download_count INTEGER,dept_id INTEGER,start_time DATETIME,finish_time DATETIME,tenant_id TEXT,created_dept INTEGER,created_by INTEGER,updated_by INTEGER)", + } { + if _, err := db.Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + if _, err := db.Exec(ctx, "INSERT INTO batch_main (id,batch_no,batch_type,batch_async_status,tenant_id) VALUES (1,'ACCURACY',?,2,'isolated')", batchType); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(ctx, "INSERT INTO data_operation_parent (batch_no,ready) VALUES ('ACCURACY',0)"); err != nil { + t.Fatal(err) + } + generator := &accuracyArtifactGenerator{} + return &Repository{db: db, resultFiles: generator}, generator, ctx +} + +type accuracyArtifactGenerator struct{ requests []batchresult.Request } + +func (g *accuracyArtifactGenerator) Create(_ context.Context, request batchresult.Request) (batchresult.Artifact, error) { + g.requests = append(g.requests, request) + return batchresult.Artifact{FileName: "accuracy.xlsx", OriginalName: request.BatchNo + "-result.xlsx", FileSuffix: ".xlsx", Path: "accuracy.xlsx", URL: "/accuracy.xlsx"}, nil +} + +type accuracyNoUpstream struct{ t *testing.T } + +func (u *accuracyNoUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + u.t.Fatal("隔离回归不得调用同步上游") + return nil, nil +} +func (u *accuracyNoUpstream) SubmitSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + u.t.Fatal("隔离回归不得提交上游") + return nil, nil +} +func (u *accuracyNoUpstream) QuerySimApnFunctionResult(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + u.t.Fatal("隔离回归不得查询上游") + return nil, nil +} diff --git a/internal/repository/data/sub_batch.go b/internal/repository/data/sub_batch.go new file mode 100644 index 0000000..f59026a --- /dev/null +++ b/internal/repository/data/sub_batch.go @@ -0,0 +1,841 @@ +package data + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "reflect" + "sort" + "strings" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/google/uuid" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" + data "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data" +) + +const subTable = "data_operation_sub_batch" +const claimTable = "data_operation_card_claim" +const parentTable = "data_operation_parent" +const subLease = 5 * time.Minute + +type finalizeTokenKey struct{} +type preparingKey struct{} + +func (r *Repository) subLock(q *gdb.Model) *gdb.Model { + if strings.EqualFold(r.database().GetConfig().Type, "sqlite") { + return q + } + return q.LockUpdate() +} +func (r *Repository) lockSubCards(ctx context.Context, tx gdb.TX, tenantID string, keys []*model.SimKey) ([]cardClaim, error) { + keys = append([]*model.SimKey(nil), keys...) + sort.Slice(keys, func(i, j int) bool { return keys[i].ICCID < keys[j].ICCID }) + claims := make([]cardClaim, len(keys)) + for i, key := range keys { + if err := r.subLock(tx.Ctx(ctx).Model(claimTable).Where("tenant_id", tenantID).Where("iccid", key.ICCID)).Scan(&claims[i]); err != nil { + return nil, err + } + } + return claims, nil +} + +func noRows(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err +} +func (r *Repository) ignoreInsert(ctx context.Context, tx gdb.TX, table string, values g.Map) error { + _, err := tx.Ctx(ctx).Model(table).Data(values).InsertIgnore() + return err +} +func (r *Repository) durableTx(ctx context.Context, fn func(context.Context, gdb.TX) error) error { + var err error + for attempt := 0; attempt < 3; attempt++ { + err = r.database().Transaction(ctx, fn) + if err == nil { + return nil + } + text := err.Error() + if !strings.Contains(text, "1213") && !strings.Contains(text, "1205") && !strings.Contains(text, "database is locked") { + return err + } + if ctx.Err() != nil { + return ctx.Err() + } + } + return err +} + +type subRow struct { + ID int64 `orm:"id"` + BatchNo string `orm:"batch_no"` + BatchType int `orm:"batch_type"` + ChanKey string `orm:"chan_key"` + OperationID string `orm:"operation_id"` + DetailIDs string `orm:"detail_ids"` + RequestData string `orm:"request_data"` + JobID string `orm:"job_id"` + Status string `orm:"status"` + PollCount int `orm:"poll_count"` + SubmittedAt *time.Time `orm:"submitted_at"` + NextCheckAt *time.Time `orm:"next_check_at"` + LeaseToken string `orm:"lease_token"` + LeaseUntil *time.Time `orm:"lease_until"` + CreatedAt *time.Time `orm:"created_at"` +} +type cardClaim struct { + BatchID int64 `orm:"batch_id"` + OperationID string `orm:"operation_id"` + ActiveBatchID int64 `orm:"active_batch_id"` + ActiveOperationID string `orm:"active_operation_id"` +} + +func decodeSub(row subRow) (data.SubBatch, error) { + out := data.SubBatch{ID: row.ID, BatchNo: row.BatchNo, BatchType: row.BatchType, ChanKey: row.ChanKey, OperationID: row.OperationID, JobID: row.JobID, Status: row.Status, PollCount: row.PollCount, LeaseToken: row.LeaseToken} + if row.SubmittedAt != nil { + out.SubmittedAt = *row.SubmittedAt + } + if row.NextCheckAt != nil { + out.NextCheckAt = *row.NextCheckAt + } + if row.LeaseUntil != nil { + out.LeaseUntil = *row.LeaseUntil + } + if err := json.Unmarshal([]byte(row.DetailIDs), &out.DetailIDs); err != nil { + return out, err + } + if err := json.Unmarshal([]byte(row.RequestData), &out.Request); err != nil { + return out, err + } + if len(out.DetailIDs) == 0 || len(out.DetailIDs) != len(out.Request.SimKeys) { + return out, fmt.Errorf("APN冻结请求与明细数不匹配: %s", row.OperationID) + } + return out, nil +} + +func (r *Repository) HasDurableParent(ctx context.Context, batchNo string) (bool, error) { + n, err := r.database().Model(parentTable).Ctx(ctx).Where("batch_no", batchNo).Count() + return n == 1, err +} + +// ResumeOrPrepare只恢复由Pending领取事务写入的凭据,绝不接管无映射的历史同步批。 +func (r *Repository) ResumeOrPrepare(ctx context.Context, batchNo string, prepare func(context.Context) error) error { + if ctx.Value(preparingKey{}) != nil { + return prepare(ctx) + } + err := r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error { + var main struct { + ID int64 `orm:"id"` + Status int `orm:"batch_async_status"` + } + if err := noRows(r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("id,batch_async_status").Where("batch_no", batchNo)).Scan(&main)); err != nil { + return err + } + if main.ID == 0 || main.Status != consts.BatchAsyncStatusProcessing { + return nil + } + var parent *struct { + Ready int `orm:"ready"` + } + if err := noRows(r.subLock(tx.Ctx(ctx).Model(parentTable).Where("batch_no", batchNo)).Scan(&parent)); err != nil { + return err + } + if parent == nil { + return fmt.Errorf("历史APN批次缺少持久化凭据,须人工核查,禁止自动提交: %s", batchNo) + } + if parent.Ready == 1 { + return nil + } + if err := prepare(context.WithValue(ctx, preparingKey{}, true)); err != nil { + return err + } + _, err := tx.Ctx(ctx).Model(parentTable).Where("batch_no", batchNo).Data(g.Map{"ready": 1}).Update() + return err + }) + if err != nil { + return err + } + return r.FinalizeReadyBatch(ctx, batchNo) +} + +func (r *Repository) PrepareSubBatch(ctx context.Context, batch data.SubBatch) error { + return r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error { + var main struct { + ID int64 `orm:"id"` + TenantID string `orm:"tenant_id"` + } + if err := r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("id,tenant_id").Where("batch_no", batch.BatchNo).Where("batch_async_status", consts.BatchAsyncStatusProcessing)).Scan(&main); err != nil { + return err + } + if main.ID == 0 || len(batch.DetailIDs) == 0 || len(batch.DetailIDs) > 100 || len(batch.DetailIDs) != len(batch.Request.SimKeys) { + return fmt.Errorf("APN准备参数或父批状态不合法") + } + keys := append([]*model.SimKey(nil), batch.Request.SimKeys...) + for _, key := range keys { + if key == nil { + return fmt.Errorf("APN卡标识为空") + } + } + sort.Slice(keys, func(i, j int) bool { return keys[i].ICCID < keys[j].ICCID }) + seen := map[string]bool{} + for _, key := range keys { + if key == nil || key.ICCID == "" || key.ChanKey != batch.ChanKey || seen[key.ICCID] { + return fmt.Errorf("APN子批卡重复或跨通道") + } + seen[key.ICCID] = true + if err := r.ignoreInsert(ctx, tx, claimTable, g.Map{"tenant_id": main.TenantID, "iccid": key.ICCID, "batch_id": 0, "operation_id": "", "active_batch_id": 0, "active_operation_id": ""}); err != nil { + return err + } + var claim cardClaim + if err := r.subLock(tx.Ctx(ctx).Model(claimTable).Where("tenant_id", main.TenantID).Where("iccid", key.ICCID)).Scan(&claim); err != nil { + return err + } + if claim.BatchID < main.ID { + if _, err := tx.Ctx(ctx).Model(claimTable).Where("tenant_id", main.TenantID).Where("iccid", key.ICCID).WhereLT("batch_id", main.ID).Data(g.Map{"batch_id": main.ID, "operation_id": batch.OperationID}).Update(); err != nil { + return err + } + } else if claim.BatchID == main.ID && claim.OperationID != batch.OperationID { + return fmt.Errorf("同批ICCID重复,禁止二次提交: %s", key.ICCID) + } + } + var old subRow + if err := noRows(r.subLock(tx.Ctx(ctx).Model(subTable).Where("operation_id", batch.OperationID)).Scan(&old)); err != nil { + return err + } + if old.ID != 0 { + stored, err := decodeSub(old) + if err != nil { + return err + } + if stored.BatchNo != batch.BatchNo || stored.ChanKey != batch.ChanKey || !reflect.DeepEqual(stored.Request, batch.Request) || !reflect.DeepEqual(stored.DetailIDs, batch.DetailIDs) { + return fmt.Errorf("operationId绑定了不同请求: %s", batch.OperationID) + } + return nil + } + ids, err := json.Marshal(batch.DetailIDs) + if err != nil { + return err + } + request, err := json.Marshal(batch.Request) + if err != nil { + return err + } + if _, err = tx.Ctx(ctx).Model(subTable).Data(g.Map{"batch_no": batch.BatchNo, "batch_type": batch.BatchType, "chan_key": batch.ChanKey, "operation_id": batch.OperationID, "detail_ids": string(ids), "request_data": string(request), "status": "prepared", "next_check_at": time.Now()}).Insert(); err != nil { + return err + } + for i, detailID := range batch.DetailIDs { + if _, err := tx.Ctx(ctx).Model("data_operation_sub_detail").Data(g.Map{"detail_id": detailID, "operation_id": batch.OperationID, "batch_no": batch.BatchNo, "iccid": batch.Request.SimKeys[i].ICCID}).Insert(); err != nil { + return fmt.Errorf("明细已归属其他冻结意图: %w", err) + } + } + return nil + }) +} + +func (r *Repository) GetSubBatch(ctx context.Context, operationID string) (*data.SubBatch, error) { + var row subRow + if err := noRows(r.database().Model(subTable).Ctx(ctx).Where("operation_id", operationID).Scan(&row)); err != nil { + return nil, err + } + if row.ID == 0 { + return nil, nil + } + sub, err := decodeSub(row) + return &sub, err +} + +func (r *Repository) DelaySubBatchPrecheck(ctx context.Context, snapshot data.SubBatch, next time.Time, reason string) error { + if snapshot.Status == "done" || snapshot.Status == "review" { + return nil + } + q := r.database().Model(subTable).Ctx(ctx).Where("operation_id", snapshot.OperationID).Where("status", snapshot.Status).Where("COALESCE(lease_token,'')=?", snapshot.LeaseToken).Where("lease_until IS NULL OR lease_until<=?", time.Now()) + if snapshot.NextCheckAt.IsZero() { + q = q.WhereNull("next_check_at") + } else { + q = q.Where("next_check_at", snapshot.NextCheckAt) + } + if snapshot.LeaseUntil.IsZero() { + q = q.WhereNull("lease_until") + } else { + q = q.Where("lease_until", snapshot.LeaseUntil) + } + _, err := q.Data(g.Map{"next_check_at": next, "last_reason": truncateReason("只读预检未就绪:"+reason, 1024)}).Update() + return err +} + +func (r *Repository) ReleaseUnsentSubBatch(ctx context.Context, batch data.SubBatch, reason string) error { + return r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error { + var owner struct { + TenantID string `orm:"tenant_id"` + } + if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("tenant_id").Where("batch_no", batch.BatchNo).Scan(&owner); err != nil { + return err + } + if _, err := r.lockSubCards(ctx, tx, owner.TenantID, batch.Request.SimKeys); err != nil { + return err + } + var row subRow + if err := r.subLock(tx.Ctx(ctx).Model(subTable).Where("id", batch.ID)).Scan(&row); err != nil { + return err + } + if row.LeaseToken != batch.LeaseToken || row.LeaseUntil == nil || !row.LeaseUntil.After(time.Now()) { + return fmt.Errorf("未发送回退租约失效") + } + changes := g.Map{"status": batch.Status, "last_reason": truncateReason("确定未发送:"+reason, 1024), "next_check_at": time.Now().Add(15 * time.Second), "lease_token": gdb.Raw("NULL"), "lease_until": gdb.Raw("NULL")} + if batch.Status == "prepared" { + changes["submitted_at"] = gdb.Raw("NULL") + var main struct { + TenantID string `orm:"tenant_id"` + } + if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("tenant_id").Where("batch_no", batch.BatchNo).Scan(&main); err != nil { + return err + } + keys := append([]*model.SimKey(nil), batch.Request.SimKeys...) + sort.Slice(keys, func(i, j int) bool { return keys[i].ICCID < keys[j].ICCID }) + for _, key := range keys { + if _, err := tx.Ctx(ctx).Model(claimTable).Where("tenant_id", main.TenantID).Where("iccid", key.ICCID).Where("active_operation_id", batch.OperationID).Data(g.Map{"active_operation_id": "", "active_batch_id": 0}).Update(); err != nil { + return err + } + } + } else if batch.PollCount > 0 { + changes["poll_count"] = batch.PollCount - 1 + } + _, err := tx.Ctx(ctx).Model(subTable).Where("id", batch.ID).Where("lease_token", batch.LeaseToken).Data(changes).Update() + return err + }) +} + +func (r *Repository) SubBatchChannel(ctx context.Context, operationID string) (string, error) { + row, err := r.database().GetOne(ctx, "SELECT chan_key FROM data_operation_sub_batch WHERE operation_id=?", operationID) + if err != nil { + return "", err + } + if len(row) == 0 { + return "", fmt.Errorf("APN子批不存在") + } + return row["chan_key"].String(), nil +} + +func (r *Repository) ListDueSubBatches(ctx context.Context, now time.Time, limit int) ([]string, error) { + var rows []struct { + OperationID string `orm:"operation_id"` + } + err := r.database().Model(subTable).Ctx(ctx).Fields("operation_id").WhereIn("status", []string{"prepared", "submitting", "accepted", "uncertain"}).WhereLTE("next_check_at", now).Where("lease_until IS NULL OR lease_until <= ?", now).Where("EXISTS (SELECT 1 FROM data_operation_parent p WHERE p.batch_no=data_operation_sub_batch.batch_no AND p.ready=1)").OrderAsc("next_check_at").Limit(limit).Scan(&rows) + ids := make([]string, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.OperationID) + } + return ids, err +} + +func (r *Repository) ClaimSubBatch(ctx context.Context, operationID, token string, now time.Time) (*data.SubBatch, error) { + var out *data.SubBatch + err := r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error { + out = nil + var hint subRow + if err := noRows(tx.Ctx(ctx).Model(subTable).Where("operation_id", operationID).Scan(&hint)); err != nil { + return err + } + if hint.ID == 0 { + return nil + } + var parent struct { + Ready int `orm:"ready"` + } + if err := noRows(r.subLock(tx.Ctx(ctx).Model(parentTable).Where("batch_no", hint.BatchNo)).Scan(&parent)); err != nil { + return err + } + if parent.Ready != 1 { + return nil + } + var main struct { + ID int64 `orm:"id"` + TenantID string `orm:"tenant_id"` + Status int `orm:"batch_async_status"` + } + if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("id,tenant_id,batch_async_status").Where("batch_no", hint.BatchNo).Scan(&main); err != nil { + return err + } + if main.Status != consts.BatchAsyncStatusProcessing { + return nil + } + frozen, err := decodeSub(hint) + if err != nil { + return err + } + claims, err := r.lockSubCards(ctx, tx, main.TenantID, frozen.Request.SimKeys) + if err != nil { + return err + } + var row subRow + if err := r.subLock(tx.Ctx(ctx).Model(subTable).Where("id", hint.ID)).Scan(&row); err != nil { + return err + } + if row.Status == "done" || row.Status == "review" || row.Status == "review_query" || (row.NextCheckAt != nil && row.NextCheckAt.After(now)) || (row.LeaseUntil != nil && row.LeaseUntil.After(now)) { + return nil + } + sub, err := decodeSub(row) + if err != nil { + return err + } + if row.Status == "prepared" { + keys := append([]*model.SimKey(nil), sub.Request.SimKeys...) + sort.Slice(keys, func(i, j int) bool { return keys[i].ICCID < keys[j].ICCID }) + for _, claim := range claims { + if claim.ActiveOperationID != "" && claim.ActiveOperationID != operationID { + var previous subRow + if err := noRows(tx.Ctx(ctx).Model(subTable).Fields("status").Where("operation_id", claim.ActiveOperationID).Scan(&previous)); err != nil { + return err + } + if previous.Status == "review" || (row.CreatedAt != nil && !row.CreatedAt.Add(15*time.Minute).After(now)) { + reason := "未提交:同卡前序办理仍在途或结果未知,须人工核对前序后重新发起" + if _, err := tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Data(g.Map{"status": "review", "last_reason": reason, "next_check_at": gdb.Raw("NULL")}).Update(); err != nil { + return err + } + _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).WhereIn("id", sub.DetailIDs).Where("detail_status", consts.BatchDetailStatusRunning).Data(g.Map{"detail_status": consts.BatchDetailStatusUnknown, "error_code": "NOT_SUBMITTED", "error_reason": reason}).Update() + return err + } + _, err := tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Data(g.Map{"last_reason": "同卡前序办理在途或结果未知,等待核对后才能提交", "next_check_at": now.Add(15 * time.Second)}).Update() + return err + } + } + // 父批创建ID决定已冻结意图的队序;不能用可能交错的冻结ID替代。 + for _, key := range keys { + query := "SELECT s.operation_id FROM batch_main m JOIN data_operation_sub_detail sd ON sd.batch_no=m.batch_no AND sd.iccid=? JOIN data_operation_sub_batch s ON s.operation_id=sd.operation_id JOIN batch_detail d ON d.id=sd.detail_id WHERE m.tenant_id=? AND m.id= 4 { + if _, err := tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Data(g.Map{"status": "review", "last_reason": "补查窗口耗尽,结果未知,禁止重发", "next_check_at": gdb.Raw("NULL")}).Update(); err != nil { + return err + } + _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).WhereIn("id", sub.DetailIDs).Where("detail_status", consts.BatchDetailStatusRunning).Data(g.Map{"detail_status": consts.BatchDetailStatusUnknown, "error_code": "UNKNOWN", "error_reason": "补查窗口耗尽,结果未知,禁止重发"}).Update() + return err + } + changes := g.Map{"lease_token": token, "lease_until": now.Add(subLease), "next_check_at": now.Add(subLease)} + if row.Status == "prepared" { + changes["status"] = "submitting" + changes["submitted_at"] = now + sub.SubmittedAt = now + } else { + changes["poll_count"] = row.PollCount + 1 + sub.PollCount++ + } + result, err := tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Where("status", row.Status).Where("lease_until IS NULL OR lease_until <= ?", now).Data(changes).Update() + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return fmt.Errorf("APN租约CAS失败") + } + sub.LeaseToken = token + out = &sub + return nil + }) + return out, err +} + +func (r *Repository) ClaimReviewSubBatch(ctx context.Context, operationID, token string, now time.Time) (*data.SubBatch, error) { + var out *data.SubBatch + err := r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error { + out = nil + var hint subRow + if err := noRows(tx.Ctx(ctx).Model(subTable).Where("operation_id", operationID).Scan(&hint)); err != nil { + return err + } + if hint.ID == 0 { + return nil + } + var main struct { + Status int `orm:"batch_async_status"` + } + if err := noRows(r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("batch_async_status").Where("batch_no", hint.BatchNo)).Scan(&main)); err != nil { + return err + } + if main.Status != consts.BatchAsyncStatusReview && main.Status != consts.BatchAsyncStatusProcessing { + return nil + } + var parent struct { + Ready int `orm:"ready"` + FinalizeUntil *time.Time `orm:"finalize_until"` + } + if err := noRows(r.subLock(tx.Ctx(ctx).Model(parentTable).Where("batch_no", hint.BatchNo)).Scan(&parent)); err != nil { + return err + } + if parent.Ready != 1 || (parent.FinalizeUntil != nil && parent.FinalizeUntil.After(now)) { + return nil + } + var row subRow + if err := r.subLock(tx.Ctx(ctx).Model(subTable).Where("id", hint.ID)).Scan(&row); err != nil { + return err + } + if (row.Status != "review" && row.Status != "review_query") || row.SubmittedAt == nil || (row.LeaseUntil != nil && row.LeaseUntil.After(now)) { + return nil + } + sub, err := decodeSub(row) + if err != nil { + return err + } + result, err := tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Where("status", row.Status).Where("lease_until IS NULL OR lease_until <= ?", now).Data(g.Map{"status": "review_query", "lease_token": token, "lease_until": now.Add(subLease)}).Update() + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil || n != 1 { + return err + } + sub.Status = "review_query" + sub.LeaseToken = token + sub.LeaseUntil = now.Add(subLease) + out = &sub + return nil + }) + return out, err +} + +// ListReviewSubBatches 只列出该父批下已冻结、仍待核查的子批. +// 手动同步据此按既有 operationId 查询上游,不重发办理,也不重开自动补查窗口。 +func (r *Repository) ListReviewSubBatches(ctx context.Context, batchNo string) ([]string, error) { + var rows []struct { + OperationID string `orm:"operation_id"` + } + if err := r.database().Model(subTable).Ctx(ctx). + Fields("operation_id"). + Where("batch_no", batchNo). + WhereIn("status", []string{"review", "review_query"}). + Where("lease_until IS NULL OR lease_until <= ?", time.Now()). + Order("id"). + Scan(&rows); err != nil { + return nil, err + } + out := make([]string, 0, len(rows)) + for _, row := range rows { + if id := strings.TrimSpace(row.OperationID); id != "" { + out = append(out, id) + } + } + return out, nil +} + +func (r *Repository) UpdateSubBatch(ctx context.Context, batch data.SubBatch, status, jobID, reason string, next time.Time, pollCount int) error { + return r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error { + var nextAt any = gdb.Raw("NULL") + if !next.IsZero() { + nextAt = next + } + result, err := tx.Ctx(ctx).Model(subTable).Where("id", batch.ID).Where("lease_token", batch.LeaseToken).WhereGT("lease_until", time.Now()).WhereIn("status", []string{"submitting", "accepted", "uncertain", "review_query"}).Data(g.Map{"status": status, "job_id": jobID, "last_reason": truncateReason(reason, 1024), "next_check_at": nextAt, "poll_count": pollCount, "lease_token": gdb.Raw("NULL"), "lease_until": gdb.Raw("NULL")}).Update() + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return fmt.Errorf("APN租约已失效: %s", batch.OperationID) + } + if status == "review" { + if _, err = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).Where("batch_no", batch.BatchNo).WhereIn("id", batch.DetailIDs).WhereIn("detail_status", []int{consts.BatchDetailStatusRunning, consts.BatchDetailStatusUnknown}).Data(g.Map{"detail_status": consts.BatchDetailStatusUnknown, "error_code": "UNKNOWN", "error_reason": batchfields.DetailReason(reason)}).Update(); err != nil { + return err + } + } + if status != "review" && status != "done" { + return nil + } + // 手动补查把 review_query 子批改成终态时父批可能已是待核查,需重新纳入汇总。 + return r.releaseReviewParentWhenSettled(ctx, tx, batch.BatchNo) + }) +} +func truncateReason(value string, n int) string { + runes := []rune(value) + if len(runes) > n { + return string(runes[:n]) + } + return value +} + +func (r *Repository) SaveSubBatchProgress(ctx context.Context, batch data.SubBatch, outcomes []data.Outcome) error { + return r.saveProgress(ctx, batch, outcomes, false) +} +func (r *Repository) CompleteSubBatch(ctx context.Context, batch data.SubBatch, outcomes []data.Outcome) error { + return r.saveProgress(ctx, batch, outcomes, true) +} +func (r *Repository) saveProgress(ctx context.Context, batch data.SubBatch, outcomes []data.Outcome, complete bool) error { + if len(outcomes) != len(batch.DetailIDs) { + return fmt.Errorf("APN明细结果数量不符") + } + return r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error { + var owner struct { + TenantID string `orm:"tenant_id"` + } + if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("tenant_id").Where("batch_no", batch.BatchNo).Scan(&owner); err != nil { + return err + } + if _, err := r.lockSubCards(ctx, tx, owner.TenantID, batch.Request.SimKeys); err != nil { + return err + } + var current subRow + if err := r.subLock(tx.Ctx(ctx).Model(subTable).Where("id", batch.ID)).Scan(¤t); err != nil { + return err + } + if current.LeaseToken != batch.LeaseToken || current.LeaseUntil == nil || !current.LeaseUntil.After(time.Now()) || current.Status == "done" || current.Status == "review" { + return fmt.Errorf("APN进度租约失效") + } + if batch.JobID != "" { + if _, err := tx.Ctx(ctx).Model(subTable).Where("id", batch.ID).Where("lease_token", batch.LeaseToken).Data(g.Map{"job_id": batch.JobID}).Update(); err != nil { + return err + } + } + task, err := r.loadTaskDetails(ctx, batch.BatchNo, batch.DetailIDs) + if err != nil { + return err + } + byID := map[int64]*data.Detail{} + for _, detail := range task.Details { + byID[detail.ID] = detail + } + order := make([]int, len(batch.DetailIDs)) + for i := range order { + order[i] = i + } + sort.Slice(order, func(i, j int) bool { + return batch.Request.SimKeys[order[i]].ICCID < batch.Request.SimKeys[order[j]].ICCID + }) + for _, i := range order { + outcome := outcomes[i] + if outcome.Pending { + continue + } + detail := byID[batch.DetailIDs[i]] + if detail == nil { + return fmt.Errorf("缺少APN明细") + } + if detail.DetailStatus != consts.BatchDetailStatusRunning && !(batch.Status == "review_query" && detail.DetailStatus == consts.BatchDetailStatusUnknown) { + continue + } + var claim cardClaim + if err := r.subLock(tx.Ctx(ctx).Model(claimTable).Where("tenant_id", task.Batch.TenantID).Where("iccid", detail.ICCID)).Scan(&claim); err != nil { + return err + } + previousStatus := detail.DetailStatus + detail.ApplyOutcome(outcome) + detail.Reason = batchfields.DetailReason(detail.Reason) + if detail.Card != nil { + detail.StatusBefore = detail.Card.DataServiceStatus + detail.StatusAfter = detail.StatusBefore + } + status := consts.BatchDetailStatusFailed + if outcome.Success { + status = consts.BatchDetailStatusSuccess + detail.StatusAfter = task.Operation.TargetStatus + } + if outcome.Success && detail.Card != nil && claim.ActiveOperationID == batch.OperationID { + if _, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).Where("id", detail.Card.ID).Where("tenant_id", task.Batch.TenantID).Data(dataServiceUpdateData(task, detail)).Update(); err != nil { + return err + } + } + result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).Where("id", detail.ID).Where("detail_status", previousStatus).Data(g.Map{"detail_status": status, "error_code": fmt.Sprint(outcome.Code), "error_reason": detail.Reason, "snapshot": detail.Snapshot(task.Operation, task.ApnName).String(), "updated_by": task.Batch.CreatedBy}).Update() + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return fmt.Errorf("APN明细终态CAS失败") + } + if _, err := tx.Ctx(ctx).Model(claimTable).Where("tenant_id", task.Batch.TenantID).Where("iccid", detail.ICCID).Where("active_operation_id", batch.OperationID).Data(g.Map{"active_operation_id": "", "active_batch_id": 0}).Update(); err != nil { + return err + } + } + if complete { + n, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).WhereIn("id", batch.DetailIDs).WhereIn("detail_status", []int{consts.BatchDetailStatusRunning, consts.BatchDetailStatusUnknown}).Count() + if err != nil { + return err + } + if n != 0 { + return fmt.Errorf("APN子批仍有未确认明细,不能完成") + } + if _, err = tx.Ctx(ctx).Model(subTable).Where("id", batch.ID).Where("lease_token", batch.LeaseToken).Data(g.Map{"status": "done", "job_id": batch.JobID, "next_check_at": gdb.Raw("NULL"), "lease_token": gdb.Raw("NULL"), "lease_until": gdb.Raw("NULL")}).Update(); err != nil { + return err + } + return r.releaseReviewParentWhenSettled(ctx, tx, batch.BatchNo) + } + return nil + }) +} + +// releaseReviewParentWhenSettled 在同一事务里:当子批转入终态、且该父批已无未终结子批时, +// 把父批从“待核查”退回“处理中”,交给既有恢复扫描重新触发汇总。 +// 修复“子批与明细都已终态,但汇总失败或进程退出后父批永久挂在待核查”的死角。 +// 只在确有终态转移处调用,因此合法的待核查批次不会被反复捞起空转。 +// 状态读取不加锁,最后用带状态的 CAS 更新收口,避免在已持有卡/子批锁的事务里再锁父批造成锁序反转。 +func (r *Repository) releaseReviewParentWhenSettled(ctx context.Context, tx gdb.TX, batchNo string) error { + var main struct { + Status int `orm:"batch_async_status"` + } + if err := noRows(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("batch_async_status").Where("batch_no", batchNo).Scan(&main)); err != nil { + return err + } + if main.Status != consts.BatchAsyncStatusReview { + return nil + } + // 只有已完成准备(ready=1)的父批才允许回到处理中,避免恢复扫描重跑准备阶段产生重复提交。 + var parent struct { + Ready int `orm:"ready"` + } + if err := noRows(tx.Ctx(ctx).Model(parentTable).Fields("ready").Where("batch_no", batchNo).Scan(&parent)); err != nil { + return err + } + if parent.Ready != 1 { + return nil + } + // 待核查父批的子批只可能是终态或在途手动补查:在途补查不阻塞退回, + // 因为 FinalizeReadyBatch 会再校验一次;不依赖一致读快照,避免并发补查互相看不见对方的终态。 + pending, err := tx.Ctx(ctx).Model(subTable).Where("batch_no", batchNo).WhereNotIn("status", []string{"done", "review", "review_query"}).Count() + if err != nil || pending != 0 { + return err + } + _, err = tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where("batch_no", batchNo).Where("batch_async_status", consts.BatchAsyncStatusReview).Data(g.Map{"batch_async_status": consts.BatchAsyncStatusProcessing}).Update() + return err +} + +func (r *Repository) FinalizeReadyBatch(ctx context.Context, batchNo string) error { + token := uuid.NewString() + claimed := false + err := r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error { + claimed = false + var main struct { + Status int `orm:"batch_async_status"` + } + if err := noRows(r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("batch_async_status").Where("batch_no", batchNo)).Scan(&main)); err != nil { + return err + } + if main.Status != consts.BatchAsyncStatusProcessing && main.Status != consts.BatchAsyncStatusReview { + return nil + } + var parent struct { + Ready int `orm:"ready"` + } + if err := noRows(r.subLock(tx.Ctx(ctx).Model(parentTable).Where("batch_no", batchNo)).Scan(&parent)); err != nil { + return err + } + if parent.Ready != 1 { + return nil + } + n, err := tx.Ctx(ctx).Model(subTable).Where("batch_no", batchNo).WhereNotIn("status", []string{"done", "review"}).Count() + if err != nil || n != 0 { + return err + } + n, err = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).Where("batch_no", batchNo).WhereIn("detail_status", []int{consts.BatchDetailStatusPending, consts.BatchDetailStatusRunning}).Count() + if err != nil || n != 0 { + return err + } + n, err = tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where("batch_no", batchNo).WhereIn("batch_async_status", []int{consts.BatchAsyncStatusProcessing, consts.BatchAsyncStatusReview}).Count() + if err != nil || n == 0 { + return err + } + result, err := tx.Ctx(ctx).Model(parentTable).Where("batch_no", batchNo).Where("finalize_until IS NULL OR finalize_until <= ?", time.Now()).Data(g.Map{"finalize_token": token, "finalize_until": time.Now().Add(subLease)}).Update() + if err != nil { + return err + } + count, err := result.RowsAffected() + claimed = count == 1 + return err + }) + if err != nil || !claimed { + return err + } + finalCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 4*time.Minute) + defer cancel() + task, err := r.loadTask(finalCtx, batchNo, true) + if err == nil { + // 冻结归属决定有效数:只有通过校验的明细才会进入子批。 + if err = r.markFrozenValidation(finalCtx, task); err == nil { + task.ValidationOK = true + err = r.Finalize(context.WithValue(finalCtx, finalizeTokenKey{}, token), task) + } + } + if err != nil { + _, _ = r.database().Model(parentTable).Ctx(finalCtx).Where("batch_no", batchNo).Where("finalize_token", token).Data(g.Map{"finalize_token": gdb.Raw("NULL"), "finalize_until": gdb.Raw("NULL"), "last_reason": truncateReason(err.Error(), 1024)}).Update() + } + return err +} +func (r *Repository) ListRecoverableBatches(ctx context.Context, limit int) ([]string, error) { + var rows []struct { + BatchNo string `orm:"batch_no"` + } + // 未准备父批与可最终化父批轮转领取;在途父批交给子任务扫描,不挤占准备恢复窗口。 + err := r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error { + rows = nil + q := tx.Ctx(ctx).Model(dao.BatchMain.Table()+" m").InnerJoin(parentTable+" p", "p.batch_no=m.batch_no").Fields("m.batch_no").WhereIn("m.batch_type", []int{107, 109, 110}).Where("m.batch_async_status", consts.BatchAsyncStatusProcessing).Where("m.start_time IS NULL OR m.start_time <= ?", time.Now().Add(-30*time.Second)).Where("p.ready=0 OR (NOT EXISTS (SELECT 1 FROM data_operation_sub_batch s WHERE s.batch_no=m.batch_no AND s.status NOT IN ('done','review')) AND NOT EXISTS (SELECT 1 FROM batch_detail d WHERE d.batch_no=m.batch_no AND d.detail_status IN (0,1)))").Order("COALESCE(p.recovery_attempt_at,'1970-01-01'),m.id").Limit(limit) + if err := q.Scan(&rows); err != nil { + return err + } + for _, row := range rows { + if _, err := tx.Ctx(ctx).Model(parentTable).Where("batch_no", row.BatchNo).Data(g.Map{"recovery_attempt_at": time.Now()}).Update(); err != nil { + return err + } + } + return nil + }) + out := make([]string, 0, len(rows)) + for _, row := range rows { + out = append(out, row.BatchNo) + } + return out, err +} +func (r *Repository) ProtectExecutionFailure(ctx context.Context, batchNo, reason string) error { + return r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error { + var main struct { + Status int `orm:"batch_async_status"` + } + if err := noRows(r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("batch_async_status").Where("batch_no", batchNo)).Scan(&main)); err != nil { + return err + } + if main.Status != consts.BatchAsyncStatusProcessing { + return nil + } + _, err := tx.Ctx(ctx).Model(parentTable).Where("batch_no", batchNo).Data(g.Map{"last_reason": truncateReason("执行中断,交持久化调度恢复:"+reason, 1024)}).Update() + return err + }) +} diff --git a/internal/repository/data/sub_batch_concurrency_test.go b/internal/repository/data/sub_batch_concurrency_test.go new file mode 100644 index 0000000..a93586a --- /dev/null +++ b/internal/repository/data/sub_batch_concurrency_test.go @@ -0,0 +1,523 @@ +package data + +import ( + "context" + "fmt" + "os" + "sync" + "sync/atomic" + + "github.com/gogf/gf/v2/database/gdb" + "testing" + "time" + + service "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data" +) + +func TestSubBatchPrecheckDelayCASNeverOverwritesClaimOrCompletion(t *testing.T) { + repo, ctx := testSubBatchDB(t) + b := testSub("S-1", "precheck-cas", "a", []int64{1}, []string{"A"}) + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + snapshot, err := repo.GetSubBatch(ctx, b.OperationID) + if err != nil { + t.Fatal(err) + } + next := time.Now().Add(30 * time.Second) + if err := repo.DelaySubBatchPrecheck(ctx, *snapshot, next, "能力拒绝"); err != nil { + t.Fatal(err) + } + stored, err := repo.GetSubBatch(ctx, b.OperationID) + if err != nil || stored.NextCheckAt.Sub(next).Abs() > time.Millisecond { + t.Fatalf("退避未保存: %+v %v", stored, err) + } + if stored.Status != "prepared" || stored.PollCount != 0 || !stored.SubmittedAt.IsZero() || stored.LeaseToken != "" { + t.Fatalf("退避改变状态: %+v", stored) + } + if _, err := repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=NULL WHERE operation_id=?", b.OperationID); err != nil { + t.Fatal(err) + } + snapshot, _ = repo.GetSubBatch(ctx, b.OperationID) + claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner", time.Now()) + if err != nil || claimed == nil { + t.Fatalf("领取: %+v %v", claimed, err) + } + if err := repo.DelaySubBatchPrecheck(ctx, *snapshot, time.Now().Add(time.Hour), "陈旧拒绝"); err != nil { + t.Fatal(err) + } + stored, _ = repo.GetSubBatch(ctx, b.OperationID) + if stored.Status != "submitting" || stored.LeaseToken != "owner" || stored.NextCheckAt.After(time.Now().Add(6*time.Minute)) { + t.Fatalf("陈旧预检覆盖领取: %+v", stored) + } + if _, err := repo.database().Exec(ctx, "UPDATE batch_detail SET item_id='A' WHERE id=1"); err != nil { + t.Fatal(err) + } + if err := repo.CompleteSubBatch(ctx, *claimed, []service.Outcome{{Code: 9999, Message: "明确失败"}}); err != nil { + t.Fatal(err) + } + if err := repo.DelaySubBatchPrecheck(ctx, *snapshot, time.Now().Add(time.Hour), "更晚拒绝"); err != nil { + t.Fatal(err) + } + stored, _ = repo.GetSubBatch(ctx, b.OperationID) + if stored.Status != "done" || !stored.NextCheckAt.IsZero() { + t.Fatalf("陈旧预检覆盖完成: %+v", stored) + } +} + +func TestSubBatchConcurrentPredecessorCompletionAndNewClaim(t *testing.T) { + repo, base := testSubBatchDB(t) + ctx, cancel := context.WithTimeout(base, 20*time.Second) + defer cancel() + if _, err := repo.database().Exec(ctx, "UPDATE batch_detail SET item_id='A',batch_type=CASE WHEN batch_no='S-2' THEN 109 ELSE 110 END"); err != nil { + t.Fatal(err) + } + older := testSub("S-1", "concurrent-old", "a", []int64{1}, []string{"A"}) + newer := testSub("S-2", "concurrent-new", "a", []int64{3}, []string{"A"}) + newer.BatchType = 109 + for _, b := range []service.SubBatch{older, newer} { + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + } + first, err := repo.ClaimSubBatch(ctx, older.OperationID, "old", time.Now()) + if err != nil || first == nil { + t.Fatalf("旧批领取: %+v %v", first, err) + } + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + errs <- repo.CompleteSubBatch(ctx, *first, []service.Outcome{{Success: true}}) + }() + go func() { + defer wg.Done() + <-start + _, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new", time.Now()) + errs <- err + }() + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + stored, _ := repo.GetSubBatch(ctx, newer.OperationID) + if stored.LeaseToken == "" { + if _, err := repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=NULL WHERE operation_id=?", newer.OperationID); err != nil { + t.Fatal(err) + } + got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "after", time.Now()) + if err != nil || got == nil { + t.Fatal(fmt.Errorf("旧批完成后新批不可领取: %+v %v", got, err)) + } + } + row, err := repo.database().GetOne(ctx, "SELECT active_operation_id FROM data_operation_card_claim WHERE iccid='A'") + if err != nil || row["active_operation_id"].String() != newer.OperationID { + t.Fatalf("顺序屏障结果: %v %v", row, err) + } +} + +func TestSubBatchPrecheckBackoffMakes129thDatabaseTaskDue(t *testing.T) { + repo, ctx := testSubBatchDB(t) + base := time.Now().Add(-time.Minute) + for i := 0; i < 129; i++ { + id := int64(1000 + i) + if _, err := repo.database().Exec(ctx, "INSERT INTO batch_detail(id,batch_no,detail_status)VALUES(?,'S-1',1)", id); err != nil { + t.Fatal(err) + } + channel := "removed" + if i == 128 { + channel = "healthy" + } + b := testSub("S-1", fmt.Sprintf("db-fair-%03d", i), channel, []int64{id}, []string{fmt.Sprintf("fake-%03d", i)}) + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + if _, err := repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=? WHERE operation_id=?", base.Add(time.Duration(i)*time.Millisecond), b.OperationID); err != nil { + t.Fatal(err) + } + } + ids, err := repo.ListDueSubBatches(ctx, time.Now(), 128) + if err != nil || len(ids) != 128 { + t.Fatalf("首窗口: %d %v", len(ids), err) + } + for _, id := range ids { + snapshot, err := repo.GetSubBatch(ctx, id) + if err != nil { + t.Fatal(err) + } + if snapshot.ChanKey != "removed" { + t.Fatal("健康任务不应处于首128") + } + if err := repo.DelaySubBatchPrecheck(ctx, *snapshot, time.Now().Add(30*time.Second), "通道已移除"); err != nil { + t.Fatal(err) + } + } + ids, err = repo.ListDueSubBatches(ctx, time.Now(), 128) + if err != nil || len(ids) != 1 || ids[0] != "db-fair-128" { + t.Fatalf("健康第129仍被饿死: %v %v", ids, err) + } + if got, err := repo.ClaimSubBatch(ctx, ids[0], "healthy", time.Now()); err != nil || got == nil { + t.Fatalf("健康任务无法领取: %+v %v", got, err) + } + n, err := repo.database().Model(subTable).Ctx(ctx).Where("chan_key", "removed").Where("status", "prepared").Where("poll_count", 0).WhereNull("submitted_at").WhereNull("lease_token").Count() + if err != nil || n != 128 { + t.Fatalf("预检失败意图被消耗: %d %v", n, err) + } + n, err = repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id != ''").Where("active_operation_id != ?", "db-fair-128").Count() + if err != nil || n != 0 { + t.Fatalf("预检退避错误占active: %d %v", n, err) + } +} + +func TestSubBatchMySQLCardWaitThenConcurrentReleaseAndReverseClaim(t *testing.T) { + if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { + t.Skip("仅隔离MySQL验证真实锁等待和两卡反向顺序") + } + repo, base := testSubBatchDB(t) + ctx, cancel := context.WithTimeout(base, 20*time.Second) + defer cancel() + if _, err := repo.database().Exec(ctx, "INSERT INTO batch_detail(id,batch_no,detail_status)VALUES(4,'S-2',1)"); err != nil { + t.Fatal(err) + } + older := testSub("S-1", "wait-old-BA", "a", []int64{1, 2}, []string{"B", "A"}) + newer := testSub("S-2", "wait-new-AB", "a", []int64{3, 4}, []string{"A", "B"}) + for _, b := range []service.SubBatch{older, newer} { + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + } + claimed, err := repo.ClaimSubBatch(ctx, older.OperationID, "old-owner", time.Now()) + if err != nil || claimed == nil { + t.Fatalf("旧批领取: %+v %v", claimed, err) + } + holder, err := repo.database().Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer holder.Rollback() + if _, err := holder.GetAll("SELECT iccid FROM data_operation_card_claim WHERE tenant_id='tenant' AND iccid='A' FOR UPDATE"); err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + errs := make(chan error, 2) + go func() { + <-start + errs <- repo.ReleaseUnsentSubBatch(ctx, *claimed, "确定未发送") + }() + go func() { + <-start + _, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new-owner", time.Now()) + errs <- err + }() + close(start) + // 实际观察InnoDB锁等待,而非只靠两goroutine恰好同时运行。 + waiting := false + deadline := time.After(5 * time.Second) + tick := time.NewTicker(10 * time.Millisecond) + defer tick.Stop() +observe: + for { + select { + case <-deadline: + break observe + case <-tick.C: + value, err := repo.database().GetValue(ctx, "SELECT COUNT(*) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='data_operation_card_claim'") + if err != nil { + t.Fatal(err) + } + if value.Int() >= 2 { + waiting = true + break observe + } + } + } + if err := holder.Commit(); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := <-errs; err != nil { + t.Fatal(err) + } + } + if !waiting { + t.Fatal("未实际观察到两条同卡交易的InnoDB锁等待") + } + stored, err := repo.GetSubBatch(ctx, older.OperationID) + if err != nil || stored.Status != "prepared" || !stored.SubmittedAt.IsZero() { + t.Fatalf("确定未发送回退状态: %+v %v", stored, err) + } + n, err := repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id != ''").Count() + if err != nil || n != 0 { + t.Fatalf("旧prepared未完成而新批抢先或残留部分active: %d %v", n, err) + } + t.Log("已观察两条事务等待同卡锁,释放后均提交成功,两卡输入BA/AB未形成反向环") +} + +func TestSubBatchMySQLClaimDoesNotLockBusinessParent(t *testing.T) { + if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { + t.Skip("仅隔离MySQL验证前序JOIN不锁业务父批") + } + repo, base := testSubBatchDB(t) + ctx, cancel := context.WithTimeout(base, 10*time.Second) + defer cancel() + older := testSub("S-1", "main-lock-old", "a", []int64{1}, []string{"A"}) + newer := testSub("S-2", "main-lock-new", "a", []int64{3}, []string{"A"}) + for _, b := range []service.SubBatch{older, newer} { + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + } + holder, err := repo.database().Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer holder.Rollback() + if _, err := holder.GetAll("SELECT id FROM batch_main WHERE batch_no='S-1' FOR UPDATE"); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new-owner", time.Now()) + if err == nil && got != nil { + err = fmt.Errorf("新批越过旧批") + } + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("Claim持card等待业务main,存在main→card反向环") + } + t.Log("旧业务main保持FOR UPDATE期间,新批前序当前读完成且未越序") +} + +func TestSubBatchMySQLCrossTenantSameCardsDoNotLockOtherSub(t *testing.T) { + if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { + t.Skip("仅隔离MySQL验证跨租户同ICCID双卡并发") + } + repo, base := testSubBatchDB(t) + ctx, cancel := context.WithTimeout(base, 15*time.Second) + defer cancel() + for _, q := range []string{ + "UPDATE batch_main SET tenant_id='other-tenant' WHERE batch_no='S-2'", + "INSERT INTO batch_detail(id,batch_no,detail_status)VALUES(4,'S-2',1)", + } { + if _, err := repo.database().Exec(ctx, q); err != nil { + t.Fatal(err) + } + } + first := testSub("S-1", "tenant-one-BA", "a", []int64{1, 2}, []string{"B", "A"}) + second := testSub("S-2", "tenant-two-AB", "a", []int64{3, 4}, []string{"A", "B"}) + for _, b := range []service.SubBatch{first, second} { + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + } + start := make(chan struct{}) + errs := make(chan error, 2) + for _, b := range []service.SubBatch{first, second} { + go func(b service.SubBatch) { + <-start + got, err := repo.ClaimSubBatch(ctx, b.OperationID, b.OperationID, time.Now()) + if err == nil && got == nil { + err = fmt.Errorf("跨租户意图不应互相阻断: %s", b.OperationID) + } + errs <- err + }(b) + } + close(start) + for i := 0; i < 2; i++ { + if err := <-errs; err != nil { + t.Fatal(err) + } + } + n, err := repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id != ''").Count() + if err != nil || n != 4 { + t.Fatalf("跨tenant同ICCID active归属丢失: %d %v", n, err) + } +} + +func TestSubBatchMySQLCrossTenantForeignDetailLockDoesNotBlock(t *testing.T) { + if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { + t.Skip("仅隔离MySQL验证异租户相同ICCID不锁候选范围") + } + repo, base := testSubBatchDB(t) + ctx, cancel := context.WithTimeout(base, 15*time.Second) + defer cancel() + if _, err := repo.database().Exec(ctx, "UPDATE batch_main SET tenant_id='other-tenant' WHERE batch_no='S-2'"); err != nil { + t.Fatal(err) + } + foreign := testSub("S-1", "foreign-A", "a", []int64{1}, []string{"A"}) + local := testSub("S-2", "local-A", "a", []int64{3}, []string{"A"}) + for _, b := range []service.SubBatch{foreign, local} { + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + } + holder, err := repo.database().Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer holder.Rollback() + if _, err := holder.GetAll("SELECT detail_id FROM data_operation_sub_detail WHERE operation_id=? FOR UPDATE", foreign.OperationID); err != nil { + t.Fatal(err) + } + result := make(chan error, 1) + go func() { + got, err := repo.ClaimSubBatch(ctx, local.OperationID, "other-tenant-owner", time.Now()) + if err == nil && got == nil { + err = fmt.Errorf("异租户独立意图未领取") + } + result <- err + }() + blocked := false + var claimErr error + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + wait := time.After(2 * time.Second) +loop: + for { + select { + case claimErr = <-result: + break loop + case <-wait: + blocked = true + break loop + case <-ticker.C: + value, err := repo.database().GetValue(ctx, "SELECT COUNT(*) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='data_operation_sub_detail'") + if err != nil { + t.Fatal(err) + } + if value.Int() > 0 { + blocked = true + break loop + } + } + } + if err := holder.Commit(); err != nil { + t.Fatal(err) + } + if blocked { + claimErr = <-result + } + if claimErr != nil { + t.Fatal(claimErr) + } + if blocked { + t.Fatal("异租户旧明细持锁无谓阻塞新租户同ICCID Claim") + } +} + +func TestSubBatchMySQLRepeatableReadStillSeesCommittedPredecessor(t *testing.T) { + if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { + t.Skip("仅隔离MySQL验证RR快照之后的前序当前读") + } + repo, base := testSubBatchDB(t) + ctx, cancel := context.WithTimeout(base, 15*time.Second) + defer cancel() + older := testSub("S-1", "rr-old-stop", "a", []int64{1}, []string{"A"}) + newer := testSub("S-2", "rr-new-open", "a", []int64{3}, []string{"A"}) + if err := repo.PrepareSubBatch(ctx, newer); err != nil { + t.Fatal(err) + } + // 外层事务先建立无旧意图的RR快照,再由独立连接提交旧父批的冻结意图。 + tx, err := repo.database().Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + if _, err := tx.GetAll("SELECT operation_id FROM data_operation_sub_batch"); err != nil { + t.Fatal(err) + } + prepared := make(chan error, 1) + go func() { prepared <- repo.PrepareSubBatch(base, older) }() + if err := <-prepared; err != nil { + t.Fatal(err) + } + rows, err := tx.GetAll("SELECT operation_id FROM data_operation_sub_batch WHERE operation_id=?", older.OperationID) + if err != nil || len(rows) != 0 { + t.Fatalf("未实际建立看不到新提交前序的RR快照: %v %v", rows, err) + } + if gdb.TXFromCtx(tx.GetCtx(), repo.database().GetGroup()) == nil { + t.Fatal("外层RR事务未绑定上下文") + } + got, err := repo.ClaimSubBatch(tx.GetCtx(), newer.OperationID, "rr-owner", time.Now()) + if err != nil || got != nil { + t.Fatalf("当前读遗漏已提交前序: %+v %v", got, err) + } + t.Log("普通RR快照仍看不到旧意图,Claim的锁定当前读已阻止新批越序") +} + +func TestSubBatchMySQLDeadlockRetriesWholeTransaction(t *testing.T) { + if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { + t.Skip("仅隔离MySQL验证真实1213回滚重试") + } + repo, base := testSubBatchDB(t) + ctx, cancel := context.WithTimeout(base, 15*time.Second) + defer cancel() + if _, err := repo.database().Exec(ctx, "INSERT INTO data_operation_card_claim(tenant_id,iccid,batch_id,operation_id)VALUES('tenant','dead-A',0,''),('tenant','dead-B',0,'')"); err != nil { + t.Fatal(err) + } + locked := make(chan struct{}, 2) + proceed := make(chan struct{}) + errs := make(chan error, 2) + var total atomic.Int32 + for _, keys := range [][2]string{{"dead-A", "dead-B"}, {"dead-B", "dead-A"}} { + go func(keys [2]string) { + attempt := 0 + errs <- repo.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error { + attempt++ + total.Add(1) + if _, err := tx.Exec("UPDATE data_operation_card_claim SET batch_id=batch_id+1 WHERE tenant_id='tenant' AND iccid=?", keys[0]); err != nil { + return err + } + if attempt == 1 { + locked <- struct{}{} + select { + case <-proceed: + case <-ctx.Done(): + return ctx.Err() + } + } + _, err := tx.Exec("UPDATE data_operation_card_claim SET batch_id=batch_id+1 WHERE tenant_id='tenant' AND iccid=?", keys[1]) + return err + }) + }(keys) + } + for i := 0; i < 2; i++ { + select { + case <-locked: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + close(proceed) + for i := 0; i < 2; i++ { + if err := <-errs; err != nil { + t.Fatal(err) + } + } + if total.Load() < 3 { + t.Fatal("未触发真实死锁重试") + } + rows, err := repo.database().GetAll(ctx, "SELECT batch_id FROM data_operation_card_claim WHERE iccid IN ('dead-A','dead-B')") + if err != nil || len(rows) != 2 { + t.Fatalf("结果: %v %v", rows, err) + } + for _, row := range rows { + if row["batch_id"].Int() != 2 { + t.Fatalf("回滚后重复或遗漏更新: %v", rows) + } + } + t.Logf("两事务成功提交,实际执行轮数=%d", total.Load()) +} diff --git a/internal/repository/data/sub_batch_mysql_test.go b/internal/repository/data/sub_batch_mysql_test.go new file mode 100644 index 0000000..3bd866a --- /dev/null +++ b/internal/repository/data/sub_batch_mysql_test.go @@ -0,0 +1,232 @@ +package data + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/os/gtime" + + mysql "github.com/go-sql-driver/mysql" + _ "github.com/gogf/gf/contrib/drivers/mysql/v2" + "github.com/gogf/gf/v2/database/gdb" + "gopkg.in/yaml.v3" +) + +// 仅显式启用时使用已核验的本机专用测试库;认证始终仅在内存中处理。 +func testSubBatchMySQL(t *testing.T) gdb.DB { + t.Helper() + if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { + t.Fatal("未显式启用隔离MySQL测试") + } + _, file, _, _ := runtime.Caller(0) + root := filepath.Clean(filepath.Join(filepath.Dir(file), "../../../..")) + raw, err := os.ReadFile(filepath.Join(root, "iotlcm-sms/deploy/config.yaml")) + if err != nil { + t.Fatal("读取本机认证配置失败,未打印凭据") + } + var config map[string]any + if err := yaml.Unmarshal(raw, &config); err != nil { + t.Fatal("解析本机认证配置失败,未打印内容") + } + var dsn string + var visit func(map[string]any) + visit = func(m map[string]any) { + for key, value := range m { + if key == "sceneDbDsn" { + if text, ok := value.(string); ok { + dsn = text + } + } + if nested, ok := value.(map[string]any); ok { + visit(nested) + } + } + } + visit(config) + parsed, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal("本机DSN格式不正确,未打印内容") + } + if parsed.Net != "tcp" || (parsed.Addr != "127.0.0.1:3306" && parsed.Addr != "localhost:3306") { + t.Fatal("认证配置并非允许的本机3306,拒绝连接") + } + const database = "iotdb_apn_worker_test_20261015" + parsed.DBName = database + connection, err := sql.Open("mysql", parsed.FormatDSN()) + if err != nil { + t.Fatal("创建隔离连接失败") + } + defer connection.Close() + ctx := context.Background() + var uuid, datadir, name, version string + var port int + if err := connection.QueryRowContext(ctx, "SELECT @@server_uuid,@@datadir,@@port,DATABASE(),VERSION()").Scan(&uuid, &datadir, &port, &name, &version); err != nil { + t.Fatal("只读核验本机实例身份失败") + } + normalized := strings.TrimRight(strings.ReplaceAll(datadir, "\\", "/"), "/") + if uuid != "4fa545af-ab36-11f1-be02-040e3c9de361" || port != 3306 || name != database || !strings.EqualFold(normalized, "D:/MySQL/MySQL Server 8.0/Data") { + t.Fatal("实例身份不匹配,拒绝任何写入") + } + t.Logf("隔离MySQL身份:version=%s uuid=%s port=%d database=%s", version, uuid, port, name) + db, err := gdb.New(gdb.ConfigNode{Type: "mysql", Host: "127.0.0.1", Port: "3306", User: parsed.User, Pass: parsed.Passwd, Name: database, Charset: "utf8mb4", Timezone: "Asia/Shanghai", Extra: "parseTime=true"}) + if err != nil { + t.Fatal("创建GoFrame隔离连接失败,未打印认证") + } + // 仅重建此专用库中的固定fixture表,不读取或修改任何业务库。 + for _, table := range []string{"data_operation_sub_detail", "data_operation_sub_batch", "data_operation_card_claim", "data_operation_parent", "batch_detail", "batch_main", "t_sim_card_info", "sys_oss", "t_resource_batch_download"} { + if _, err := db.Exec(ctx, "DROP TABLE IF EXISTS `"+database+"`.`"+table+"`"); err != nil { + t.Fatalf("重建专用fixture失败:%s", table) + } + } + migration, err := os.ReadFile(filepath.Join(root, "iotlcm-database/SQL/20261015_data_operation_sub_batch.sql")) + if err != nil { + t.Fatal("读取worker迁移失败") + } + for _, statement := range strings.Split(string(migration), ";") { + statement = strings.TrimSpace(statement) + if statement == "" { + continue + } + if _, err := db.Exec(ctx, statement); err != nil { + t.Fatalf("执行专用库worker迁移失败:%v", err) + } + } + return db +} + +func TestSubBatchMySQLMigrationRepeatPreservesAcceptedIntent(t *testing.T) { + if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { + t.Skip("仅隔离MySQL验证真实迁移重复执行") + } + repo, ctx := testSubBatchDB(t) + b := testSub("S-1", "migration-accepted", "a", []int64{1}, []string{"fake-A"}) + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner", time.Now()) + if err != nil || claimed == nil { + t.Fatalf("领取: %+v %v", claimed, err) + } + if err := repo.UpdateSubBatch(ctx, *claimed, "accepted", "preserved-job", "已受理", time.Now(), 2); err != nil { + t.Fatal(err) + } + query, err := repo.ClaimSubBatch(ctx, b.OperationID, "preserved-query-owner", time.Now().Add(time.Second)) + if err != nil || query == nil { + t.Fatalf("领取在途查询租约: %+v %v", query, err) + } + before, err := repo.database().GetOne(ctx, "SELECT request_data,detail_ids,job_id,poll_count,submitted_at,status,lease_token,lease_until,next_check_at FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID) + if err != nil { + t.Fatal(err) + } + if before["status"].String() != "accepted" || before["job_id"].String() != "preserved-job" || before["poll_count"].Int() != 3 || before["lease_token"].String() != "preserved-query-owner" || before["lease_until"].IsNil() { + t.Fatalf("未实际构造accepted在途租约: %v", before) + } + _, file, _, _ := runtime.Caller(0) + path := filepath.Join(filepath.Dir(file), "../../../../iotlcm-database/SQL/20261015_data_operation_sub_batch.sql") + migration, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for repeat := 0; repeat < 2; repeat++ { + for _, statement := range strings.Split(string(migration), ";") { + if strings.TrimSpace(statement) == "" { + continue + } + if _, err := repo.database().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + } + after, err := repo.database().GetOne(ctx, "SELECT request_data,detail_ids,job_id,poll_count,submitted_at,status,lease_token,lease_until,next_check_at FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID) + if err != nil { + t.Fatal(err) + } + for key, value := range before { + if value.String() != after[key].String() { + t.Fatalf("重复迁移改变受理字段: %s", key) + } + } + n, err := repo.database().Model("data_operation_sub_detail").Ctx(ctx).Where("operation_id", b.OperationID).Count() + if err != nil || n != 1 { + t.Fatalf("重复迁移丢明细归属: %d %v", n, err) + } + n, err = repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id", b.OperationID).Count() + if err != nil || n != 1 { + t.Fatalf("重复迁移丢active: %d %v", n, err) + } +} + +func TestSubBatchMySQLDifferentDatabaseDefaultCollation(t *testing.T) { + if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { + t.Skip("仅隔离MySQL验证不同默认排序规则") + } + repo, ctx := testSubBatchDB(t) + value, err := repo.database().GetValue(ctx, "SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME=DATABASE()") + if err != nil || value.String() != "utf8mb4_0900_ai_ci" { + t.Fatalf("未实际覆盖不同DB默认排序规则: %v %v", value, err) + } + rows, err := repo.database().GetAll(ctx, "SELECT TABLE_NAME,COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND COLUMN_NAME='batch_no' AND TABLE_NAME IN ('batch_main','batch_detail','data_operation_parent','data_operation_sub_batch','data_operation_sub_detail')") + if err != nil || len(rows) != 5 { + t.Fatalf("关联列元数据: %v %v", rows, err) + } + for _, row := range rows { + if row["COLLATION_NAME"].String() != "utf8mb4_general_ci" { + t.Fatalf("关联列未匹配旧基线: %v", rows) + } + } + b := testSub("S-1", "collation-real-join", "a", []int64{1}, []string{"fake-A"}) + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + if ids, err := repo.ListDueSubBatches(ctx, time.Now(), 128); err != nil || len(ids) != 1 { + t.Fatalf("跨新旧表due查询: %v %v", ids, err) + } + if ids, err := repo.ListRecoverableBatches(ctx, 128); err != nil { + t.Fatalf("跨新旧表恢复查询: %v %v", ids, err) + } + if got, err := repo.ClaimSubBatch(ctx, b.OperationID, "collation-owner", time.Now()); err != nil || got == nil { + t.Fatalf("跨新旧表前序锁定JOIN: %+v %v", got, err) + } + indexRows, err := repo.database().GetAll(ctx, "EXPLAIN SELECT STRAIGHT_JOIN s.operation_id FROM batch_main m FORCE INDEX(PRIMARY) JOIN data_operation_sub_detail sd ON sd.batch_no=m.batch_no AND sd.iccid='fake-A' JOIN data_operation_sub_batch s ON s.operation_id=sd.operation_id JOIN batch_detail d ON d.id=sd.detail_id WHERE m.tenant_id='tenant' AND m.id<11 AND d.detail_status=1 AND s.status IN ('prepared','submitting','accepted','uncertain') LIMIT 1 FOR UPDATE OF sd,s,d") + if err != nil || len(indexRows) < 2 || indexRows[0]["table"].String() != "m" || indexRows[1]["table"].String() != "sd" || indexRows[1]["key"].String() != "uk_data_sub_batch_card" { + t.Fatalf("前序锁定查询未按父批→同卡映射驱动: %v %v", indexRows, err) + } + t.Logf("前序EXPLAIN首表=%s key=%s;第二表=%s key=%s", indexRows[0]["table"].String(), indexRows[0]["key"].String(), indexRows[1]["table"].String(), indexRows[1]["key"].String()) + t.Logf("DB默认=%s,五张新旧表batch_no均general_ci,真实due/recovery/claim JOIN成功", value.String()) +} + +func logMySQLLease(t *testing.T, repo *Repository, batchID int64, token string) { + t.Helper() + if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { + return + } + ctx := context.Background() + raw, err := repo.database().GetOne(ctx, "SELECT CAST(lease_until AS CHAR) AS raw_lease, CAST(NOW(6) AS CHAR) AS db_now, CAST(UTC_TIMESTAMP(6) AS CHAR) AS db_utc, @@session.time_zone AS session_zone FROM data_operation_sub_batch WHERE id=?", batchID) + if err != nil { + t.Fatal(err) + } + var row subRow + if err := repo.database().Model(subTable).Ctx(ctx).Where("id", batchID).Scan(&row); err != nil { + t.Fatal(err) + } + var alternate struct { + LeaseUntil *gtime.Time `orm:"lease_until"` + } + if err := repo.database().Model(subTable).Ctx(ctx).Where("id", batchID).Scan(&alternate); err != nil { + t.Fatal(err) + } + now := time.Now() + t.Logf("租约原值 raw=%s dbNow=%s dbUTC=%s sessionZone=%s tokenMatch=%t status=%s nil=%t", raw["raw_lease"].String(), raw["db_now"].String(), raw["db_utc"].String(), raw["session_zone"].String(), row.LeaseToken == token, row.Status, row.LeaseUntil == nil) + if row.LeaseUntil != nil { + t.Logf("time扫描=%s location=%s unixNano=%d; now=%s location=%s unixNano=%d", row.LeaseUntil.Format(time.RFC3339Nano), row.LeaseUntil.Location(), row.LeaseUntil.UnixNano(), now.Format(time.RFC3339Nano), now.Location(), now.UnixNano()) + } + if alternate.LeaseUntil != nil { + t.Logf("gtime扫描=%s location=%s unixNano=%d", alternate.LeaseUntil.Format("c"), alternate.LeaseUntil.Time.Location(), alternate.LeaseUntil.UnixNano()) + } +} diff --git a/internal/repository/data/sub_batch_order_test.go b/internal/repository/data/sub_batch_order_test.go new file mode 100644 index 0000000..3cd4107 --- /dev/null +++ b/internal/repository/data/sub_batch_order_test.go @@ -0,0 +1,123 @@ +package data + +import ( + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + service "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data" +) + +func TestSubBatchFrozenOrderNewClaimFirstDoesNotCancelOlderCards(t *testing.T) { + repo, ctx := testSubBatchDB(t) + for _, q := range []string{ + `UPDATE batch_detail SET item_id=CASE id WHEN 2 THEN 'B' ELSE 'A' END,snapshot='{"authorized":true}'`, + `UPDATE batch_detail SET batch_type=109 WHERE batch_no='S-2'`, + `INSERT INTO t_sim_card_info(id,tenant_id,iccid,data_service_status)VALUES(1,'tenant','A',1),(2,'tenant','B',1)`, + } { + if _, err := repo.database().Exec(ctx, q); err != nil { + t.Fatal(err) + } + } + older := testSub("S-1", "frozen-stop-AB", "a", []int64{1, 2}, []string{"A", "B"}) + newer := testSub("S-2", "frozen-open-A", "a", []int64{3}, []string{"A"}) + newer.BatchType = 109 + newer.Request.ReqData.OperType = "0" + for _, sub := range []service.SubBatch{older, newer} { + if err := repo.PrepareSubBatch(ctx, sub); err != nil { + t.Fatal(err) + } + } + if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new-first", time.Now()); err != nil || got != nil { + t.Fatalf("newer overtook frozen predecessor: %+v %v", got, err) + } + row, err := repo.database().GetOne(ctx, "SELECT status FROM data_operation_sub_batch WHERE operation_id=?", older.OperationID) + if err != nil || row["status"].String() != "prepared" { + t.Fatalf("older work cancelled: %v %v", row, err) + } + first, err := repo.ClaimSubBatch(ctx, older.OperationID, "old-first", time.Now()) + if err != nil || first == nil { + t.Fatalf("older not claimable: %+v %v", first, err) + } + if err := repo.CompleteSubBatch(ctx, *first, []service.Outcome{{Success: true}, {Success: true}}); err != nil { + t.Fatal(err) + } + rows, err := repo.database().GetAll(ctx, "SELECT iccid,data_service_status FROM t_sim_card_info ORDER BY iccid") + if err != nil || len(rows) != 2 || rows[0]["data_service_status"].Int() != 0 || rows[1]["data_service_status"].Int() != 0 { + t.Fatalf("stop A/B not persisted: %v %v", rows, err) + } + if _, err := repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=NULL WHERE operation_id=?", newer.OperationID); err != nil { + t.Fatal(err) + } + second, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new-after-old", time.Now()) + if err != nil || second == nil { + t.Fatalf("newer not released after old terminal: %+v %v", second, err) + } + if err := repo.CompleteSubBatch(ctx, *second, []service.Outcome{{Success: true}}); err != nil { + t.Fatal(err) + } + rows, err = repo.database().GetAll(ctx, "SELECT iccid,data_service_status FROM t_sim_card_info ORDER BY iccid") + if err != nil || rows[0]["data_service_status"].Int() != 1 || rows[1]["data_service_status"].Int() != 0 { + t.Fatalf("reverse should reopen only A, keep B stopped: %v %v", rows, err) + } + details, err := repo.database().GetAll(ctx, "SELECT detail_status FROM batch_detail ORDER BY id") + if err != nil || len(details) != 3 { + t.Fatalf("details: %v %v", details, err) + } + for _, d := range details { + if d["detail_status"].Int() != consts.BatchDetailStatusSuccess { + t.Fatalf("card was omitted or cancelled: %v", details) + } + } +} + +func TestSubBatchParentOrderSurvivesReverseFreezeOrder(t *testing.T) { + repo, ctx := testSubBatchDB(t) + older := testSub("S-1", "parent-old-freeze-second", "a", []int64{1, 2}, []string{"A", "B"}) + newer := testSub("S-2", "parent-new-freeze-first", "a", []int64{3}, []string{"A"}) + for _, b := range []service.SubBatch{newer, older} { + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + } + oldStored, _ := repo.GetSubBatch(ctx, older.OperationID) + newStored, _ := repo.GetSubBatch(ctx, newer.OperationID) + if oldStored.ID <= newStored.ID { + t.Fatal("未实际复现父批与冻结ID相反顺序") + } + if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new-first", time.Now()); err != nil || got != nil { + t.Fatalf("新批越过已冻结的旧父批: %+v %v", got, err) + } + if got, err := repo.ClaimSubBatch(ctx, older.OperationID, "old-first", time.Now()); err != nil || got == nil { + t.Fatalf("旧停AB应完整可领取: %+v %v", got, err) + } + n, err := repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id", older.OperationID).Count() + if err != nil || n != 2 { + t.Fatalf("旧批不能遗漏B: %d %v", n, err) + } +} + +func TestSubBatchPreparedPredecessorWaitHasFiniteUnsubmittedOutcome(t *testing.T) { + repo, ctx := testSubBatchDB(t) + older := testSub("S-1", "prepared-old", "a", []int64{1}, []string{"A"}) + newer := testSub("S-2", "prepared-new", "a", []int64{3}, []string{"A"}) + for _, sub := range []service.SubBatch{older, newer} { + if err := repo.PrepareSubBatch(ctx, sub); err != nil { + t.Fatal(err) + } + } + if _, err := repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET created_at=?,next_check_at=NULL WHERE operation_id=?", time.Now().Add(-16*time.Minute), newer.OperationID); err != nil { + t.Fatal(err) + } + if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "expired", time.Now()); err != nil || got != nil { + t.Fatalf("expired later intent was submitted: %+v %v", got, err) + } + row, err := repo.database().GetOne(ctx, "SELECT status,submitted_at,poll_count FROM data_operation_sub_batch WHERE operation_id=?", newer.OperationID) + if err != nil || row["status"].String() != "review" || !row["submitted_at"].IsNil() || row["poll_count"].Int() != 0 { + t.Fatalf("later intent not finitely closed unsent: %v %v", row, err) + } + row, err = repo.database().GetOne(ctx, "SELECT status FROM data_operation_sub_batch WHERE operation_id=?", older.OperationID) + if err != nil || row["status"].String() != "prepared" { + t.Fatalf("older intent cancelled: %v %v", row, err) + } +} diff --git a/internal/repository/data/sub_batch_test.go b/internal/repository/data/sub_batch_test.go new file mode 100644 index 0000000..a54da3e --- /dev/null +++ b/internal/repository/data/sub_batch_test.go @@ -0,0 +1,781 @@ +package data + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult" + service "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data" +) + +func testSubBatchDB(t *testing.T) (*Repository, context.Context) { + t.Helper() + ctx := context.Background() + var db gdb.DB + var err error + mysqlTest := os.Getenv("APN_WORKER_MYSQL_TEST") == "1" + if mysqlTest { + db = testSubBatchMySQL(t) + } else { + db, err = gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(" + t.TempDir() + "/apn.db)"}) + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(ctx); err != nil { + t.Error(err) + } + }) + if !mysqlTest { + if _, err := db.Exec(ctx, "PRAGMA journal_mode=WAL"); err != nil { + t.Fatal(err) + } + } + for _, statement := range []string{ + "CREATE TABLE batch_main (id INTEGER PRIMARY KEY, batch_no TEXT, batch_type INTEGER, batch_async_status INTEGER, tenant_id TEXT, start_time DATETIME, updated_at DATETIME, created_by INTEGER, created_dept INTEGER, valid_count INTEGER, success_count INTEGER, fail_count INTEGER, run_result INTEGER, fail_reason TEXT, batch_info TEXT, end_time DATETIME, updated_by INTEGER)", + "CREATE TABLE data_operation_parent (batch_no TEXT PRIMARY KEY, ready INTEGER DEFAULT 0, finalize_token TEXT, finalize_until DATETIME, recovery_attempt_at DATETIME, last_reason TEXT)", + "INSERT INTO data_operation_parent (batch_no,ready) VALUES ('S-1',1),('S-2',1)", + "CREATE TABLE t_sim_card_info (id INTEGER PRIMARY KEY, tenant_id TEXT, iccid TEXT, msisdn TEXT, imsi TEXT, chan_key TEXT, data_service_status INTEGER, status_sync_time DATETIME, traffic_cutoff_triggered_at DATETIME, updated_by INTEGER)", + "CREATE TABLE batch_detail (id INTEGER PRIMARY KEY, batch_no TEXT, batch_type INTEGER DEFAULT 110, row_no INTEGER, item_id TEXT, detail_status INTEGER, error_code TEXT, error_reason TEXT, snapshot TEXT, updated_by INTEGER)", + "CREATE TABLE sys_oss (id INTEGER PRIMARY KEY AUTOINCREMENT,tenant_id TEXT,file_name TEXT,original_name TEXT,file_suffix TEXT,path TEXT,url TEXT,created_dept INTEGER,created_by INTEGER,updated_by INTEGER,service TEXT,md5 TEXT,file_size INTEGER)", + "CREATE TABLE t_resource_batch_download (id INTEGER PRIMARY KEY AUTOINCREMENT,batch_no TEXT,task_name TEXT,resource_type INTEGER,export_scene INTEGER,total_count INTEGER,success_count INTEGER,fail_count INTEGER,download_status INTEGER,oss_id INTEGER,file_name TEXT,file_ext TEXT,file_url TEXT,file_path TEXT,file_size INTEGER,file_md5 TEXT,download_count INTEGER,max_download_count INTEGER,dept_id INTEGER,start_time DATETIME,finish_time DATETIME,tenant_id TEXT,created_dept INTEGER,created_by INTEGER,updated_by INTEGER)", + "CREATE TABLE data_operation_sub_batch (id INTEGER PRIMARY KEY AUTOINCREMENT, batch_no TEXT, batch_type INTEGER, chan_key TEXT, operation_id TEXT UNIQUE, detail_ids TEXT, request_data TEXT, job_id TEXT, status TEXT, poll_count INTEGER DEFAULT 0, next_check_at DATETIME, submitted_at DATETIME, lease_token TEXT, lease_until DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_reason TEXT)", + "CREATE TABLE data_operation_sub_detail (detail_id INTEGER PRIMARY KEY, operation_id TEXT, batch_no TEXT, iccid TEXT, UNIQUE(batch_no,iccid))", + "CREATE TABLE data_operation_card_claim (tenant_id TEXT, iccid TEXT, batch_id INTEGER, operation_id TEXT, active_batch_id INTEGER DEFAULT 0, active_operation_id TEXT DEFAULT '', PRIMARY KEY(tenant_id,iccid))", + "INSERT INTO batch_main (id,batch_no,batch_type,batch_async_status,tenant_id) VALUES (10,'S-1',110,2,'tenant'),(11,'S-2',109,2,'tenant')", + "INSERT INTO batch_detail (id,batch_no,detail_status) VALUES (1,'S-1',1),(2,'S-1',1),(3,'S-2',1)", + } { + if mysqlTest { + if strings.HasPrefix(statement, "CREATE TABLE data_operation_") { + continue + } + statement = strings.ReplaceAll(statement, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGINT PRIMARY KEY AUTO_INCREMENT") + statement = strings.ReplaceAll(statement, " TEXT", " VARCHAR(512)") + statement = strings.ReplaceAll(statement, "snapshot VARCHAR(512)", "snapshot JSON") + statement = strings.ReplaceAll(statement, "batch_info VARCHAR(512)", "batch_info JSON") + if strings.HasPrefix(statement, "CREATE TABLE ") { + statement += " DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" + } + } + if _, err := db.Exec(ctx, statement); err != nil { + t.Fatalf("setup %q: %v", statement, err) + } + } + return &Repository{db: db}, ctx +} + +func testSub(batchNo, operationID, chanKey string, detailIDs []int64, iccids []string) service.SubBatch { + keys := make([]*model.SimKey, 0, len(detailIDs)) + for i, id := range detailIDs { + _ = id + keys = append(keys, &model.SimKey{ICCID: iccids[i], ChanKey: chanKey, MSISDN: fmt.Sprint(i + 1)}) + } + return service.SubBatch{BatchNo: batchNo, BatchType: 110, ChanKey: chanKey, OperationID: operationID, DetailIDs: detailIDs, Request: model.SimCardApnChangeInput{SimKeys: keys, ReqData: model.SimCardApnChangeInputItem{OperType: "1", OperationID: operationID, SubmitOnly: true}}} +} + +func TestSubBatchDurableClaimCASAndReview(t *testing.T) { + repo, ctx := testSubBatchDB(t) + b := testSub("S-1", "intent-1", "a", []int64{1, 2}, []string{"A", "B"}) + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatalf("幂等准备失败: %v", err) + } + first, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner1", time.Now()) + if err != nil { + t.Fatal(err) + } + if first == nil || first.Status != "prepared" { + t.Fatalf("claim=%+v", first) + } + second, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner2", time.Now()) + if err != nil { + t.Fatal(err) + } + if second != nil { + t.Fatal("并发claim不应成功") + } + if err := repo.UpdateSubBatch(ctx, *first, "accepted", "job-1", "已受理", time.Now().Add(time.Minute), 0); err != nil { + t.Fatal(err) + } + if err := repo.UpdateSubBatch(ctx, *first, "done", "", "stale", time.Time{}, 0); err == nil { + t.Fatal("旧lease竟能覆盖") + } + row, err := repo.database().GetOne(ctx, "SELECT status,job_id FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID) + if err != nil { + t.Fatal(err) + } + if row["status"].String() != "accepted" || row["job_id"].String() != "job-1" { + t.Fatalf("jobId未立即落库: %+v", row) + } + if _, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner3", time.Now()); err != nil { + t.Fatal(err) + } + if second, err = repo.ClaimSubBatch(ctx, b.OperationID, "owner3", time.Now().Add(2*time.Minute)); err != nil { + t.Fatal(err) + } + if second == nil || second.JobID != "job-1" { + t.Fatalf("无法恢复已受理jobId: %+v", second) + } + if err := repo.UpdateSubBatch(ctx, *second, "review", "job-1", "结果未确认", time.Time{}, 4); err != nil { + t.Fatal(err) + } + for _, id := range b.DetailIDs { + row, err := repo.database().GetOne(ctx, "SELECT detail_status,error_reason FROM batch_detail WHERE id=?", id) + if err != nil { + t.Fatal(err) + } + if row["detail_status"].Int() != consts.BatchDetailStatusUnknown || row["error_reason"].String() != "结果未确认" { + t.Fatalf("未知状态落库失败 id=%d row=%+v", id, row) + } + } + if ids, err := repo.ListDueSubBatches(ctx, time.Now().Add(time.Hour), 10); err != nil { + t.Fatal(err) + } else if len(ids) != 0 { + t.Fatalf("review不应自动重试: %+v", ids) + } +} + +func TestSubBatchPreparationRollbackAndReadyBarrier(t *testing.T) { + repo, ctx := testSubBatchDB(t) + b := testSub("S-1", "barrier", "a", []int64{1}, []string{"A"}) + _, _ = repo.database().Exec(ctx, "UPDATE data_operation_parent SET ready=0 WHERE batch_no='S-1'") + interrupted := fmt.Errorf("模拟准备中断") + err := repo.ResumeOrPrepare(ctx, "S-1", func(prep context.Context) error { + if err := repo.PrepareSubBatch(prep, b); err != nil { + return err + } + return interrupted + }) + if err == nil { + t.Fatal("中断应回滚") + } + n, _ := repo.database().Model(subTable).Ctx(ctx).Count() + if n != 0 { + t.Fatal("部分准备未回滚") + } + if err := repo.ResumeOrPrepare(ctx, "S-1", func(prep context.Context) error { return repo.PrepareSubBatch(prep, b) }); err != nil { + t.Fatal(err) + } + _, _ = repo.database().Exec(ctx, "UPDATE data_operation_parent SET ready=0 WHERE batch_no='S-1'") + if got, err := repo.ClaimSubBatch(ctx, b.OperationID, "premature", time.Now()); err != nil || got != nil { + t.Fatalf("ready前可领取: %+v %v", got, err) + } + ids, err := repo.ListDueSubBatches(ctx, time.Now().Add(time.Minute), 10) + if err != nil || len(ids) != 0 { + t.Fatalf("ready前可扫描: %v %v", ids, err) + } +} + +func TestSubBatchActiveBlocksReverseAndReviewTerminatesUnsubmitted(t *testing.T) { + repo, ctx := testSubBatchDB(t) + older := testSub("S-1", "active-old", "a", []int64{1}, []string{"A"}) + newer := testSub("S-2", "queued-new", "a", []int64{3}, []string{"A"}) + if err := repo.PrepareSubBatch(ctx, older); err != nil { + t.Fatal(err) + } + first, err := repo.ClaimSubBatch(ctx, older.OperationID, "owner1", time.Now()) + if err != nil || first == nil { + t.Fatalf("%+v %v", first, err) + } + if err := repo.PrepareSubBatch(ctx, newer); err != nil { + t.Fatal(err) + } + if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "reverse", time.Now()); err != nil || got != nil { + t.Fatalf("在途前序未阻断反向: %+v %v", got, err) + } + row, _ := repo.database().GetOne(ctx, "SELECT active_operation_id,operation_id FROM data_operation_card_claim WHERE iccid='A'") + if row["active_operation_id"].String() != older.OperationID || row["operation_id"].String() != newer.OperationID { + t.Fatalf("queued抢占active: %v", row) + } + if err := repo.UpdateSubBatch(ctx, *first, "review", "", "前序未知", time.Time{}, 4); err != nil { + t.Fatal(err) + } + _, _ = repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=NULL WHERE operation_id=?", newer.OperationID) + if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "reverse", time.Now()); err != nil || got != nil { + t.Fatalf("未知前序未阻断: %+v %v", got, err) + } + row, _ = repo.database().GetOne(ctx, "SELECT status,submitted_at,poll_count FROM data_operation_sub_batch WHERE operation_id=?", newer.OperationID) + if row["status"].String() != "review" || !row["submitted_at"].IsNil() || row["poll_count"].Int() != 0 { + t.Fatalf("未提交后继应有限收尾: %v", row) + } + row, _ = repo.database().GetOne(ctx, "SELECT active_operation_id FROM data_operation_card_claim WHERE iccid='A'") + if row["active_operation_id"].String() != older.OperationID { + t.Fatal("未知active被释放") + } +} + +func TestSubBatchBlockedGroupNeverAcquiresPartialActive(t *testing.T) { + repo, ctx := testSubBatchDB(t) + old := testSub("S-1", "old-B", "a", []int64{1}, []string{"B"}) + if err := repo.PrepareSubBatch(ctx, old); err != nil { + t.Fatal(err) + } + if _, err := repo.ClaimSubBatch(ctx, old.OperationID, "old", time.Now()); err != nil { + t.Fatal(err) + } + _, _ = repo.database().Exec(ctx, "INSERT INTO batch_detail(id,batch_no,detail_status)VALUES(4,'S-2',1)") + newer := testSub("S-2", "AB", "a", []int64{3, 4}, []string{"A", "B"}) + if err := repo.PrepareSubBatch(ctx, newer); err != nil { + t.Fatal(err) + } + if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new", time.Now()); err != nil || got != nil { + t.Fatalf("阻塞组竟领取: %+v %v", got, err) + } + row, _ := repo.database().GetOne(ctx, "SELECT active_operation_id FROM data_operation_card_claim WHERE iccid='A'") + if row["active_operation_id"].String() != "" { + t.Fatal("部分卡占active产生死锁") + } + _, _ = repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET created_at=?,next_check_at=NULL WHERE operation_id=?", time.Now().Add(-16*time.Minute), newer.OperationID) + if _, err := repo.ClaimSubBatch(ctx, newer.OperationID, "deadline", time.Now()); err != nil { + t.Fatal(err) + } + row, _ = repo.database().GetOne(ctx, "SELECT status FROM data_operation_sub_batch WHERE operation_id=?", newer.OperationID) + if row["status"].String() != "review" { + t.Fatal("在途阻塞超过窗口仍永久Running") + } +} + +func TestSubBatchRecoveryWindowIsFair(t *testing.T) { + repo, ctx := testSubBatchDB(t) + for i := 0; i < 35; i++ { + no := fmt.Sprintf("R-%02d", i) + if _, err := repo.database().Exec(ctx, "INSERT INTO batch_main(id,batch_no,batch_type,batch_async_status,tenant_id)VALUES(?,?,109,2,'tenant')", 100+i, no); err != nil { + t.Fatal(err) + } + if _, err := repo.database().Exec(ctx, "INSERT INTO data_operation_parent(batch_no,ready)VALUES(?,0)", no); err != nil { + t.Fatal(err) + } + } + seen := map[string]bool{} + for i := 0; i < 3; i++ { + ids, err := repo.ListRecoverableBatches(ctx, 16) + if err != nil { + t.Fatal(err) + } + for _, id := range ids { + seen[id] = true + } + } + for i := 0; i < 35; i++ { + if !seen[fmt.Sprintf("R-%02d", i)] { + t.Fatalf("固定恢复窗口饥饿: %d", i) + } + } +} + +type durableGenerator struct { + calls atomic.Int32 + create func(context.Context, batchresult.Request) (batchresult.Artifact, error) + request batchresult.Request +} + +func (f *durableGenerator) Create(ctx context.Context, request batchresult.Request) (batchresult.Artifact, error) { + n := f.calls.Add(1) + f.request = request + if f.create != nil { + return f.create(ctx, request) + } + return batchresult.Artifact{FileName: fmt.Sprintf("%s-%d.xlsx", request.BatchNo, n), OriginalName: request.BatchNo + ".xlsx"}, nil +} + +func TestSubBatchProgressIsMonotonicAndMissingCardSafe(t *testing.T) { + repo, ctx := testSubBatchDB(t) + _, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET item_id=CASE id WHEN 1 THEN 'A' ELSE 'B' END,snapshot='{\"authorized\":true,\"statusBefore\":1}' WHERE batch_no='S-1'") + _, _ = repo.database().Exec(ctx, "INSERT INTO t_sim_card_info(id,tenant_id,iccid,data_service_status)VALUES(1,'tenant','A',1)") + b := testSub("S-1", "partial", "a", []int64{1, 2}, []string{"A", "B"}) + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "first", time.Now()) + if err != nil || claimed == nil { + t.Fatalf("claim: %v %v", claimed, err) + } + claimed.JobID = "job-partial" + if err := repo.SaveSubBatchProgress(ctx, *claimed, []service.Outcome{{Success: true, OrderNum: "order-A"}, {Pending: true}}); err != nil { + t.Fatal(err) + } + row, _ := repo.database().GetOne(ctx, "SELECT job_id FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID) + if row["job_id"].String() != "job-partial" { + t.Fatal("jobId未随部分结果落库") + } + // A已明确成功,后续缺失不得覆盖;B无卡实体也不能panic丢掉整组结果。 + if err := repo.SaveSubBatchProgress(ctx, *claimed, []service.Outcome{{Pending: true}, {Success: false, Code: 42, Message: "明确拒绝"}}); err != nil { + t.Fatal(err) + } + if err := repo.UpdateSubBatch(ctx, *claimed, "review", claimed.JobID, "窗口结束", time.Time{}, 4); err != nil { + t.Fatal(err) + } + rows, err := repo.database().GetAll(ctx, "SELECT detail_status,error_reason FROM batch_detail WHERE batch_no='S-1' ORDER BY id") + if err != nil { + t.Fatal(err) + } + if rows[0]["detail_status"].Int() != consts.BatchDetailStatusSuccess || rows[1]["detail_status"].Int() != consts.BatchDetailStatusFailed || rows[1]["error_reason"].String() != "明确拒绝" { + t.Fatalf("review覆盖逐卡终态: %v", rows) + } + gen := &durableGenerator{} + repo.resultFiles = gen + if err := repo.FinalizeReadyBatch(ctx, "S-1"); err != nil { + t.Fatal(err) + } + if len(gen.request.Rows) != 2 || !gen.request.Rows[0].Success || gen.request.Rows[1].Success { + t.Fatalf("真实DB导出接线错误: %+v", gen.request) + } +} + +func TestSubBatchUnknownOnlyChangesUnconfirmedRows(t *testing.T) { + repo, ctx := testSubBatchDB(t) + _, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET item_id=CASE id WHEN 1 THEN 'A' ELSE 'B' END,snapshot='{\"authorized\":true}' WHERE batch_no='S-1'") + b := testSub("S-1", "unknown", "a", []int64{1, 2}, []string{"A", "B"}) + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner", time.Now()) + if err != nil { + t.Fatal(err) + } + if err := repo.SaveSubBatchProgress(ctx, *claimed, []service.Outcome{{Success: true, OrderNum: "confirmed"}, {Pending: true}}); err != nil { + t.Fatal(err) + } + if err := repo.UpdateSubBatch(ctx, *claimed, "review", "job", "具体超时原因", time.Time{}, 4); err != nil { + t.Fatal(err) + } + gen := &durableGenerator{} + repo.resultFiles = gen + if err := repo.FinalizeReadyBatch(ctx, "S-1"); err != nil { + t.Fatal(err) + } + main, _ := repo.database().GetOne(ctx, "SELECT batch_async_status,success_count,fail_count,valid_count,batch_info FROM batch_main WHERE batch_no='S-1'") + if main["batch_async_status"].Int() != consts.BatchAsyncStatusReview || main["success_count"].Int() != 1 || main["fail_count"].Int() != 0 || main["valid_count"].Int() != 2 { + t.Fatalf("未知误计失败或清零有效数: %v", main) + } + if len(gen.request.Rows) != 2 || gen.request.Rows[1].DetailStatus != consts.BatchDetailStatusUnknown || gen.request.Rows[1].Reason != "具体超时原因" { + t.Fatalf("待核查导出原因丢失: %+v", gen.request) + } +} + +func TestSubBatchZeroChildrenFinalizeLeaseRecoveryAndConcurrency(t *testing.T) { + repo, ctx := testSubBatchDB(t) + _, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET detail_status=3,error_reason='校验不通过' WHERE batch_no='S-1'") + started := make(chan struct{}) + release := make(chan struct{}) + gen := &durableGenerator{create: func(context.Context, batchresult.Request) (batchresult.Artifact, error) { + close(started) + <-release + return batchresult.Artifact{FileName: "unique.xlsx"}, nil + }} + repo.resultFiles = gen + var wg sync.WaitGroup + wg.Add(1) + var firstErr error + go func() { defer wg.Done(); firstErr = repo.FinalizeReadyBatch(ctx, "S-1") }() + <-started + if err := repo.FinalizeReadyBatch(ctx, "S-1"); err != nil { + t.Fatal(err) + } + close(release) + wg.Wait() + if firstErr != nil { + t.Fatal(firstErr) + } + if gen.calls.Load() != 1 { + t.Fatal("并发最终化重复生成") + } + n, _ := repo.database().Model("sys_oss").Ctx(ctx).Count() + if n != 1 { + t.Fatal("重复发布OSS") + } + n, _ = repo.database().Model("t_resource_batch_download").Ctx(ctx).Count() + if n != 1 { + t.Fatal("重复下载记录") + } + // 真正零明细父批,以及前任已过期租约应能恢复。 + _, _ = repo.database().Exec(ctx, "DELETE FROM batch_detail WHERE batch_no='S-2'") + _, _ = repo.database().Exec(ctx, "UPDATE data_operation_parent SET finalize_token='crashed',finalize_until=? WHERE batch_no='S-2'", time.Now().Add(-time.Minute)) + repo.resultFiles = &durableGenerator{} + if err := repo.FinalizeReadyBatch(ctx, "S-2"); err != nil { + t.Fatalf("零明细/过期租约未恢复: %v", err) + } + row, _ := repo.database().GetOne(ctx, "SELECT batch_async_status FROM batch_main WHERE batch_no='S-2'") + if row["batch_async_status"].Int() == consts.BatchAsyncStatusProcessing { + t.Fatal("零子批不收尾") + } +} + +func TestSubBatchFinalizeFailureReleasesLease(t *testing.T) { + repo, ctx := testSubBatchDB(t) + _, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET detail_status=3 WHERE batch_no='S-1'") + repo.resultFiles = &durableGenerator{create: func(context.Context, batchresult.Request) (batchresult.Artifact, error) { + return batchresult.Artifact{}, errors.New("模拟文件生成失败") + }} + if err := repo.FinalizeReadyBatch(ctx, "S-1"); err == nil { + t.Fatal("应暴露生成失败") + } + repo.resultFiles = &durableGenerator{} + if err := repo.FinalizeReadyBatch(ctx, "S-1"); err != nil { + t.Fatal(err) + } +} + +func TestSubBatchExplicitFailureReleasesActiveForReverse(t *testing.T) { + repo, ctx := testSubBatchDB(t) + _, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET item_id='A' WHERE id=1") + old := testSub("S-1", "failed-old", "a", []int64{1}, []string{"A"}) + if err := repo.PrepareSubBatch(ctx, old); err != nil { + t.Fatal(err) + } + claimed, err := repo.ClaimSubBatch(ctx, old.OperationID, "owner", time.Now()) + if err != nil || claimed == nil { + t.Fatalf("claim: %+v %v", claimed, err) + } + logMySQLLease(t, repo, claimed.ID, claimed.LeaseToken) + if err := repo.CompleteSubBatch(ctx, *claimed, []service.Outcome{{Code: 9999, Message: "任务状态4,已完成且明确失败"}}); err != nil { + t.Fatal(err) + } + row, _ := repo.database().GetOne(ctx, "SELECT detail_status FROM batch_detail WHERE id=1") + if row["detail_status"].Int() != consts.BatchDetailStatusFailed { + t.Fatal("明确9999未持久终态") + } + row, _ = repo.database().GetOne(ctx, "SELECT active_operation_id FROM data_operation_card_claim WHERE iccid='A'") + if row["active_operation_id"].String() != "" { + t.Fatal("明确失败仍保留active") + } + reverse := testSub("S-2", "reverse-after-failed", "a", []int64{3}, []string{"A"}) + if err := repo.PrepareSubBatch(ctx, reverse); err != nil { + t.Fatal(err) + } + if got, err := repo.ClaimSubBatch(ctx, reverse.OperationID, "reverse", time.Now()); err != nil || got == nil { + t.Fatalf("明确失败后反向被阻塞: %+v %v", got, err) + } +} + +func TestSubBatchTypedUnsentReleasesOnlyOwnActiveAndRestoresPrepared(t *testing.T) { + repo, ctx := testSubBatchDB(t) + b := testSub("S-1", "unsent", "a", []int64{1, 2}, []string{"A", "B"}) + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "first", time.Now()) + if err != nil || claimed == nil { + t.Fatalf("%+v %v", claimed, err) + } + if err := repo.ReleaseUnsentSubBatch(ctx, *claimed, "第二次能力探测拒绝"); err != nil { + t.Fatal(err) + } + row, _ := repo.database().GetOne(ctx, "SELECT status,submitted_at,poll_count,lease_token FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID) + if row["status"].String() != "prepared" || !row["submitted_at"].IsNil() || row["poll_count"].Int() != 0 || row["lease_token"].String() != "" { + t.Fatalf("回退prepared失败: %v", row) + } + n, _ := repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id", b.OperationID).Count() + if n != 0 { + t.Fatal("未发送仍占active") + } + if err := repo.ReleaseUnsentSubBatch(ctx, *claimed, "陈旧回退"); err == nil { + t.Fatal("陈旧lease可重放回退") + } + _, _ = repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=NULL WHERE operation_id=?", b.OperationID) + retry, err := repo.ClaimSubBatch(ctx, b.OperationID, "retry", time.Now()) + if err != nil || retry == nil || retry.Status != "prepared" { + t.Fatalf("回退后无法唯一首发: %+v %v", retry, err) + } + if err := repo.UpdateSubBatch(ctx, *retry, "accepted", "job", "已受理", time.Now(), 0); err != nil { + t.Fatal(err) + } + query, err := repo.ClaimSubBatch(ctx, b.OperationID, "query", time.Now().Add(time.Second)) + if err != nil || query == nil { + t.Fatalf("%+v %v", query, err) + } + submitted := query.SubmittedAt + if err := repo.ReleaseUnsentSubBatch(ctx, *query, "查询探测拒绝"); err != nil { + t.Fatal(err) + } + stored, err := repo.GetSubBatch(ctx, b.OperationID) + if err != nil || stored.PollCount != 0 || stored.Status != "accepted" || !stored.SubmittedAt.Equal(submitted) { + t.Fatalf("未发查询消耗轮次或丢首发时点: %+v %v", stored, err) + } + n, _ = repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id", b.OperationID).Count() + if n != 2 { + t.Fatal("已真实提交的active被查询门禁释放") + } +} + +type prepareOnlyAPN struct{} + +func (prepareOnlyAPN) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return nil, errors.New("准备阶段不得调用上游") +} +func (prepareOnlyAPN) SubmitSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return nil, errors.New("准备阶段不得首发") +} +func (prepareOnlyAPN) QuerySimApnFunctionResult(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return nil, errors.New("准备阶段不得补查") +} + +func TestSubBatchCutoffDoesNotSkipDisabledCardWithActiveOpen(t *testing.T) { + repo, ctx := testSubBatchDB(t) + if _, err := repo.database().Exec(ctx, "UPDATE batch_main SET batch_type=109 WHERE batch_no='S-1'"); err != nil { + t.Fatal(err) + } + if _, err := repo.database().Exec(ctx, "UPDATE batch_main SET batch_type=107 WHERE batch_no='S-2'"); err != nil { + t.Fatal(err) + } + if _, err := repo.database().Exec(ctx, "UPDATE batch_detail SET batch_type=107,item_id='A',snapshot='{\"authorized\":true}' WHERE id=3"); err != nil { + t.Fatal(err) + } + if _, err := repo.database().Exec(ctx, "INSERT INTO t_sim_card_info(id,tenant_id,iccid,chan_key,data_service_status)VALUES(1,'tenant','A','a',?)", consts.SimDataServiceStatusDisabled); err != nil { + t.Fatal(err) + } + old := testSub("S-1", "opening", "a", []int64{1}, []string{"A"}) + old.BatchType = 109 + old.Request.ReqData.OperType = consts.SimApnOperTypeOpen + if err := repo.PrepareSubBatch(ctx, old); err != nil { + t.Fatal(err) + } + if got, err := repo.ClaimSubBatch(ctx, old.OperationID, "open-owner", time.Now()); err != nil || got == nil { + t.Fatalf("开通领取: %+v %v", got, err) + } + if _, err := repo.database().Exec(ctx, "UPDATE data_operation_parent SET ready=0 WHERE batch_no='S-2'"); err != nil { + t.Fatal(err) + } + if err := service.NewExecutor(repo, prepareOnlyAPN{}).Execute(ctx, "S-2"); err != nil { + t.Fatal(err) + } + row, err := repo.database().GetOne(ctx, "SELECT operation_id,status FROM data_operation_sub_batch WHERE batch_no='S-2'") + if err != nil || len(row) == 0 { + t.Fatalf("本地disabled绕过冻结: %v %v", row, err) + } + detail, _ := repo.database().GetOne(ctx, "SELECT detail_status FROM batch_detail WHERE id=3") + if detail["detail_status"].Int() != consts.BatchDetailStatusRunning { + t.Fatal("开在途时达量停被提前成功") + } + if got, err := repo.ClaimSubBatch(ctx, row["operation_id"].String(), "cutoff", time.Now()); err != nil || got != nil { + t.Fatalf("达量停绕过前序active: %+v %v", got, err) + } +} + +func TestSubBatchLegacyProcessingWithoutParentNeverCreatesIntent(t *testing.T) { + repo, ctx := testSubBatchDB(t) + if _, err := repo.database().Exec(ctx, "DELETE FROM data_operation_parent WHERE batch_no='S-1'"); err != nil { + t.Fatal(err) + } + ids, err := repo.ListRecoverableBatches(ctx, 128) + if err != nil { + t.Fatal(err) + } + for _, id := range ids { + if id == "S-1" { + t.Fatal("恢复扫描捡起无凭据旧批") + } + } + called := false + if err := repo.ResumeOrPrepare(ctx, "S-1", func(context.Context) error { called = true; return nil }); err == nil || called { + t.Fatalf("旧批进入准备/提交: called=%t err=%v", called, err) + } + if err := repo.ProtectExecutionFailure(ctx, "S-1", "旧MQ重投失败"); err != nil { + t.Fatal(err) + } + if exists, err := repo.HasDurableParent(ctx, "S-1"); err != nil || exists { + t.Fatalf("失败保护或扫描凭空补凭据: exists=%t err=%v", exists, err) + } + n, err := repo.database().Model(subTable).Ctx(ctx).Where("batch_no", "S-1").Count() + if err != nil || n != 0 { + t.Fatalf("旧批产生新提交意图: %d %v", n, err) + } +} + +func TestSubBatchDetailAndCardClaimProtectNewerOperation(t *testing.T) { + repo, ctx := testSubBatchDB(t) + older := testSub("S-1", "intent-old", "a", []int64{1}, []string{"A"}) + newer := testSub("S-2", "intent-new", "a", []int64{3}, []string{"A"}) + if err := repo.PrepareSubBatch(ctx, older); err != nil { + t.Fatal(err) + } + if err := repo.PrepareSubBatch(ctx, newer); err != nil { + t.Fatal(err) + } + claim, err := repo.database().GetOne(ctx, "SELECT batch_id,operation_id FROM data_operation_card_claim WHERE tenant_id='tenant' AND iccid='A'") + if err != nil { + t.Fatal(err) + } + if claim["batch_id"].Int() != 11 || claim["operation_id"].String() != newer.OperationID { + t.Fatalf("后续反向操作claim未生效: %+v", claim) + } + // Even with a differently named intent, the same detail cannot join another submission. + duplicate := testSub("S-1", "intent-duplicate", "a", []int64{1}, []string{"A"}) + if err := repo.PrepareSubBatch(ctx, duplicate); err == nil { + t.Fatal("同一明细重新归属另一提交意图应失败") + } + count, err := repo.database().GetOne(ctx, "SELECT COUNT(*) AS n FROM data_operation_sub_batch WHERE batch_no='S-1'") + if err != nil { + t.Fatal(err) + } + if count["n"].Int() != 1 { + t.Fatalf("失败准备未回滚: %+v", count) + } +} + +// 并发的两次手动补查(不同 operationId)不得互相阻塞退回: +// 在途 review_query 子批不是“未终结”,否则两边都会以为对方还没完成而同时放弃退回。 +func TestSubBatchReviewParentReturnsDespiteInFlightManualQuery(t *testing.T) { + repo, ctx := testSubBatchDB(t) + inflight := testSub("S-1", "inflight", "a", []int64{1}, []string{"A"}) + settle := testSub("S-1", "settle", "a", []int64{2}, []string{"B"}) + for _, sub := range []service.SubBatch{inflight, settle} { + if err := repo.PrepareSubBatch(ctx, sub); err != nil { + t.Fatal(err) + } + } + first, err := repo.ClaimSubBatch(ctx, inflight.OperationID, "owner-a", time.Now()) + if err != nil || first == nil { + t.Fatalf("claim A: %+v %v", first, err) + } + if err := repo.UpdateSubBatch(ctx, *first, "review", "job-a", "补查窗口耗尽", time.Time{}, 4); err != nil { + t.Fatal(err) + } + second, err := repo.ClaimSubBatch(ctx, settle.OperationID, "owner-b", time.Now()) + if err != nil || second == nil { + t.Fatalf("claim B: %+v %v", second, err) + } + if _, err := repo.database().Exec(ctx, "UPDATE batch_main SET batch_async_status=? WHERE batch_no='S-1'", consts.BatchAsyncStatusReview); err != nil { + t.Fatal(err) + } + // A 处于已提交的在途补查状态;B 此时写回终态,仍必须能退回处理中。 + if manual, err := repo.ClaimReviewSubBatch(ctx, inflight.OperationID, "manual", time.Now()); err != nil || manual == nil { + t.Fatalf("A 在途补查领取失败: %+v %v", manual, err) + } + if err := repo.UpdateSubBatch(ctx, *second, "done", "job-b", "", time.Time{}, second.PollCount); err != nil { + t.Fatal(err) + } + main, _ := repo.database().GetOne(ctx, "SELECT batch_async_status FROM batch_main WHERE batch_no='S-1'") + if main["batch_async_status"].Int() != consts.BatchAsyncStatusProcessing { + t.Fatalf("在途补查阻塞了退回,父批将永久挂待核查: %v", main) + } +} + +// 子批全部终结后父批必须能回到恢复扫描重新汇总: +// 修复“手动补查已把子批写成终态,但汇总失败/进程退出后父批永久挂待核查”的死角, +// 同时保证合法停查的批次不会因此被反复捞起空转。 +func TestSubBatchReviewParentReentersRecoveryOnceSettled(t *testing.T) { + repo, ctx := testSubBatchDB(t) + _, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET item_id=CASE id WHEN 1 THEN 'A' ELSE 'B' END,snapshot='{\"authorized\":true}' WHERE batch_no='S-1'") + b := testSub("S-1", "settle", "a", []int64{1, 2}, []string{"A", "B"}) + if err := repo.PrepareSubBatch(ctx, b); err != nil { + t.Fatal(err) + } + claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner", time.Now()) + if err != nil || claimed == nil { + t.Fatalf("claim: %+v %v", claimed, err) + } + if err := repo.UpdateSubBatch(ctx, *claimed, "review", "job-1", "补查窗口耗尽", time.Time{}, 4); err != nil { + t.Fatal(err) + } + // 模拟此前一次汇总已把父批停在待核查。 + if _, err := repo.database().Exec(ctx, "UPDATE batch_main SET batch_async_status=? WHERE batch_no='S-1'", consts.BatchAsyncStatusReview); err != nil { + t.Fatal(err) + } + // 手动同步领取并写回终态:这正是汇总失败/进程退出前最后一步。 + manual, err := repo.ClaimReviewSubBatch(ctx, b.OperationID, "manual", time.Now()) + if err != nil || manual == nil { + t.Fatalf("手动补查领取失败: %+v %v", manual, err) + } + if err := repo.UpdateSubBatch(ctx, *manual, "done", "job-1", "", time.Time{}, manual.PollCount); err != nil { + t.Fatal(err) + } + main, _ := repo.database().GetOne(ctx, "SELECT batch_async_status FROM batch_main WHERE batch_no='S-1'") + if main["batch_async_status"].Int() != consts.BatchAsyncStatusProcessing { + t.Fatalf("终态后未退回处理中,父批将永久挂待核查: %v", main) + } + if !recoverableContains(t, repo, ctx, "S-1") { + t.Fatal("退回处理中后仍未被恢复扫描领取,无法自动汇总") + } + // 汇总成功后若仍有未知明细,父批回到待核查且不再被捞起,避免每 5 秒空转生成结果文件。 + repo.resultFiles = &durableGenerator{} + if err := repo.FinalizeReadyBatch(ctx, "S-1"); err != nil { + t.Fatal(err) + } + main, _ = repo.database().GetOne(ctx, "SELECT batch_async_status FROM batch_main WHERE batch_no='S-1'") + if main["batch_async_status"].Int() != consts.BatchAsyncStatusReview { + t.Fatalf("待核查明细应仍停在待核查: %v", main) + } + if recoverableContains(t, repo, ctx, "S-1") { + t.Fatal("合法待核查批次被反复纳入恢复扫描,将导致空转汇总") + } +} + +func recoverableContains(t *testing.T, repo *Repository, ctx context.Context, batchNo string) bool { + t.Helper() + ids, err := repo.ListRecoverableBatches(ctx, 128) + if err != nil { + t.Fatal(err) + } + for _, id := range ids { + if id == batchNo { + return true + } + } + return false +} + +// 手动同步只能看到该父批下仍待核查的子批,且不会因为已完成的子批而误触发。 +func TestSubBatchListReviewSubBatchesForManualSync(t *testing.T) { + repo, ctx := testSubBatchDB(t) + review := testSub("S-1", "intent-review", "a", []int64{1}, []string{"A"}) + done := testSub("S-1", "intent-done", "a", []int64{2}, []string{"B"}) + other := testSub("S-2", "intent-other", "a", []int64{3}, []string{"C"}) + for _, sub := range []service.SubBatch{review, done, other} { + if err := repo.PrepareSubBatch(ctx, sub); err != nil { + t.Fatal(err) + } + claimed, err := repo.ClaimSubBatch(ctx, sub.OperationID, "owner-"+sub.OperationID, time.Now()) + if err != nil || claimed == nil { + t.Fatalf("claim失败 operationId=%s err=%v", sub.OperationID, err) + } + status, jobID, reason := "review", "", "补查窗口耗尽,结果未知" + if sub.OperationID == done.OperationID { + status, jobID, reason = "done", "job-done", "" + } + if err := repo.UpdateSubBatch(ctx, *claimed, status, jobID, reason, time.Time{}, 4); err != nil { + t.Fatal(err) + } + } + + ids, err := repo.ListReviewSubBatches(ctx, "S-1") + if err != nil { + t.Fatal(err) + } + if len(ids) != 1 || ids[0] != review.OperationID { + t.Fatalf("手动同步候选错误: %+v", ids) + } + // 租约未过期的在途补查不能被第二个请求抢走。 + if _, err := repo.database().Exec(ctx, + "UPDATE data_operation_sub_batch SET status='review_query', lease_token='owner', lease_until=? WHERE operation_id=?", + time.Now().Add(time.Minute), review.OperationID, + ); err != nil { + t.Fatal(err) + } + if ids, err = repo.ListReviewSubBatches(ctx, "S-1"); err != nil || len(ids) != 0 { + t.Fatalf("在途补查不应进入手动同步: %+v err=%v", ids, err) + } + // 租约过期后允许恢复,避免卡在 review_query 永不收敛。 + if _, err := repo.database().Exec(ctx, + "UPDATE data_operation_sub_batch SET lease_until=? WHERE operation_id=?", + time.Now().Add(-time.Minute), review.OperationID, + ); err != nil { + t.Fatal(err) + } + if ids, err = repo.ListReviewSubBatches(ctx, "S-1"); err != nil || len(ids) != 1 || ids[0] != review.OperationID { + t.Fatalf("过期租约应可恢复: %+v err=%v", ids, err) + } +} diff --git a/internal/repository/imei/repository_test.go b/internal/repository/imei/repository_test.go index 7ac8a96..ebf0ef3 100644 --- a/internal/repository/imei/repository_test.go +++ b/internal/repository/imei/repository_test.go @@ -127,8 +127,8 @@ func TestApplyBatchMixedGroups(t *testing.T) { repo := NewRepository() if err := repo.ApplyBatch(ctx, []Result{ {CardID: 1, Synced: true, ValueChanged: true, Imei: "865466070817587"}, // 值变更 - {CardID: 2, Synced: true, ValueChanged: false}, // 心跳 - {CardID: 3, Synced: false}, // 失败跳过 + {CardID: 2, Synced: true, ValueChanged: false}, // 心跳 + {CardID: 3, Synced: false}, // 失败跳过 {CardID: 4, Synced: true, ValueChanged: true, Imei: "111222333444555"}, // 从空到有值 }); err != nil { t.Fatalf("ApplyBatch: %v", err) @@ -156,12 +156,12 @@ func TestApplyBatchRejectsInvalidImeiValue(t *testing.T) { repo := NewRepository() malicious := []string{ - "865466070817587'", // 引号逃逸 - "865466070817587'; DROP TABLE x; --", // 注入尝试 - "865466070817587 OR 1=1", // 逻辑注入 - "abc", // 非数字 - "865466070817587;999999999999999", // 分号拼接(本链路不支持多值) - strings.Repeat("9", 33), // 超长 + "865466070817587'", // 引号逃逸 + "865466070817587'; DROP TABLE x; --", // 注入尝试 + "865466070817587 OR 1=1", // 逻辑注入 + "abc", // 非数字 + "865466070817587;999999999999999", // 分号拼接(本链路不支持多值) + strings.Repeat("9", 33), // 超长 } for _, value := range malicious { err := repo.ApplyBatch(ctx, []Result{ @@ -185,9 +185,9 @@ func TestApplyBatchAcceptsBoundaryLengths(t *testing.T) { repo := NewRepository() values := []string{ - "865466070817587", // 15 位 - "86546607081758712", // 17 位 - strings.Repeat("9", 32), // varchar(32) 上限 + "865466070817587", // 15 位 + "86546607081758712", // 17 位 + strings.Repeat("9", 32), // varchar(32) 上限 } for i, value := range values { cardID := int64(i + 1) diff --git a/internal/repository/inbound/repository.go b/internal/repository/inbound/repository.go index 3e7661a..9e95f93 100644 --- a/internal/repository/inbound/repository.go +++ b/internal/repository/inbound/repository.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" @@ -14,6 +15,7 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult" @@ -385,10 +387,23 @@ func (r *Repository) findExistingStocksByField(ctx context.Context, field, label } func (r *Repository) SaveValidationFailure(ctx context.Context, task *inbound.Task) error { - return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + err := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { batchInfo := buildInboundBatchInfo(task) task.Batch.BatchInfo = batchInfo details := buildBatchDetails(task, consts.BatchDetailStatusFailed) + failCount := 0 + detailIndex := 0 + for _, row := range task.Rows { + if row == nil { + continue + } + if inbound.IsCascadeFailure(row) { + details[detailIndex].DetailStatus = consts.BatchDetailStatusNotExecuted + } else { + failCount++ + } + detailIndex++ + } if err := insertChunkedRows(ctx, tx, dao.BatchDetail.Table(), "inbound detail", details, inboundWriteBatchSize(ctx)); err != nil { return err } @@ -397,14 +412,38 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *inbound.Ta TotalCount: len(task.Rows), ValidCount: task.ValidCount, SuccessCount: 0, - FailCount: len(task.Rows), + FailCount: failCount, RunResult: consts.BatchRunResultFailed, - FailReason: task.FailureReason, + FailReason: batchfields.FailureReason(task.FailureReason), BatchInfo: batchInfo, EndTime: gtime.Now(), UpdatedBy: task.Batch.CreatedBy, }) }) + if err == nil { + return nil + } + g.Log().Errorf(ctx, "save inbound validation details failed, batchNo:%s, err:%s", task.Batch.BatchNo, batchfields.FailureReason(err.Error())) + finalizationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + fallbackErr := r.database().Transaction(finalizationCtx, func(ctx context.Context, tx gdb.TX) error { + return updateBatchMain(ctx, tx, task, do.BatchMain{ + BatchAsyncStatus: consts.BatchAsyncStatusFailed, + TotalCount: len(task.Rows), + ValidCount: task.ValidCount, + SuccessCount: 0, + FailCount: len(task.Rows), + RunResult: consts.BatchRunResultFailed, + FailReason: batchfields.FailureReason("错误明细保存失败,本次整批未入库"), + BatchInfo: task.Batch.BatchInfo, + EndTime: gtime.Now(), + UpdatedBy: task.Batch.CreatedBy, + }) + }) + if fallbackErr != nil { + return gerror.Wrapf(fallbackErr, "save inbound failure details failed: %v; finalize batch failed", err) + } + return nil } func (r *Repository) SaveSuccess(ctx context.Context, task *inbound.Task) error { @@ -481,8 +520,8 @@ func batchDetailRow(task *inbound.Task, row *inbound.Row, status int) do.BatchDe DetailStatus: status, BlankOutStatus: 1, RowNo: row.RowNo, - ItemId: row.ICCID, - ErrorReason: row.Reason, + ItemId: batchfields.ItemID(row.ICCID), + ErrorReason: batchfields.DetailReason(row.Reason), Snapshot: rowSnapshot(row), TenantId: task.Batch.TenantID, CreatedDept: task.Batch.CreatedDept, @@ -526,7 +565,7 @@ func (r *Repository) SaveResultFile(ctx context.Context, task *inbound.Task, req ExportScene: task.Batch.BatchType, TotalCount: len(task.Rows), SuccessCount: successRows(task.Rows), - FailCount: len(task.Rows) - successRows(task.Rows), + FailCount: failedRows(task.Rows), DownloadStatus: consts.DownloadStatusGenerated, OssId: ossID, FileName: artifact.FileName, @@ -863,3 +902,13 @@ func successRows(rows []*inbound.Row) int { } return count } + +func failedRows(rows []*inbound.Row) int { + count := 0 + for _, row := range rows { + if row != nil && row.Reason != "" && !inbound.IsCascadeFailure(row) { + count++ + } + } + return count +} diff --git a/internal/repository/inbound/repository_test.go b/internal/repository/inbound/repository_test.go index c213006..5956b92 100644 --- a/internal/repository/inbound/repository_test.go +++ b/internal/repository/inbound/repository_test.go @@ -1,16 +1,96 @@ package inbound import ( + "context" "os" "path/filepath" + "strings" "testing" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" - serviceinbound "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/inbound" - + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields" + serviceinbound "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/inbound" ) +func TestBatchDetailRowClampsInvalidIdentifiersAndReasons(t *testing.T) { + task := &serviceinbound.Task{Batch: serviceinbound.Batch{BatchNo: "INB-TEST", BatchType: 103}} + row := &serviceinbound.Row{RowNo: 2, ICCID: strings.Repeat("9", 100), Reason: strings.Repeat("错", 1000)} + detail := batchDetailRow(task, row, consts.BatchDetailStatusFailed) + if detail.ItemId != "" || len([]rune(detail.ErrorReason.(string))) != batchfields.DetailReasonLimit { + t.Fatalf("invalid detail: itemID=%v, reason=%v", detail.ItemId, detail.ErrorReason) + } + if row.ICCID != strings.Repeat("9", 100) || row.Reason != strings.Repeat("错", 1000) { + t.Fatal("source row was mutated; full value must remain available to result export") + } +} + +func TestSaveValidationFailureHandlesOversizedDetailsAndFallback(t *testing.T) { + ctx := context.Background() + oldAdapter := g.Cfg().GetAdapter() + adapter, err := gcfg.NewAdapterContent("batch:\n inbound:\n writeBatchSize: 1000\n") + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + defer g.Cfg().SetAdapter(oldAdapter) + for _, test := range []struct { + name string + constraint string + wantFallback bool + }{ + {name: "oversized invalid row is saved", constraint: "CHECK(length(item_id) <= 64 AND length(error_reason) <= 512)"}, + {name: "detail write fails", constraint: "CHECK(error_reason = 'allowed')", wantFallback: true}, + } { + t.Run(test.name, func(t *testing.T) { + db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close(ctx) }) + statements := []string{ + "CREATE TABLE batch_main (batch_no TEXT PRIMARY KEY, batch_type INTEGER, batch_async_status INTEGER, total_count INTEGER, valid_count INTEGER, success_count INTEGER, fail_count INTEGER, run_result INTEGER, fail_reason TEXT, batch_info TEXT, end_time DATETIME, updated_by INTEGER)", + "CREATE TABLE batch_detail (id INTEGER PRIMARY KEY AUTOINCREMENT, batch_no TEXT, batch_type INTEGER, detail_status INTEGER, blank_out_status INTEGER, row_no INTEGER, item_id TEXT, error_reason TEXT " + test.constraint + ", snapshot TEXT, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, updated_by INTEGER)", + "INSERT INTO batch_main (batch_no, batch_type, batch_async_status) VALUES ('INB-TEST', 103, 2)", + } + for _, statement := range statements { + if _, err := db.Exec(ctx, statement); err != nil { + t.Fatalf("fixture failed: %v", err) + } + } + task := &serviceinbound.Task{ + Batch: serviceinbound.Batch{BatchNo: "INB-TEST", BatchType: 103}, + Rows: []*serviceinbound.Row{{RowNo: 2, ICCID: strings.Repeat("9", 100), Reason: strings.Repeat("错", 1000)}}, + FailureReason: "输入格式不正确", + } + if err := (&Repository{db: db}).SaveValidationFailure(ctx, task); err != nil { + t.Fatalf("failed batch must be finalized: %v", err) + } + status, err := db.Model("batch_main").Ctx(ctx).Where("batch_no", "INB-TEST").Value("batch_async_status") + if err != nil || status.Int() != consts.BatchAsyncStatusFailed { + t.Fatalf("status = %v, err = %v", status, err) + } + reason, err := db.Model("batch_main").Ctx(ctx).Where("batch_no", "INB-TEST").Value("fail_reason") + if err != nil || strings.Contains(reason.String(), "错误明细保存失败") != test.wantFallback { + t.Fatalf("fallback reason = %v, err = %v", reason, err) + } + detailCount, err := db.Model("batch_detail").Ctx(ctx).Where("batch_no", "INB-TEST").Count() + wantDetails := 1 + if test.wantFallback { + wantDetails = 0 + } + if err != nil || detailCount != wantDetails { + t.Fatalf("details = %d, want %d, err = %v", detailCount, wantDetails, err) + } + }) + } +} + func TestResolveLocalOssPath(t *testing.T) { uploadRoot := t.TempDir() expectedPath := filepath.Join(uploadRoot, "000000", "user", "file", "20260721", "source.xlsx") diff --git a/internal/repository/lifecycle/failure_text.go b/internal/repository/lifecycle/failure_text.go new file mode 100644 index 0000000..d7116c6 --- /dev/null +++ b/internal/repository/lifecycle/failure_text.go @@ -0,0 +1,70 @@ +package lifecycle + +import ( + "fmt" + "sort" + "strings" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" + lifecycle "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/lifecycle" +) + +// batchFailureSummary 生成中文失败汇总,替代原先的英文「N SIM cards failed」。 +// 形如:共50张,成功28张,失败22张;主要失败原因:当前卡状态不支持该操作 +func batchFailureSummary(total, success, fail int, reasons []string) string { + var builder strings.Builder + fmt.Fprintf(&builder, "共%d张,成功%d张,失败%d张", total, success, fail) + if reason := dominantReason(reasons); reason != "" { + builder.WriteString(";主要失败原因:") + builder.WriteString(reason) + } + return builder.String() +} + +// dominantReason 取出现次数最多的失败原因;次数相同时按字典序,保证结果稳定可测。 +func dominantReason(reasons []string) string { + counts := make(map[string]int, len(reasons)) + for _, reason := range reasons { + trimmed := strings.TrimSpace(reason) + if trimmed == "" { + continue + } + counts[trimmed]++ + } + if len(counts) == 0 { + return "" + } + keys := make([]string, 0, len(counts)) + for key := range counts { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if counts[keys[i]] != counts[keys[j]] { + return counts[keys[i]] > counts[keys[j]] + } + return keys[i] < keys[j] + }) + return keys[0] +} + +// lifecycleFailureSummary 依据内存中的任务明细生成汇总。 +func lifecycleFailureSummary(details []*lifecycle.Detail, success, fail int) string { + reasons := make([]string, 0, fail) + for _, detail := range details { + if detail != nil && strings.TrimSpace(detail.Reason) != "" { + reasons = append(reasons, detail.Reason) + } + } + return batchFailureSummary(len(details), success, fail, reasons) +} + +// storedFailureSummary 依据已落库的批次明细生成汇总(重启批次的复机阶段使用)。 +func storedFailureSummary(details []*entity.BatchDetail, success, fail int) string { + reasons := make([]string, 0, fail) + for _, detail := range details { + if detail != nil && strings.TrimSpace(detail.ErrorReason) != "" { + reasons = append(reasons, detail.ErrorReason) + } + } + return batchFailureSummary(len(details), success, fail, reasons) +} diff --git a/internal/repository/lifecycle/failure_text_test.go b/internal/repository/lifecycle/failure_text_test.go new file mode 100644 index 0000000..22e8b66 --- /dev/null +++ b/internal/repository/lifecycle/failure_text_test.go @@ -0,0 +1,73 @@ +package lifecycle + +import ( + "strings" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" + lifecycle "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/lifecycle" +) + +// TestBatchFailureSummaryUsesChineseAndCounts 固化用户要求的失败文案: +// 必须是中文、带成功失败张数,并附带最主要的失败原因。 +func TestBatchFailureSummaryUsesChineseAndCounts(t *testing.T) { + got := batchFailureSummary(50, 28, 22, []string{ + "当前卡状态不支持该操作", + "当前卡状态不支持该操作", + "请求上游超时", + }) + want := "共50张,成功28张,失败22张;主要失败原因:当前卡状态不支持该操作" + if got != want { + t.Fatalf("失败汇总=%q,期望%q", got, want) + } + if strings.Contains(got, "SIM cards failed") { + t.Fatal("仍包含英文文案") + } +} + +// TestBatchFailureSummaryWithoutReason 覆盖没有具体原因的情况,不能拼出空的原因段。 +func TestBatchFailureSummaryWithoutReason(t *testing.T) { + if got := batchFailureSummary(3, 1, 2, nil); got != "共3张,成功1张,失败2张" { + t.Fatalf("无原因时=%q", got) + } + if got := batchFailureSummary(3, 1, 2, []string{"", " "}); got != "共3张,成功1张,失败2张" { + t.Fatalf("空白原因应被忽略,实际=%q", got) + } +} + +// TestDominantReasonStableOnTie 同频时按字节序取最小值,保证同一批次文案稳定。 +// 注意这里是字节序而非拼音序:「乙」的 UTF-8 编码小于「甲」,所以同频时取「乙原因」。 +func TestDominantReasonStableOnTie(t *testing.T) { + if got := dominantReason([]string{"甲原因", "乙原因"}); got != "乙原因" { + t.Fatalf("同频应按字节序取最小,实际=%q", got) + } + if got := dominantReason([]string{"少数", "多数", "多数"}); got != "多数" { + t.Fatalf("应取出现次数最多,实际=%q", got) + } +} + +// TestStoredFailureSummarySkipsNilAndEmpty 覆盖重启复机阶段:落库明细里的空原因与空指针不能参与统计。 +func TestStoredFailureSummarySkipsNilAndEmpty(t *testing.T) { + details := []*entity.BatchDetail{ + {DetailStatus: 2}, + nil, + {DetailStatus: 3, ErrorReason: "当前卡状态不支持该操作"}, + {DetailStatus: 3, ErrorReason: ""}, + } + got := storedFailureSummary(details, 1, 2) + if !strings.HasPrefix(got, "共4张,成功1张,失败2张") || !strings.Contains(got, "当前卡状态不支持该操作") { + t.Fatalf("落库汇总=%q", got) + } +} + +// TestLifecycleFailureSummaryUsesDetailReason 覆盖停复机首阶段:内存明细的 Reason 作为主要失败原因。 +func TestLifecycleFailureSummaryUsesDetailReason(t *testing.T) { + details := []*lifecycle.Detail{ + {Reason: ""}, + {Reason: "停机或预销户卡,不能办理该业务"}, + } + got := lifecycleFailureSummary(details, 1, 1) + if got != "共2张,成功1张,失败1张;主要失败原因:停机或预销户卡,不能办理该业务" { + t.Fatalf("首阶段汇总=%q", got) + } +} diff --git a/internal/repository/lifecycle/repository.go b/internal/repository/lifecycle/repository.go index f38c496..3c5c31b 100644 --- a/internal/repository/lifecycle/repository.go +++ b/internal/repository/lifecycle/repository.go @@ -211,7 +211,7 @@ func (r *Repository) SaveResult(ctx context.Context, task *lifecycle.Task, detai if resolved.Success { if detail.Card == nil { resolved.Success = false - resolved.Message = "SIM card record is missing" + resolved.Message = "卡记录不存在,未办理" } else { result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()). Where(dao.TSimCardInfo.Columns().Id, detail.Card.ID). @@ -231,7 +231,7 @@ func (r *Repository) SaveResult(ctx context.Context, task *lifecycle.Task, detai } if affected != 1 { resolved.Success = false - resolved.Message = "SIM card local status changed" + resolved.Message = "卡本地状态已变更" } } } @@ -276,7 +276,7 @@ func (r *Repository) Finalize(ctx context.Context, task *lifecycle.Task) error { failCount := len(task.Details) - successCount batchAsyncStatus, runResult := batchResult(successCount, failCount) if task.FailureReason == "" && failCount > 0 { - task.FailureReason = strconv.Itoa(failCount) + " SIM cards failed" + task.FailureReason = lifecycleFailureSummary(task.Details, successCount, failCount) } validCount := 0 if task.ValidationOK { diff --git a/internal/repository/lifecycle/restart.go b/internal/repository/lifecycle/restart.go index ff59bb3..e6223f6 100644 --- a/internal/repository/lifecycle/restart.go +++ b/internal/repository/lifecycle/restart.go @@ -135,7 +135,7 @@ func (r *Repository) SaveRestartValidationFailure(ctx context.Context, task *lif continue } if strings.TrimSpace(detail.Reason) == "" { - detail.Reason = "restart validation failed; upstream request was skipped" + detail.Reason = "重启校验未通过,未发起上游请求" } outcome := lifecycle.Outcome{Message: detail.Reason} result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). @@ -175,7 +175,7 @@ func (r *Repository) SaveRestartResult(ctx context.Context, task *lifecycle.Task if resolved.Success { if detail.Card == nil { resolved.Success = false - resolved.Message = "SIM card record is missing" + resolved.Message = "卡记录不存在,未办理" } else { result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()). Where(dao.TSimCardInfo.Columns().Id, detail.Card.ID). @@ -195,7 +195,7 @@ func (r *Repository) SaveRestartResult(ctx context.Context, task *lifecycle.Task } if affected != 1 { resolved.Success = false - resolved.Message = "SIM card local status changed" + resolved.Message = "卡本地状态已变更" } } } @@ -327,7 +327,7 @@ func (r *Repository) FinalizeRestart(ctx context.Context, batchNo string) error batchAsyncStatus, runResult := batchResult(successCount, failCount) failReason := "" if failCount > 0 { - failReason = strconv.Itoa(failCount) + " SIM cards failed" + failReason = storedFailureSummary(details, successCount, failCount) } validCount := 0 if restartValidationOK(main.BatchInfo) { diff --git a/internal/repository/renewchange/change_order_repository.go b/internal/repository/renewchange/change_order_repository.go index 89cd813..b8f6cdc 100644 --- a/internal/repository/renewchange/change_order_repository.go +++ b/internal/repository/renewchange/change_order_repository.go @@ -77,7 +77,7 @@ type changeCardUpdateDO struct { DataServiceStatus any `orm:"data_service_status"` PackageTotalFlowMb any `orm:"package_total_flow_mb"` UsedFlowMb any `orm:"used_flow_mb"` - TrafficCutoffStatus any `orm:"traffic_cutoff_status"` + TrafficCutoffStatus any `orm:"traffic_cutoff_status"` TrafficCutoffThreshold any `orm:"traffic_cutoff_threshold"` SmsServiceStatus any `orm:"sms_service_status"` PackageTotalSms any `orm:"package_total_sms"` @@ -344,7 +344,7 @@ func (r *Repository) UpdateChangeCard(ctx context.Context, tx gdb.TX, iccid, pac DataServiceStatus: 1, PackageTotalFlowMb: packageFlowMb, UsedFlowMb: 0, - TrafficCutoffStatus: 0, + TrafficCutoffStatus: 0, TrafficCutoffThreshold: 0, SmsServiceStatus: 0, PackageTotalSms: 0, diff --git a/internal/repository/trafficcutoff/repository.go b/internal/repository/trafficcutoff/repository.go index 60acdbc..96bc0cd 100644 --- a/internal/repository/trafficcutoff/repository.go +++ b/internal/repository/trafficcutoff/repository.go @@ -23,7 +23,7 @@ const ( resultFileStatusSuccess = "success" resultFileStatusFailed = "failed" - validationFailureReason = "batch validation failed; traffic cutoff update skipped" + validationFailureReason = "批次校验未通过,已跳过流量断网更新" ) type Repository struct { @@ -338,7 +338,7 @@ func (r *Repository) lockCards(ctx context.Context, tx gdb.TX, tenantID string, func validateLockedTask(task *trafficcutoff.Task) bool { if task == nil || len(task.Details) == 0 { if task != nil { - task.FailureReason = "traffic cutoff batch has no valid details" + task.FailureReason = "流量断网批次无有效明细" } return false } @@ -360,15 +360,15 @@ func validateLockedTask(task *trafficcutoff.Task) bool { } detail.Reason = "" if detail.Card == nil { - detail.Reason = "ICCID does not exist in SIM card records" + detail.Reason = "ICCID不存在或不属于当前租户" } else if !detail.Authorized { - detail.Reason = "ICCID is outside the current operator authorization scope" + detail.Reason = "ICCID不在当前操作员授权范围内" } else if detail.InputMSISDN != "" && detail.InputMSISDN != detail.Card.MSISDN { - detail.Reason = "MSISDN does not match the SIM card record" + detail.Reason = "MSISDN与卡档案不一致" } else if detail.InputIMSI != "" && detail.InputIMSI != detail.Card.IMSI { - detail.Reason = "IMSI does not match the SIM card record" + detail.Reason = "IMSI与卡档案不一致" } else if detail.Card.BossCardStatus == trafficcutoff.BossCardStatusCanceled { - detail.Reason = "ICCID is already canceled" + detail.Reason = "ICCID已销户" } if detail.Reason != "" { hasFailure = true @@ -379,7 +379,7 @@ func validateLockedTask(task *trafficcutoff.Task) bool { if len(statusSet) > 1 { for _, detail := range task.Details { if detail != nil && detail.Reason == "" { - detail.Reason = "same batch contains different card statuses" + detail.Reason = "同一批次内卡状态不一致" } } hasFailure = true @@ -390,7 +390,7 @@ func validateLockedTask(task *trafficcutoff.Task) bool { } for _, detail := range task.Details { if detail != nil && detail.Reason == "" { - detail.Reason = "same batch has invalid traffic cutoff rows; update skipped" + detail.Reason = "同一批次存在无效流量断网明细,已跳过更新" } } task.FailureReason = validationFailureReason diff --git a/internal/repository/trafficusage/routing_test.go b/internal/repository/trafficusage/routing_test.go new file mode 100644 index 0000000..f5b44a1 --- /dev/null +++ b/internal/repository/trafficusage/routing_test.go @@ -0,0 +1,84 @@ +package trafficusage + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/tdengine" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" +) + +type routingDriver struct{ statements []string } +type routingConnection struct{ owner *routingDriver } + +func (recorder *routingDriver) Open(string) (driver.Conn, error) { + return &routingConnection{owner: recorder}, nil +} +func (connection *routingConnection) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} +func (connection *routingConnection) Close() error { return nil } +func (connection *routingConnection) Begin() (driver.Tx, error) { + return nil, errors.New("unexpected transaction") +} +func (connection *routingConnection) Ping(context.Context) error { return nil } +func (connection *routingConnection) ExecContext(_ context.Context, statement string, _ []driver.NamedValue) (driver.Result, error) { + connection.owner.statements = append(connection.owner.statements, statement) + return driver.RowsAffected(1), nil +} + +var routingRecorder = &routingDriver{} + +func init() { + sql.Register("traffic_usage_split_routing", routingRecorder) +} + +func TestRepositoryWritesToConfiguredSplitDatabase(t *testing.T) { + recorder := routingRecorder + recorder.statements = nil + previous := g.Cfg().GetAdapter() + t.Cleanup(func() { _ = tdengine.Close(); g.Cfg().SetAdapter(previous) }) + adapter, err := gcfg.NewAdapterContent("tsdb:\n database: TdEngine\n tdengine:\n type: traffic_usage_split_routing\n dsn: test\n dbName: legacy\n dailyDbName: iotlcm_daily\n monthlyDbName: iotlcm_monthly\n") + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + _ = tdengine.Close() + repository := New() + period := time.Date(2026, 10, 21, 0, 0, 0, 0, time.FixedZone("CST", 8*60*60)) + base := model.TrafficUsageBase{Iccid: "8986112520609988658", UsageMb: 12.5, PackageCode: "test"} + if err := repository.UpdateDaily(context.Background(), []model.TrafficUsageDailyItem{{TrafficUsageBase: base, Date: period}}); err != nil { + t.Fatal(err) + } + if err := repository.UpdateMonthly(context.Background(), []model.TrafficUsageMonthlyItem{{TrafficUsageBase: base, Month: period}}); err != nil { + t.Fatal(err) + } + if len(recorder.statements) != 2 { + t.Fatalf("statements=%v", recorder.statements) + } + for index, expected := range []string{ + "INSERT INTO iotlcm_daily.sim_card_usage_d_8986112520609988658 USING iotlcm_daily.t_sim_card_usage_daily", + "INSERT INTO iotlcm_monthly.sim_card_usage_m_8986112520609988658 USING iotlcm_monthly.t_sim_card_usage_monthly", + } { + if !strings.HasPrefix(recorder.statements[index], expected) { + t.Errorf("statement=%s want prefix=%s", recorder.statements[index], expected) + } + } + if err := repository.UpdateDaily(context.Background(), nil); err != nil { + t.Fatal(err) + } + if err := repository.UpdateMonthly(context.Background(), nil); err != nil { + t.Fatal(err) + } + if len(recorder.statements) != 2 { + t.Fatal("empty updates issued SQL") + } +} diff --git a/internal/repository/unsub/repository.go b/internal/repository/unsub/repository.go index 96cd014..ebd992c 100644 --- a/internal/repository/unsub/repository.go +++ b/internal/repository/unsub/repository.go @@ -332,7 +332,7 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *unsub.Task } reason := task.FailureReason if reason == "" { - reason = "batch validation failed; unsub request was skipped" + reason = "批次校验未通过,已跳过退订请求" } now := gtime.Now() successCount := 0 diff --git a/internal/service/allocate/atomic_parent.go b/internal/service/allocate/atomic_parent.go index ceb8e15..4be8e0b 100644 --- a/internal/service/allocate/atomic_parent.go +++ b/internal/service/allocate/atomic_parent.go @@ -46,7 +46,7 @@ func (r *Repository) loadParentTaskTx(ctx context.Context, tx gdb.TX, parent *en continue } detail := &Detail{OriginalSnapshot: batchDetail.Snapshot, ID: batchDetail.Id, BatchNo: batchDetail.BatchNo, RowNo: batchDetail.RowNo, DetailStatus: batchDetail.DetailStatus, ICCID: strings.TrimSpace(batchDetail.ItemId), Reason: strings.TrimSpace(batchDetail.ErrorReason)} - if detail.DetailStatus != consts.BatchDetailStatusSuccess && detail.Reason == "" { + if detail.DetailStatus != consts.BatchDetailStatusSuccess && detail.DetailStatus != consts.BatchDetailStatusRunning && detail.Reason == "" { detail.Reason = "子批次明细尚未全部验证成功" } if snapshot := batchDetail.Snapshot; snapshot != nil { diff --git a/internal/service/allocate/detail_update_test.go b/internal/service/allocate/detail_update_test.go index 52ab8c8..67834a4 100644 --- a/internal/service/allocate/detail_update_test.go +++ b/internal/service/allocate/detail_update_test.go @@ -2,19 +2,19 @@ package allocate import ( "context" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/os/gtime" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" "testing" ) func TestPersistedAllocateDetailsRequireExactBusinessResult(t *testing.T) { task := &Task{Batch: Batch{BatchNo: "ALC-child", TenantID: "000000", CreatedBy: 133, CreatedDept: 175}} - detail := &Detail{ID: 1, ICCID: "89860401790000277308", TargetDeptID: 185} + detail := &Detail{ID: 1, ICCID: "89860401791508277308", TargetDeptID: 185} good := entity.BatchDetail{Id: 1, BatchNo: task.Batch.BatchNo, BatchType: consts.BatchTypeAllocate, TenantId: task.Batch.TenantID, DetailStatus: consts.BatchDetailStatusSuccess, UpdatedBy: 133, Snapshot: gjson.New(detail.Snapshot(task.Batch).String())} if err := validatePersistedDetailResults(task, []*Detail{detail}, []*entity.BatchDetail{&good}); err != nil { t.Fatal(err) diff --git a/internal/service/allocate/executor.go b/internal/service/allocate/executor.go index b7cf66f..4848042 100644 --- a/internal/service/allocate/executor.go +++ b/internal/service/allocate/executor.go @@ -166,68 +166,99 @@ func markAtomicFailure(task *Task, reason string) { task.FailureReason = reason for _, detail := range task.Details { if detail != nil && strings.TrimSpace(detail.Reason) == "" { - detail.Reason = "本批次存在失败明细,整批未执行" + detail.Reason = atomicNotExecutedReason } } } +const atomicNotExecutedReason = "校验通过,但本批次存在失败明细,整批未执行划拨" + +func allocationDetailStatus(detail *Detail) int { + if detail.Reason == atomicNotExecutedReason { + return consts.BatchDetailStatusNotExecuted + } + if detail.Success() { + return consts.BatchDetailStatusSuccess + } + return consts.BatchDetailStatusFailed +} + +func countValidationFailures(task *Task) int { + count := 0 + for _, detail := range task.Details { + if detail != nil && !detail.Success() && detail.Reason != atomicNotExecutedReason { + count++ + } + } + return count +} + +func persistedAllocationDetailStatus(task *Task, detail *Detail) int { + status := allocationDetailStatus(detail) + if strings.TrimSpace(task.Batch.ParentBatchNo) != "" && status == consts.BatchDetailStatusSuccess { + return consts.BatchDetailStatusRunning + } + return status +} + func validateDetail(task *Task, detail *Detail) { - if detail == nil || strings.TrimSpace(detail.Reason) != "" { + if task == nil || detail == nil { return } + var reasons []string + if reason := strings.TrimSpace(detail.Reason); reason != "" { + reasons = append(reasons, reason) + } + defer func() { detail.Reason = strings.Join(reasons, ";") }() if detail.ICCID == "" { - detail.Reason = "ICCID不能为空" + reasons = append(reasons, "ICCID不能为空") return } + detail.Reason = "" target := resolveTarget(task, detail) if target == nil { - return - } - detail.Target = target - detail.TargetDeptID = target.DeptID - if detail.TargetDeptKey == "" { - detail.TargetDeptKey = strings.TrimSpace(target.DeptKey) - } - if detail.TargetDeptName == "" { - detail.TargetDeptName = strings.TrimSpace(target.DeptName) + reasons = append(reasons, detail.Reason) + } else { + detail.Target = target + detail.TargetDeptID = target.DeptID + if detail.TargetDeptKey == "" { + detail.TargetDeptKey = strings.TrimSpace(target.DeptKey) + } + if detail.TargetDeptName == "" { + detail.TargetDeptName = strings.TrimSpace(target.DeptName) + } } if detail.Card == nil { - detail.Reason = "ICCID不存在或不属于当前租户" + reasons = append(reasons, "ICCID不存在或不属于当前租户") return } if strings.TrimSpace(detail.ICCID) != strings.TrimSpace(detail.Card.ICCID) { - detail.Reason = "ICCID与卡档案不一致" + reasons = append(reasons, "ICCID与卡档案不一致") return } detail.MSISDN = coalesce(detail.MSISDN, detail.Card.MSISDN) detail.IMSI = coalesce(detail.IMSI, detail.Card.IMSI) if detail.Card.DeptID != task.Batch.CreatedDept { - detail.Reason = "ICCID不在当前操作员授权范围内" - return + reasons = append(reasons, "ICCID不在当前操作员授权范围内") } if strings.TrimSpace(detail.MSISDN) != "" && strings.TrimSpace(detail.MSISDN) != detail.Card.MSISDN { - detail.Reason = "MSISDN与卡档案不一致" - return + reasons = append(reasons, "MSISDN与卡档案不一致") } if strings.TrimSpace(detail.IMSI) != "" && strings.TrimSpace(detail.IMSI) != detail.Card.IMSI { - detail.Reason = "IMSI与卡档案不一致" - return + reasons = append(reasons, "IMSI与卡档案不一致") } if detail.Card.BossCardStatus == consts.SimBossCardStatusStopped { - detail.Reason = "卡已停机,不能划拨" - return + reasons = append(reasons, "卡已停机,不能划拨") } if detail.Card.BossCardStatus == consts.SimBossCardStatusCanceled { - detail.Reason = "卡已销户,不能划拨" - return + reasons = append(reasons, "卡已销户,不能划拨") } if detail.Card.PackageStatus == consts.PackageStatusExpired || (detail.Card.FlowPkgEndAt != nil && !detail.Card.FlowPkgEndAt.IsZero() && detail.Card.FlowPkgEndAt.Before(gtime.Now())) { - detail.Reason = "套餐已过期,不能划拨" - return + reasons = append(reasons, "套餐已过期,不能划拨") } - if detail.Card.DeptID == target.DeptID { - detail.Reason = "卡已属于目标客户,无需重复划拨" + if target != nil && detail.Card.DeptID == target.DeptID { + reasons = append(reasons, "卡已属于目标客户,无需重复划拨") } } diff --git a/internal/service/allocate/executor_test.go b/internal/service/allocate/executor_test.go index bedfea9..85681ac 100644 --- a/internal/service/allocate/executor_test.go +++ b/internal/service/allocate/executor_test.go @@ -74,7 +74,7 @@ func TestAllocateBatchSizesRemainBounded(t *testing.T) { func TestBuildDetailUpdateStatementUsesBoundParameters(t *testing.T) { task := &Task{Batch: Batch{BatchNo: "ALC-1", CreatedBy: 7}} details := []*Detail{ - {ID: 11, DetailStatus: consts.BatchDetailStatusPending, Reason: "unsafe ' reason"}, + {ID: 11, DetailStatus: consts.BatchDetailStatusPending, Reason: atomicNotExecutedReason}, {ID: 12, DetailStatus: consts.BatchDetailStatusRunning, Reason: "second"}, } statement, args := buildDetailUpdateStatement(task, details, gtime.Now()) @@ -84,8 +84,11 @@ func TestBuildDetailUpdateStatementUsesBoundParameters(t *testing.T) { if !strings.Contains(statement, "CASE") || !strings.Contains(statement, "IN (?,?)") { t.Fatalf("unexpected statement: %s", statement) } - if len(args) != len(details)*7+9 { - t.Fatalf("argument count = %d, want %d", len(args), len(details)*7+9) + if len(args) != len(details)*7+10 { + t.Fatalf("argument count = %d, want %d", len(args), len(details)*7+10) + } + if args[1] != consts.BatchDetailStatusNotExecuted || args[3] != consts.BatchDetailStatusFailed { + t.Fatalf("status parameters = %v, %v; want not executed and failed", args[1], args[3]) } } @@ -96,20 +99,68 @@ func TestNormalizeAtomicParentResultRejectsPartialSuccess(t *testing.T) { }} successCount, failCount := normalizeAtomicParentResult(task) - if successCount != 0 || failCount != len(task.Details) { - t.Fatalf("normalizeAtomicParentResult() = %d, %d; want 0, %d", successCount, failCount, len(task.Details)) + if successCount != 0 || failCount != 1 { + t.Fatalf("normalizeAtomicParentResult() = %d, %d; want 0, 1", successCount, failCount) } for _, detail := range task.Details { if detail.Success() { t.Fatalf("detail %s remained successful", detail.ICCID) } } + if allocationDetailStatus(task.Details[0]) != consts.BatchDetailStatusNotExecuted || allocationDetailStatus(task.Details[1]) != consts.BatchDetailStatusFailed { + t.Fatalf("wrong detail statuses: %+v", task.Details) + } + rows := task.ResultRows() + if rows[0].Status != "未执行" || rows[1].Status != "失败" { + t.Fatalf("wrong result file statuses: %+v", rows) + } status, result, _ := parentBatchResult(successCount, failCount) if status != consts.BatchAsyncStatusFailed || result != consts.BatchRunResultFailed { t.Fatalf("parentBatchResult() = %d, %d; want failed, failed", status, result) } } +func TestAllocateChildValidationDoesNotReportExecutedSuccess(t *testing.T) { + task := &Task{Batch: Batch{BatchNo: "child", ParentBatchNo: "parent"}} + detail := &Detail{ID: 1, ICCID: "valid"} + _, args := buildDetailUpdateStatement(task, []*Detail{detail}, gtime.Now()) + if args[1] != consts.BatchDetailStatusRunning { + t.Fatalf("validated child status = %v, want running until parent commits", args[1]) + } + task.Batch.ParentBatchNo = "" + _, args = buildDetailUpdateStatement(task, []*Detail{detail}, gtime.Now()) + if args[1] != consts.BatchDetailStatusSuccess { + t.Fatalf("committed parent status = %v, want success", args[1]) + } +} + +func TestValidateTaskCollectsAllCardFailuresBeforeBlockingBatch(t *testing.T) { + task := &Task{ + Batch: Batch{CreatedDept: 100, TargetDeptID: 200}, + TargetsByID: map[int64]*Target{200: {DeptID: 200, ParentID: 100}}, + Details: []*Detail{ + {ICCID: "missing"}, + {ICCID: "valid", Card: &Card{ICCID: "valid", DeptID: 100}}, + {ICCID: "stopped", Card: &Card{ICCID: "stopped", DeptID: 100, BossCardStatus: consts.SimBossCardStatusStopped, PackageStatus: consts.PackageStatusExpired}}, + }, + } + validateTask(task) + if task.Details[0].Reason != "ICCID不存在或不属于当前租户" { + t.Fatalf("first failure was lost: %q", task.Details[0].Reason) + } + if task.Details[1].Reason != atomicNotExecutedReason || allocationDetailStatus(task.Details[1]) != consts.BatchDetailStatusNotExecuted { + t.Fatalf("validated card was not blocked: %+v", task.Details[1]) + } + for _, reason := range []string{"卡已停机,不能划拨", "套餐已过期,不能划拨"} { + if !strings.Contains(task.Details[2].Reason, reason) { + t.Fatalf("later card was not fully validated: %q", task.Details[2].Reason) + } + } + if successes, failures := normalizeAtomicParentResult(task); successes != 0 || failures != 2 { + t.Fatalf("got successes=%d failures=%d, want 0 and 2", successes, failures) + } +} + func TestValidateDetailAllowsCurrentHolderReallocation(t *testing.T) { task := &Task{ Batch: Batch{ diff --git a/internal/service/allocate/repository.go b/internal/service/allocate/repository.go index d97bcc7..ff64e1c 100644 --- a/internal/service/allocate/repository.go +++ b/internal/service/allocate/repository.go @@ -283,6 +283,9 @@ func (r *Repository) Commit(ctx context.Context, task *Task) error { markAtomicFailure(task, "划拨批次存在失败明细,整批未执行") } successCount, failCount := countResult(task) + if failCount > 0 { + failCount = countValidationFailures(task) + } autoSplitChild := strings.TrimSpace(task.Batch.ParentBatchNo) != "" var artifact batchresult.Artifact var artifactErr error @@ -443,7 +446,7 @@ func (r *Repository) aggregateParentOnce(ctx context.Context, parentBatchNo stri finalInfo["successCount"] = successCount finalInfo["failCount"] = failCount startTime := resolveAllocateParentStartTime(parent.StartTime, aggregate.EarliestStartTime, now) - data := do.BatchMain{StartTime: startTime, BatchAsyncStatus: status, RunResult: runResult, TotalCount: len(task.Details), ValidCount: successCount, SuccessCount: successCount, FailCount: failCount, FailReason: failReason, BatchInfo: mergeBatchInfo(gjson.New(finalInfo), ossID, downloadID, artifactErr != nil), EndTime: now, UpdatedBy: parent.CreatedBy, UpdatedAt: now} + data := do.BatchMain{StartTime: startTime, BatchAsyncStatus: status, RunResult: runResult, TotalCount: len(task.Details), ValidCount: len(task.Details) - failCount, SuccessCount: successCount, FailCount: failCount, FailReason: failReason, BatchInfo: mergeBatchInfo(gjson.New(finalInfo), ossID, downloadID, artifactErr != nil), EndTime: now, UpdatedBy: parent.CreatedBy, UpdatedAt: now} result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where(mainColumns.Id, parent.Id).WhereIn(mainColumns.BatchAsyncStatus, []int{consts.BatchAsyncStatusPending, consts.BatchAsyncStatusProcessing}).Data(data).Update() if err != nil { if artifactErr == nil { @@ -536,7 +539,7 @@ func normalizeAtomicParentResult(task *Task) (int, int) { } task.FailureReason = coalesce(task.FailureReason, "划拨批次存在失败明细,整批未执行") markAtomicFailure(task, task.FailureReason) - return 0, len(task.Details) + return 0, countValidationFailures(task) } func (r *Repository) updateDetails(ctx context.Context, tx gdb.TX, task *Task, now *gtime.Time) error { @@ -644,10 +647,7 @@ func validatePersistedDetailResults(task *Task, details []*Detail, persisted []* } for _, detail := range details { row := byID[detail.ID] - status := consts.BatchDetailStatusFailed - if detail.Success() { - status = consts.BatchDetailStatusSuccess - } + status := persistedAllocationDetailStatus(task, detail) if row == nil || row.DetailStatus != status || row.ErrorReason != detail.Reason || row.UpdatedBy != task.Batch.CreatedBy || row.Snapshot == nil || !sameDetailSnapshot(row.Snapshot, detail.Snapshot(task.Batch)) { return gerror.Newf("划拨明细最终结果不一致,明细ID:%d", detail.ID) } @@ -669,12 +669,9 @@ func buildDetailUpdateStatement(task *Task, details []*Detail, now *gtime.Time) columns := dao.BatchDetail.Columns() var builder strings.Builder fmt.Fprintf(&builder, "UPDATE %s SET %s = CASE %s ", dao.BatchDetail.Table(), columns.DetailStatus, columns.Id) - args := make([]any, 0, len(details)*7+8) + args := make([]any, 0, len(details)*7+9) for _, detail := range details { - status := consts.BatchDetailStatusFailed - if detail.Success() { - status = consts.BatchDetailStatusSuccess - } + status := persistedAllocationDetailStatus(task, detail) builder.WriteString("WHEN ? THEN ? ") args = append(args, detail.ID, status) } @@ -690,7 +687,7 @@ func buildDetailUpdateStatement(task *Task, details []*Detail, now *gtime.Time) } fmt.Fprintf(&builder, "ELSE %s END, %s = ?, %s = ? ", columns.Snapshot, columns.UpdatedBy, columns.UpdatedAt) args = append(args, task.Batch.CreatedBy, now) - fmt.Fprintf(&builder, "WHERE %s = ? AND %s = ? AND %s = ? AND %s IN (?, ?, ?, ?) AND %s IN (%s)", + fmt.Fprintf(&builder, "WHERE %s = ? AND %s = ? AND %s = ? AND %s IN (?, ?, ?, ?, ?) AND %s IN (%s)", columns.BatchNo, columns.BatchType, columns.TenantId, @@ -706,6 +703,7 @@ func buildDetailUpdateStatement(task *Task, details []*Detail, now *gtime.Time) consts.BatchDetailStatusRunning, consts.BatchDetailStatusSuccess, consts.BatchDetailStatusFailed, + consts.BatchDetailStatusNotExecuted, ) for _, detail := range details { args = append(args, detail.ID) @@ -845,10 +843,7 @@ func (r *Repository) updateDetail(ctx context.Context, tx gdb.TX, task *Task, de detail.DetailStatus != consts.BatchDetailStatusRunning) { return nil } - status := consts.BatchDetailStatusFailed - if detail.Success() { - status = consts.BatchDetailStatusSuccess - } + status := persistedAllocationDetailStatus(task, detail) result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). Where(dao.BatchDetail.Columns().Id, detail.ID). Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo). @@ -949,7 +944,7 @@ func (r *Repository) finalizeBatch(ctx context.Context, tx gdb.TX, task *Task, s BatchAsyncStatus: batchAsyncStatus, RunResult: runResult, TotalCount: len(task.Details), - ValidCount: successCount, + ValidCount: len(task.Details) - failCount, SuccessCount: successCount, FailCount: failCount, FailReason: task.FailureReason, diff --git a/internal/service/allocate/types.go b/internal/service/allocate/types.go index cb423fa..61afc2a 100644 --- a/internal/service/allocate/types.go +++ b/internal/service/allocate/types.go @@ -6,6 +6,7 @@ import ( "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/os/gtime" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult" ) @@ -155,8 +156,19 @@ func (t *Task) ResultRows() []batchresult.Row { TargetDeptName: targetName, Operation: "卡划拨", Success: detail.Success(), + Status: allocationResultStatus(detail), Reason: strings.TrimSpace(detail.Reason), }) } return rows } + +func allocationResultStatus(detail *Detail) string { + if allocationDetailStatus(detail) == consts.BatchDetailStatusNotExecuted { + return "未执行" + } + if detail.Success() { + return "成功" + } + return "失败" +} diff --git a/internal/service/batchresult/generator.go b/internal/service/batchresult/generator.go index 2068eed..e21b3aa 100644 --- a/internal/service/batchresult/generator.go +++ b/internal/service/batchresult/generator.go @@ -3,6 +3,7 @@ package batchresult import ( "context" "crypto/md5" + "crypto/rand" "encoding/hex" "fmt" "io" @@ -11,6 +12,8 @@ import ( "strings" "time" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/xuri/excelize/v2" @@ -32,7 +35,9 @@ type Row struct { TargetStatus int Operation string ApnName string + DetailStatus int Success bool + Status string Reason string UpstreamCode int OrderNum string @@ -88,13 +93,27 @@ func (gtr *generator) Create(ctx context.Context, request Request) (Artifact, er if err = os.MkdirAll(directory, 0o755); err != nil { return Artifact{}, gerror.Wrap(err, "create batch result directory") } - fileName := request.BatchNo + "-result.xlsx" + originalName := request.BatchNo + "-result.xlsx" + var nonce [16]byte + if _, err = rand.Read(nonce[:]); err != nil { + return Artifact{}, gerror.Wrap(err, "generate batch result file name") + } + fileName := request.BatchNo + "-result-" + hex.EncodeToString(nonce[:]) + ".xlsx" filePath := filepath.Join(directory, fileName) + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o666) + if err != nil { + return Artifact{}, gerror.Wrap(err, "reserve batch result file") + } + if err = file.Close(); err != nil { + _ = os.Remove(filePath) + return Artifact{}, gerror.Wrap(err, "close reserved result file") + } logicalPath := filepath.Join(uploadPath, objectDir, fileName) if err = writeWorkbook(filePath, request); err != nil { + _ = os.Remove(filePath) return Artifact{}, err } - artifact, err := inspectArtifact(ctx, filePath, logicalPath, fileName, tenantID) + artifact, err := inspectArtifact(ctx, filePath, logicalPath, fileName, originalName, tenantID) if err != nil { _ = os.Remove(filePath) return Artifact{}, err @@ -262,6 +281,9 @@ func writeAllocateWorkbook(file *excelize.File, sheet, path string, request Requ if row.Success { status = "成功" } + if row.Status != "" { + status = row.Status + } values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, row.TargetDeptName, status, row.Reason} cell, err := excelize.CoordinatesToCellName(1, index+2) if err != nil { @@ -387,6 +409,9 @@ func writeInboundWorkbook(file *excelize.File, sheet, path string, request Reque if row.Success { status = "成功" } + if row.Status != "" { + status = row.Status + } values := []interface{}{row.RowNo, row.ICCID, status, row.Reason} cell, err := excelize.CoordinatesToCellName(1, index+2) if err != nil { @@ -480,13 +505,15 @@ func writeCancelWorkbook(file *excelize.File, sheet, path string, request Reques } func writeDataServiceWorkbook(file *excelize.File, sheet, path string, request Request) error { - headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "操作", "APN", "执行结果", "失败原因", "上游状态码", "上游订单号"} + headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "操作", "APN", "执行结果", "失败/待核查原因", "上游状态码", "上游订单号"} if err := file.SetSheetRow(sheet, "A1", &headers); err != nil { return gerror.Wrap(err, "write data service result headers") } for index, row := range request.Rows { status := "失败" - if row.Success { + if row.DetailStatus == consts.BatchDetailStatusUnknown { + status = "待核查" + } else if row.Success { status = "成功" } values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, row.Operation, row.ApnName, status, row.Reason, row.UpstreamCode, row.OrderNum} @@ -513,7 +540,7 @@ func writeDataServiceWorkbook(file *excelize.File, sheet, path string, request R return nil } -func inspectArtifact(ctx context.Context, localPath, logicalPath, fileName string, tenantID string) (Artifact, error) { +func inspectArtifact(ctx context.Context, localPath, logicalPath, fileName, originalName, tenantID string) (Artifact, error) { file, err := os.Open(localPath) if err != nil { return Artifact{}, gerror.Wrap(err, "open result file") @@ -526,7 +553,7 @@ func inspectArtifact(ctx context.Context, localPath, logicalPath, fileName strin } return Artifact{ FileName: fileName, - OriginalName: fileName, + OriginalName: originalName, FileSuffix: "xlsx", Path: logicalPath, LocalPath: localPath, diff --git a/internal/service/batchresult/generator_test.go b/internal/service/batchresult/generator_test.go index 09d8bc6..92e2b01 100644 --- a/internal/service/batchresult/generator_test.go +++ b/internal/service/batchresult/generator_test.go @@ -4,13 +4,189 @@ import ( "context" "os" "path/filepath" + "reflect" + "strconv" "strings" + "sync" "testing" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gcfg" + "github.com/xuri/excelize/v2" ) +func TestWriteDataServiceWorkbookPreservesReviewStatusAndReason(t *testing.T) { + for _, batchType := range []int{consts.BatchTypeDataCut, consts.BatchTypeDataStart, consts.BatchTypeDataStop} { + t.Run(TaskName(batchType), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "result.xlsx") + rows := []Row{ + {RowNo: 1, ICCID: "test-success", DetailStatus: consts.BatchDetailStatusSuccess, Success: true}, + {RowNo: 2, ICCID: "test-failed", DetailStatus: consts.BatchDetailStatusFailed, Reason: "上游明确拒绝"}, + {RowNo: 3, ICCID: "test-unknown", DetailStatus: consts.BatchDetailStatusUnknown, Reason: "查询超时,尚未确认上游执行结果", UpstreamCode: 202, OrderNum: "test-job-id"}, + {RowNo: 4, ICCID: "test-review-priority", DetailStatus: consts.BatchDetailStatusUnknown, Success: true, Reason: "明细仍待核查"}, + {RowNo: 5, ICCID: "test-legacy-success", Success: true}, + {RowNo: 6, ICCID: "test-legacy-failed", Reason: "未受理"}, + } + if err := writeWorkbook(path, Request{BatchType: batchType, Rows: rows}); err != nil { + t.Fatalf("写入数据服务结果失败:%v", err) + } + file, err := excelize.OpenFile(path) + if err != nil { + t.Fatalf("打开数据服务结果失败:%v", err) + } + defer func() { _ = file.Close() }() + for cell, want := range map[string]string{ + "G1": "执行结果", "H1": "失败/待核查原因", + "G2": "成功", "G3": "失败", "H3": "上游明确拒绝", + "G4": "待核查", "H4": rows[2].Reason, "I4": "202", "J4": "test-job-id", + "G5": "待核查", "H5": rows[3].Reason, + "G6": "成功", "G7": "失败", "H7": "未受理", + } { + got, err := file.GetCellValue("处理结果", cell) + if err != nil || got != want { + t.Fatalf("单元格 %s = %q,预期 %q,错误:%v", cell, got, want, err) + } + } + }) + } +} + +func TestCreateSameBatchUsesIndependentResultFiles(t *testing.T) { + // Keep the generated workbook path below Excel's 207-character limit on Windows. + root, rootErr := os.MkdirTemp("", "batch-result-") + if rootErr != nil { + t.Fatal(rootErr) + } + t.Cleanup(func() { + if err := os.RemoveAll(root); err != nil { + t.Errorf("remove test result directory: %v", err) + } + }) + t.Setenv("IOTLCM_WORKER_UPLOAD_ROOT", root) + oldAdapter := g.Cfg().GetAdapter() + adapter, err := gcfg.NewAdapterContent(` +system: + upload: + local: + path: "resource/upload" +`) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) }) + request := Request{BatchNo: "DATA-REVIEW", BatchType: consts.BatchTypeDataStart, TenantID: "tenant", Rows: []Row{{RowNo: 1, ICCID: "first-card", DetailStatus: consts.BatchDetailStatusUnknown, Reason: "待核查"}}} + requests := []Request{request, request} + requests[1].Rows = []Row{{RowNo: 1, ICCID: "second-card", DetailStatus: consts.BatchDetailStatusUnknown, Reason: "待核查"}} + artifacts := make([]Artifact, 2) + errors := make([]error, 2) + var workers sync.WaitGroup + for index := range requests { + workers.Add(1) + go func(index int) { + defer workers.Done() + artifacts[index], errors[index] = New().Create(context.Background(), requests[index]) + }(index) + } + workers.Wait() + for _, err := range errors { + if err != nil { + t.Fatal(err) + } + } + first, second := artifacts[0], artifacts[1] + defer RemoveLocalFile(first) + defer RemoveLocalFile(second) + if first.Path == second.Path || first.LocalPath == second.LocalPath || first.FileName == second.FileName { + t.Fatalf("同批结果物理文件冲突: first=%+v second=%+v", first, second) + } + if first.OriginalName != "DATA-REVIEW-result.xlsx" || second.OriginalName != first.OriginalName { + t.Fatalf("用户下载原始文件名被更改: %q / %q", first.OriginalName, second.OriginalName) + } + RemoveLocalFile(first) + if _, err := os.Stat(first.LocalPath); !os.IsNotExist(err) { + t.Fatalf("首次生成文件未删除: %v", err) + } + file, err := excelize.OpenFile(second.LocalPath) + if err != nil { + t.Fatalf("删除第一份后第二份不可读: %v", err) + } + defer file.Close() + got, err := file.GetCellValue("处理结果", "B2") + if err != nil || got != "second-card" { + t.Fatalf("第二份结果内容损坏: B2=%q err=%v", got, err) + } +} + +func TestCreatePreservesWorkbookAndPermissionsForAllBatchTypes(t *testing.T) { + root, err := os.MkdirTemp("", "batchresult-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + t.Setenv("IOTLCM_WORKER_UPLOAD_ROOT", root) + oldAdapter := g.Cfg().GetAdapter() + adapter, err := gcfg.NewAdapterContent(` +system: + upload: + local: + path: "resource/upload" +`) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) }) + for _, batchType := range []int{1, 3, 4, 11, 12, 40, 41, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110} { + t.Run(strconv.Itoa(batchType), func(t *testing.T) { + request := Request{BatchNo: "COMPAT-" + strconv.Itoa(batchType), BatchType: batchType, TenantID: "tenant", Rows: []Row{ + {RowNo: 1, ICCID: "test-success", ObjectCode: "test-success", Success: true}, + {RowNo: 2, ICCID: "test-failed", ObjectCode: "test-failed", Reason: "原有失败原因"}, + }} + baseline := filepath.Join(t.TempDir(), "baseline.xlsx") + if err := writeWorkbook(baseline, request); err != nil { + t.Fatal(err) + } + artifact, err := New().Create(context.Background(), request) + if err != nil { + t.Fatal(err) + } + defer RemoveLocalFile(artifact) + if artifact.OriginalName != request.BatchNo+"-result.xlsx" || filepath.Ext(artifact.LocalPath) != ".xlsx" { + t.Fatalf("结果文件名或后缀发生变化: %+v", artifact) + } + baselineInfo, err := os.Stat(baseline) + if err != nil { + t.Fatal(err) + } + actualInfo, err := os.Stat(artifact.LocalPath) + if err != nil || actualInfo.Mode().Perm() != baselineInfo.Mode().Perm() { + t.Fatalf("结果文件权限未保留原SaveAs行为: %v", err) + } + before, err := excelize.OpenFile(baseline) + if err != nil { + t.Fatal(err) + } + defer before.Close() + after, err := excelize.OpenFile(artifact.LocalPath) + if err != nil { + t.Fatal(err) + } + defer after.Close() + want, err := before.GetRows("处理结果") + if err != nil { + t.Fatal(err) + } + got, err := after.GetRows("处理结果") + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("随机物理名改变了批次%d工作簿内容: err=%v", batchType, err) + } + }) + } +} + func TestCreateWritesToMountedUploadAndKeepsLogicalPath(t *testing.T) { wd := t.TempDir() oldWd, err := os.Getwd() @@ -44,7 +220,7 @@ system: BatchNo: "INB202608140001001", BatchType: 1, TenantID: "000000", - Rows: []Row{{RowNo: 1, ICCID: "89860401780000567308", Success: true}}, + Rows: []Row{{RowNo: 1, ICCID: "89860401786527567308", Success: true}}, }) if err != nil { t.Fatalf("Create() error = %v", err) diff --git a/internal/service/cancel/executor.go b/internal/service/cancel/executor.go index c8be145..f1d3e7c 100644 --- a/internal/service/cancel/executor.go +++ b/internal/service/cancel/executor.go @@ -12,10 +12,10 @@ import ( ) const ( - cascadeFailureReason = "same batch has invalid cancellation rows; this row was skipped" - validationSkipReason = "batch validation failed; cancellation was skipped" - emptyICCIDReason = "ICCID cannot be empty" - duplicateICCIDReason = "duplicate ICCID in batch" + cascadeFailureReason = "同一批次存在无效销户明细,该行已跳过" + validationSkipReason = "批次校验未通过,已跳过取消操作" + emptyICCIDReason = "ICCID不能为空" + duplicateICCIDReason = "ICCID在批次内重复" ) type executor struct { @@ -77,7 +77,7 @@ func (e *executor) saveResultFile(ctx context.Context, task *Task) { func validateTask(task *Task) bool { if task == nil || len(task.Details) == 0 { if task != nil { - task.FailureReason = "cancel batch has no valid details" + task.FailureReason = "销户批次无有效明细" } return false } diff --git a/internal/service/data/async.go b/internal/service/data/async.go new file mode 100644 index 0000000..f2db617 --- /dev/null +++ b/internal/service/data/async.go @@ -0,0 +1,431 @@ +package data + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/google/uuid" + "sort" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" +) + +// 首查 +3min,此后每 3min 重试 3 次(+3/+6/+9/+12min)。 +// 2026-10-15 潍坊、濮阳实测:10/30/100 卡共12笔首次可见终态约 65~225s; +// 15s 采样只能给出完成区间,不能据此承诺所有高峰时段的完成上限。 +// 偶发超长任务不增加自动重试;超窗记 review,保留受理号和原因供手动查询。 +// 未确认不得记办理失败,也不得重新提交办理请求。 +const ( + subBatchLease = 5 * time.Minute + firstResultDelay = 3 * time.Minute + furtherResultDelay = 3 * time.Minute + maxResultQueries = 4 +) + +func newSubBatch(task *Task, details []*Detail) SubBatch { + keys := make([]*model.SimKey, 0, len(details)) + ids := make([]int64, 0, len(details)) + for _, detail := range details { + ids = append(ids, detail.ID) + keys = append(keys, &model.SimKey{ChanKey: detail.ChanKey, ICCID: detail.ICCID, MSISDN: detail.MSISDN, IMSI: detail.IMSI}) + } + raw, _ := json.Marshal(struct { + BatchNo string + BatchType int + IDs []int64 + }{task.Batch.BatchNo, task.Batch.BatchType, ids}) + digest := sha256.Sum256(raw) + id := hex.EncodeToString(digest[:]) + input := model.SimCardApnChangeInput{SimKeys: keys, ReqData: model.SimCardApnChangeInputItem{OperType: task.Operation.ApnOperType, ApnName: task.ApnName, OperationID: id, SubmitOnly: true}} + return SubBatch{BatchNo: task.Batch.BatchNo, BatchType: task.Batch.BatchType, ChanKey: details[0].ChanKey, OperationID: id, DetailIDs: ids, Request: input, Status: "prepared"} +} + +func splitAsyncDetails(details []*Detail) [][]*Detail { + withMSISDN := make([]*Detail, 0, len(details)) + batches := make([][]*Detail, 0) + for _, detail := range details { + if strings.TrimSpace(detail.MSISDN) == "" { + batches = append(batches, []*Detail{detail}) + } else { + withMSISDN = append(withMSISDN, detail) + } + } + for _, chunk := range splitDetails(withMSISDN, consts.BatchSubBatchSize) { + if len(chunk) < 10 { + for _, detail := range chunk { + batches = append(batches, []*Detail{detail}) + } + } else { + batches = append(batches, chunk) + } + } + return batches +} + +// prepareAndSubmit persists every intent before a single write leaves the process. +func (e *executor) prepareAndSubmit(ctx context.Context, task *Task, repo SubBatchRepository, upstream AsyncUpstream) error { + groups := make(map[string][]*Detail) + for _, detail := range task.Details { + if detail != nil && detail.Reason == "" && (detail.DetailStatus == 0 || detail.DetailStatus == consts.BatchDetailStatusRunning) { + groups[detail.ChanKey] = append(groups[detail.ChanKey], detail) + } + } + keys := make([]string, 0, len(groups)) + for key := range groups { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + for _, slice := range splitAsyncDetails(groups[key]) { + sub := newSubBatch(task, slice) + if err := repo.PrepareSubBatch(ctx, sub); err != nil { + return fmt.Errorf("保存APN提交意图失败(未提交运营商): %w", err) + } + } + } + return nil +} + +// processSubBatch only submits while prepared. Any interrupted submission is recovered by operationId, never repeated. +func (e *executor) processSubBatch(ctx context.Context, repo SubBatchRepository, upstream AsyncUpstream, operationID string) error { + channel, err := repo.SubBatchChannel(ctx, operationID) + if err != nil { + return err + } + value, _ := e.channelSlots.LoadOrStore(channel, make(chan struct{}, consts.BatchSubBatchConcurrency)) + slots := value.(chan struct{}) + select { + case slots <- struct{}{}: + defer func() { <-slots }() + case <-ctx.Done(): + return ctx.Err() + } + token := uuid.NewString() + callCtx, cancel := context.WithTimeout(ctx, 4*time.Minute) + defer cancel() + if checker, ok := upstream.(interface { + CheckSimApnAsyncCapability(context.Context, *model.SimCardApnChangeInput) error + }); ok { + snapshot, err := repo.GetSubBatch(callCtx, operationID) + if err != nil || snapshot == nil { + return err + } + if snapshot.Status == "done" || snapshot.Status == "review" { + return nil + } + if snapshot.Status == "prepared" { + if err := checker.CheckSimApnAsyncCapability(callCtx, &snapshot.Request); err != nil { + persistCtx, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer stop() + if delayErr := repo.DelaySubBatchPrecheck(persistCtx, *snapshot, time.Now().Add(30*time.Second), err.Error()); delayErr != nil { + return fmt.Errorf("保存APN预检调度退避失败: %w", delayErr) + } + return fmt.Errorf("APN提交预检未就绪,未领取提交意图: %w", err) + } + } + } + sub, err := repo.ClaimSubBatch(ctx, operationID, token, time.Now()) + if err != nil || sub == nil { + return err + } + if sub.Status == "prepared" { + response, submitErr := upstream.SubmitSimApnFunction(callCtx, &sub.Request) + if submitErr != nil { + if isAPNRequestNotSent(submitErr) { + return releaseUnsent(ctx, repo, *sub, submitErr) + } + return e.rememberUncertain(ctx, repo, *sub, "提交结果未确认(网络超时或连接中断): "+submitErr.Error()) + } + return e.handleResponse(ctx, repo, *sub, response, false) + } + if sub.Status == "submitting" || sub.Status == "uncertain" || (sub.Status == "accepted" && sub.JobID == "") { + sub.Request.ReqData.RecoverOnly = true + sub.Request.ReqData.QueryJobID = "" + response, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request) + if queryErr != nil { + if isAPNRequestNotSent(queryErr) { + return releaseUnsent(ctx, repo, *sub, queryErr) + } + return e.rememberUncertain(ctx, repo, *sub, "受理映射恢复失败(不重发): "+queryErr.Error()) + } + return e.handleResponse(ctx, repo, *sub, response, false) + } + sub.Request.ReqData.QueryJobID = sub.JobID + sub.Request.ReqData.RecoverOnly = false + response, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request) + if queryErr != nil { + if isAPNRequestNotSent(queryErr) { + return releaseUnsent(ctx, repo, *sub, queryErr) + } + return e.queryUnavailable(ctx, repo, *sub, "查询请求超时或不可用: "+queryErr.Error()) + } + return e.handleResponse(ctx, repo, *sub, response, true) +} + +// QueryReview仅执行一次只读补查,不重新开启自动轮询或提交办理。 +func (e *executor) QueryReview(ctx context.Context, operationID string) error { + operationID = strings.TrimSpace(operationID) + if operationID == "" { + return fmt.Errorf("operationId不能为空") + } + repo, ok := e.repository.(SubBatchRepository) + if !ok { + return fmt.Errorf("未配置APN持久化仓储") + } + reviewRepo, ok := e.repository.(ReviewRepository) + if !ok { + return fmt.Errorf("未配置APN待核查查询仓储") + } + upstream, ok := e.upstream.(AsyncUpstream) + if !ok { + return fmt.Errorf("未配置APN只读补查客户端") + } + sub, err := reviewRepo.ClaimReviewSubBatch(ctx, operationID, uuid.NewString(), time.Now()) + if err != nil { + return err + } + if sub == nil { + snapshot, err := repo.GetSubBatch(ctx, operationID) + if err != nil { + return err + } + if snapshot != nil && snapshot.Status == "done" { + return repo.FinalizeReadyBatch(ctx, snapshot.BatchNo) + } + return fmt.Errorf("子批不处于可查询的待核查状态,或已有查询/汇总正在运行") + } + callCtx, cancel := context.WithTimeout(ctx, 4*time.Minute) + defer cancel() + sub.Request.ReqData.SubmitOnly = false + sub.Request.ReqData.QueryJobID = sub.JobID + sub.Request.ReqData.RecoverOnly = sub.JobID == "" + items, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request) + if queryErr != nil { + if err := e.deferResult(ctx, repo, *sub, "review", "手动补查未确认:"+queryErr.Error()); err != nil { + return err + } + return queryErr + } + return e.handleResponse(ctx, repo, *sub, items, true) +} + +// QueryReviewBatch 是"手动同步上游结果"入口:只对父批下已冻结的待核查子批 +// 做一次只读补查,然后尝试汇总父批。 +// 不重发办理请求、不清零自动补查计数、不重新开启轮询调度。 +func (e *executor) QueryReviewBatch(ctx context.Context, batchNo string) (int, error) { + batchNo = strings.TrimSpace(batchNo) + if batchNo == "" { + return 0, fmt.Errorf("批次编号不能为空") + } + repo, ok := e.repository.(ReviewBatchRepository) + if !ok { + return 0, fmt.Errorf("未配置APN待核查批量同步仓储") + } + if _, ok := e.upstream.(AsyncUpstream); !ok { + return 0, fmt.Errorf("未配置APN只读补查客户端") + } + ids, err := repo.ListReviewSubBatches(ctx, batchNo) + if err != nil { + return 0, err + } + queried := 0 + var firstErr error + for _, id := range ids { + if err := e.QueryReview(ctx, id); err != nil { + g.Log().Warningf(ctx, "手动同步待核查子批未完成 batchNo=%s operationId=%s error=%v", batchNo, id, err) + if firstErr == nil { + firstErr = fmt.Errorf("operationId=%s: %w", id, err) + } + continue + } + queried++ + } + // 已确认终态的子批会在这里收敛父批;仍为 review 的子批继续留在待核查。 + if err := repo.FinalizeReadyBatch(ctx, batchNo); err != nil && firstErr == nil { + firstErr = err + } + g.Log().Infof(ctx, "手动同步APN上游结果 batchNo=%s 待核查子批=%d 本次已同步=%d", batchNo, len(ids), queried) + if firstErr != nil { + return queried, firstErr + } + return queried, nil +} + +func isAPNRequestNotSent(err error) bool { + var notSent interface{ APNRequestNotSent() bool } + return errors.As(err, ¬Sent) && notSent.APNRequestNotSent() +} +func releaseUnsent(ctx context.Context, repo SubBatchRepository, sub SubBatch, cause error) error { + persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + return repo.ReleaseUnsentSubBatch(persistCtx, sub, cause.Error()) +} + +func nextResultCheck(sub SubBatch) time.Time { + base := sub.SubmittedAt + if base.IsZero() { + base = time.Now() + } + return base.Add(firstResultDelay + time.Duration(sub.PollCount)*furtherResultDelay) +} + +func (e *executor) rememberUncertain(ctx context.Context, repo SubBatchRepository, sub SubBatch, reason string) error { + return e.deferResult(ctx, repo, sub, "uncertain", reason) +} + +func (e *executor) queryUnavailable(ctx context.Context, repo SubBatchRepository, sub SubBatch, reason string) error { + return e.deferResult(ctx, repo, sub, "accepted", reason) +} + +func (e *executor) deferResult(ctx context.Context, repo SubBatchRepository, sub SubBatch, state, reason string) error { + persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + g.Log().Warningf(persistCtx, "APN补查 operationId=%s jobId=%s pollCount=%d reason=%s", sub.OperationID, sub.JobID, sub.PollCount, reason) + if sub.Status == "review_query" || sub.PollCount >= maxResultQueries { + if err := repo.UpdateSubBatch(persistCtx, sub, "review", sub.JobID, "补查窗口结束,办理结果未知:"+reason, time.Time{}, sub.PollCount); err != nil { + return err + } + return repo.FinalizeReadyBatch(persistCtx, sub.BatchNo) + } + return repo.UpdateSubBatch(persistCtx, sub, state, sub.JobID, reason, nextResultCheck(sub), sub.PollCount) +} + +func (e *executor) handleResponse(ctx context.Context, repo SubBatchRepository, sub SubBatch, items []model.SimBaseRes[model.SimCardApnChangeOutputItem], queried bool) error { + persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) + defer cancel() + outcomes := make([]Outcome, len(sub.Request.SimKeys)) + used := make([]bool, len(items)) + pending := false + reason := "未返回该卡结果,办理未确认" + for i, key := range sub.Request.SimKeys { + matched := -1 + for j, item := range items { + if !used[j] && sameSim(&Detail{ICCID: key.ICCID, MSISDN: key.MSISDN, IMSI: key.IMSI}, item.SimKey) { + matched = j + break + } + } + if matched < 0 { + outcomes[i] = Outcome{Pending: true, Message: reason} + pending = true + continue + } + used[matched] = true + item := items[matched] + if item.ResData.Pending || item.ResData.Uncertain { + pending = true + outcomes[i] = Outcome{Pending: true, Code: item.Code, Message: item.Message} + if strings.TrimSpace(item.Message) != "" { + reason = item.Message + } + // Only Pending identifies a batch job: a synchronous per-card order is never queried as a job. + if item.ResData.Pending && strings.TrimSpace(item.ResData.OrderNum) != "" { + sub.JobID = strings.TrimSpace(item.ResData.OrderNum) + } + continue + } + outcomes[i] = Outcome{Success: item.Code == 0, Code: item.Code, Message: item.Message, OrderNum: item.ResData.OrderNum} + } + if pending { + // Confirmed cards are committed now. Later missing/pending responses cannot overwrite their terminal rows. + if err := repo.SaveSubBatchProgress(persistCtx, sub, outcomes); err != nil { + return err + } + state := "accepted" + if sub.JobID == "" { + state = "uncertain" + reason += "(缺少jobId,仅按operationId恢复,不重发)" + } + return e.deferResult(persistCtx, repo, sub, state, reason) + } + if err := repo.CompleteSubBatch(persistCtx, sub, outcomes); err != nil { + return err + } + return repo.FinalizeReadyBatch(persistCtx, sub.BatchNo) +} + +func mapOutcomesFromKeys(keys []*model.SimKey, response []model.SimBaseRes[model.SimCardApnChangeOutputItem]) []Outcome { + details := make([]*Detail, 0, len(keys)) + for _, key := range keys { + details = append(details, &Detail{ICCID: key.ICCID, MSISDN: key.MSISDN, IMSI: key.IMSI}) + } + return mapOutcomes(details, response) +} + +// RunReconciler uses a fixed global worker bound; waiting for a job never retains a worker slot. +func (e *executor) RunReconciler(ctx context.Context) error { + repo, ok := e.repository.(SubBatchRepository) + if !ok { + return nil + } + upstream, ok := e.upstream.(AsyncUpstream) + if !ok { + return nil + } + jobs := make(chan string, 64) + var inFlight sync.Map + var workers sync.WaitGroup + for i := 0; i < 20; i++ { + workers.Add(1) + go func() { + defer workers.Done() + for { + select { + case <-ctx.Done(): + return + case id := <-jobs: + if err := e.processSubBatch(ctx, repo, upstream, id); err != nil { + g.Log().Errorf(ctx, "APN恢复失败 operationId=%s error=%v", id, err) + } + inFlight.Delete(id) + } + } + }() + } + defer workers.Wait() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + batches, err := repo.ListRecoverableBatches(ctx, 32) + if err != nil { + g.Log().Errorf(ctx, "读取APN恢复父批失败: %v", err) + } else { + for _, batchNo := range batches { + if err := e.Execute(ctx, batchNo); err != nil { + g.Log().Errorf(ctx, "恢复APN父批失败 batchNo=%s error=%v", batchNo, err) + } + } + } + ids, err := repo.ListDueSubBatches(ctx, time.Now(), 128) + if err != nil { + g.Log().Errorf(ctx, "读取APN补查子批失败: %v", err) + } else { + for _, id := range ids { + if _, loaded := inFlight.LoadOrStore(id, true); loaded { + continue + } + select { + case jobs <- id: + case <-ctx.Done(): + inFlight.Delete(id) + return nil + default: + inFlight.Delete(id) + } + } + } + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + } +} diff --git a/internal/service/data/async_fairness_test.go b/internal/service/data/async_fairness_test.go new file mode 100644 index 0000000..0ba1274 --- /dev/null +++ b/internal/service/data/async_fairness_test.go @@ -0,0 +1,126 @@ +package data + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" +) + +type fairnessRepository struct{ *durableFake } + +func (f *fairnessRepository) Load(context.Context, string) (*Task, error) { + return nil, errors.New("公平性测试不应准备父批") +} +func (f *fairnessRepository) SaveValidationFailure(context.Context, *Task) error { return nil } +func (f *fairnessRepository) SaveResult(context.Context, *Task, *Detail, Outcome) (Outcome, error) { + return Outcome{}, nil +} +func (f *fairnessRepository) Finalize(context.Context, *Task) error { return nil } + +type fairnessUpstream struct { + asyncFake + rejected atomic.Int32 +} + +func (f *fairnessUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return nil, errors.New("调度不得走同步办理") +} + +func (f *fairnessUpstream) CheckSimApnAsyncCapability(_ context.Context, in *model.SimCardApnChangeInput) error { + if in.SimKeys[0].ChanKey == "removed" { + f.rejected.Add(1) + return errors.New("通道已移除,无protocolVersion") + } + return nil +} + +func TestDurableAPNPrecheckBackoffAllowsHealthy129thTask(t *testing.T) { + repo := newDurableFake() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + for i := 0; i < 129; i++ { + sub := fixtureSub() + sub.OperationID = fmt.Sprintf("fair-%03d", i) + sub.Request.ReqData.OperationID = sub.OperationID + sub.ChanKey = "removed" + if i == 128 { + sub.ChanKey = "healthy" + } + for _, key := range sub.Request.SimKeys { + key.ChanKey = sub.ChanKey + } + if err := repo.PrepareSubBatch(ctx, sub); err != nil { + t.Fatal(err) + } + } + completed := make(chan struct{}, 1) + upstream := &fairnessUpstream{} + upstream.submit = func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + if in.SimKeys[0].ChanKey != "healthy" { + t.Error("拒绝通道发出办理") + } + out := pendingResponse(in, 0) + for i := range out { + out[i].ResData.Pending = false + } + completed <- struct{}{} + return out, nil + } + e := &executor{repository: &fairnessRepository{repo}, upstream: upstream} + done := make(chan error, 1) + go func() { done <- e.RunReconciler(ctx) }() + select { + case <-completed: + case <-time.After(15 * time.Second): + cancel() + <-done + t.Fatal("前128个预检拒绝使第129健康任务饥饿") + } + // 健康提交可能早于已入队的拒绝任务完成;等待退避持久化后再取消调度器。 + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + timeout := time.NewTimer(15 * time.Second) + defer timeout.Stop() + for { + repo.mu.Lock() + delayed := 0 + for id, sub := range repo.batches { + if sub.ChanKey == "removed" && !repo.next[id].IsZero() { + delayed++ + } + } + repo.mu.Unlock() + if delayed == 128 { + break + } + select { + case <-ticker.C: + case <-timeout.C: + cancel() + <-done + t.Fatalf("拒绝任务退避未全部持久化: delayed=%d rejected=%d", delayed, upstream.rejected.Load()) + } + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } + if upstream.submitCalls != 1 || upstream.rejected.Load() != 128 { + t.Fatalf("submit=%d rejected=%d", upstream.submitCalls, upstream.rejected.Load()) + } + repo.mu.Lock() + defer repo.mu.Unlock() + for id, sub := range repo.batches { + if sub.ChanKey == "healthy" { + continue + } + if sub.Status != "prepared" || sub.PollCount != 0 || !sub.SubmittedAt.IsZero() || sub.LeaseToken != "" || repo.next[id].IsZero() { + t.Fatalf("只读退避改变办理意图: %+v", sub) + } + } +} diff --git a/internal/service/data/async_manual_sync_test.go b/internal/service/data/async_manual_sync_test.go new file mode 100644 index 0000000..886f4dc --- /dev/null +++ b/internal/service/data/async_manual_sync_test.go @@ -0,0 +1,201 @@ +package data + +import ( + "context" + "errors" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" +) + +// reviewBatchFake 复用 durableFake 的持久化语义,只补手动同步需要的仓储能力。 +type reviewBatchFake struct { + *durableFake + candidates []string + finalized []string +} + +func newReviewBatchFake(candidates ...string) *reviewBatchFake { + return &reviewBatchFake{durableFake: newDurableFake(), candidates: candidates} +} + +func (f *reviewBatchFake) ListReviewSubBatches(_ context.Context, _ string) ([]string, error) { + return append([]string(nil), f.candidates...), nil +} + +// 手动同步不触碰校验/终态写入路径,这里只补齐 Repository 契约。 +func (f *reviewBatchFake) Load(context.Context, string) (*Task, error) { return &Task{}, nil } +func (f *reviewBatchFake) SaveValidationFailure(context.Context, *Task) error { return nil } +func (f *reviewBatchFake) SaveResult(context.Context, *Task, *Detail, Outcome) (Outcome, error) { + return Outcome{}, nil +} +func (f *reviewBatchFake) Finalize(context.Context, *Task) error { return nil } + +// manualSyncUpstream 补上同步办理入口,确保手动同步一旦误走同步路径立即暴露。 +type manualSyncUpstream struct { + *asyncFake +} + +func (u *manualSyncUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return nil, errors.New("手动同步不得调用同步办理入口") +} + +func (f *reviewBatchFake) FinalizeReadyBatch(_ context.Context, batchNo string) error { + f.finalized = append(f.finalized, batchNo) + return nil +} + +func (f *reviewBatchFake) ClaimReviewSubBatch(_ context.Context, operationID, token string, now time.Time) (*SubBatch, error) { + f.mu.Lock() + defer f.mu.Unlock() + sub, ok := f.batches[operationID] + if !ok || (sub.Status != "review" && sub.Status != "review_query") { + return nil, nil + } + sub.Status = "review_query" + sub.LeaseToken = token + sub.LeaseUntil = now.Add(subBatchLease) + f.batches[operationID] = sub + out := sub + return &out, nil +} + +// 手动同步只用既有受理号只读补查:不重发办理、不清零自动补查计数。 +func TestManualSyncQueriesFrozenIntentsWithoutResubmitting(t *testing.T) { + ctx := context.Background() + sub := fixtureSub() + repo := newReviewBatchFake(sub.OperationID) + sub.Status = "review" + sub.JobID = "job-1" + sub.PollCount = 4 + repo.batches[sub.OperationID] = sub + + upstream := &manualSyncUpstream{asyncFake: &asyncFake{ + submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + t.Fatal("手动同步不得重发办理") + return nil, nil + }, + query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + if in.ReqData.QueryJobID != "job-1" { + t.Fatalf("手动同步必须按既有 jobId 查询: %+v", in.ReqData) + } + out := make([]model.SimBaseRes[model.SimCardApnChangeOutputItem], 0, len(in.SimKeys)) + for _, key := range in.SimKeys { + out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: 0, ResData: model.SimCardApnChangeOutputItem{OrderNum: "job-1"}}) + } + return out, nil + }, + }} + executor := &executor{repository: repo, upstream: upstream} + + queried, err := executor.QueryReviewBatch(ctx, " P-1 ") + if err != nil { + t.Fatalf("QueryReviewBatch() error = %v", err) + } + if queried != 1 { + t.Fatalf("queried = %d, want 1", queried) + } + if upstream.submitCalls != 0 { + t.Fatalf("submitCalls = %d, want 0", upstream.submitCalls) + } + got := repo.batches[sub.OperationID] + if got.Status != "done" { + t.Fatalf("状态 = %s, want done", got.Status) + } + if got.PollCount != 4 { + t.Fatalf("pollCount = %d, want 4(手动同步不得清零自动补查计数)", got.PollCount) + } + // 子批收敛与批量入口都会尝试汇总父批,重复汇总是幂等的。 + if len(repo.finalized) == 0 || repo.finalized[0] != "P-1" { + t.Fatalf("父批未汇总: %+v", repo.finalized) + } + if len(repo.outcomes[sub.OperationID]) != len(sub.DetailIDs) { + t.Fatalf("逐卡结果未写回: %+v", repo.outcomes[sub.OperationID]) + } +} + +// 上游仍无结论时明细保持待核查,且可以再次手动同步。 +func TestManualSyncKeepsUnknownWhenUpstreamStillPending(t *testing.T) { + ctx := context.Background() + sub := fixtureSub() + repo := newReviewBatchFake(sub.OperationID) + sub.Status = "review" + repo.batches[sub.OperationID] = sub + + upstream := &manualSyncUpstream{asyncFake: &asyncFake{ + submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + t.Fatal("手动同步不得重发办理") + return nil, nil + }, + query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return pendingResponse(in, 14008), nil + }, + }} + executor := &executor{repository: repo, upstream: upstream} + + if _, err := executor.QueryReviewBatch(ctx, "P-1"); err != nil { + t.Fatalf("QueryReviewBatch() error = %v", err) + } + got := repo.batches[sub.OperationID] + if got.Status != "review" { + t.Fatalf("状态 = %s, want review(未确认不得记失败)", got.Status) + } + if got.JobID != "job-1" { + t.Fatalf("jobId = %s, want job-1", got.JobID) + } + if got.PollCount != 0 { + t.Fatalf("pollCount = %d, want 0(手动同步不得占用自动补查次数)", got.PollCount) + } + // 再次同步仍可用,说明没有被自动窗口锁死。 + if _, err := executor.QueryReviewBatch(ctx, "P-1"); err != nil { + t.Fatalf("第二次 QueryReviewBatch() error = %v", err) + } +} + +// 单个子批补查失败不影响其余子批,并把失败如实返回给调用方。 +func TestManualSyncReportsPerIntentFailureAndContinues(t *testing.T) { + ctx := context.Background() + first := fixtureSub() + first.Status = "review" + first.JobID = "job-1" + second := fixtureSub() + second.OperationID = "intent-2" + second.Status = "review" + repo := newReviewBatchFake(first.OperationID, second.OperationID) + repo.batches[first.OperationID] = first + repo.batches[second.OperationID] = second + + upstream := &manualSyncUpstream{asyncFake: &asyncFake{ + submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + t.Fatal("手动同步不得重发办理") + return nil, nil + }, + query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + if in.ReqData.RecoverOnly { + return nil, errors.New("上游查询不可用") + } + return pendingResponse(in, 0), nil + }, + }} + executor := &executor{repository: repo, upstream: upstream} + + queried, err := executor.QueryReviewBatch(ctx, "P-1") + if err == nil { + t.Fatal("QueryReviewBatch() error = nil, want error") + } + if queried != 1 { + t.Fatalf("queried = %d, want 1(成功一个,失败一个)", queried) + } + if upstream.submitCalls != 0 { + t.Fatalf("submitCalls = %d, want 0", upstream.submitCalls) + } +} + +// 空批次号直接拒绝,避免误触发全量扫描。 +func TestManualSyncRejectsBlankBatchNo(t *testing.T) { + executor := &executor{repository: newReviewBatchFake(), upstream: &manualSyncUpstream{asyncFake: &asyncFake{}}} + if _, err := executor.QueryReviewBatch(context.Background(), " "); err == nil { + t.Fatal("QueryReviewBatch() blank batchNo error = nil, want error") + } +} diff --git a/internal/service/data/async_test.go b/internal/service/data/async_test.go new file mode 100644 index 0000000..c9cae96 --- /dev/null +++ b/internal/service/data/async_test.go @@ -0,0 +1,650 @@ +package data + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" + upstreamclient "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/client/upstream" + "net/http" + "net/http/httptest" + "sort" + "strings" + "sync" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" +) + +func TestDurableAPNResultScheduleIsThreeMinutesAndThreeRetries(t *testing.T) { + if maxResultQueries != 4 { + t.Fatalf("首次加三次重试应为四次查询: got=%d", maxResultQueries) + } + base := time.Date(2026, 10, 15, 8, 0, 0, 0, time.UTC) + for i, minute := range []int{3, 6, 9, 12} { + got := nextResultCheck(SubBatch{SubmittedAt: base, PollCount: i}) + want := base.Add(time.Duration(minute) * time.Minute) + if !got.Equal(want) { + t.Fatalf("第%d次查询: got=%v want=%v", i+1, got, want) + } + } +} + +type durableFake struct { + mu sync.Mutex + batches map[string]SubBatch + next map[string]time.Time + outcomes map[string][]Outcome + preparedBeforeSubmit int +} + +func newDurableFake() *durableFake { + return &durableFake{batches: map[string]SubBatch{}, next: map[string]time.Time{}, outcomes: map[string][]Outcome{}} +} +func (f *durableFake) HasDurableParent(context.Context, string) (bool, error) { return true, nil } +func (f *durableFake) PrepareSubBatch(_ context.Context, b SubBatch) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.batches[b.OperationID]; !ok { + b.ID = int64(len(f.batches) + 1) + f.batches[b.OperationID] = b + } + return nil +} +func (f *durableFake) ResumeOrPrepare(ctx context.Context, _ string, p func(context.Context) error) error { + return p(ctx) +} +func (f *durableFake) GetSubBatch(_ context.Context, id string) (*SubBatch, error) { + f.mu.Lock() + defer f.mu.Unlock() + b := f.batches[id] + b.NextCheckAt = f.next[id] + return &b, nil +} +func (f *durableFake) DelaySubBatchPrecheck(_ context.Context, snapshot SubBatch, next time.Time, _ string) error { + f.mu.Lock() + defer f.mu.Unlock() + b := f.batches[snapshot.OperationID] + if b.Status == snapshot.Status && b.LeaseToken == snapshot.LeaseToken && f.next[snapshot.OperationID].Equal(snapshot.NextCheckAt) { + f.next[snapshot.OperationID] = next + } + return nil +} +func (f *durableFake) ReleaseUnsentSubBatch(_ context.Context, b SubBatch, _ string) error { + old := f.batches[b.OperationID] + old.Status = b.Status + old.LeaseToken = "" + if b.Status == "prepared" { + old.SubmittedAt = time.Time{} + } else { + old.PollCount-- + } + f.batches[b.OperationID] = old + return nil +} +func (f *durableFake) SubBatchChannel(_ context.Context, id string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.batches[id].ChanKey, nil +} +func (f *durableFake) ProtectExecutionFailure(context.Context, string, string) error { return nil } +func (f *durableFake) SaveSubBatchProgress(_ context.Context, b SubBatch, results []Outcome) error { + f.mu.Lock() + defer f.mu.Unlock() + old := f.outcomes[b.OperationID] + if old == nil { + old = make([]Outcome, len(results)) + for i := range old { + old[i].Pending = true + } + } + for i, result := range results { + if old[i].Pending && !result.Pending { + old[i] = result + } + } + confirmed := false + for _, result := range old { + confirmed = confirmed || !result.Pending + } + if confirmed { + f.outcomes[b.OperationID] = old + } + return nil +} +func (f *durableFake) ClaimSubBatch(_ context.Context, id, token string, now time.Time) (*SubBatch, error) { + f.mu.Lock() + defer f.mu.Unlock() + b, ok := f.batches[id] + if !ok || b.LeaseToken != "" || b.Status == "done" || b.Status == "review" || f.next[id].After(now) { + return nil, nil + } + out := b + b.LeaseToken = token + out.LeaseToken = token + if b.Status == "prepared" { + b.Status = "submitting" + b.SubmittedAt = now + out.SubmittedAt = now + } else { + b.PollCount++ + out.PollCount = b.PollCount + } + f.batches[id] = b + return &out, nil +} +func (f *durableFake) ListDueSubBatches(_ context.Context, now time.Time, limit int) ([]string, error) { + f.mu.Lock() + defer f.mu.Unlock() + var ids []string + for id, b := range f.batches { + if b.Status != "done" && b.Status != "review" && !f.next[id].After(now) { + ids = append(ids, id) + } + } + sort.Slice(ids, func(i, j int) bool { + a, b := f.next[ids[i]], f.next[ids[j]] + if a.Equal(b) { + return ids[i] < ids[j] + } + return a.Before(b) + }) + if len(ids) > limit { + ids = ids[:limit] + } + return ids, nil +} +func (f *durableFake) UpdateSubBatch(_ context.Context, b SubBatch, status, job, reason string, next time.Time, count int) error { + f.mu.Lock() + defer f.mu.Unlock() + old := f.batches[b.OperationID] + if old.LeaseToken != b.LeaseToken { + return errors.New("lease lost") + } + old.Status = status + old.JobID = job + old.PollCount = count + old.LeaseToken = "" + f.batches[b.OperationID] = old + f.next[b.OperationID] = next + return nil +} +func (f *durableFake) CompleteSubBatch(_ context.Context, b SubBatch, results []Outcome) error { + f.mu.Lock() + defer f.mu.Unlock() + old := f.batches[b.OperationID] + if old.LeaseToken != b.LeaseToken { + return errors.New("lease lost") + } + old.Status = "done" + old.LeaseToken = "" + f.batches[b.OperationID] = old + f.outcomes[b.OperationID] = results + return nil +} +func (f *durableFake) FinalizeReadyBatch(context.Context, string) error { return nil } +func (f *durableFake) ListRecoverableBatches(context.Context, int) ([]string, error) { return nil, nil } + +type asyncFake struct { + submitCalls int + queryCalls int + submit func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) + query func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) +} + +func (f *asyncFake) SubmitSimApnFunction(_ context.Context, in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + f.submitCalls++ + return f.submit(in) +} +func (f *asyncFake) QuerySimApnFunctionResult(_ context.Context, in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + f.queryCalls++ + return f.query(in) +} +func fixtureSub() SubBatch { + op, _ := OperationByBatchType(consts.BatchTypeDataStop) + return newSubBatch(&Task{Batch: Batch{BatchNo: "P-1", BatchType: consts.BatchTypeDataStop}, Operation: op, ApnName: "CMIOT"}, []*Detail{{ID: 1, ICCID: "iccid-1", MSISDN: "1001", ChanKey: "a"}, {ID: 2, ICCID: "iccid-2", MSISDN: "1002", ChanKey: "a"}}) +} +func pendingResponse(in *model.SimCardApnChangeInput, code int) []model.SimBaseRes[model.SimCardApnChangeOutputItem] { + var out []model.SimBaseRes[model.SimCardApnChangeOutputItem] + for _, key := range in.SimKeys { + out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: code, Message: "结果暂不可查", ResData: model.SimCardApnChangeOutputItem{OrderNum: "job-1", Pending: true}}) + } + return out +} + +func TestDurableAPNRestartDoesNotSubmitAcceptedAgain(t *testing.T) { + ctx := context.Background() + repo := newDurableFake() + sub := fixtureSub() + _ = repo.PrepareSubBatch(ctx, sub) + upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + if repo.batches[sub.OperationID].Status != "submitting" { + t.Fatal("提交前未保存意图") + } + return pendingResponse(in, 0), nil + }, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + if in.ReqData.QueryJobID != "job-1" { + t.Fatalf("查询丢失jobId: %+v", in.ReqData) + } + out := pendingResponse(in, 0) + out[0].ResData.Pending = false + out[1].ResData.Pending = false + out[1].Code = 12047 + out[1].Message = "明确失败" + return out, nil + }} + e := &executor{} + before := time.Now() + if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + if repo.next[sub.OperationID].Before(before.Add(firstResultDelay)) || repo.next[sub.OperationID].After(time.Now().Add(firstResultDelay)) { + t.Fatalf("首查未按firstResultDelay排程: %v", repo.next[sub.OperationID]) + } + // A second process must respect next_check_at and never issue a second write. + restarted := &executor{} + if err := restarted.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + if upstream.queryCalls != 0 { + t.Fatal("过早查询") + } + repo.next[sub.OperationID] = time.Time{} + if err := restarted.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + if upstream.submitCalls != 1 || upstream.queryCalls != 1 { + t.Fatalf("submit=%d query=%d", upstream.submitCalls, upstream.queryCalls) + } + outcomes := repo.outcomes[sub.OperationID] + if len(outcomes) != 2 || !outcomes[0].Success || outcomes[1].Success { + t.Fatalf("部分失败未逐卡保留: %+v", outcomes) + } +} + +func TestDurableAPNTimeoutRecoversMappingWithoutResubmission(t *testing.T) { + ctx := context.Background() + repo := newDurableFake() + sub := fixtureSub() + _ = repo.PrepareSubBatch(ctx, sub) + upstream := &asyncFake{submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return nil, context.DeadlineExceeded + }, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + if !in.ReqData.RecoverOnly || in.ReqData.QueryJobID != "" || in.ReqData.OperationID != sub.OperationID { + t.Fatalf("不是按operationId恢复: %+v", in.ReqData) + } + return pendingResponse(in, 0), nil + }} + e := &executor{} + if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + if len(repo.outcomes) != 0 { + t.Fatal("超时不能标记办理失败") + } + repo.next[sub.OperationID] = time.Time{} + if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + if upstream.submitCalls != 1 || upstream.queryCalls != 1 || repo.batches[sub.OperationID].JobID != "job-1" { + t.Fatalf("受理恢复错误: %+v", repo.batches[sub.OperationID]) + } +} + +func TestDurableAPNPendingNonzeroStopsAfterQueryWindowWithoutFailure(t *testing.T) { + ctx := context.Background() + repo := newDurableFake() + sub := fixtureSub() + sub.Status = "accepted" + sub.JobID = "job-1" + _ = repo.PrepareSubBatch(ctx, sub) + upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return pendingResponse(in, 500), nil + }} + for i := 1; i <= maxResultQueries; i++ { + repo.next[sub.OperationID] = time.Time{} + if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + b := repo.batches[sub.OperationID] + if b.PollCount != i { + t.Fatalf("poll count=%d want %d", b.PollCount, i) + } + if i < maxResultQueries && b.Status != "accepted" { + t.Fatalf("pending误终态: %+v", b) + } + } + if repo.batches[sub.OperationID].Status != "review" || len(repo.outcomes) != 0 { + t.Fatal("补查窗口耗尽后未确认不得写办理失败") + } +} + +func TestDurableAPNMixedResultsPersistAcrossMissingAndReview(t *testing.T) { + ctx := context.Background() + repo := newDurableFake() + sub := fixtureSub() + _ = repo.PrepareSubBatch(ctx, sub) + upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + out := pendingResponse(in, 0) + out[0].ResData.Pending = false + out[0].ResData.OrderNum = "single-order" + return out, nil + }, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return nil, nil + }} + if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + if got := repo.outcomes[sub.OperationID]; len(got) != 2 || !got[0].Success || !got[1].Pending { + t.Fatalf("混合确认结果未立即保存: %+v", got) + } + base := repo.batches[sub.OperationID].SubmittedAt + for i := 1; i <= maxResultQueries; i++ { + repo.next[sub.OperationID] = time.Time{} + if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + b := repo.batches[sub.OperationID] + if b.PollCount != i || !b.SubmittedAt.Equal(base) { + t.Fatalf("重启丢失持久时点/次数: %+v", b) + } + if i < maxResultQueries && !repo.next[sub.OperationID].Equal(base.Add(firstResultDelay+time.Duration(i)*furtherResultDelay)) { + t.Fatalf("排程未按submitted_at: %v", repo.next[sub.OperationID]) + } + } + if !repo.outcomes[sub.OperationID][0].Success || repo.batches[sub.OperationID].Status != "review" || upstream.submitCalls != 1 { + t.Fatal("后续缺卡覆盖终态或重发") + } +} + +func TestDurableAPNMappingRecoveryKeepsConsumedPollCount(t *testing.T) { + ctx := context.Background() + repo := newDurableFake() + sub := fixtureSub() + sub.Status = "uncertain" + sub.PollCount = 2 + sub.SubmittedAt = time.Now().Add(-8 * time.Minute) + _ = repo.PrepareSubBatch(ctx, sub) + upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + if !in.ReqData.RecoverOnly { + t.Fatal("恢复应为只读") + } + return pendingResponse(in, 0), nil + }} + if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + b := repo.batches[sub.OperationID] + if b.JobID != "job-1" || b.PollCount != 3 || !repo.next[sub.OperationID].Equal(sub.SubmittedAt.Add(firstResultDelay+time.Duration(b.PollCount)*furtherResultDelay)) { + t.Fatalf("恢复jobId清零或重排: %+v next=%v", b, repo.next[sub.OperationID]) + } +} + +func TestDurableAPNExplicitUncertainAndMissingNeverFailCard(t *testing.T) { + ctx := context.Background() + repo := newDurableFake() + sub := fixtureSub() + _ = repo.PrepareSubBatch(ctx, sub) + upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return []model.SimBaseRes[model.SimCardApnChangeOutputItem]{{SimKey: in.SimKeys[0], Code: 9999, Message: "代理未确认", ResData: model.SimCardApnChangeOutputItem{Uncertain: true}}}, nil + }} + if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + if len(repo.outcomes) != 0 || repo.batches[sub.OperationID].Status != "uncertain" { + t.Fatal("显式未知或缺卡不能转明确失败") + } +} + +type capabilityFake struct { + *asyncFake + checkErr error +} + +func (f *capabilityFake) CheckSimApnAsyncCapability(context.Context, *model.SimCardApnChangeInput) error { + return f.checkErr +} + +type requestNotSent struct{} + +func (requestNotSent) Error() string { return "能力预检明确拒绝" } +func (requestNotSent) APNRequestNotSent() bool { return true } + +func TestDurableAPNCapabilityRefusalDoesNotClaimOrSpendQueries(t *testing.T) { + ctx := context.Background() + repo := newDurableFake() + sub := fixtureSub() + _ = repo.PrepareSubBatch(ctx, sub) + upstream := &capabilityFake{asyncFake: &asyncFake{}, checkErr: errors.New("门禁关闭")} + if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err == nil { + t.Fatal("应拒绝预检") + } + b := repo.batches[sub.OperationID] + if b.Status != "prepared" || b.LeaseToken != "" || !b.SubmittedAt.IsZero() || b.PollCount != 0 || upstream.submitCalls != 0 { + t.Fatalf("预检拒绝却已领取或提交: %+v", b) + } + upstream.checkErr = nil + upstream.submit = func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return pendingResponse(in, 0), nil + } + if upstream.submitCalls != 0 || !repo.next[sub.OperationID].After(time.Now()) { + t.Fatal("只读预检拒绝未持久退避") + } + repo.next[sub.OperationID] = time.Time{} + if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + if upstream.submitCalls != 1 || repo.batches[sub.OperationID].Status != "accepted" { + t.Fatal("门禁打开且退避到期后不能恢复首发") + } +} + +func TestDurableAPNCapabilityRefusalDoesNotBlockReadOnlyQueries(t *testing.T) { + for _, tt := range []struct { + name string + status string + jobID string + recoverOnly bool + }{ + {name: "accepted_with_job_id", status: "accepted", jobID: "job-1"}, + {name: "submitting", status: "submitting", recoverOnly: true}, + {name: "uncertain", status: "uncertain", recoverOnly: true}, + {name: "accepted_without_job_id", status: "accepted", recoverOnly: true}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + repo := newDurableFake() + sub := fixtureSub() + sub.Status = tt.status + sub.JobID = tt.jobID + sub.SubmittedAt = time.Now().Add(-firstResultDelay) + if err := repo.PrepareSubBatch(ctx, sub); err != nil { + t.Fatal(err) + } + upstream := &capabilityFake{ + checkErr: errors.New("门禁关闭"), + asyncFake: &asyncFake{ + submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + t.Fatal("只读补查不得重新提交") + return nil, nil + }, + query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + if in.ReqData.OperationID != sub.OperationID || in.ReqData.QueryJobID != tt.jobID || in.ReqData.RecoverOnly != tt.recoverOnly { + t.Fatalf("只读查询参数错误: %+v", in.ReqData) + } + return pendingResponse(in, 0), nil + }, + }, + } + if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatalf("只读查询被提交门禁阻断: %v", err) + } + if upstream.queryCalls != 1 || upstream.submitCalls != 0 { + t.Fatalf("应仅查询一次: query=%d submit=%d", upstream.queryCalls, upstream.submitCalls) + } + b := repo.batches[sub.OperationID] + if b.Status != "accepted" || b.JobID != "job-1" || b.PollCount != 1 || b.LeaseToken != "" { + t.Fatalf("只读查询结果未持久化: %+v", b) + } + }) + } +} + +func TestDurableAPNOnlyTypedUnsentCanReturnPrepared(t *testing.T) { + ctx := context.Background() + repo := newDurableFake() + sub := fixtureSub() + _ = repo.PrepareSubBatch(ctx, sub) + upstream := &asyncFake{submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return nil, fmt.Errorf("预检竞态: %w", requestNotSent{}) + }} + if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + b := repo.batches[sub.OperationID] + if b.Status != "prepared" || !b.SubmittedAt.IsZero() || b.PollCount != 0 { + t.Fatalf("确定未发送应安全回退: %+v", b) + } + upstream.submit = func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + return nil, context.DeadlineExceeded + } + if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + if repo.batches[sub.OperationID].Status != "uncertain" { + t.Fatal("普通网络错误被误作确定未发送") + } +} + +func TestDurableAPNFinishedFailure9999IsTerminal(t *testing.T) { + ctx := context.Background() + repo := newDurableFake() + sub := fixtureSub() + sub.Status = "accepted" + sub.JobID = "failed-job" + sub.SubmittedAt = time.Now().Add(-time.Minute) + _ = repo.PrepareSubBatch(ctx, sub) + upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + var out []model.SimBaseRes[model.SimCardApnChangeOutputItem] + for _, key := range in.SimKeys { + out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: 9999, Message: "任务已完成且明确失败"}) + } + return out, nil + }} + if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil { + t.Fatal(err) + } + if repo.batches[sub.OperationID].Status != "done" || len(repo.outcomes[sub.OperationID]) != 2 { + t.Fatal("明确9999被误判待查") + } + for _, out := range repo.outcomes[sub.OperationID] { + if out.Pending || out.Success || out.Code != 9999 { + t.Fatalf("明确失败终态错误: %+v", out) + } + } +} + +func TestDurableAPNRealClientSecondProbeRejectsThenSubmitsOnce(t *testing.T) { + oldAdapter := g.Cfg().GetAdapter() + defer g.Cfg().SetAdapter(oldAdapter) + ctx := context.Background() + repo := newDurableFake() + sub := fixtureSub() + _ = repo.PrepareSubBatch(ctx, sub) + probes, submits := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var in model.SimCardApnChangeInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + t.Error(err) + http.Error(w, "bad", 400) + return + } + if r.URL.Path == "/sim/query/sim-apn-capability" { + probes++ + if probes == 2 { + http.Error(w, "第二次探测拒绝", 404) + return + } + type capability struct { + ProtocolVersion int `json:"protocolVersion"` + } + out := make([]model.SimBaseRes[capability], 0, len(in.SimKeys)) + for _, key := range in.SimKeys { + out = append(out, model.SimBaseRes[capability]{SimKey: key, ResData: capability{ProtocolVersion: 1}}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": out}) + return + } + submits++ + out := pendingResponse(&in, 0) + for i := range out { + out[i].ResData.Pending = false + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": out}) + })) + defer server.Close() + adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(server.URL, "http://"))) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + client := upstreamclient.New() + if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil { + t.Fatal(err) + } + b := repo.batches[sub.OperationID] + if probes != 2 || submits != 0 || b.Status != "prepared" || !b.SubmittedAt.IsZero() || b.LeaseToken != "" { + t.Fatalf("双probe竞态烧掉意图: probes=%d submits=%d sub=%+v", probes, submits, b) + } + if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil { + t.Fatal(err) + } + if submits != 1 || repo.batches[sub.OperationID].Status != "done" { + t.Fatalf("门禁恢复未唯一首发: submits=%d sub=%+v", submits, repo.batches[sub.OperationID]) + } + if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil { + t.Fatal(err) + } + if submits != 1 { + t.Fatal("完成后重复首发") + } +} + +func TestAsyncSubBatchStableAndChannelIsolated(t *testing.T) { + op, _ := OperationByBatchType(consts.BatchTypeDataStart) + task := &Task{Batch: Batch{BatchNo: "P-2", BatchType: consts.BatchTypeDataStart}, Operation: op} + groups := map[string][]*Detail{"a": {}, "b": {}} + for i := 0; i < 203; i++ { + key := "a" + if i >= 201 { + key = "b" + } + groups[key] = append(groups[key], &Detail{ID: int64(i + 1), ICCID: fmt.Sprint(i), MSISDN: fmt.Sprint(i), ChanKey: key}) + } + total := 0 + for key, details := range groups { + for _, chunk := range splitAsyncDetails(details) { + if len(chunk) > 100 { + t.Fatal("超过100卡") + } + first := newSubBatch(task, chunk) + second := newSubBatch(task, chunk) + if first.OperationID != second.OperationID { + t.Fatal("operationId不稳定") + } + for _, sim := range first.Request.SimKeys { + if sim.ChanKey != key { + t.Fatal("跨通道子批") + } + } + total++ + } + } + if total != 5 { + t.Fatalf("sub batches=%d want5", total) + } +} diff --git a/internal/service/data/executor.go b/internal/service/data/executor.go index 57b578e..c3c6521 100644 --- a/internal/service/data/executor.go +++ b/internal/service/data/executor.go @@ -9,13 +9,34 @@ import ( "github.com/gogf/gf/v2/errors/gerror" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" ) type executor struct { - repository Repository - upstream Upstream + repository Repository + upstream Upstream + channelSlots sync.Map +} + +func (e *executor) ClaimsProcessingReentry() bool { return true } +func (e *executor) CanExecuteProcessing(ctx context.Context, main *entity.BatchMain) (bool, error) { + repo, durable := e.repository.(SubBatchRepository) + _, async := e.upstream.(AsyncUpstream) + if !durable || !async { + return false, nil + } + return repo.HasDurableParent(ctx, strings.TrimSpace(main.BatchNo)) +} +func (e *executor) ProtectExecutionFailure(ctx context.Context, batchNo string, batchType int, reason string) (bool, error) { + repo, durable := e.repository.(SubBatchRepository) + _, async := e.upstream.(AsyncUpstream) + if !durable || !async { + return false, nil + } + return true, repo.ProtectExecutionFailure(ctx, batchNo, reason) } func NewExecutor(repository Repository, upstream Upstream) Executor { @@ -23,6 +44,18 @@ func NewExecutor(repository Repository, upstream Upstream) Executor { } func (e *executor) Execute(ctx context.Context, batchNo string) error { + if durable, ok := e.repository.(SubBatchRepository); ok { + if _, ok := e.upstream.(AsyncUpstream); ok { + // Earlier child intents were durably frozen: a re-delivered parent cannot re-split running rows. + return batch.Retryable(durable.ResumeOrPrepare(ctx, strings.TrimSpace(batchNo), func(prepareCtx context.Context) error { + return e.executeFresh(prepareCtx, batchNo) + })) + } + } + return e.executeFresh(ctx, batchNo) +} + +func (e *executor) executeFresh(ctx context.Context, batchNo string) error { task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo)) if err != nil { return gerror.Wrap(err, "load data service batch") @@ -34,6 +67,11 @@ func (e *executor) Execute(ctx context.Context, batchNo string) error { if err = e.repository.SaveValidationFailure(ctx, task); err != nil { return gerror.Wrap(err, "save data service validation failure") } + if _, durable := e.repository.(SubBatchRepository); durable { + if _, async := e.upstream.(AsyncUpstream); async { + return nil + } + } return gerror.Wrap(e.repository.Finalize(ctx, task), "finalize data service validation failure") } @@ -45,6 +83,11 @@ func (e *executor) executeTrafficCutoff(ctx context.Context, task *Task) error { if err := e.repository.SaveValidationFailure(ctx, task); err != nil { return gerror.Wrap(err, "save traffic cutoff validation failure") } + if _, durable := e.repository.(SubBatchRepository); durable { + if _, async := e.upstream.(AsyncUpstream); async { + return nil + } + } return gerror.Wrap(e.repository.Finalize(ctx, task), "finalize traffic cutoff validation failure") } if err := e.refreshDataServiceState(ctx, task); err != nil { @@ -53,6 +96,12 @@ func (e *executor) executeTrafficCutoff(ctx context.Context, task *Task) error { if err := e.saveInvalidTrafficCutoffRows(ctx, task); err != nil { return err } + if _, durable := e.repository.(SubBatchRepository); durable { + if _, async := e.upstream.(AsyncUpstream); async { + // 本地disabled不能证明前序开通已终结;持久化路径统一经过卡级提交屏障。 + return e.executeValidated(ctx, task) + } + } if err := e.saveIdempotentCutoffSkips(ctx, task); err != nil { return err } @@ -60,6 +109,26 @@ func (e *executor) executeTrafficCutoff(ctx context.Context, task *Task) error { } func (e *executor) executeValidated(ctx context.Context, task *Task) error { + if durable, ok := e.repository.(SubBatchRepository); ok { + if async, ok := e.upstream.(AsyncUpstream); ok { + seen := map[string]bool{} + for _, detail := range task.Details { + if detail == nil || detail.Reason != "" || (detail.DetailStatus != consts.BatchDetailStatusPending && detail.DetailStatus != consts.BatchDetailStatusRunning) { + continue + } + if seen[detail.ICCID] { + resolved, err := e.repository.SaveResult(ctx, task, detail, Outcome{Message: "重复ICCID,未提交上游"}) + if err != nil { + return err + } + detail.ApplyOutcome(resolved) + continue + } + seen[detail.ICCID] = true + } + return e.prepareAndSubmit(ctx, task, durable, async) + } + } groups := groupByChanKey(task.Operation, task.Details) keys := make([]string, 0, len(groups)) for chanKey := range groups { @@ -96,7 +165,7 @@ func (e *executor) saveIdempotentCutoffSkips(ctx context.Context, task *Task) er } resolved, saveErr := e.repository.SaveResult(ctx, task, detail, Outcome{ Success: true, - Message: "data service is already disabled; upstream request was skipped", + Message: "数据服务已关闭,未发起上游请求", }) if saveErr != nil { return gerror.Wrapf(saveErr, "save data service detail %d idempotent skip result", detail.ID) @@ -108,6 +177,9 @@ func (e *executor) saveIdempotentCutoffSkips(ctx context.Context, task *Task) er func validateTask(task *Task) bool { if task == nil || len(task.Details) == 0 { + if task != nil && task.FailureReason == "" { + task.FailureReason = "数据开停批次没有有效明细" + } return false } hasFailure := false @@ -132,17 +204,17 @@ func validateTask(task *Task) bool { } for _, detail := range task.Details { if detail != nil && detail.Reason == "" { - detail.Reason = "same batch contains validation failure; upstream request was skipped" + detail.Reason = "同批次存在校验失败卡,本卡未请求上游" } } - task.FailureReason = "batch validation failed; upstream request was skipped" + task.FailureReason = "批次校验未通过,未发起上游请求" return false } func validateTrafficCutoffTask(task *Task) bool { if task == nil || len(task.Details) == 0 { if task != nil { - task.FailureReason = "traffic cutoff batch has no valid details" + task.FailureReason = "流量断网批次无有效明细" } return false } @@ -160,13 +232,13 @@ func validateTrafficCutoffTask(task *Task) bool { if hasSuccess { task.ValidationOK = true if failed := countFailedDetails(task); failed > 0 { - task.FailureReason = strconv.Itoa(failed) + " SIM cards failed validation" + task.FailureReason = strconv.Itoa(failed) + " 张卡未通过校验" } else { task.FailureReason = "" } return true } - task.FailureReason = "traffic cutoff batch validation failed; upstream request was skipped" + task.FailureReason = "流量断网批次校验未通过,未发起上游请求" return false } @@ -188,7 +260,7 @@ func validateDetail(_ Operation, detail *Detail) { return } if detail.Card == nil { - detail.Reason = "ICCID does not exist in SIM card records" + detail.Reason = "ICCID不存在或不属于当前租户" return } detail.MSISDN = strings.TrimSpace(detail.Card.MSISDN) @@ -198,26 +270,28 @@ func validateDetail(_ Operation, detail *Detail) { detail.StatusAfter = detail.Card.DataServiceStatus if !detail.Authorized { - detail.Reason = "ICCID is outside the current operator authorization scope" + detail.Reason = "ICCID不在当前操作员授权范围内" return } + var reasons []string if detail.ChanKey == "" { - detail.Reason = "ICCID is missing chanKey" - return + reasons = append(reasons, "ICCID缺少通道标识") } if detail.InputMSISDN != "" && detail.InputMSISDN != detail.MSISDN { - detail.Reason = "MSISDN does not match the SIM card record" - return + reasons = append(reasons, "MSISDN与卡档案不一致") } if detail.InputIMSI != "" && detail.InputIMSI != detail.IMSI { - detail.Reason = "IMSI does not match the SIM card record" - return + reasons = append(reasons, "IMSI与卡档案不一致") } + detail.Reason = strings.Join(reasons, ";") } func groupByChanKey(operation Operation, details []*Detail) map[string][]*Detail { groups := make(map[string][]*Detail) for _, detail := range details { + if detail == nil || detail.Reason != "" { + continue + } if operation.BatchType == consts.BatchTypeDataCut && (detail == nil || !detail.ValidationPassed || detail.Reason != "") { continue } @@ -316,7 +390,7 @@ func (e *executor) changeSubBatch(ctx context.Context, task *Task, details []*De if err != nil { outcomes := make([]Outcome, len(details)) for index := range outcomes { - outcomes[index].Message = "upstream request failed: " + err.Error() + outcomes[index].Message = "请求上游失败:" + err.Error() } return outcomes } @@ -354,14 +428,14 @@ func mapOutcomes(details []*Detail, response []model.SimBaseRes[model.SimCardApn matched = detailIndex } if matched < 0 { - outcomes[detailIndex] = Outcome{Message: "upstream did not return a result for this SIM card"} + outcomes[detailIndex] = Outcome{Message: "上游未返回该卡处理结果"} continue } used[matched] = true item := response[matched] message := strings.TrimSpace(item.Message) if item.Code != 0 && message == "" { - message = "upstream processing failed" + message = "上游处理失败" } outcomes[detailIndex] = Outcome{Success: item.Code == 0, Code: item.Code, Message: message, OrderNum: strings.TrimSpace(item.ResData.OrderNum)} } diff --git a/internal/service/data/executor_accuracy_test.go b/internal/service/data/executor_accuracy_test.go new file mode 100644 index 0000000..38cfbc7 --- /dev/null +++ b/internal/service/data/executor_accuracy_test.go @@ -0,0 +1,253 @@ +package data + +import ( + "context" + "errors" + "fmt" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" +) + +func TestAccuracyInvalidDuplicateKeepsValidTrafficCutoffRow(t *testing.T) { + for _, invalidFirst := range []bool{true, false} { + for _, invalidInput := range []string{"MSISDN", "IMSI", "authorization", "existing"} { + t.Run(fmt.Sprintf("%s/invalidFirst=%v", invalidInput, invalidFirst), func(t *testing.T) { + operation, err := OperationByBatchType(107) + if err != nil { + t.Fatal(err) + } + card := &Card{ID: 10, ICCID: "same-card", MSISDN: "1001", IMSI: "2001", ChanKey: "channel", DataServiceStatus: 1} + invalid := &Detail{ID: 1, ICCID: card.ICCID, Authorized: true, Card: card} + var wantReason string + switch invalidInput { + case "MSISDN": + invalid.InputMSISDN = "wrong" + wantReason = "MSISDN与卡档案不一致" + case "IMSI": + invalid.InputIMSI = "wrong" + wantReason = "IMSI与卡档案不一致" + case "authorization": + invalid.Authorized = false + wantReason = "ICCID不在当前操作员授权范围内" + case "existing": + wantReason = "导入行格式校验失败" + invalid.Reason = wantReason + } + valid := &Detail{ID: 2, ICCID: card.ICCID, Authorized: true, Card: card} + otherInvalid := &Detail{ID: 3, ICCID: card.ICCID, Reason: "该行独立校验失败"} + duplicate := &Detail{ID: 4, ICCID: card.ICCID, Authorized: true, Card: card} + details := []*Detail{invalid, valid, otherInvalid, duplicate} + if !invalidFirst { + details[0], details[1] = details[1], details[0] + } + task := &Task{Batch: Batch{BatchNo: "ACCURACY-CUT", BatchType: 107}, Operation: operation, ApnName: "CMIOT", Details: details} + base := &accuracyRepository{task: task, saved: map[int64]Outcome{}} + repo := &accuracyDurableRepository{accuracyRepository: base} + if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil { + t.Fatal(err) + } + if len(repo.prepared) != 1 || len(repo.prepared[0].DetailIDs) != 1 || repo.prepared[0].DetailIDs[0] != valid.ID { + t.Fatalf("有效行未独立冻结: %+v", repo.prepared) + } + if valid.Reason != "" || valid.DetailStatus == consts.BatchDetailStatusFailed { + t.Fatalf("有效行被无效重复行阻断: %+v", valid) + } + for _, check := range []struct { + detail *Detail + reason string + }{{invalid, wantReason}, {otherInvalid, "该行独立校验失败"}, {duplicate, "重复ICCID,未提交上游"}} { + outcome, ok := base.saved[check.detail.ID] + if !ok || outcome.Success || outcome.Message != check.reason || check.detail.Reason != check.reason { + t.Fatalf("逐行原因被改写: id=%d detail=%+v outcome=%+v", check.detail.ID, check.detail, outcome) + } + } + if repo.prepared[0].Request.ReqData.OperType != "1" || operation.TargetStatus != 0 { + t.Fatalf("107开停方向倒置: %+v", repo.prepared[0]) + } + }) + } + } +} + +func TestAccuracyFrozenRequestsKeepAPNDirection(t *testing.T) { + for _, tc := range []struct { + batchType int + operType string + target int + }{{107, "1", 0}, {109, "0", 1}, {110, "1", 0}} { + t.Run(fmt.Sprint(tc.batchType), func(t *testing.T) { + operation, err := OperationByBatchType(tc.batchType) + if err != nil { + t.Fatal(err) + } + card := &Card{ID: 10, ICCID: "card", MSISDN: "1001", ChanKey: "channel", DataServiceStatus: 1 - tc.target} + task := &Task{ + Batch: Batch{BatchNo: "ACCURACY-DIRECTION", BatchType: tc.batchType}, Operation: operation, ApnName: "CMIOT", + Details: []*Detail{{ID: 1, ICCID: card.ICCID, Authorized: true, Card: card}, {ID: 2, ICCID: card.ICCID, Authorized: true, Card: card}}, + } + repo := &accuracyDurableRepository{accuracyRepository: &accuracyRepository{task: task, saved: map[int64]Outcome{}}} + if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil { + t.Fatal(err) + } + if len(repo.prepared) != 1 || len(repo.prepared[0].DetailIDs) != 1 || repo.prepared[0].DetailIDs[0] != 1 { + t.Fatalf("同ICCID未只保留首个有效行: %+v", repo.prepared) + } + if operation.TargetStatus != tc.target || repo.prepared[0].Request.ReqData.OperType != tc.operType { + t.Fatalf("批次%d开停方向错误: operation=%+v request=%+v", tc.batchType, operation, repo.prepared[0].Request) + } + }) + } +} + +func TestAccuracyEmptyBatchKeepsValidationReason(t *testing.T) { + for _, batchType := range []int{107, 109, 110} { + t.Run(fmt.Sprint(batchType), func(t *testing.T) { + operation, err := OperationByBatchType(batchType) + if err != nil { + t.Fatal(err) + } + task := &Task{Batch: Batch{BatchNo: "ACCURACY-EMPTY", BatchType: batchType}, Operation: operation} + repo := &accuracyRepository{task: task} + if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil { + t.Fatal(err) + } + if !repo.validationSaved || !repo.finalized || task.ValidationOK || task.FailureReason == "" { + t.Fatalf("空明细校验失败原因缺失: task=%+v saved=%v finalized=%v", task, repo.validationSaved, repo.finalized) + } + }) + } +} + +type accuracyRepository struct { + task *Task + saved map[int64]Outcome + validationSaved bool + finalized bool +} + +func (r *accuracyRepository) Load(context.Context, string) (*Task, error) { return r.task, nil } +func (r *accuracyRepository) SaveValidationFailure(context.Context, *Task) error { + r.validationSaved = true + return nil +} +func (r *accuracyRepository) SaveResult(_ context.Context, _ *Task, detail *Detail, outcome Outcome) (Outcome, error) { + r.saved[detail.ID] = outcome + return outcome, nil +} +func (r *accuracyRepository) Finalize(context.Context, *Task) error { + r.finalized = true + return nil +} + +type accuracyDurableRepository struct { + *accuracyRepository + SubBatchRepository + prepared []SubBatch +} + +func (r *accuracyDurableRepository) ResumeOrPrepare(ctx context.Context, _ string, prepare func(context.Context) error) error { + return prepare(ctx) +} +func (r *accuracyDurableRepository) PrepareSubBatch(_ context.Context, sub SubBatch) error { + r.prepared = append(r.prepared, sub) + return nil +} + +func TestAccuracyCanExecuteProcessingRequiresExistingParent(t *testing.T) { + ctx := context.Background() + for _, tc := range []struct { + name string + parent bool + readErr error + wantOK bool + wantErr bool + }{ + {name: "旧Processing无凭据不可接管"}, + {name: "新批次ready0已持久化凭据", parent: true, wantOK: true}, + {name: "缺少迁移查询失败安全拒绝", readErr: errors.New("no such table: data_operation_parent"), wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := &accuracyParentRepository{accuracyDurableRepository: &accuracyDurableRepository{accuracyRepository: &accuracyRepository{}}, present: tc.parent, readErr: tc.readErr} + e := NewExecutor(repo, &accuracyNoUpstream{t: t}).(*executor) + ok, err := e.CanExecuteProcessing(ctx, &entity.BatchMain{BatchNo: " APN-EXISTING "}) + if ok != tc.wantOK || (err != nil) != tc.wantErr || repo.lookups != 1 || repo.batchNo != "APN-EXISTING" { + t.Fatalf("parent gate ok=%v err=%v lookups=%d batchNo=%q", ok, err, repo.lookups, repo.batchNo) + } + }) + } + base := &accuracyRepository{} + for _, tc := range []struct { + name string + repo Repository + upstream Upstream + }{ + {name: "缺少持久仓储", repo: base, upstream: &accuracyNoUpstream{t: t}}, + {name: "仅同步上游", repo: &accuracyParentRepository{accuracyDurableRepository: &accuracyDurableRepository{accuracyRepository: base}}, upstream: &accuracySyncOnlyUpstream{}}, + } { + t.Run(tc.name, func(t *testing.T) { + e := NewExecutor(tc.repo, tc.upstream).(*executor) + ok, err := e.CanExecuteProcessing(ctx, &entity.BatchMain{BatchNo: "APN-EXISTING"}) + if ok || err != nil { + t.Fatalf("不支持持久异步不得接管Processing: ok=%v err=%v", ok, err) + } + }) + } +} + +func TestAccuracyDurableRepositoryWithSyncUpstreamFinalizesValidationFailure(t *testing.T) { + for _, batchType := range []int{107, 109, 110} { + t.Run(fmt.Sprint(batchType), func(t *testing.T) { + operation, err := OperationByBatchType(batchType) + if err != nil { + t.Fatal(err) + } + task := &Task{Batch: Batch{BatchNo: "SYNC-FAILED", BatchType: batchType}, Operation: operation} + base := &accuracyRepository{task: task} + repo := &accuracyDurableRepository{accuracyRepository: base} + if err := NewExecutor(repo, &accuracySyncOnlyUpstream{}).Execute(context.Background(), task.Batch.BatchNo); err != nil { + t.Fatal(err) + } + if !base.validationSaved || !base.finalized || task.FailureReason == "" { + t.Fatalf("同步兜底遗漏校验失败父批: saved=%v finalized=%v task=%+v", base.validationSaved, base.finalized, task) + } + }) + } +} + +type accuracyParentRepository struct { + *accuracyDurableRepository + present bool + readErr error + lookups int + batchNo string +} + +func (r *accuracyParentRepository) HasDurableParent(_ context.Context, batchNo string) (bool, error) { + r.lookups++ + r.batchNo = batchNo + return r.present, r.readErr +} + +type accuracySyncOnlyUpstream struct{} + +func (*accuracySyncOnlyUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + panic("校验失败不得发运营商请求") +} + +type accuracyNoUpstream struct{ t *testing.T } + +func (u *accuracyNoUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + u.t.Fatal("该回归不得调用同步上游") + return nil, nil +} +func (u *accuracyNoUpstream) SubmitSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + u.t.Fatal("该回归只冻结意图,不得提交上游") + return nil, nil +} +func (u *accuracyNoUpstream) QuerySimApnFunctionResult(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { + u.t.Fatal("该回归不得查询上游") + return nil, nil +} diff --git a/internal/service/data/executor_test.go b/internal/service/data/executor_test.go index 4a3a1fd..6ce3785 100644 --- a/internal/service/data/executor_test.go +++ b/internal/service/data/executor_test.go @@ -211,7 +211,7 @@ func TestExecuteTrafficCutoffValidationFailureAllowsValidRows(t *testing.T) { if len(repo.saved) != 2 { t.Fatalf("saved count = %d, want 2; saved = %+v", len(repo.saved), repo.saved) } - if repo.saved[0].Success || repo.saved[0].Message != "ICCID is outside the current operator authorization scope" { + if repo.saved[0].Success || repo.saved[0].Message != "ICCID不在当前操作员授权范围内" { t.Fatalf("invalid row result = %+v", repo.saved[0]) } if !repo.saved[1].Success { @@ -522,3 +522,22 @@ func (u *fakeUpstream) ChangeSimApnFunction(_ context.Context, input *model.SimC u.input = input return u.response, u.responseError } + +func TestValidateDetailCollectsIndependentDiagnostics(t *testing.T) { + detail := &Detail{ICCID: "card", Authorized: true, InputMSISDN: "wrong-phone", InputIMSI: "wrong-imsi", Card: &Card{MSISDN: "phone", IMSI: "imsi"}} + validateDetail(Operation{}, detail) + want := "ICCID缺少通道标识;MSISDN与卡档案不一致;IMSI与卡档案不一致" + if detail.Reason != want { + t.Fatalf("独立校验原因丢失: got=%q want=%q", detail.Reason, want) + } + unauthorized := &Detail{ICCID: "card", InputMSISDN: "wrong", Card: &Card{MSISDN: "phone"}} + validateDetail(Operation{}, unauthorized) + if unauthorized.Reason != "ICCID不在当前操作员授权范围内" { + t.Fatalf("越权卡不应返回其他数据差异: %q", unauthorized.Reason) + } + existing := &Detail{Reason: "原始输入格式错误", Authorized: true, Card: &Card{}} + validateDetail(Operation{}, existing) + if existing.Reason != "原始输入格式错误" { + t.Fatal("原始原因被覆盖") + } +} diff --git a/internal/service/data/types.go b/internal/service/data/types.go index 1ff38b5..5cd59f3 100644 --- a/internal/service/data/types.go +++ b/internal/service/data/types.go @@ -3,6 +3,7 @@ package data import ( "context" "strings" + "time" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/os/gtime" @@ -39,6 +40,7 @@ type Card struct { type Detail struct { ID int64 + DetailStatus int RowNo int ICCID string InputMSISDN string @@ -59,6 +61,7 @@ type Detail struct { } type Outcome struct { + Pending bool Success bool Code int Message string @@ -76,6 +79,11 @@ type Task struct { func (d *Detail) ApplyOutcome(outcome Outcome) { d.OrderNum = strings.TrimSpace(outcome.OrderNum) + if outcome.Success { + d.DetailStatus = 2 + } else { + d.DetailStatus = 3 + } d.UpstreamCode = outcome.Code d.UpstreamReason = strings.TrimSpace(outcome.Message) if outcome.Success { @@ -83,7 +91,7 @@ func (d *Detail) ApplyOutcome(outcome Outcome) { return } if d.UpstreamReason == "" { - d.UpstreamReason = "upstream processing failed" + d.UpstreamReason = "上游处理失败" } d.Reason = d.UpstreamReason } @@ -138,10 +146,66 @@ type Repository interface { Finalize(ctx context.Context, task *Task) error } +// SubBatch is a durable, channel-isolated intent; Request is frozen before submission. +type SubBatch struct { + ID int64 + BatchNo string + BatchType int + ChanKey string + OperationID string + DetailIDs []int64 + Request model.SimCardApnChangeInput + JobID string + Status string + PollCount int + SubmittedAt time.Time + LeaseToken string + LeaseUntil time.Time + NextCheckAt time.Time +} + +// SubBatchRepository keeps the synchronous legacy tests separate from durable scheduling. +type SubBatchRepository interface { + HasDurableParent(ctx context.Context, batchNo string) (bool, error) + PrepareSubBatch(ctx context.Context, batch SubBatch) error + ResumeOrPrepare(ctx context.Context, batchNo string, prepare func(context.Context) error) error + ClaimSubBatch(ctx context.Context, operationID, token string, now time.Time) (*SubBatch, error) + ListDueSubBatches(ctx context.Context, now time.Time, limit int) ([]string, error) + GetSubBatch(ctx context.Context, operationID string) (*SubBatch, error) + DelaySubBatchPrecheck(ctx context.Context, snapshot SubBatch, next time.Time, reason string) error + ReleaseUnsentSubBatch(ctx context.Context, batch SubBatch, reason string) error + SubBatchChannel(ctx context.Context, operationID string) (string, error) + UpdateSubBatch(ctx context.Context, batch SubBatch, status, jobID, reason string, next time.Time, pollCount int) error + CompleteSubBatch(ctx context.Context, batch SubBatch, outcomes []Outcome) error + SaveSubBatchProgress(ctx context.Context, batch SubBatch, outcomes []Outcome) error + ProtectExecutionFailure(ctx context.Context, batchNo, reason string) error + FinalizeReadyBatch(ctx context.Context, batchNo string) error + ListRecoverableBatches(ctx context.Context, limit int) ([]string, error) +} + type Upstream interface { ChangeSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) } +// AsyncUpstream provides an explicit submit/recovery/query protocol: never resend a write request. +type AsyncUpstream interface { + SubmitSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) + QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) +} + +type ReviewRepository interface { + ClaimReviewSubBatch(ctx context.Context, operationID, token string, now time.Time) (*SubBatch, error) +} + +// ReviewBatchRepository 支撑"手动同步上游结果":按父批找出待核查子批并只读补查, +// 不重发办理、不重置自动补查计数。 +type ReviewBatchRepository interface { + ListReviewSubBatches(ctx context.Context, batchNo string) ([]string, error) + FinalizeReadyBatch(ctx context.Context, batchNo string) error +} + type Executor interface { Execute(ctx context.Context, batchNo string) error + QueryReview(ctx context.Context, operationID string) error + QueryReviewBatch(ctx context.Context, batchNo string) (int, error) } diff --git a/internal/service/imei/handler_test.go b/internal/service/imei/handler_test.go index 4fec9a4..d92d649 100644 --- a/internal/service/imei/handler_test.go +++ b/internal/service/imei/handler_test.go @@ -43,7 +43,7 @@ func task(tempKey string) queue.Task { ChanKey: "173", TempKey: tempKey, CardID: 1, - ICCID: "898604F4152000015474", + ICCID: "898604F4152391115474", MSISDN: "1064000000001", IMSI: "460000000000001", Operator: 1, @@ -54,7 +54,7 @@ func item(code int, errCode, imei string) model.SimBaseRes[model.SimImeiItem] { return model.SimBaseRes[model.SimImeiItem]{ Code: code, ErrCode: errCode, - SimKey: &model.SimKey{ChanKey: "173", ICCID: "898604F4152000015474"}, + SimKey: &model.SimKey{ChanKey: "173", ICCID: "898604F4152391115474"}, ResData: model.SimImeiItem{Imei: imei}, } } diff --git a/internal/service/inbound/executor.go b/internal/service/inbound/executor.go index 8b4e9fb..0695661 100644 --- a/internal/service/inbound/executor.go +++ b/internal/service/inbound/executor.go @@ -22,7 +22,7 @@ import ( ) const ( - cascadeFailureReason = "同批存在校验失败,本次整批未入库" + cascadeFailureReason = "校验通过,但本批次存在失败明细,整批未执行入库" maxIdentifierLength = 32 minICCIDLength = 19 minIMSILength = 15 @@ -237,6 +237,9 @@ func (e *executor) populateExcelRows(ctx context.Context, task *Task) error { if err != nil { return gerror.Wrap(err, "read inbound Excel rows failed") } + if len(lines) == 0 || !hasInboundIdentifierHeader(headerIndex(lines[0])) { + return gerror.New("入库文件表头无效:缺少 ICCID、MSISDN 或 IMSI 列") + } task.Rows = parseTableRows(lines) return nil } @@ -281,9 +284,19 @@ func (e *executor) validate(ctx context.Context, task *Task) error { } syncInboundOptionsFromRows(task) markSameBatchDuplicates(task.Rows) + if hasFailedRows(task.Rows) { + markIncompleteRows(task.Rows) + task.ValidCount = countValidRows(task.Rows) + return nil + } if err := e.markStockDuplicates(ctx, task); err != nil { return err } + if hasFailedRows(task.Rows) { + markIncompleteRows(task.Rows) + task.ValidCount = countValidRows(task.Rows) + return nil + } task.Options.ValidateUpstreamBasicInfo = normalizeUpstreamBasicInfoValidationOption(task.Options.ValidateUpstreamBasicInfo) if e.upstream != nil && task.Options.ValidateUpstreamBasicInfo == "1" { if err := e.validateUpstreamBasicInfo(ctx, task); err != nil { @@ -350,6 +363,15 @@ func parseTableRows(lines [][]string) []*Row { return rows } +func hasInboundIdentifierHeader(header map[string]int) bool { + for _, key := range []string{"iccid", "msisdn", "imsi"} { + if _, ok := header[key]; ok { + return true + } + } + return false +} + func rowFromHeader(rowNo int, fields []string, header map[string]int) *Row { raw := make(map[string]string, len(header)) for key, index := range header { @@ -447,7 +469,7 @@ func headerIndex(fields []string) map[string]int { indexes[key] = index } } - if _, ok := indexes["iccid"]; !ok { + if !hasInboundIdentifierHeader(indexes) { return nil } return indexes @@ -1382,13 +1404,27 @@ func countValidRows(rows []*Row) int { func countFailedRows(rows []*Row) int { count := 0 for _, row := range rows { - if row != nil && row.Reason != "" { + if row != nil && row.Reason != "" && row.Reason != cascadeFailureReason { count++ } } return count } +func inboundRowStatus(row *Row) string { + if IsCascadeFailure(row) { + return "未执行" + } + if row.Reason != "" { + return "失败" + } + return "成功" +} + +func IsCascadeFailure(row *Row) bool { + return row != nil && row.Reason == cascadeFailureReason +} + func rowKeys(rows []*Row) ([]string, []string, []string) { iccidSet := make(map[string]struct{}, len(rows)) msisdnSet := make(map[string]struct{}, len(rows)) @@ -1541,6 +1577,7 @@ func resultRequest(task *Task) batchresult.Request { MSISDN: row.MSISDN, IMSI: row.IMSI, Success: row.Reason == "", + Status: inboundRowStatus(row), Reason: row.Reason, }) } diff --git a/internal/service/inbound/executor_test.go b/internal/service/inbound/executor_test.go index 7ce7ff0..7fbc05a 100644 --- a/internal/service/inbound/executor_test.go +++ b/internal/service/inbound/executor_test.go @@ -3,15 +3,29 @@ package inbound import ( "context" "errors" + "os" "strings" "testing" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" + "github.com/xuri/excelize/v2" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult" ) +func TestMain(m *testing.M) { + adapter, err := gcfg.NewAdapterContent("batch:\n inbound:\n upstreamBatchSize: 1\n") + if err != nil { + panic(err) + } + g.Cfg().SetAdapter(adapter) + os.Exit(m.Run()) +} + func TestAdminSourceTypeContract(t *testing.T) { if consts.BatchSourceTypeRange != 1 || consts.BatchSourceTypeBatchInput != 2 || consts.BatchSourceTypeFile != 3 { t.Fatalf("source_type contract mismatch: range=%d input=%d file=%d", @@ -19,6 +33,47 @@ func TestAdminSourceTypeContract(t *testing.T) { } } +func TestCascadeFailurePreservesPerCardValidation(t *testing.T) { + task := &Task{Rows: []*Row{ + {ICCID: "invalid", Reason: "卡号不合法"}, + {ICCID: "valid"}, + }} + applyCascadeFailure(task) + if countFailedRows(task.Rows) != 1 || !IsCascadeFailure(task.Rows[1]) { + t.Fatalf("unexpected validation outcomes: %+v", task.Rows) + } + if task.Rows[0].Reason != "卡号不合法" || inboundRowStatus(task.Rows[0]) != "失败" || inboundRowStatus(task.Rows[1]) != "未执行" { + t.Fatalf("wrong per-card status: %+v", task.Rows) + } +} + +func TestValidateCollectsLaterInboundFailuresBeforeBlockingBatch(t *testing.T) { + task := &Task{Rows: []*Row{ + {ICCID: "bad", ChanKey: "channel"}, + {ICCID: "8986000000000000001", MSISDN: "13800008000", IMSI: "460001234567890", ChanKey: "channel"}, + {ICCID: "8986000000000000002", MSISDN: "invalid", IMSI: "invalid", ChanKey: "channel"}, + }} + executor := &executor{repository: &inboundRepositoryStub{}} + if err := executor.validate(context.Background(), task); err != nil { + t.Fatal(err) + } + applyCascadeFailure(task) + if !strings.Contains(task.Rows[0].Reason, "ICCID格式不正确") { + t.Fatalf("first failure was lost: %q", task.Rows[0].Reason) + } + if !IsCascadeFailure(task.Rows[1]) { + t.Fatalf("validated card was not blocked: %q", task.Rows[1].Reason) + } + for _, reason := range []string{"MSISDN格式不正确", "IMSI格式不正确"} { + if !strings.Contains(task.Rows[2].Reason, reason) { + t.Fatalf("later card was not fully validated: %q", task.Rows[2].Reason) + } + } + if countFailedRows(task.Rows) != 2 || countValidRows(task.Rows) != 0 { + t.Fatalf("batch must have two validation failures and no executable cards: %+v", task) + } +} + func TestValidateRowRequiresAtLeastOneCardIdentifierAndChanKey(t *testing.T) { row := &Row{} validateRow(row, "") @@ -32,7 +87,7 @@ func TestValidateRowRequiresAtLeastOneCardIdentifierAndChanKey(t *testing.T) { func TestParseInputTextAcceptsOneIdentifierPerLine(t *testing.T) { rows := parseInputText(strings.Join([]string{ - "89860401780000567308", + "89860401786527567308", "14100007308", "460046527567308", }, "\n")) @@ -40,7 +95,7 @@ func TestParseInputTextAcceptsOneIdentifierPerLine(t *testing.T) { if len(rows) != 3 { t.Fatalf("row count = %d, want 3", len(rows)) } - if rows[0].ICCID != "89860401780000567308" || rows[1].MSISDN != "14100007308" || rows[2].IMSI != "460046527567308" { + if rows[0].ICCID != "89860401786527567308" || rows[1].MSISDN != "14100007308" || rows[2].IMSI != "460046527567308" { t.Fatalf("unexpected parsed rows: %+v", rows) } for _, row := range rows { @@ -111,7 +166,7 @@ func TestValidateRowSelectedMSISDNDoesNotRequireOtherIdentifiers(t *testing.T) { } func TestValidateRowAllowsAlphaNumericICCID(t *testing.T) { - row := &Row{ICCID: "898604F4152000015473", MSISDN: "13800008000", IMSI: "460001234567890", ChanKey: "chan-a"} + row := &Row{ICCID: "898604F4152391115473", MSISDN: "13800008000", IMSI: "460001234567890", ChanKey: "chan-a"} validateRow(row, "") if row.Reason != "" { t.Fatalf("unexpected validation reason: %q", row.Reason) @@ -149,14 +204,14 @@ func TestMarkSameBatchDuplicatesChecksIMSI(t *testing.T) { } func TestValidateUpstreamBasicInfoAcceptsMatchingIdentifiers(t *testing.T) { - rows := []*Row{{RowNo: 1, ICCID: "898604F4152000015473", MSISDN: "1440000615473", IMSI: "460087436015473", ChanKey: "chan-a"}} + rows := []*Row{{RowNo: 1, ICCID: "898604F4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{ { Code: 0, - SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604F4152000015473"}, + SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604F4152391115473"}, ResData: model.SimBasicInfoItem{ - ICCID: "898604F4152000015473", - MSISDN: "1440000615473", + ICCID: "898604F4152391115473", + MSISDN: "1441543615473", IMSI: "460087436015473", }, }, @@ -176,7 +231,7 @@ func TestValidateUpstreamBasicInfoAcceptsMatchingIdentifiers(t *testing.T) { t.Fatalf("unexpected upstream inputs: %+v", upstream.inputs) } requestKey := upstream.inputs[0].SimKeys[0] - if requestKey.ChanKey != "chan-a" || requestKey.ICCID != "898604F4152000015473" { + if requestKey.ChanKey != "chan-a" || requestKey.ICCID != "898604F4152391115473" { t.Fatalf("unexpected upstream request key: %+v", requestKey) } if requestKey.MSISDN != "" || requestKey.IMSI != "" { @@ -185,11 +240,11 @@ func TestValidateUpstreamBasicInfoAcceptsMatchingIdentifiers(t *testing.T) { } func TestValidateUpstreamBasicInfoRequiresCompleteResData(t *testing.T) { - rows := []*Row{{RowNo: 1, ICCID: "898604F4152000015473", MSISDN: "1440000615473", IMSI: "460087436015473", ChanKey: "chan-a"}} + rows := []*Row{{RowNo: 1, ICCID: "898604F4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{ { Code: 0, - SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604f4152000015473", MSISDN: "1440000615473", IMSI: "460087436015473"}, + SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604f4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473"}, ResData: model.SimBasicInfoItem{ ActiveDate: "2026-08-12 00:00:00", OpenDate: "2026-08-12 00:00:00", @@ -232,7 +287,7 @@ func TestValidateUpstreamBasicInfoMatchesByMSISDNAndBackfillsCodes(t *testing.T) Code: 0, SimKey: &model.SimKey{ChanKey: "chan-a", MSISDN: "14100007308"}, ResData: model.SimBasicInfoItem{ - ICCID: "89860401780000567308", + ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", }, @@ -246,7 +301,7 @@ func TestValidateUpstreamBasicInfoMatchesByMSISDNAndBackfillsCodes(t *testing.T) if rows[0].Reason != "" { t.Fatalf("unexpected reason: %q", rows[0].Reason) } - if rows[0].ICCID != "89860401780000567308" || rows[0].IMSI != "460046527567308" { + if rows[0].ICCID != "89860401786527567308" || rows[0].IMSI != "460046527567308" { t.Fatalf("expected upstream codes to be backfilled, got %+v", rows[0]) } requestKey := upstream.inputs[0].SimKeys[0] @@ -263,7 +318,7 @@ func TestValidateBatchInputSingleMSISDNBackfillsBeforeCompletenessCheck(t *testi Code: 0, SimKey: &model.SimKey{ChanKey: "chan-a", MSISDN: "14100007308"}, ResData: model.SimBasicInfoItem{ - ICCID: "89860401780000567308", + ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", }, @@ -282,7 +337,7 @@ func TestValidateBatchInputSingleMSISDNBackfillsBeforeCompletenessCheck(t *testi if task.ValidCount != 1 { t.Fatalf("valid count = %d, want 1, reason=%q", task.ValidCount, rows[0].Reason) } - if rows[0].ICCID != "89860401780000567308" || rows[0].IMSI != "460046527567308" { + if rows[0].ICCID != "89860401786527567308" || rows[0].IMSI != "460046527567308" { t.Fatalf("expected upstream codes to be backfilled, got %+v", rows[0]) } if upstream.calls != 1 { @@ -293,7 +348,7 @@ func TestValidateBatchInputSingleMSISDNBackfillsBeforeCompletenessCheck(t *testi func TestValidateSkipsUpstreamBasicInfoWhenDisabled(t *testing.T) { rows := []*Row{{ RowNo: 1, - ICCID: "89860401780000567308", + ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", ChanKey: "chan-a", @@ -323,7 +378,7 @@ func TestValidateSkipsUpstreamBasicInfoWhenDisabled(t *testing.T) { func TestValidateRunsUpstreamBasicInfoWhenEnabled(t *testing.T) { rows := []*Row{{ RowNo: 1, - ICCID: "89860401780000567308", + ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", ChanKey: "chan-a", @@ -363,7 +418,7 @@ func TestPopulateExcelRowsReturnsRetryableErrorWhenSourceIsUnreadable(t *testing func TestValidateUpstreamBasicInfoWaitsForRateLimitBeforeQuery(t *testing.T) { var order []string - rows := []*Row{{RowNo: 1, ICCID: "89860401780000567308", MSISDN: "14100007308", IMSI: "460046527567308", ChanKey: "chan-a"}} + rows := []*Row{{RowNo: 1, ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", ChanKey: "chan-a"}} limiter := &recordingRateLimiter{ onWait: func(context.Context, string, int) error { order = append(order, "limit") @@ -377,9 +432,9 @@ func TestValidateUpstreamBasicInfoWaitsForRateLimitBeforeQuery(t *testing.T) { response: []model.SimBaseRes[model.SimBasicInfoItem]{ { Code: 0, - SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "89860401780000567308"}, + SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "89860401786527567308"}, ResData: model.SimBasicInfoItem{ - ICCID: "89860401780000567308", + ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", }, @@ -406,7 +461,7 @@ func TestValidateUpstreamBasicInfoWaitsForRateLimitBeforeQuery(t *testing.T) { } func TestValidateUpstreamBasicInfoStopsWhenRateLimitFails(t *testing.T) { - rows := []*Row{{RowNo: 1, ICCID: "89860401780000567308", ChanKey: "chan-a"}} + rows := []*Row{{RowNo: 1, ICCID: "89860401786527567308", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{} limiter := &recordingRateLimiter{err: errors.New("redis down")} @@ -460,14 +515,14 @@ func TestValidateUpstreamBasicInfoRejectsUpstreamError(t *testing.T) { func TestParseTableRowsAcceptsChineseInboundHeaders(t *testing.T) { rows := parseTableRows([][]string{ {"ICCID号", "手机号", "国际移动用户识别码", "国际移动设备识别码", "通道编码", "卡类型", "上游套餐编码", "供应商编码"}, - {"898604F4152000015473", "1440000615473", "460087436015473", "865111111111111", "channel_003", "通用插拔 SIM 卡", "UP001", "SUP001"}, + {"898604F4152391115473", "1441543615473", "460087436015473", "865111111111111", "channel_003", "通用插拔 SIM 卡", "UP001", "SUP001"}, }) if len(rows) != 1 { t.Fatalf("row count = %d, want 1", len(rows)) } row := rows[0] - if row.ICCID != "898604F4152000015473" || row.MSISDN != "1440000615473" || + if row.ICCID != "898604F4152391115473" || row.MSISDN != "1441543615473" || row.IMSI != "460087436015473" || row.IMEI != "865111111111111" || row.ChanKey != "channel_003" { t.Fatalf("unexpected parsed row: %+v", row) } @@ -479,19 +534,88 @@ func TestParseTableRowsAcceptsChineseInboundHeaders(t *testing.T) { func TestParseTableRowsAcceptsRequiredMarkerHeaders(t *testing.T) { rows := parseTableRows([][]string{ {"ICCID*", "MSISDN*", "IMSI*", "通道编号*"}, - {"89860401780000567308", "14100007308", "460046527567308", "channel_003"}, + {"89860401786527567308", "14100007308", "460046527567308", "channel_003"}, }) if len(rows) != 1 { t.Fatalf("row count = %d, want 1", len(rows)) } row := rows[0] - if row.ICCID != "89860401780000567308" || row.MSISDN != "14100007308" || + if row.ICCID != "89860401786527567308" || row.MSISDN != "14100007308" || row.IMSI != "460046527567308" || row.ChanKey != "channel_003" { t.Fatalf("unexpected parsed row: %+v", row) } } +func TestInboundExcelHeaderRequiresRecognizedIdentifier(t *testing.T) { + for _, fields := range [][]string{{"随机内容", "乱码"}, {"通道编号", "卡类型"}, {"ICCID", "通道编号"}, {"MSISDN", "通道编号"}, {"IMSI", "通道编号"}} { + got := hasInboundIdentifierHeader(headerIndex(fields)) + want := fields[0] == "ICCID" || fields[0] == "MSISDN" || fields[0] == "IMSI" + if got != want { + t.Fatalf("header %v valid = %t, want %t", fields, got, want) + } + } +} + +func TestPopulateExcelRowsRejectsInvalidHeaderAndAcceptsMSISDNHeader(t *testing.T) { + for _, test := range []struct { + name string + header []string + valid bool + }{ + {name: "invalid", header: []string{"乱码", "通道编号"}}, + {name: "msisdn", header: []string{"MSISDN", "通道编号"}, valid: true}, + } { + t.Run(test.name, func(t *testing.T) { + workbook := excelize.NewFile() + defer func() { _ = workbook.Close() }() + for index, value := range test.header { + cell, err := excelize.CoordinatesToCellName(index+1, 1) + if err != nil { + t.Fatal(err) + } + if err := workbook.SetCellValue("Sheet1", cell, value); err != nil { + t.Fatal(err) + } + } + if err := workbook.SetCellValue("Sheet1", "A2", "14100007308"); err != nil { + t.Fatal(err) + } + content, err := workbook.WriteToBuffer() + if err != nil { + t.Fatal(err) + } + task := &Task{Source: Source{ImportFileOssID: 1}} + repository := &inboundRepositoryStub{readOssContent: content.Bytes(), readOssName: "cards.xlsx"} + err = (&executor{repository: repository}).populateExcelRows(context.Background(), task) + if !test.valid { + if err == nil || len(task.Rows) != 0 { + t.Fatalf("invalid header: err=%v, rows=%v", err, task.Rows) + } + return + } + if err != nil || len(task.Rows) != 1 || task.Rows[0].MSISDN != "14100007308" { + t.Fatalf("valid MSISDN file: err=%v, rows=%v", err, task.Rows) + } + }) + } +} + +func TestValidateInvalidRowSkipsUpstreamForEntireBatch(t *testing.T) { + rows := []*Row{ + {RowNo: 2, ICCID: "invalid", ChanKey: "channel_003"}, + {RowNo: 3, ICCID: "89860401786527567308", ChanKey: "channel_003"}, + } + upstream := &basicInfoUpstreamStub{} + task := &Task{Rows: rows, Options: Options{ChanKey: "channel_003", ValidateUpstreamBasicInfo: "1"}} + if err := (&executor{repository: &inboundRepositoryStub{}, upstream: upstream}).validate(context.Background(), task); err != nil { + t.Fatal(err) + } + if upstream.calls != 0 || task.ValidCount != 0 || !hasFailedRows(rows) { + t.Fatalf("invalid batch should skip upstream: calls=%d valid=%d", upstream.calls, task.ValidCount) + } +} + func TestValidateSkipsUpstreamWhenLocalIdentifierFormatFails(t *testing.T) { rows := []*Row{{RowNo: 1, ICCID: "4", MSISDN: "4", IMSI: "4", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{} @@ -539,8 +663,8 @@ func TestValidateRequiresCompleteIdentifiersAfterUpstreamStep(t *testing.T) { func TestValidateNormalizesExcelInboundOptionsAndBackfillsBatchInfo(t *testing.T) { rows := parseTableRows([][]string{ {"ICCID*", "MSISDN*", "IMSI*", "通道编号*", "卡类型", "上游套餐编码", "供应商编码", "是否贴片转插拔", "是否运营商达量断网"}, - {"89860401780000567308", "14100007308", "460046527567308", "channel_003", "MP1-消费级插拔物联卡", "UP001", "SUP001", "是", "否"}, - {"89860401780000567309", "14100007309", "460046527567309", "channel_003", "", "UP001", "SUP001", "", ""}, + {"89860401786527567308", "14100007308", "460046527567308", "channel_003", "MP1-消费级插拔物联卡", "UP001", "SUP001", "是", "否"}, + {"89860401786527567309", "14100007309", "460046527567309", "channel_003", "", "UP001", "SUP001", "", ""}, }) task := &Task{Rows: rows, Batch: Batch{SourceType: consts.BatchSourceTypeFile}} @@ -563,8 +687,8 @@ func TestValidateNormalizesExcelInboundOptionsAndBackfillsBatchInfo(t *testing.T func TestValidateRejectsMixedInboundOptionsInOneExcelBatch(t *testing.T) { rows := parseTableRows([][]string{ {"ICCID*", "MSISDN*", "IMSI*", "通道编号*", "卡类型"}, - {"89860401780000567308", "14100007308", "460046527567308", "channel_003", "1"}, - {"89860401780000567309", "14100007309", "460046527567309", "channel_003", "2"}, + {"89860401786527567308", "14100007308", "460046527567308", "channel_003", "1"}, + {"89860401786527567309", "14100007309", "460046527567309", "channel_003", "2"}, }) task := &Task{Rows: rows, Batch: Batch{SourceType: consts.BatchSourceTypeFile}} diff --git a/internal/service/inbound/rate_limiter.go b/internal/service/inbound/rate_limiter.go index f277f37..b0b9c4d 100644 --- a/internal/service/inbound/rate_limiter.go +++ b/internal/service/inbound/rate_limiter.go @@ -9,9 +9,9 @@ import ( "sync" "time" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" "github.com/gogf/gf/v2/container/gvar" "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" ) const ( diff --git a/internal/service/lifecycle/executor.go b/internal/service/lifecycle/executor.go index 2aabf3f..2002f51 100644 --- a/internal/service/lifecycle/executor.go +++ b/internal/service/lifecycle/executor.go @@ -73,10 +73,10 @@ func validateTask(task *Task) bool { } for _, detail := range task.Details { if detail != nil && detail.Reason == "" { - detail.Reason = "same batch contains validation failure; upstream request was skipped" + detail.Reason = "同批次存在校验失败卡,本卡未请求上游" } } - task.FailureReason = "batch validation failed; upstream request was skipped" + task.FailureReason = "批次校验未通过,未发起上游请求" return false } @@ -85,7 +85,7 @@ func validateDetail(operation Operation, detail *Detail) { return } if detail.Card == nil { - detail.Reason = "ICCID does not exist in SIM card records" + detail.Reason = "ICCID不存在或不属于当前租户" return } detail.MSISDN = strings.TrimSpace(detail.Card.MSISDN) @@ -94,21 +94,20 @@ func validateDetail(operation Operation, detail *Detail) { detail.StatusBefore = detail.Card.BossCardStatus if !detail.Authorized { - detail.Reason = "ICCID is outside the current operator authorization scope" + detail.Reason = "ICCID不在当前操作员授权范围内" return } + var reasons []string if detail.ChanKey == "" { - detail.Reason = "ICCID is missing chanKey" - return + reasons = append(reasons, "ICCID缺少通道标识") } if detail.InputMSISDN != "" && detail.InputMSISDN != detail.MSISDN { - detail.Reason = "MSISDN does not match the SIM card record" - return + reasons = append(reasons, "MSISDN与卡档案不一致") } if detail.InputIMSI != "" && detail.InputIMSI != detail.IMSI { - detail.Reason = "IMSI does not match the SIM card record" - return + reasons = append(reasons, "IMSI与卡档案不一致") } + detail.Reason = strings.Join(reasons, ";") // TODO: 正常系统开发完成后恢复卡状态限制 // 注释原因:开发环境经常出现卡状态不在预期可操作范围内的情况,先放行 // if detail.Card.BossCardStatus != operation.ExpectedStatus { @@ -162,7 +161,7 @@ func (e *executor) changeSubBatch(ctx context.Context, task *Task, details []*De if err != nil { outcomes := make([]Outcome, len(details)) for index := range outcomes { - outcomes[index].Message = "upstream request failed: " + err.Error() + outcomes[index].Message = "请求上游失败:" + err.Error() } return outcomes } diff --git a/internal/service/lifecycle/executor_test.go b/internal/service/lifecycle/executor_test.go index 372fb25..808d056 100644 --- a/internal/service/lifecycle/executor_test.go +++ b/internal/service/lifecycle/executor_test.go @@ -141,7 +141,7 @@ func TestValidateDetailDetectsMSISDNMismatch(t *testing.T) { }, } validateDetail(operation, detail) - if detail.Reason != "MSISDN does not match the SIM card record" { + if detail.Reason != "MSISDN与卡档案不一致" { t.Fatalf("unexpected reason: %q", detail.Reason) } if detail.MSISDN != "msisdn-card" || detail.InputMSISDN != "msisdn-input" { @@ -190,3 +190,22 @@ func (u *fakeUpstream) ChangeSimStatus(_ context.Context, input *model.SimCardSt u.input = input return u.response, u.responseError } + +func TestValidateDetailCollectsIndependentDiagnostics(t *testing.T) { + detail := &Detail{ICCID: "card", Authorized: true, InputMSISDN: "wrong-phone", InputIMSI: "wrong-imsi", Card: &Card{MSISDN: "phone", IMSI: "imsi"}} + validateDetail(Operation{}, detail) + want := "ICCID缺少通道标识;MSISDN与卡档案不一致;IMSI与卡档案不一致" + if detail.Reason != want { + t.Fatalf("独立校验原因丢失: got=%q want=%q", detail.Reason, want) + } + unauthorized := &Detail{ICCID: "card", InputMSISDN: "wrong", Card: &Card{MSISDN: "phone"}} + validateDetail(Operation{}, unauthorized) + if unauthorized.Reason != "ICCID不在当前操作员授权范围内" { + t.Fatalf("越权卡不应返回其他数据差异: %q", unauthorized.Reason) + } + existing := &Detail{Reason: "原始输入格式错误", Authorized: true, Card: &Card{}} + validateDetail(Operation{}, existing) + if existing.Reason != "原始输入格式错误" { + t.Fatal("原始原因被覆盖") + } +} diff --git a/internal/service/rolesync/role.go b/internal/service/rolesync/role.go new file mode 100644 index 0000000..76b5877 --- /dev/null +++ b/internal/service/rolesync/role.go @@ -0,0 +1,208 @@ +package rolesync + +import ( + "context" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rolepolicy" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" + "slices" + "strings" +) + +func SyncInheritedRole(ctx context.Context, message *model.AgentRoleSyncMessage) error { + if message == nil || message.SourceRoleId <= 0 || message.SourceDeptId <= 0 || message.TenantId == "" { + return gerror.New("invalid agent role sync message") + } + template, templateErr := rolepolicy.Template(ctx) + if templateErr != nil { + return templateErr + } + if message.TemplateRole && (template == nil || message.SourceRoleId != template.RoleId) { + message.Superseded = true + return nil + } + if template != nil && message.SourceRoleId == template.RoleId && message.SourceDeptId == template.DeptId && (message.TenantId == template.TenantId || message.TenantId == "") { + return syncTemplateRoles(ctx, template, message) + } + if template != nil { + message.Superseded = true + return nil + } + var source *entity.SysRole + sourceModel := dao.SysRole.Ctx(ctx).Master().Where(dao.SysRole.Columns().RoleId, message.SourceRoleId) + if message.TenantId != "" { + sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, message.TenantId) + } + if err := sourceModel.Scan(&source); err != nil { + return err + } + if source == nil || source.IsPass != 1 || source.DeletedAt != nil { + message.Superseded = true + return nil + } + if message.SourceDeptId > 0 && message.SourceDeptId != source.DeptId { + return gerror.New("role sync source department changed") + } + var children []*entity.SysDept + if err := dao.SysDept.Ctx(ctx).Master(). + Where(dao.SysDept.Columns().TenantId, source.TenantId). + Where(dao.SysDept.Columns().ParentId, source.DeptId). + Where(dao.SysDept.Columns().DeptType, 1). + Scan(&children); err != nil { + return err + } + failed := 0 + if len(message.ChildDeptIds) == 0 { + message.TotalCount = len(children) + message.SuccessCount = 0 + } + failedDeptIds := make([]int64, 0) + var firstErr error + for _, child := range children { + if err := ctx.Err(); err != nil { + return err + } + if len(message.ChildDeptIds) > 0 && !slices.Contains(message.ChildDeptIds, child.DeptId) { + continue + } + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + return syncInheritedRoleChildrenTx(ctx, tx, message.SourceRoleId, source.TenantId, source.DeptId, message.ActorId, child.DeptId) + }) + if err != nil { + failed++ + failedDeptIds = append(failedDeptIds, child.DeptId) + if firstErr == nil { + firstErr = err + } + g.Log().Warningf(ctx, "agent role sync child failed, sourceRoleId:%d, childDeptId:%d, err:%v", source.RoleId, child.DeptId, err) + } else { + message.SuccessCount++ + } + } + message.ChildDeptIds = failedDeptIds + message.FailedCount = len(failedDeptIds) + if firstErr != nil { + return gerror.Wrapf(firstErr, "agent role sync failed for %d of %d children", failed, len(children)) + } + return nil +} + +func syncInheritedRoleChildrenTx(ctx context.Context, tx gdb.TX, sourceRoleID int64, tenantId string, sourceDeptId, actorId, childDeptId int64) error { + if sourceRoleID == 0 { + return nil + } + var source *entity.SysRole + sourceModel := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, sourceRoleID) + if tenantId != "" { + sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, tenantId) + } + if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { + sourceModel = sourceModel.LockUpdate() + } + if err := sourceModel.Scan(&source); err != nil { + return err + } + if source == nil || source.RoleId == 0 || source.DeletedAt != nil || source.IsPass != 1 { + return nil + } + if sourceDeptId > 0 && source.DeptId != sourceDeptId { + return gerror.New("role sync source department changed") + } + var childDepts []*entity.SysDept + childDeptModel := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where(dao.SysDept.Columns().TenantId, source.TenantId).Where(dao.SysDept.Columns().ParentId, source.DeptId).Where(dao.SysDept.Columns().DeptType, 1).Where(dao.SysDept.Columns().DeptId, childDeptId) + if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { + childDeptModel = childDeptModel.LockUpdate() + } + if err := childDeptModel.Scan(&childDepts); err != nil { + return err + } + var menus []*entity.SysRoleMenu + if err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, sourceRoleID).OrderAsc(dao.SysRoleMenu.Columns().MenuId).Scan(&menus); err != nil { + return err + } + menuIDs := make([]int64, 0, len(menus)) + for _, menu := range menus { + menuIDs = append(menuIDs, menu.MenuId) + } + allowed, err := rolepolicy.RestrictMenus(ctx, source.TenantId, source.DeptId, menuIDs) + if err != nil { + return err + } + template, templateErr := rolepolicy.Template(ctx) + if templateErr != nil { + return templateErr + } + filtered := make([]*entity.SysRoleMenu, 0, len(menus)) + for _, menu := range menus { + if slices.Contains(allowed, menu.MenuId) { + copy := *menu + if template != nil { + copy.DataScope = consts.SysRoleMenuDataScopeRole + } + filtered = append(filtered, ©) + } + } + menus = filtered + for _, childDept := range childDepts { + var children []*entity.SysRole + if err := tx.Ctx(ctx).Model(dao.SysRole.Table()+" r"). + Fields("r.*"). + Where("r.tenant_id", source.TenantId). + Where("r.dept_id", childDept.DeptId). + Where("r.created_dept", source.DeptId). + Where("r.role_id", childDept.DeptRole). + Scan(&children); err != nil { + return err + } + if len(children) > 1 { + return gerror.Newf("multiple inherited roles in department %d for role %d", childDept.DeptId, source.RoleId) + } + if len(children) == 0 { + var legacy *entity.SysRole + if childDept.DeptRole > 0 { + if err := tx.Ctx(ctx).Model(dao.SysRole.Table()). + Where(dao.SysRole.Columns().RoleId, childDept.DeptRole). + Where(dao.SysRole.Columns().DeptId, childDept.DeptId). + Where(dao.SysRole.Columns().TenantId, source.TenantId). + Scan(&legacy); err != nil { + return err + } + } + if legacy != nil && legacy.RoleId > 0 { + if legacy.CreatedDept != source.DeptId || legacy.RoleKey != source.RoleKey { + return gerror.Newf("cannot identify inherited role for department %d", childDept.DeptId) + } + children = []*entity.SysRole{legacy} + } else { + if childDept.DeptRole > 0 { + return gerror.Newf("missing default role for department %d", childDept.DeptId) + } + return gerror.Newf("missing default role for department %d", childDept.DeptId) + } + } + for _, child := range children { + if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, child.RoleId).Data(do.SysRole{RoleName: strings.TrimSpace(source.RoleName), RoleKey: source.RoleKey, RoleSort: source.RoleSort, DataScope: source.DataScope, MenuCheckStrictly: source.MenuCheckStrictly, DeptCheckStrictly: source.DeptCheckStrictly, Status: source.Status, Remark: source.Remark, UpdatedBy: actorId}).OmitNil().Update(); err != nil { + return err + } + if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, child.RoleId).Delete(); err != nil { + return err + } + menuData := make([]*do.SysRoleMenu, 0, len(menus)) + for _, menu := range menus { + menuData = append(menuData, &do.SysRoleMenu{TenantId: source.TenantId, RoleId: child.RoleId, MenuId: menu.MenuId, DataScope: menu.DataScope}) + } + if len(menuData) > 0 { + if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(menuData).OmitNil().Batch(200).Insert(); err != nil { + return err + } + } + } + } + return nil +} diff --git a/internal/service/rolesync/role_test.go b/internal/service/rolesync/role_test.go new file mode 100644 index 0000000..1548b07 --- /dev/null +++ b/internal/service/rolesync/role_test.go @@ -0,0 +1,283 @@ +package rolesync + +import ( + "context" + "fmt" + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rolepolicy" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" + "reflect" + "testing" +) + +func TestTemplateSwitchOverwritesDefaultRolesAndPreservesOtherRoles(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',2,1,31),(4,'000000',0,1,41)") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,role_name,role_key,status,data_scope,is_pass,created_dept) VALUES (10,'000000',1,'模板','template','0','4',0,1),(11,'000000',1,'新模板','next','0','4',0,1),(21,'000000',2,'管理员','admin','0','4',0,1),(31,'000000',3,'三级管理员','admin','0','4',0,2),(22,'000000',2,'财务','finance','0','3',0,2),(41,'000000',4,'其他机构','other','0','4',0,4)") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES ('000000','agent.role.template.role_id','10')") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,100,'0'),('000000',10,200,'0'),('000000',11,200,'0'),('000000',22,100,'3'),('000000',41,100,'0')") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_user_role(user_id,role_id) VALUES(99,21)") + message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true} + for attempt := 0; attempt < 2; attempt++ { + if err := SyncInheritedRole(ctx, message); err != nil { + t.Fatal(err) + } + } + if message.TotalCount != 2 || message.SuccessCount != 2 { + t.Fatalf("unexpected hierarchy counts: %+v", message) + } + for _, roleID := range []int64{21, 31} { + assertAgentRoleSyncCount(t, ctx, fmt.Sprintf("SELECT COUNT(*) FROM sys_role_menu WHERE role_id=%d AND menu_id IN(100,200)", roleID), 2) + } + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=22 AND menu_id=200", 0) + mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_config SET config_value='11'") + ids, err := rolepolicy.RestrictMenus(ctx, "000000", 3, []int64{100, 200}) + if err != nil || !reflect.DeepEqual(ids, []int64{100, 200}) { + t.Fatalf("template switch must wait for confirmed distribution: %v %v", ids, err) + } + if err := SyncInheritedRole(ctx, message); err != nil || !message.Superseded { + t.Fatalf("obsolete template must not propagate: %+v %v", message, err) + } + next := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 11, SourceDeptId: 1, TemplateRole: true} + if err := SyncInheritedRole(ctx, next); err != nil { + t.Fatal(err) + } + for _, roleID := range []int64{21, 31} { + assertAgentRoleSyncCount(t, ctx, fmt.Sprintf("SELECT COUNT(*) FROM sys_role_menu WHERE role_id=%d AND menu_id=100", roleID), 0) + assertAgentRoleSyncCount(t, ctx, fmt.Sprintf("SELECT COUNT(*) FROM sys_role_menu WHERE role_id=%d AND menu_id=200", roleID), 1) + } + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id=31 AND created_dept=2", 1) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=22 AND menu_id=100", 1) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=41 AND menu_id=100", 1) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role WHERE user_id=99 AND role_id=21", 1) + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,is_pass,status,data_scope) VALUES(25,'000000',2,1,'0','4')") + legacy := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 25, SourceDeptId: 2} + if err := SyncInheritedRole(ctx, legacy); err != nil || !legacy.Superseded { + t.Fatalf("legacy local templates must not overwrite configured hierarchy: %+v %v", legacy, err) + } + mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_config SET config_value='invalid'") + if _, err := rolepolicy.RestrictMenus(ctx, "000000", 3, []int64{200}); err == nil { + t.Fatal("invalid template must deny") + } +} + +func TestSyncInheritedRoleOnlyUpdatesDirectEnterpriseChildren(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept (dept_id, tenant_id, parent_id, dept_type, dept_role) VALUES + (1, '000000', 0, 1, 0), + (2, '000000', 1, 1, 21), + (3, '000000', 2, 1, 0), + (4, '000000', 1, 0, 0)`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role + (role_id, tenant_id, dept_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly, status, is_pass, created_dept, created_by, updated_by, remark) + VALUES + (10, '000000', 1, '企业客户', 'enterpriseCustomer', 1, '4', 1, 1, '0', 1, 1, 1, 1, 'source'), + (20, '000000', 2, '下级自建角色', 'localRole', 5, '3', 1, 1, '0', 0, 2, 2, 2, 'local'), + (21, '000000', 2, '旧下发角色', 'enterpriseCustomer', 1, '4', 1, 1, '0', 0, 1, 1, 1, 'legacy')`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu (tenant_id, role_id, menu_id, data_scope) VALUES ('000000', 10, 100, '7')`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu (tenant_id, role_id, menu_id, data_scope) VALUES ('000000', 21, 200, '7')`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_user_role (user_id, role_id) VALUES (99, 21)`) + + message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, ActorId: 9} + if err := SyncInheritedRole(ctx, message); err != nil { + t.Fatalf("first sync failed: %v", err) + } + if err := SyncInheritedRole(ctx, message); err != nil { + t.Fatalf("second sync failed: %v", err) + } + + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 2 AND created_dept = 1", 1) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id = 21 AND created_dept = 1", 1) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role WHERE user_id = 99 AND role_id = 21", 1) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id = 21 AND menu_id = 200", 0) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 3 AND created_dept = 1", 0) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 4 AND created_dept = 1", 0) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id = 20 AND role_name = '下级自建角色' AND created_dept = 2", 1) + + menuScope, err := g.DB().GetValue(ctx, `SELECT rm.data_scope FROM sys_role_menu rm JOIN sys_role r ON r.role_id = rm.role_id WHERE r.dept_id = 2 AND r.created_dept = 1 AND rm.menu_id = 100`) + if err != nil { + t.Fatalf("query inherited menu data scope failed: %v", err) + } + if menuScope.String() != "7" { + t.Fatalf("inherited menu data scope = %q, want 7", menuScope.String()) + } + + mustExecAgentRoleSyncTest(t, ctx, `UPDATE sys_role SET role_name = '停止下发后的名称', is_pass = 0 WHERE role_id = 10`) + if err := SyncInheritedRole(ctx, message); err != nil { + t.Fatalf("disabled source sync failed: %v", err) + } + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 2 AND created_dept = 1 AND role_name = '企业客户'", 1) +} + +func TestSyncInheritedRoleRejectsUnrelatedDefaultRole(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept (dept_id, tenant_id, parent_id, dept_type, dept_role) VALUES (1, '000000', 0, 1, 0), (2, '000000', 1, 1, 21)`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role (role_id, tenant_id, dept_id, role_name, role_key, is_pass, created_dept) VALUES (10, '000000', 1, '下发角色', 'source', 1, 1), (21, '000000', 2, '自建默认角色', 'local', 0, 2)`) + err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}) + if err == nil { + t.Fatal("expected ambiguous default role to be rejected") + } + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id = 21 AND created_dept = 2 AND role_name = '自建默认角色'", 1) +} + +func TestTemplateSyncReportsMissingRoles(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,10),(2,'000000',1,1,0),(3,'000000',1,1,31)") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,status,data_scope,is_pass) VALUES (10,'000000',1,'0','4',1),(31,'000000',3,'0','4',0)") + mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_role SET created_dept=1 WHERE role_id=31") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_config (config_id,tenant_id,config_key,config_value) VALUES (1,'000000','agent.role.template.role_id','10')") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu (tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,200,'0')") + if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil { + t.Fatal("missing default role must report partial failure") + } + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=200", 1) + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,status,data_scope) VALUES (21,'000000',2,'0','4')") + mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_role SET created_dept=1 WHERE role_id=21") + mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET dept_role=21 WHERE dept_id=2") + if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err != nil { + t.Fatal(err) + } + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=200", 1) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200", 1) +} +func setupAgentRoleSyncTestDB(t *testing.T) { + t.Helper() + if err := gdb.SetConfig(gdb.Config{ + "default": gdb.ConfigGroup{ + {Type: "sqlite", Link: "sqlite::@file(:memory:)", Debug: false}, + }, + }); err != nil { + t.Fatalf("configure sqlite failed: %v", err) + } + ctx := context.Background() + t.Cleanup(func() { _ = g.DB().Close(ctx) }) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_type INTEGER, status TEXT DEFAULT '0', dept_role INTEGER, deleted_at DATETIME)`) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_config (config_id INTEGER PRIMARY KEY, tenant_id TEXT, config_name TEXT, config_key TEXT, config_value TEXT, config_type TEXT, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, remark TEXT)`) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role ( + role_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, dept_id INTEGER, role_name TEXT, role_key TEXT, role_sort INTEGER, data_scope TEXT, + menu_check_strictly INTEGER, dept_check_strictly INTEGER, status TEXT, is_pass INTEGER, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, deleted_by INTEGER, deleted_at DATETIME, remark TEXT)`) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role_menu (tenant_id TEXT, role_id INTEGER, menu_id INTEGER, data_scope TEXT)`) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role_dept (tenant_id TEXT, role_id INTEGER, dept_id INTEGER)`) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_user_role (tenant_id TEXT DEFAULT '000000', user_id INTEGER, role_id INTEGER)`) +} + +func mustExecAgentRoleSyncTest(t *testing.T, ctx context.Context, sql string) { + t.Helper() + if _, err := g.DB().Exec(ctx, sql); err != nil { + t.Fatalf("execute sql failed: %v", err) + } +} + +func assertAgentRoleSyncCount(t *testing.T, ctx context.Context, sql string, expected int) { + t.Helper() + count, err := g.DB().GetValue(ctx, sql) + if err != nil { + t.Fatalf("query count failed: %v", err) + } + if count.Int() != expected { + t.Fatalf("count = %d, want %d, sql=%s", count.Int(), expected, sql) + } +} + +func TestRoleSyncRetriesOnlyFailedDepartments(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',1,1,31)`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role (role_id,tenant_id,dept_id,role_name,role_key,is_pass,created_dept) VALUES (10,'000000',1,'source','source',1,1),(21,'000000',2,'before','source',0,1),(31,'000000',3,'local','local',0,3)`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu (tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,100,'7')`) + message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1} + if err := SyncInheritedRole(ctx, message); err == nil { + t.Fatal("expected invalid department failure") + } + if !reflect.DeepEqual(message.ChildDeptIds, []int64{3}) { + t.Fatalf("unexpected failures: %v", message.ChildDeptIds) + } + mustExecAgentRoleSyncTest(t, ctx, `UPDATE sys_role SET role_name='already-successful' WHERE role_id=21`) + mustExecAgentRoleSyncTest(t, ctx, `UPDATE sys_role SET created_dept=1,role_key='source' WHERE role_id=31`) + if err := SyncInheritedRole(ctx, message); err != nil { + t.Fatal(err) + } + assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role WHERE role_id=21 AND role_name='already-successful'`, 1) + assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=100`, 1) + if len(message.ChildDeptIds) != 0 { + t.Fatal("failure list must clear after success") + } +} + +func TestObsoleteTemplateTaskDoesNotPropagateAsNormalRole(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,10),(2,'000000',1,1,21)") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,role_key,is_pass,created_dept) VALUES (10,'000000',1,'source',1,1),(21,'000000',2,'source',0,1)") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu (tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,100,'7'),('000000',21,200,'7')") + message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true} + if err := SyncInheritedRole(ctx, message); err != nil { + t.Fatal(err) + } + if !message.Superseded { + t.Fatal("obsolete template task must be marked superseded") + } + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200", 1) +} + +func TestMissingDefaultRoleDoesNotCreateOrphanRole(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,0)`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role(role_id,tenant_id,dept_id,role_name,role_key,is_pass,created_dept) VALUES(10,'000000',1,'source','source',1,1)`) + for attempt := 0; attempt < 2; attempt++ { + if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil { + t.Fatal("missing role must fail") + } + } + assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role WHERE dept_id=2`, 0) +} + +func TestTemplateChildRevalidatesCurrentBindingAndSource(t *testing.T) { + for _, change := range []string{ + "UPDATE sys_dept SET parent_id=99 WHERE dept_id=2", + "UPDATE sys_dept SET dept_type=0 WHERE dept_id=2", + "UPDATE sys_role SET status='1' WHERE role_id=10", + "UPDATE sys_role_menu SET data_scope='1' WHERE role_id=10", + } { + t.Run(change, func(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21)`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role(role_id,tenant_id,dept_id,status,data_scope,is_pass) VALUES(10,'000000',1,'0','4',1),(21,'000000',2,'0','3',0)`) + mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_role SET created_dept=1 WHERE role_id=21") + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',21,200,'0')`) + mustExecAgentRoleSyncTest(t, ctx, change) + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + return syncTemplateChildTx(ctx, tx, &entity.SysRole{RoleId: 10, TenantId: "000000", DeptId: 1}, &entity.SysDept{DeptId: 2, DeptRole: 21, ParentId: 1}, 7) + }) + if err == nil { + t.Fatal("changed source or binding must fail") + } + assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200`, 1) + assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=100`, 0) + }) + } +} + +func TestDeletedDefaultRoleIsNotRestoredBySynchronization(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21)`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role(role_id,tenant_id,dept_id,role_name,role_key,is_pass,created_dept,deleted_at) VALUES(10,'000000',1,'source','source',1,1,NULL),(21,'000000',2,'deleted','source',0,1,'2026-10-13 00:00:00')`) + mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',21,200,'0')`) + if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil { + t.Fatal("deleted default role must fail") + } + assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200`, 1) + assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=100`, 0) +} diff --git a/internal/service/rolesync/runtime.go b/internal/service/rolesync/runtime.go new file mode 100644 index 0000000..e5b5715 --- /dev/null +++ b/internal/service/rolesync/runtime.go @@ -0,0 +1,109 @@ +package rolesync + +import ( + "context" + "encoding/json" + "fmt" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" + "strings" + "time" +) + +func Start(ctx context.Context) (func(), error) { + if !g.Cfg().MustGet(ctx, "roleSync.enabled", false).Bool() { + return func() {}, nil + } + if !rabbitmq.Enabled(ctx) { + return nil, gerror.New("权限同步 Worker 必须启用 RabbitMQ 并配置连接") + } + workerCtx, cancel := context.WithCancel(ctx) + go superviseConsumer(workerCtx) + g.Log().Info(ctx, "role sync Worker started") + return cancel, nil +} + +func handleMessage(ctx context.Context, message model.AgentRoleSyncMessage) error { + if err := syncRoleMessage(ctx, &message); err != nil { + g.Log().Errorf(ctx, "role sync failed, taskId:%s, roleId:%d, attempt:%d, err:%+v", message.TaskId, message.SourceRoleId, message.RetryCount, err) + message.RetryCount++ + body, marshalErr := json.Marshal(message) + if marshalErr != nil { + return marshalErr + } + if message.RetryCount > 5 { + deadLetter, marshalErr := json.Marshal(model.AgentRoleSyncDeadLetterMessage{ + Message: message, Error: err.Error(), FailedAt: time.Now().Format(time.RFC3339), + }) + if marshalErr != nil { + return marshalErr + } + return publishRoleDeadLetter(ctx, deadLetter) + } + delay := time.Duration(1< 0 + if !retrying { + message.TotalCount = len(departments) + message.SuccessCount = 0 + } + pending := make(map[int64]bool, len(message.ChildDeptIds)) + for _, deptID := range message.ChildDeptIds { + pending[deptID] = true + } + failed := make(map[int64]bool) + failedDeptIDs := make([]int64, 0) + var firstErr error + for _, child := range departments { + if err := ctx.Err(); err != nil { + return err + } + if retrying && !pending[child.DeptId] { + continue + } + delete(pending, child.DeptId) + var syncErr error + if failed[child.ParentId] { + syncErr = gerror.Newf("上级机构%d尚未同步成功", child.ParentId) + } else { + syncErr = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + return syncTemplateChildTx(ctx, tx, template, child, message.ActorId) + }) + } + if syncErr != nil { + failed[child.DeptId] = true + failedDeptIDs = append(failedDeptIDs, child.DeptId) + if firstErr == nil { + firstErr = syncErr + } + g.Log().Errorf(ctx, "agent template sync child failed, deptId:%d, err:%v", child.DeptId, syncErr) + continue + } + message.SuccessCount++ + } + for deptID := range pending { + failedDeptIDs = append(failedDeptIDs, deptID) + if firstErr == nil { + firstErr = gerror.Newf("待同步机构%d已不在当前模板的下级范围,请核对机构关系", deptID) + } + } + slices.Sort(failedDeptIDs) + message.ChildDeptIds = failedDeptIDs + message.FailedCount = len(failedDeptIDs) + if firstErr != nil { + return gerror.Wrapf(firstErr, "代理模板同步失败企业数:%d", len(failedDeptIDs)) + } + return nil +} + +func templateDescendants(ctx context.Context, template *entity.SysRole) ([]*entity.SysDept, error) { + queue := []int64{template.DeptId} + seen := map[int64]bool{template.DeptId: true} + departments := make([]*entity.SysDept, 0) + for len(queue) > 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + parentID := queue[0] + queue = queue[1:] + var children []*entity.SysDept + if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", template.TenantId).Where("parent_id", parentID).Where("dept_type", 1).OrderAsc("dept_id").Scan(&children); err != nil { + return nil, err + } + for _, child := range children { + if seen[child.DeptId] { + return nil, gerror.New("机构上下级存在循环") + } + seen[child.DeptId] = true + queue = append(queue, child.DeptId) + departments = append(departments, child) + } + } + return departments, nil +} + +func syncTemplateChildTx(ctx context.Context, tx gdb.TX, template *entity.SysRole, child *entity.SysDept, actorID int64) error { + if child.DeptRole <= 0 { + return gerror.New("下级机构缺失默认角色") + } + lock := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where("dept_id", child.DeptId).Where("tenant_id", template.TenantId) + if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { + lock = lock.LockUpdate() + } + var locked *entity.SysDept + if err := lock.Scan(&locked); err != nil { + return err + } + if locked == nil || locked.DeptRole != child.DeptRole || locked.ParentId != child.ParentId || locked.DeptType != 1 { + return gerror.New("下级机构角色绑定已变化") + } + currentLock := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", child.DeptRole).Where("tenant_id", template.TenantId).Where("dept_id", child.DeptId) + if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { + currentLock = currentLock.LockUpdate() + } + var current *entity.SysRole + if err := currentLock.Scan(¤t); err != nil { + return err + } + if current == nil || current.Status != "0" { + return gerror.New("下级机构默认角色不存在或已删除") + } + if current.CreatedDept != child.ParentId { + return gerror.New("下级机构默认角色非直属上级继承角色,请核对角色来源") + } + configLock := tx.Ctx(ctx).Model(dao.SysConfig.Table()).Where("tenant_id", template.TenantId).Where("config_key", rolepolicy.TemplateConfigKey) + if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { + configLock = configLock.LockUpdate() + } + var config *entity.SysConfig + if err := configLock.Scan(&config); err != nil { + return err + } + if config == nil || strings.TrimSpace(config.ConfigValue) != strconv.FormatInt(template.RoleId, 10) { + return gerror.New("模板已切换,请重新同步") + } + templateLock := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", template.RoleId) + if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { + templateLock = templateLock.LockUpdate() + } + var latest *entity.SysRole + if err := templateLock.Scan(&latest); err != nil { + return err + } + if latest == nil || latest.Status != "0" || latest.TenantId != template.TenantId || latest.DeptId != template.DeptId || latest.DataScope != consts.SysRoleDataScopeDeptAndBelow || (latest.CreatedDept > 0 && latest.CreatedDept != latest.DeptId) { + return gerror.New("模板不可用") + } + menuLock := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("tenant_id", template.TenantId).Where("role_id", template.RoleId) + if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { + menuLock = menuLock.LockUpdate() + } + var menus []*entity.SysRoleMenu + if err := menuLock.Scan(&menus); err != nil { + return err + } + // Every linked default role receives the same template snapshot. Individual + // changes are intentionally replaced; unrelated roles are never updated. + if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("tenant_id", template.TenantId).Where("role_id", current.RoleId).Data(do.SysRole{DataScope: consts.SysRoleDataScopeDeptAndBelow, UpdatedBy: actorID}).Update(); err != nil { + return err + } + if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("tenant_id", template.TenantId).Where("role_id", current.RoleId).Delete(); err != nil { + return err + } + menuData := make([]*do.SysRoleMenu, 0, len(menus)) + for _, menu := range menus { + if menu.DataScope == consts.SysRoleDataScopeAll || menu.DataScope == consts.SysRoleDataScopeCustom { + return gerror.New("模板按钮数据范围已变化,请重新同步") + } + menuData = append(menuData, &do.SysRoleMenu{TenantId: template.TenantId, RoleId: current.RoleId, MenuId: menu.MenuId, DataScope: consts.SysRoleMenuDataScopeRole}) + } + if len(menuData) > 0 { + if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(menuData).Batch(200).Insert(); err != nil { + return err + } + } + return nil +} diff --git a/internal/service/rolesync/template_hierarchy_test.go b/internal/service/rolesync/template_hierarchy_test.go new file mode 100644 index 0000000..240dbf4 --- /dev/null +++ b/internal/service/rolesync/template_hierarchy_test.go @@ -0,0 +1,97 @@ +package rolesync + +import ( + "context" + "reflect" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" +) + +func TestTemplateHierarchyRetriesFailedParentAndDescendants(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + for _, statement := range []string{ + "INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,0),(3,'000000',2,1,31),(4,'000000',1,1,41),(5,'other',1,1,51),(6,'000000',1,0,61)", + "INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4'),(31,'000000',3,'0',0,2,'4'),(41,'000000',4,'0',0,1,'4'),(51,'other',5,'0',0,1,'4'),(61,'000000',6,'0',0,1,'4')", + "INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')", + "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',31,999,'0')", + } { + mustExecAgentRoleSyncTest(t, ctx, statement) + } + message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true} + if err := SyncInheritedRole(ctx, message); err == nil { + t.Fatal("missing parent default role must fail") + } + if !reflect.DeepEqual(message.ChildDeptIds, []int64{2, 3}) || message.TotalCount != 3 || message.SuccessCount != 1 || message.FailedCount != 2 { + t.Fatalf("unexpected failed hierarchy: %+v", message) + } + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=999", 1) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(51,61)", 0) + mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET dept_role=21 WHERE dept_id=2") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',41,777,'0')") + if err := SyncInheritedRole(ctx, message); err != nil { + t.Fatal(err) + } + if message.SuccessCount != 3 || message.FailedCount != 0 || len(message.ChildDeptIds) != 0 { + t.Fatalf("retry counts: %+v", message) + } + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=100", 1) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=999", 0) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=41 AND menu_id=777", 1) + message.ChildDeptIds = []int64{3} + mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET parent_id=99 WHERE dept_id=3") + if err := SyncInheritedRole(ctx, message); err == nil || !reflect.DeepEqual(message.ChildDeptIds, []int64{3}) { + t.Fatalf("removed retry target must not be reported as successful: %+v %v", message, err) + } +} + +func TestTemplateHierarchyRejectsCycleBeforeWriting(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',2,1,0),(2,'000000',1,1,21)") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4')") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',21,999,'0')") + if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil { + t.Fatal("cycle must fail before updating roles") + } + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=999", 1) +} + +func TestTemplateOverwriteResetsIndividualDefaultsOnly(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + for _, statement := range []string{ + "INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',2,1,31)", + "INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4'),(31,'000000',3,'0',0,2,'4'),(22,'000000',2,'0',0,2,'3'),(32,'000000',3,'0',0,3,'5')", + "INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')", + "INSERT INTO sys_role_menu VALUES('000000',10,100,'0'),('000000',10,200,'0'),('000000',22,999,'3'),('000000',32,888,'5')", + "INSERT INTO sys_user_role(user_id,role_id) VALUES(99,21),(100,31),(101,22)", + } { + mustExecAgentRoleSyncTest(t, ctx, statement) + } + sync := func() { + t.Helper() + if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true}); err != nil { + t.Fatal(err) + } + } + sync() + // Platform expands one role, removes a default permission from another, and + // gives unrelated staff roles independent permissions. + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',21,999,'0'),('000000',31,888,'0')") + mustExecAgentRoleSyncTest(t, ctx, "DELETE FROM sys_role_menu WHERE role_id=21 AND menu_id=200") + mustExecAgentRoleSyncTest(t, ctx, "DELETE FROM sys_role_menu WHERE role_id=10 AND menu_id=100") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',10,300,'0')") + sync() + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31)", 4) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31) AND menu_id IN(200,300)", 4) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31) AND menu_id IN(100,888,999)", 0) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE (role_id=22 AND menu_id=999 AND data_scope='3') OR (role_id=32 AND menu_id=888 AND data_scope='5')", 2) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role", 3) + // A repeated distribution must also replace individual default-role edits. + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',31,999,'0')") + sync() + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31)", 4) +} diff --git a/internal/service/trafficcutoff/executor.go b/internal/service/trafficcutoff/executor.go index 798987c..75110dd 100644 --- a/internal/service/trafficcutoff/executor.go +++ b/internal/service/trafficcutoff/executor.go @@ -11,14 +11,14 @@ import ( ) const ( - cascadeFailureReason = "same batch has invalid traffic cutoff rows; update skipped" - validationSkipReason = "batch validation failed; traffic cutoff update skipped" - emptyICCIDReason = "ICCID cannot be empty" - duplicateICCIDReason = "duplicate ICCID in batch" - unauthorizedReason = "ICCID is outside the current operator authorization scope" - cardMissingReason = "ICCID does not exist in SIM card records" - statusCanceledReason = "ICCID is already canceled" - statusInconsistentReason = "same batch contains different card statuses" + cascadeFailureReason = "同一批次存在无效流量断网明细,已跳过更新" + validationSkipReason = "批次校验未通过,已跳过流量断网更新" + emptyICCIDReason = "ICCID不能为空" + duplicateICCIDReason = "ICCID在批次内重复" + unauthorizedReason = "ICCID不在当前操作员授权范围内" + cardMissingReason = "ICCID不存在或不属于当前租户" + statusCanceledReason = "ICCID已销户" + statusInconsistentReason = "同一批次内卡状态不一致" ) type executor struct { @@ -63,17 +63,17 @@ func (e *executor) saveResultFile(ctx context.Context, task *Task) { func validateTask(task *Task) bool { if task == nil || len(task.Details) == 0 { if task != nil { - task.FailureReason = "traffic cutoff batch has no valid details" + task.FailureReason = "流量断网批次无有效明细" } return false } if task.TrafficCutoffStatus != 1 { - task.FailureReason = "traffic cutoff batch must enable cutoff" + task.FailureReason = "流量断网批次必须开启断网" markAllFailed(task, task.FailureReason) return false } if task.TrafficCutoffThreshold <= 0 { - task.FailureReason = "traffic cutoff threshold must be greater than 0" + task.FailureReason = "流量断网阈值必须大于0" markAllFailed(task, task.FailureReason) return false } @@ -150,11 +150,11 @@ func validateDetail(detail *Detail) { detail.TrafficCutoffThresholdBefore = detail.Card.TrafficCutoffThreshold if detail.InputMSISDN != "" && detail.InputMSISDN != detail.MSISDN { - detail.Reason = "MSISDN does not match the SIM card record" + detail.Reason = "MSISDN与卡档案不一致" return } if detail.InputIMSI != "" && detail.InputIMSI != detail.IMSI { - detail.Reason = "IMSI does not match the SIM card record" + detail.Reason = "IMSI与卡档案不一致" return } if detail.BossCardStatusBefore == BossCardStatusCanceled { diff --git a/internal/service/trafficcutoff/executor_test.go b/internal/service/trafficcutoff/executor_test.go index 9b1fa1f..36fd492 100644 --- a/internal/service/trafficcutoff/executor_test.go +++ b/internal/service/trafficcutoff/executor_test.go @@ -128,7 +128,7 @@ func TestExecuteCommitFailurePropagatesAndSkipsResult(t *testing.T) { func validTask() *Task { return &Task{ Batch: Batch{BatchNo: "DCT-1", BatchType: 107, TenantID: "000000"}, - TrafficCutoffStatus: 1, + TrafficCutoffStatus: 1, TrafficCutoffThreshold: 1024.5, ThresholdUnit: "MB", Details: []*Detail{ diff --git a/internal/service/trafficcutoffconfig/executor_test.go b/internal/service/trafficcutoffconfig/executor_test.go index 9be444b..0eee317 100644 --- a/internal/service/trafficcutoffconfig/executor_test.go +++ b/internal/service/trafficcutoffconfig/executor_test.go @@ -145,7 +145,7 @@ func validConfigTask(threshold float64) *Task { CreatedBy: 7, }, Config: Config{ - TrafficCutoffStatus: 1, + TrafficCutoffStatus: 1, TrafficCutoffThreshold: threshold, }, Details: []*Detail{validConfigDetail(1, "898600000000001")}, @@ -174,7 +174,7 @@ func validConfigCard(rowNo int, iccid string) *Card { PackageStatus: 0, BossCardStatus: rowNo, PackageTotalFlowMb: 1024, - TrafficCutoffStatus: 0, + TrafficCutoffStatus: 0, TrafficCutoffThreshold: 0, } } diff --git a/internal/service/trafficusage/service.go b/internal/service/trafficusage/service.go index 2581b85..4dc4b73 100644 --- a/internal/service/trafficusage/service.go +++ b/internal/service/trafficusage/service.go @@ -6,8 +6,8 @@ import ( "strings" "time" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" "github.com/gogf/gf/v2/errors/gerror" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" ) type UpstreamClient interface { diff --git a/internal/service/unsub/executor.go b/internal/service/unsub/executor.go index a0fae9d..c82c707 100644 --- a/internal/service/unsub/executor.go +++ b/internal/service/unsub/executor.go @@ -12,10 +12,10 @@ import ( ) const ( - cascadeFailureReason = "same batch has invalid unsubscribe rows; this row was skipped" - validationSkipReason = "batch validation failed; unsub request was skipped" - emptyICCIDReason = "ICCID cannot be empty" - duplicateICCIDReason = "duplicate ICCID in batch" + cascadeFailureReason = "同一批次存在无效退订明细,该行已跳过" + validationSkipReason = "批次校验未通过,已跳过退订请求" + emptyICCIDReason = "ICCID不能为空" + duplicateICCIDReason = "ICCID在批次内重复" ) type executor struct { @@ -77,7 +77,7 @@ func (e *executor) saveResultFile(ctx context.Context, task *Task) { func validateTask(task *Task) bool { if task == nil || len(task.Details) == 0 { if task != nil { - task.FailureReason = "unsub batch has no valid details" + task.FailureReason = "退订批次无有效明细" } return false } diff --git a/internal/service/warningconfig/executor_test.go b/internal/service/warningconfig/executor_test.go index b8f3bce..64e1e19 100644 --- a/internal/service/warningconfig/executor_test.go +++ b/internal/service/warningconfig/executor_test.go @@ -42,7 +42,7 @@ func TestConfigFromDetailSnapshotUsesRowValues(t *testing.T) { "thirdWarn": 900, "warnMethods": "3,1,0", "warnPhones": "13800008000", - "warnEmails": "test@example.com", + "warnEmails": "xxx@example.com", "autoAction": warnAutoActionSuspend, "isEnabled": 1, "remark": "row config", diff --git a/internal/service/workorder/notice.go b/internal/service/workorder/notice.go index 41b0d07..6e1d21f 100644 --- a/internal/service/workorder/notice.go +++ b/internal/service/workorder/notice.go @@ -2,13 +2,13 @@ package workorder import ( "context" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do" - "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/os/gtime" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do" + "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" "net/url" ) diff --git a/internal/worker/worker.go b/internal/worker/worker.go index b02d55e..3070d88 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -53,6 +53,7 @@ import ( monthlyhistoryservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/monthlyusagehistory" packagecustomerservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer" renewchangeservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/renewchange" + rolesyncservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/rolesync" simstatusservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/simstatus" trafficcutoffconfigservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/trafficcutoffconfig" trafficusageservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/trafficusage" @@ -621,6 +622,7 @@ func Run(ctx context.Context) error { if imeiProducer != nil { rabbitMQConsumers.WithImeiQueue(imeiProducer) } + rabbitMQConsumers.WithDataReviewQuery(dataExecutor) var ( fullSyncHTTP *httpapi.Server fullSyncArchives []*fullsyncarchive.Service @@ -698,6 +700,14 @@ func Run(ctx context.Context) error { batchDispatcher.Register(consts.BatchTypeDataCut, dataExecutor) batchDispatcher.Register(consts.BatchTypeDataStart, dataExecutor) batchDispatcher.Register(consts.BatchTypeDataStop, dataExecutor) + // Resume durable APN sub-batches independently of RabbitMQ delivery and process restarts. + if reconciler, ok := dataExecutor.(interface{ RunReconciler(context.Context) error }); ok { + go func() { + if err := reconciler.RunReconciler(workerCtx); err != nil && workerCtx.Err() == nil { + g.Log().Errorf(workerCtx, "APN operation reconciler stopped: %v", err) + } + }() + } batchDispatcher.Register(consts.BatchTypeSimCardUnsub, unsubExecutor) batchDispatcher.Register(consts.BatchTypeSimCardRestart, restartExecutor) batchDispatcher.Register(consts.BatchTypeSimCardCancel, cancelExecutor) @@ -708,9 +718,17 @@ func Run(ctx context.Context) error { return gerror.Wrap(err, "start RabbitMQ consumers failed") } + stopRoleSync, err := rolesyncservice.Start(workerCtx) + if err != nil { + _ = rabbitMQConsumers.Stop(context.Background()) + return gerror.Wrap(err, "start role sync Worker failed") + } + defer stopRoleSync() + g.Log().Info(workerCtx, "iotlcm worker started") <-workerCtx.Done() g.Log().Info(ctx, "iotlcm worker stopping") + stopRoleSync() shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel()