From e4ba0dced9520991d15bc6a2e22b7bb18f4daa52 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Fri, 21 Aug 2026 21:37:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/wire_gen.go | 5 +- docs/OA_MIGRATION_PLAN.md | 1355 +++++++++++++++++ docs/system-pkg-audit.md | 4 +- go.mod | 2 +- internal/README.md | 4 +- internal/app/definition.go | 14 +- internal/app/definition_test.go | 27 + internal/biz/integration_config.go | 5 +- .../integration_config_communication_test.go | 32 + internal/biz/integration_config_definition.go | 2 +- internal/data/config_store.go | 8 +- internal/data/data.go | 16 +- internal/data/integration_config_test.go | 25 +- internal/data/integration_defaults.go | 39 + internal/data/integration_runtime.go | 8 +- internal/data/migrations.go | 13 +- internal/data/migrations_test.go | 16 +- internal/data/payment/migrations.go | 12 +- .../data/repository/integration_config.go | 19 +- .../integration_config_runtime_test.go | 53 + internal/data/repository/provider.go | 2 - internal/integration/README.md | 11 +- internal/integration/mq/emqx.go | 301 +++- internal/integration/mq/emqx_test.go | 17 +- internal/integration/provider.go | 1 + .../runtimeconfig}/store.go | 6 +- .../runtimeconfig}/store_test.go | 2 +- internal/integration/websocket/server.go | 173 ++- internal/server/gin.go | 6 + internal/server/middleware/access_log.go | 107 +- internal/server/middleware/access_log_test.go | 59 + internal/server/middleware/audit.go | 52 +- .../server/middleware/payment_audit_test.go | 56 + internal/service/route_metadata.go | 7 + pkg/mq/rabbitmq.go | 447 ++++++ pkg/mq/rabbitmq_test.go | 48 + web/src/api/payment.js | 22 +- web/src/pathInfo.json | 2 + .../view/systemTools/integration/config.vue | 7 +- web/src/view/systemTools/payment/config.vue | 475 +++++- web/src/view/systemTools/payment/orders.vue | 550 ++++++- 41 files changed, 3646 insertions(+), 364 deletions(-) create mode 100644 docs/OA_MIGRATION_PLAN.md create mode 100644 internal/app/definition_test.go create mode 100644 internal/biz/integration_config_communication_test.go create mode 100644 internal/data/integration_defaults.go create mode 100644 internal/data/repository/integration_config_runtime_test.go rename internal/{integrationruntime => integration/runtimeconfig}/store.go (95%) rename internal/{integrationruntime => integration/runtimeconfig}/store_test.go (96%) create mode 100644 internal/server/middleware/payment_audit_test.go create mode 100644 pkg/mq/rabbitmq.go create mode 100644 pkg/mq/rabbitmq_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index e99d008..12b6ae4 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -154,14 +154,15 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger routes := router.NewRoutes(v) taskMethods := worker.NewTaskMethods(taskUsecase, mediaUsecase, runtime) moduleRuntime := app.Runtime(routes, taskMethods, registry) - websocketServer, cleanup2, err := websocket.New(runtime) + store := data.NewIntegrationRuntime(dataData) + websocketServer, cleanup2, err := websocket.New(store) if err != nil { cleanup() return nil, nil, err } engine := server.NewGinEngineWithRuntime(runtime, accessControlService, authService, securityService, auditRecorder, logger, string2, moduleRuntime, websocketServer) httpServer := server.NewGinServer(confServer, engine) - mqReloadable, cleanup3, err := mq.New(runtime, logger) + mqReloadable, cleanup3, err := mq.New(store, logger) if err != nil { cleanup2() cleanup() diff --git a/docs/OA_MIGRATION_PLAN.md b/docs/OA_MIGRATION_PLAN.md new file mode 100644 index 0000000..cdb249f --- /dev/null +++ b/docs/OA_MIGRATION_PLAN.md @@ -0,0 +1,1355 @@ +# OA 到 KRA 完整迁移执行计划 + +> 计划基线日期:2026-08-21 +> 源系统:`oa` +> 目标系统:`kra-new` +> 当前状态:仅完成代码盘点与迁移规划,尚未开始业务迁移 + +## 1. 目标与结论 + +目标是在 **不重构现有移动端、PC 端和旧后台调用协议** 的前提下,将 OA 的完整系统能力迁移到 KRA,并最终由 KRA 统一承载: + +- 管理后台接口与页面; +- 会长/渠道移动端接口(`/tg`); +- 玩家及客服端接口(`/imserver`); +- 对外 SDK 接口(`/sdk`); +- 游戏、渠道、玩家、订单、扶持、福利、结算、IM、分发、报表等业务; +- 中间件、定时任务、多数据库、Redis、消息队列、对象存储和第三方服务; +- 数据初始化、历史数据迁移、部署和回滚能力。 + +盘点后的结论是:**KRA 可以承载 OA,但不能直接复制 OA 目录或模型。** 两边虽然都保留了 GVA 风格接口,但后端分层、系统表、鉴权方式和任务机制已经不同。迁移必须以 KRA 的分层、模块目录、系统表和运行时为主,通过 OA 兼容 DTO、兼容中间件和显式数据迁移保持旧客户端协议不变。 + +## 2. 当前基线 + +以下数字用于评估工作量,不代表最终有效接口数;正式迁移前仍需从运行时路由和实际客户端请求中去重、排除未注册代码。 + +| 项目 | OA | KRA 当前状态 | +|---|---:|---:| +| 路由注册语句 | 约 756 | 约 191 | +| System 模型文件 | 32 | 已有完整基础后台模型 | +| Game 模型文件 | 52 | 未迁移 | +| User 玩家模型文件 | 12 | 未迁移 | +| IM 模型文件 | 16 | 仅有通用 WebSocket 基础设施 | +| Issue 模型文件 | 4 | 未迁移 | +| Report 模型文件 | 2 | 未迁移 | +| SW 模型文件 | 4 | 未迁移 | +| OA 管理端 API 文件 | 57 | 仅 KRA 自身后台 API | + +OA 路由注册语句按原目录的粗略分布: + +| 原路由域 | 注册语句数 | 主要用途 | +|---|---:|---| +| `system` | 198 | 用户、角色、部门、平台、配置、日志、黑名单等 | +| `game` | 303 | 游戏、渠道、订单、扶持、福利、结算、打包等 | +| `user` | 60 | 玩家、角色、补单、发放、协议、短信等 | +| `tg` | 49 | 会长/渠道移动端 | +| `im` | 32 | 管理后台客服 IM | +| `imserver` | 22 | 玩家端 IM 与回调 | +| `sdk` | 18 | 对外签名接口 | +| `issue` | 28 | 分发系统 | +| `report` | 2 | 报表 | +| `sw` | 12 | 商务用户与平台绑定 | +| `common` / `example` | 32 | 公共下载、上传及遗留工具/示例 | + +## 3. 不可变更的迁移规则 + +1. **KRA 是唯一目标代码库。** 不长期保留 OA 与 KRA 两套业务实现。 +2. **KRA 系统表优先。** KRA 已有语义等价表时,不再创建 OA 同义系统表;OA 多出的字段增量加入 KRA 表。 +3. **OA 独有业务表保留业务语义。** 没有 KRA 对应物的业务表可迁入模块,但必须进入 KRA 的迁移体系和数据层。 +4. **旧客户端不改协议。** HTTP 方法、路径、参数来源、字段名、字段类型、默认值、空值形态、响应码、消息、Header 和下载行为均以旧接口契约为准。 +5. **后台能力同步迁移。** OA 有而 KRA 没有的管理功能,必须同时迁移后端接口、KRA 后台页面、菜单、按钮权限和 API 权限数据。 +6. **接口域不能混用鉴权。** 管理后台、会长端、玩家端、SDK 和第三方回调必须使用各自的 token、签名和上下文。 +7. **逐文件核对。** 每一个 OA 源文件必须被标记为“已迁移、被 KRA 替代、确认废弃”之一,不允许无记录遗漏。 +8. **逐接口差异测试。** 不以“页面能打开”作为完成标准,必须比较响应字段和业务副作用。 +9. **只做显式数据库迁移。** 禁止用无审查的 `AutoMigrate` 修改生产旧表。 +10. **迁移定义永久保留。** 已执行的 migration ID 不得从 KRA Catalog 删除或改名。 + +## 4. 目标模块结构 + +业务模块建议使用独立目录,并在模块内部继续遵守 KRA 的 `service -> biz -> data` 分层: + +```text +internal/modules// + biz/ # 领域对象、状态机、用例、仓储接口 + data/ # PO、GORM 查询、事务、数据转换、迁移 + service/ # OA 请求/响应 DTO 与领域对象转换 + server/handler/ # Gin handler + server/router/ # 路由与接口域注册 + worker/ # 本模块定时任务注册 + definition.go # migrations / menus / APIs / default tasks + providers.go # Wire ProviderSet + +web/src/modules// + api/ + view/ + components/ +``` + +修改 KRA 既有系统表和系统行为的内容仍放在现有 `internal/biz`、`internal/data/repository`、`internal/service`、`internal/server` 中,避免创建第二套用户、角色、部门和权限系统。 + +每个业务模块通过 KRA 现有的 `pkg/module` 机制贡献: + +- 数据库 migration; +- 后台菜单和 API 元数据; +- HTTP 路由; +- 默认定时任务; +- 运行时任务方法。 + +## 5. 接口域与鉴权边界 + +KRA 当前只有公共组和统一私有组,不足以直接表达 OA 的全部鉴权语义。基础阶段需要建立命名清晰的接口域: + +| 接口域 | 主要路径 | 客户端 | 必要鉴权/上下文 | +|---|---|---|---| +| Admin Public | `/base/*`、`/init/*`、公共下载 | 后台登录页 | 无登录、验证码、限流 | +| Admin Login | 菜单、用户信息、部分通讯录/IM | 后台端 | KRA/OA 兼容 JWT,不强制 Casbin | +| Admin RBAC | 系统管理和一般业务接口 | 后台端 | JWT + 多角色 Casbin + 操作审计 | +| Admin Business DB | 游戏、渠道、玩家、订单等 | 后台端 | JWT + Casbin + 数据库/平台范围 | +| TG | `/tg/*` | 移动会长端 | 管理用户 JWT + 当前渠道/平台上下文 + 层级权限 | +| IM Player Public | `/imserver/play/register*`、登录、回调 | 移动/PC 玩家端、腾讯回调 | 无登录或回调校验 | +| IM Player Auth | `/imserver/play/*` 登录后接口 | 移动/PC 玩家端 | 玩家专用 JWT,不能复用后台 claims | +| SDK Signed | `/sdk/*` | 游戏 SDK/外部服务 | OA 签名算法、重放保护、调用审计 | +| Callback | IM、支付、打包等回调 | 第三方系统 | 各供应商签名、幂等、原始 ACK | + +基础路由改造完成前,不得批量接入业务路由。 + +## 6. 系统表兼容策略 + +### 6.1 兼容结论 + +KRA 与 OA 的核心 GVA 表存在较高重合,可以兼容,但部门、公司、平台和扩展用户字段不能直接照搬。 + +| OA 表/能力 | KRA 目标 | 处理原则 | +|---|---|---| +| `sys_users` | `sys_users` | 保留 KRA 字段,增加 OA 的实名、主题、IM、注销、用户中心、用户类型等字段 | +| `sys_authorities` | `sys_authorities` | 保留 KRA `data_scope`,增加 OA 的启用、公司管理员、部门范围、JSON 配置等字段 | +| `sys_base_menus` | `sys_base_menus` | KRA ID/父 ID 类型为准;旧接口 DTO 负责输出 OA 需要的字段和类型 | +| `sys_apis` | `sys_apis` | 保留 KRA API 权限模型;补 OA 实际使用的菜单关联等字段 | +| `sys_user_authority` | `sys_user_authority` | 保留多角色;扩展 JWT 和 Casbin 为“任一角色通过即授权” | +| `sys_authority_menus` | `sys_authority_menus` | 使用 KRA 数值菜单 ID,迁移时转换 OA 旧值 | +| `sys_depart` | `sys_departments` | 只保留 KRA 表;`depart_name -> name`,补 OA 组织类别、客服、公司、企业微信等字段 | +| `sys_user_depart` | `sys_user_departments` | 转换表名和外键列名,不保留两套关系表 | +| OA 数据权限关系 | `sys_authority_departments` + KRA `data_scope` | 将 OA 部门/公司范围转换为 KRA 数据范围,同时保留旧接口表现 | +| `sys_operation_records` | `sys_operation_records` | 合并字段并保持 OA 查询响应兼容 | +| 字典表 | KRA 同名表 | 直接映射,核对唯一键、删除语义和返回字段 | +| `sys_config` | `sys_params` / `sys_integration_configs` / 模块配置表 | 按配置用途拆分,旧 `/sysConfig` API 作为兼容门面 | +| OA 独有平台、公司、黑名单、活跃池等表 | KRA 系统扩展表 | 没有等价结构时新增,但由 KRA 模块拥有和迁移 | + +### 6.2 数据库迁移硬性要求 + +KRA 当前的 `CreateMissingTables` 明确不会修改已存在表,因此必须先补充一套经过测试的显式增量 migration 能力: + +- `AddColumnIfMissing`; +- `CreateIndexIfMissing`; +- 可审计的数据回填步骤; +- 表名/列名转换脚本; +- 大表分批迁移和断点续传; +- 每个步骤可重复执行且结果一致; +- 迁移前后行数、主键范围、空值数、金额合计和关键关系校验; +- 禁止启动时自动删除列、改列类型或重建大表。 + +历史数据迁移默认保留原主键。确实不能保留时,必须建立永久 ID 映射表,并同步更新所有外键、缓存键和外部引用。 + +## 7. 每个模块必须生成的四份清单 + +每个模块在 `docs/migration//` 下维护以下文件,缺一不可: + +1. `source-ledger.md`:OA 源文件逐一映射到 KRA 目标文件,记录迁移/替代/废弃结论。 +2. `schema-map.md`:表、字段、类型、默认值、索引、关系、状态值和历史数据转换规则。 +3. `api-contract.md`:方法、路径、接口域、请求字段、响应字段、Header、错误码、消息和副作用。 +4. `verification.md`:单测、集成测试、差异测试、前端回归、数据核对结果及获批差异。 + +## 8. 总体任务阶段 + +### 阶段 0:基线冻结与自动核对工具 + +#### MIG-000 源代码与运行时清单冻结 + +- 从 OA 实际启动后的 `engine.Routes()` 导出有效路由,不能只统计路由文件。 +- 扫描 OA 管理端 57 个 API 文件及所有页面调用。 +- 收集移动端、PC 端、后台端当前发布版本的请求样本或网关日志。 +- 将所有 OA model/api/router/service/middleware/timer/config/frontend 文件登记到总 source ledger。 +- 标记未注册路由、注释代码、一次性脚本和 GVA 示例功能,但未经确认不得删除。 + +完成标准:OA 所有源文件和有效接口均有唯一编号,未分类数量为 0。 + +#### MIG-001 接口差异测试框架 + +- 使用同一份脱敏数据分别启动 OA 和 KRA。 +- 建立请求语料库,向两边发送相同请求。 +- 比较 HTTP 状态、Header、`code/data/msg`、字段存在性、JSON 类型、`null/[]/{}`、排序和分页。 +- 对时间、token、随机 ID 等非确定字段只做规则化处理,不允许整段忽略。 +- 比较数据库写入、Redis 键、消息队列消息和第三方适配器调用。 +- 输出机器可读差异报告和人工批准的 exception ledger。 + +完成标准:框架能对至少登录、菜单、用户列表、一个业务 CRUD 和一个下载接口出具差异报告。 + +#### MIG-002 模块脚手架与持续验证 + +- 建立 OA 业务模块目录和统一 ProviderSet/Definition/Routes/Tasks 接入方式。 +- 扩展 `internal/app`、Wire 和 module runtime,使新增模块无需修改系统业务实现。 +- 在持续检查中加入 `go test ./...`、`go vet ./...`、`go build ./...`、前端 lint/build 和契约测试。 +- 增加路由冲突、migration ID 冲突、菜单/API 重复和跨层 import 检查。 + +完成标准:空示例模块可独立贡献表、路由、菜单和定时任务,并通过完整构建。 + +### 阶段 1:KRA 兼容底座 + +#### FND-001 显式增量数据库迁移 + +- 实现第 6.2 节的增列、索引、回填和校验工具。 +- 增加 MySQL、PostgreSQL、SQL Server、Oracle、SQLite 的方言测试;生产实际使用的数据库优先达到完整覆盖。 +- 增加 dry-run/检查模式和失败后的恢复说明。 + +#### FND-002 OA 请求与响应兼容层 + +- 支持 OA `PageResult` 和 `OaPageResult`,包括 `maxCount`、`fialData`、`extend` 等历史字段。 +- 保持旧接口成功/失败 `code`、`msg` 和 HTTP 状态。 +- 兼容 `new-token`、`new-expires-at`、文件下载 Header 和 OA 上传响应。 +- 固化时间格式、金额精度、布尔/整数状态、空集合和软删除字段的 JSON 表现。 +- 共享路径发生 KRA/OA 契约冲突时,以仍在使用的旧客户端契约为准,并登记 KRA 侧调整。 + +#### FND-003 多接口域与中间件 + +- 建立第 5 节的命名路由域。 +- 扩展后台 JWT claims:多角色、手机号、公司、管理员标识等 OA 必需上下文。 +- Casbin 改为多角色任一命中;保留 `888` 超级管理员旁路。 +- 迁移/重写 `DbHandler`、`TgAuth`、`SignAuth`、操作记录、错误审计、限流、CORS 和初始化保护。 +- 对签名和回调增加时间窗、nonce/幂等键,保持旧签名算法输入输出不变。 + +#### FND-004 多数据库、公司、平台上下文 + +- 复用 KRA `database_list`,统一按 alias 获取业务库。 +- 将 OA 请求体中的 `dbName`、当前公司、当前平台和角色可见数据库解析为显式 request context。 +- 禁止业务层读取全局 DB 或直接从 Gin Context 取值。 +- 对单库、跨库查询、批量多库请求和无权限库建立测试。 + +#### FND-005 外部集成基础设施 + +- 直接复用 KRA 已有 Redis/cache、对象存储、邮件、支付、WebSocket 能力。 +- 补齐 RabbitMQ AMQP 运行时客户端;KRA 当前只有 RabbitMQ 配置定义,没有 OA 客服队列所需的完整 AMQP 生命周期实现。 +- 新增腾讯 IM、用户中心、短信、OCR、游戏业务站、PC 分包、契约/企业签及各 CP 扶持接口适配器。 +- 所有客户端放入 `internal/integration`,业务模块只依赖接口,不复制 OA 的全局单例和 `utils` 网络调用。 +- 所有密钥从安全配置注入,禁止复制 OA 配置文件中的明文凭据;上线前完成旧凭据轮换。 + +#### FND-006 历史数据迁移程序 + +- 建立独立迁移命令,支持源 OA 库到目标 KRA 库的结构检查、分批导入、续跑和校验。 +- 支持主系统库及 `db-list` 中各业务库。 +- 所有转换规则来源于各模块 `schema-map.md`。 +- 迁移日志不得打印密码、身份证、token、签名密钥等敏感值。 + +### 阶段 2:KRA 系统能力合并 + +#### SYS-001 用户、登录与账号生命周期 + +范围:账号密码登录、手机号登录、会长登录、注册、忘记密码、用户列表、个人信息、多角色、部门、公司子账号、切换公司、黑名单、注销、实名认证、常用菜单和 IM 客服字段。 + +子任务: + +- `SYS-001-A`:扩展 KRA `sys_users` DO/PO/repo 和显式 migration。 +- `SYS-001-B`:迁移 OA 登录、注册、用户中心同步、JWT claims 和 token 刷新行为。 +- `SYS-001-C`:补齐所有 `/base/*`、`/user/*` 兼容接口。 +- `SYS-001-D`:迁移 KRA 后台用户页面缺少的字段、筛选、批量操作和公司账户功能。 +- `SYS-001-E`:逐文件对照 OA `sys_user` model/request/response/api/service/router 及相关中间件。 + +#### SYS-002 角色、菜单、API、按钮和数据权限 + +- 合并 OA 角色扩展字段和多角色鉴权。 +- 对齐菜单树、默认路由、参数、按钮权限、API 权限和 Casbin 策略。 +- 迁移角色与平台、角色与部门/公司范围关系。 +- 验证超级管理员、普通角色、冻结角色、多角色用户和无菜单用户。 +- 同步 KRA 后台角色、菜单、API、按钮和成员分配页面。 + +#### SYS-003 部门、公司、岗位和通讯录 + +- 将 `sys_depart` 数据转换到 `sys_departments`,补齐 OA 字段。 +- 将 `sys_user_depart` 转换到 `sys_user_departments`。 +- 明确 OA 的角色、岗位、部门三种概念,禁止再次混用。 +- 迁移部门树、顶级公司、子公司、客服部门、账号数量、批量分配和通讯录接口。 +- 验证移动/删除部门后的祖先链、公司范围和孤儿关系。 + +#### SYS-004 系统配置、日志、文件、导出与遗留工具 + +- 迁移 `sysConfig` 兼容门面、操作日志、用户行为日志、黑名单、上传下载和 Excel 导出。 +- 复用 KRA 安全配置、登录日志、错误日志、数据访问日志、媒体库和附件分类。 +- 逐项评估 OA 代码生成器、断点续传、客户示例等遗留功能:仍被菜单/API/用户使用则迁移;确认不再使用后才进入废弃清单。 +- 对 OA 有而 KRA 没有的后台入口补齐菜单、API 元数据、按钮权限和页面。 + +### 阶段 3:平台、游戏与渠道主数据 + +#### PLT-001 联运平台、公司与商务身份 + +范围:`sys_platform`、角色平台关系、公司关系、CP session/support/gift、SW 用户/角色/偏好/平台绑定、商务用户管理。 + +依赖:`SYS-001`、`SYS-002`、`SYS-003`、`FND-004`。 + +#### GAM-001 游戏目录与基础配置 + +范围:`tab_game`、游戏类型、CP、游戏设置、原包、区服、合服、互通、禁用配置、改名日志、APP、平台配置、支付配置、系统选项和企业签日志。 + +重点验收: + +- 游戏改名涉及的所有冗余字段和日志; +- 配置 JSON、状态值和金额字段; +- 业务库选择; +- 后台列表筛选、详情、导出、配置页和按钮权限; +- OA 游戏相关源文件逐一完成 ledger。 + +#### CHN-001 渠道、商务、合同与公告 + +范围:`tab_promote`、商务归属、推广配置、登录记录、合同、公告、押金、平台币、渠道状态和上下级关系。 + +重点验收:渠道审核、驳回、商务改派、结算周期修改、不可推广游戏、层级关系、锁定/解锁和冗余快照更新。 + +#### PKG-001 渠道申请与多端打包 + +范围:`tab_promote_apply`、`tab_promote_app`、Android/iOS/H5/超级签/PC 打包、快手抖音配置、分享显示和隐藏游戏。 + +依赖:`GAM-001`、`CHN-001`、外部分包适配器。 + +重点验收: + +- `-1/0/1/2/3` 打包状态机; +- 原包与渠道申请关系; +- PC 异步提交、轮询、失败恢复和幂等; +- 旧移动端/PC 端下载 URL 和响应字段; +- KRA 后台打包管理页面。 + +### 阶段 4:玩家、订单、扶持与财务 + +#### PLY-001 玩家、角色与账号关联 + +范围:`tab_user`、登录记录、角色、角色详情、补链、补单、协议、短信日志、批量玩家、敏感字段查看和脱敏。 + +重点验收:渠道归因、跨库查询、敏感信息权限/审计、余额修改、补链跨表更新和导出字段。 + +#### ORD-001 充值、绑币、平台币与交易记录 + +范围:`tab_spend`、`tab_spend_bind`、`tab_spend_provide`、余额、渠道币、订单绑定、补单、支付/游戏通知状态、企业签日志和代金券记录。 + +依赖:`PLY-001`、`GAM-001`、`CHN-001`。 + +重点验收:金额精度、订单唯一性、重复回调、补单幂等、游戏到账、渠道归因、结算明细生成和导出。 + +#### SUP-001 扶持、福利与 CP 发放 + +范围:扶持申请、渠道扶持、禁止扶持、倍数/清零、福利配置/道具/记录、CP 礼包、自动审核与自动发放。 + +依赖:`PLY-001`、`GAM-001`、`CHN-001`。 + +重点验收:审核状态机、实际到账、失败补偿、外部 CP 调用、批量发放、周/月卡重复执行保护和后台审核页面。 + +#### FIN-001 结算、提现、代充与预支 + +范围:渠道结算、结算时间、周期单、提现、押金、代充折扣、绑币订单、预支白名单/游戏/额度/还款及相关日志。 + +依赖:`ORD-001`、`CHN-001`。 + +重点验收:结算汇总、周期确认、余额扣增、重复付款防护、跨表事务边界、提现状态和预支额度恢复。 + +### 阶段 5:移动端、PC 端、IM 与外部接口 + +#### TG-001 会长/渠道移动端完整接口 + +范围:全部 `/tg/promote`、`/tg/app`、`/tg/promoteApply`、`/tg/support` 接口。 + +依赖:`GAM-001`、`CHN-001`、`PKG-001`、`PLY-001`、`ORD-001`、`SUP-001`、`FIN-001`。 + +重点验收:平台切换、渠道层级菜单限制、首页统计、结算确认、提现、代充、福利、预支和移动端字段类型。 + +#### IM-001 管理后台客服 IM + +范围:群组、会话、联系记录、收藏、标签、快捷语、客服转接/退出、客服状态和会话容量。 + +依赖:`SYS-001`、`PLT-001`、腾讯 IM、RabbitMQ。 + +#### IM-002 玩家端与 SDK IM + +范围:全部 `/imserver`、`/sdk/im`、腾讯 IM callback、玩家注册登录、平台绑定、入群、会话、评价、未读数和 OCR。 + +重点验收: + +- 后台 JWT 与玩家 JWT 完全隔离; +- 腾讯回调原始 body、签名、ACK 和幂等; +- RabbitMQ reject/requeue/ack 语义; +- 客服并发分配和 `im_current_session` 原子更新; +- 移动端与 PC 端现有接口逐字段回归。 + +#### SDK-001 扶持及其他签名 SDK + +范围:`/sdk/support/*` 及除 IM 外的签名接口。 + +重点验收:旧签名算法、字段排序、空值参与规则、错误响应和重放保护。 + +### 阶段 6:分发与报表 + +#### ISS-001 Issue 分发系统 + +范围:Issue 玩家、补单、充值、角色和数据接口,以及相应 KRA 后台页面。 + +依赖:`PLY-001`、`ORD-001`。 + +#### REP-001 运营报表 + +范围:Top PID、每日 PID 及 OA 中由动态 SQL/跨库聚合产生的报表。 + +依赖:主业务写入模块稳定后执行。 + +重点验收:时间边界、时区、去重口径、金额合计、跨库汇总、分页和导出结果。 + +### 阶段 7:定时任务与运行保障 + +#### OPS-001 OA 定时任务迁移 + +OA 当前至少包含以下运行任务,必须逐个归属到对应模块并注册到 KRA 任务中心: + +| OA 任务 | 目标模块 | +|---|---| +| `ClearDB` | System | +| `CloseChat` | IM | +| `UnsubscribeSysUser` | System/User | +| `AutoSendSupport` | Support | +| `AutoSendGift` | Support | +| `PromoteActivePoolScan` | Platform/Channel | +| `PcRepackageScan` | Packaging | +| `LockChannelOverThreeMonth` | Channel | +| `AutoSendFuliRecord` | Welfare | +| `AutoSendWeekCard` | Welfare | +| `AutoBuLianInactiveUsers` | Player | +| `AutoAuditSupport` | Support | + +每个任务必须验证:默认 cron、秒级模式、启停配置、手动触发、并发防重、超时、失败重试、执行日志、告警、跨库范围和重复实例运行风险。 + +#### OPS-002 可观测性与故障恢复 + +- 为所有外部调用增加 request ID、耗时、结果码和脱敏错误日志。 +- 为订单、发放、提现、打包、IM 回调增加幂等键和补偿查询入口。 +- 增加任务积压、消息队列连接、第三方失败率、数据库连接和慢查询指标。 +- 编写常见故障恢复手册,覆盖 OA 维护文档中的主要故障场景。 + +### 阶段 8:全量回归、数据演练与切换 + +#### CUT-001 全量接口与后台回归 + +- 有效 OA 路由覆盖率 100%,或存在书面批准的废弃记录。 +- 旧前端 API 调用覆盖率 100%。 +- KRA 新增后台菜单、页面、按钮和权限均可按角色访问。 +- 逐接口差异报告无未批准差异。 + +#### CUT-002 数据迁移演练 + +- 使用生产结构副本和脱敏数据至少完成两次全量演练。 +- 核对行数、主键、外键、软删除、金额、状态分布和关键业务抽样。 +- 记录每张表迁移耗时、锁影响、失败恢复和增量追平方式。 + +#### CUT-003 灰度与正式切换 + +- 先影子读/流量回放,再灰度只读接口,最后灰度写接口。 +- 写接口切换前冻结或增量同步 OA 数据,防止双主写入。 +- 回调、定时任务和消费者在任一时刻只能有一套系统实际执行。 +- 保留可执行回滚方案、回滚数据点和外部回调切回步骤。 + +## 9. 单个模块的执行模板 + +每个模块都按以下顺序执行,不允许跳过最后两步: + +1. **盘点**:登记 OA model/request/response/api/router/service/frontend/timer/config 文件。 +2. **契约**:冻结路径、鉴权、请求、响应、Header、错误和副作用。 +3. **数据**:完成 schema map、migration、历史转换和校验脚本。 +4. **领域**:实现 KRA DO、状态机、用例和仓储接口。 +5. **数据层**:实现 PO、查询、事务、跨库访问和外部仓储。 +6. **接口层**:实现兼容 DTO、handler、路由和中间件绑定。 +7. **后台端**:迁移 API 文件、页面、菜单、按钮和权限种子。 +8. **任务/集成**:接入本模块定时任务、消息和第三方服务。 +9. **自动验证**:单测、集成测试、契约差异、前端构建和页面回归。 +10. **逐文件复核**:对 source ledger 每一项签署结论,生成 verification 报告。 + +## 10. 模块完成标准 + +模块只有同时满足以下条件才能标记完成: + +- source ledger 中无未处理文件; +- schema map 中无待定字段、状态或索引; +- 有效接口全部注册且鉴权域正确; +- 请求与响应字段、类型、消息、Header 和分页结构一致; +- 查询条件、Join、排序、默认值、软删除和事务行为一致; +- Redis/MQ/第三方调用及失败处理一致; +- KRA 后台页面、菜单、按钮权限完整; +- 单元测试、集成测试、差异测试、后端构建、前端 lint/build 全部通过; +- 历史数据转换通过自动校验和业务抽样; +- 所有差异均有书面原因和批准记录。 + +## 11. 推荐执行顺序与并行关系 + +```text +MIG-000/001/002 + | +FND-001..006 + | +SYS-001..004 + | +PLT-001 ---- GAM-001 ---- CHN-001 + | | | + | +---- PKG-001-+ + | | + +---- PLY-001 ---- ORD-001-+ + | | | + +-- SUP-001+-- FIN-001 + | + TG-001 / IM-001 / IM-002 / SDK-001 + | + ISS-001 / REP-001 + | + OPS-001 / OPS-002 + | + CUT-001..003 +``` + +可并行原则: + +- 基础阶段结束后,平台、游戏基础、玩家基础和 IM 适配器可由不同任务并行。 +- 同一张 KRA 系统表只允许一个任务负责 schema 和核心 repo,其他模块通过接口依赖。 +- 管理端页面可在接口契约冻结后并行迁移,但不得自行改变接口字段。 +- 定时任务代码随所属模块实现,最终在 `OPS-001` 统一做重复执行和调度验证。 + +## 12. 当前已识别的高风险点 + +1. OA 的有效接口数量远高于 KRA,且部分路由只在特定路由组注册,必须以运行时为准。 +2. KRA 当前私有路由统一经过 Casbin,而 OA 存在“仅登录即可访问”的接口,路由域必须先拆开。 +3. OA JWT 支持多角色和公司上下文,KRA 当前 claims 主要是单角色。 +4. OA 未登录响应与 KRA 当前 HTTP 401/错误码行为存在差异。 +5. OA 部门表与 KRA 表名、字段和关系表不同,不能双表共存作为长期方案。 +6. KRA 当前 migration 不会为旧表自动加列,系统字段扩展必须显式实现。 +7. OA 多数据库和跨库写入没有统一事务,迁移时要保持现有结果并补充幂等/补偿。 +8. OA 大量业务查询使用动态 map 和冗余快照字段,不能简单改为关联表实时查询。 +9. `sys_user` 服务混合了登录、IM、注销和多个 CP 发放适配,迁移时必须拆分但保持调用顺序和结果。 +10. RabbitMQ、腾讯 IM、PC 分包、用户中心、短信、OCR 和多个 CP 接口是完整迁移的外部阻塞项。 +11. 移动端和 PC 端源码不在当前两个仓库中时,必须用发布包、请求日志或抓包补齐契约基线。 +12. 回调消费者、定时任务和自动发放在双系统并行期间存在重复执行风险。 + +## 13. 第一批建议启动任务 + +正式编码时建议先只启动以下任务,完成后再大规模并行: + +- `MIG-000`:生成完整 source ledger 和有效路由清单; +- `MIG-001`:建立 OA/KRA 接口差异测试框架; +- `MIG-002`:建立第一个可插拔 OA 模块样板; +- `FND-001`:实现显式增量数据库 migration; +- `FND-002`:冻结兼容响应和分页结构; +- `FND-003`:拆分接口域和鉴权中间件; +- `SYS-001-A`:完成 `sys_users` 字段映射草案与 migration 评审; +- `SYS-003`:完成部门/公司/岗位映射草案,禁止在映射确定前导入用户关系数据。 + +这一批完成后,才具备安全拆分多个业务模块任务并行迁移的条件。 + +## 14. 细化执行目录 + +本节是对前面阶段任务的可执行拆分。编号规则如下: + +- `MIG/FND/SYS/...-主编号`:模块级里程碑; +- `...-主编号-A/B/C`:工作包,可单独分派给一个开发任务; +- `...-主编号-A1/A2`:同一工作包内的文件、接口或验收批次; +- 工作包没有通过自己的验收标准时,不得标记所属模块完成。 + +每个工作包默认包含四类动作:代码实现、数据库/配置变更、测试、文档更新。若某项不适用,必须在任务记录中写明“确认不适用”,不能留空。 + +### 14.1 任务状态和门禁 + +| 状态 | 含义 | 进入条件 | 退出条件 | +|---|---|---|---| +| `READY` | 可以开始 | 前置任务已完成,输入文件已冻结 | 开发者接单 | +| `DOING` | 开发中 | 已建立分支和任务目录 | 代码、迁移、测试全部提交 | +| `REVIEW` | 待复核 | source/schema/api/verification 四份清单已更新 | 通过代码和契约复核 | +| `BLOCKED` | 被阻塞 | 外部服务、客户端样本、数据权限或字段含义缺失 | 阻塞原因解除并补充证据 | +| `DONE` | 已完成 | 所有工作包验收通过 | 进入后续依赖 | +| `DEFERRED` | 明确延期 | 经过负责人确认不影响当前切换范围 | 重新评估,不视为完成 | + +阶段门禁: + +1. `MIG` 门禁:所有 OA 路由、前端 API、页面入口、配置和任务均已编号。 +2. `FND` 门禁:KRA 能表达 OA 的响应、鉴权、多库、外部调用和增量迁移。 +3. `SYS` 门禁:管理后台登录、用户、权限、部门、菜单和数据范围稳定。 +4. `业务主数据` 门禁:游戏、平台、渠道、玩家主键关系稳定。 +5. `交易与资金` 门禁:订单、扶持、结算和提现具备幂等及补偿能力。 +6. `端能力` 门禁:后台、TG、玩家端、PC/移动端和 SDK 契约全部通过。 +7. `CUT` 门禁:数据演练、流量回放、回滚和运维交接全部完成。 + +### 14.2 每个工作包的固定交付物 + +每个工作包至少交付以下内容,文件名可按模块替换 ``: + +```text +docs/migration//-source-ledger.md +docs/migration//-schema-map.md +docs/migration//-api-contract.md +docs/migration//-verification.md +``` + +代码交付至少应覆盖: + +- KRA `biz`:DO、状态常量、仓储接口和业务错误; +- KRA `data`:PO、转换函数、查询、事务和 migration; +- KRA `service`:请求 DTO、响应 DTO、字段转换和分页转换; +- KRA `server`:handler、router、中间件绑定和权限元数据; +- KRA `web`:API 封装、页面、菜单、按钮和路由; +- `*_test.go`:领域、数据、接口和回归测试; +- 配置/任务/集成:只在工作包确实拥有该能力时加入。 + +### 14.3 任务执行时的统一拆分动作 + +一个工作包不得直接从 OA 文件复制到 KRA。必须按以下 12 个子动作逐项销项: + +| 子动作 | 内容 | 产出 | +|---|---|---| +| `P01` 源文件登记 | model/request/response/api/service/router/web/config/timer 全量登记 | source ledger | +| `P02` 路由冻结 | 方法、路径、路由组、中间件、是否公开 | api-contract | +| `P03` 请求冻结 | JSON/form/query/header、必填、默认值、兼容别名 | api-contract | +| `P04` 响应冻结 | code/data/msg、字段、类型、空值、分页、下载 Header | api-contract | +| `P05` 数据表冻结 | 表、列、类型、默认值、索引、软删除、主键 | schema-map | +| `P06` 状态机冻结 | 状态值、允许转换、重复请求、失败回退 | schema-map | +| `P07` 领域实现 | DO、用例、错误和仓储接口 | KRA biz | +| `P08` 数据实现 | PO、查询、事务、跨库和外部仓储 | KRA data | +| `P09` 兼容接口实现 | DTO、handler、route、旧响应适配 | KRA service/server | +| `P10` 前端/后台实现 | API、页面、菜单、按钮、权限 | KRA web + seed | +| `P11` 对照验证 | OA/KRA 相同输入、相同数据、相同副作用 | verification | +| `P12` 文件签收 | 每个源文件写明迁移、替代或废弃理由 | signed ledger | + +## 15. 阶段 0 细化:盘点和工具 + +### MIG-000 源代码与运行时清单冻结 + +| 工作包 | 范围 | 主要 OA 输入 | KRA 产出 | 依赖 | 并行 | +|---|---|---|---|---|---| +| `MIG-000-A` | 后端文件清单 | `server/model`、`api/v1`、`service`、`router` | 总 source ledger | 无 | 可与 B/C 并行 | +| `MIG-000-B` | 前端调用清单 | `web/src/api`、`web/src/view`、`router` | API-to-page 映射表 | 无 | 可与 A/C 并行 | +| `MIG-000-C` | 路由运行时清单 | `initialize/router.go`、所有 `router/*` | 有效方法/路径/中间件清单 | OA 可启动环境 | 可与 A/B 并行 | +| `MIG-000-D` | 数据表清单 | `model/**/*.go`、初始化/建表逻辑 | 表字段/索引/关系目录 | A | 不与 schema 改造并行 | +| `MIG-000-E` | 定时任务清单 | `initialize/timer.go`、配置 | 任务/cron/依赖/副作用表 | A | 可与 D 并行 | +| `MIG-000-F` | 外部依赖清单 | IM、MQ、短信、UC、OCR、OSS、分包、CP 调用 | 外部服务矩阵 | A | 可与 D/E 并行 | +| `MIG-000-G` | 客户端样本冻结 | 后台页面、移动端、PC 端请求日志/抓包 | 脱敏请求语料库 | B/C | C 完成后进行 | +| `MIG-000-H` | 遗留代码归类 | 注释路由、测试、脚本、示例 | 保留/废弃审批表 | A/B | 可与 G 并行 | + +完成标准:A-H 全部 `DONE`,总清单中的 OA 文件、有效路由、页面 API、任务和外部依赖均有编号。 + +### MIG-001 接口差异测试框架 + +| 工作包 | 细化内容 | 最小验收 | +|---|---|---| +| `MIG-001-A` | 测试环境、数据库快照、脱敏种子 | OA/KRA 可用同一业务数据启动 | +| `MIG-001-B` | 请求重放器,支持 JSON/form/query/header/file | 可重放登录、列表、详情、写入 | +| `MIG-001-C` | 响应规范化,处理 token/时间/随机 ID | 不掩盖字段、类型和消息差异 | +| `MIG-001-D` | 数据库副作用快照 | 能比较新增、更新、删除、状态和金额 | +| `MIG-001-E` | Redis/MQ/外部调用断言 | 能检查 key、topic、ack、调用参数 | +| `MIG-001-F` | 差异报告和例外清单 | 每个差异有路径、字段、原因和审批人 | +| `MIG-001-G` | 端到端基线用例 | 登录、菜单、用户列表、业务 CRUD、下载各 1 组 | + +### MIG-002 模块脚手架与持续验证 + +| 工作包 | 细化内容 | +|---|---| +| `MIG-002-A` | 建立模块 Definition、Catalog、ProviderSet、RouteRegistrar、Task Contributor 样板 | +| `MIG-002-B` | 建立 module migration、菜单、API、定时任务注册样板 | +| `MIG-002-C` | 建立跨层依赖检查和路由冲突检查 | +| `MIG-002-D` | 建立 Go/前端/契约/数据校验 CI 入口 | +| `MIG-002-E` | 用空模块完成 Wire、构建、启动和初始化演练 | + +## 16. 阶段 1 细化:KRA 兼容底座 + +### FND-001 显式增量数据库迁移 + +| 工作包 | 细化内容 | 关键文件/产出 | +|---|---|---| +| `FND-001-A` | 表/列/索引存在性检查 | `pkg/database/migration` 工具和测试 | +| `FND-001-B` | MySQL 增列/索引/默认值方言 | 方言测试和 dry-run 输出 | +| `FND-001-C` | PostgreSQL/SQL Server/Oracle/SQLite 方言 | 各方言兼容测试 | +| `FND-001-D` | 批量回填、断点、限速、进度 | migration runner 扩展 | +| `FND-001-E` | 迁移前后校验、失败恢复 | checksum/校验报告 | +| `FND-001-F` | 禁止破坏性变更的静态检查 | 删除列/改类型/无 ID 检查 | +| `FND-001-G` | 用 `sys_users` 和 `sys_departments` 做演练 | 可重复执行、已有数据不丢失 | + +### FND-002 OA 请求与响应兼容层 + +| 工作包 | 细化内容 | +|---|---| +| `FND-002-A` | `{code,data,msg}` 基础响应和错误码 | 成功、失败、未登录、过期、权限不足 | +| `FND-002-B` | `PageResult`/`OaPageResult` 分页转换 | `list/total/page/pageSize/maxCount/fialData/extend` | +| `FND-002-C` | 字段类型和空值规则 | int/bool/string、null/[]/{}、时间和金额 | +| `FND-002-D` | token/Header/Cookie 兼容 | `x-token`、`new-token`、`new-expires-at` | +| `FND-002-E` | 上传、`common/download`、下载、导出和文件名兼容 | Content-Type、Content-Disposition、文件流 | +| `FND-002-F` | 全局错误和消息映射 | OA 原消息与 KRA 错误的固定映射表 | +| `FND-002-G` | 兼容层基线测试 | 至少 20 个典型接口通过差异测试 | + +### FND-003 多接口域与中间件 + +| 工作包 | 细化内容 | +|---|---| +| `FND-003-A` | Public/Admin Login/Admin RBAC/Admin DB 路由组 | 路由注册和中间件顺序固定 | +| `FND-003-B` | 后台 JWT claims 与多角色 | `Authorities`、公司、手机号、管理员标识 | +| `FND-003-C` | Casbin 多角色和超级管理员 | 任一角色命中、888 旁路、冻结角色 | +| `FND-003-D` | OA 未登录/过期/非法 token 行为 | code、HTTP 状态、reload 字段和 Header | +| `FND-003-E` | `DbHandler` 和数据范围 | body 四种 `dbName` 结构、跨库权限 | +| `FND-003-F` | `TgAuth` 平台切换和层级 | Redis 当前平台、渠道层级和登录记录 | +| `FND-003-G` | `SignAuth` 签名 | 参数排序、空值、重放、错误返回 | +| `FND-003-H` | 操作日志/错误审计/限流/CORS | 成功、失败、未授权请求的记录边界 | +| `FND-003-I` | `sys_captcha`、`sys_initdb`、初始化保护和健康检查 | `/base/captcha`、`/health`、`/init/checkdb`、`/init/initdb` | + +### FND-004 多数据库、公司、平台上下文 + +| 工作包 | 细化内容 | +|---|---| +| `FND-004-A` | OA `db-list` 到 KRA `database_list` 映射 | alias、连接、显示名、业务 URL | +| `FND-004-B` | 请求体 `dbName` 统一解析 | string/list/ids/data 四种结构 | +| `FND-004-C` | 当前公司/平台上下文 | Redis key、JWT、request context | +| `FND-004-D` | 多库只读查询 | 并发、超时、部分失败、结果合并 | +| `FND-004-E` | 多库写入和补偿 | 无跨库事务时的幂等、重试、补偿记录 | +| `FND-004-F` | 无权限库和不存在库 | 统一错误消息和审计 | + +### FND-005 外部集成基础设施 + +| 工作包 | 细化内容 | 依赖 | +|---|---|---| +| `FND-005-A` | RabbitMQ AMQP 连接、声明、发布、消费、ack/requeue | MQ 配置 | +| `FND-005-B` | 腾讯 IM client、UserSig、回调签名和请求封装 | 腾讯配置 | +| `FND-005-C` | 用户中心 UC 注册、登录、加密和短信接口 | UC 配置 | +| `FND-005-D` | 阿里云短信发送、验证码存储和限流 | 短信配置 | +| `FND-005-E` | OCR 身份证/营业执照适配器 | OCR 配置 | +| `FND-005-F` | 游戏业务站/CP 发放 HTTP client | 外部业务站矩阵 | +| `FND-005-G` | PC 重打包适配器和轮询 | 分包服务协议 | +| `FND-005-H` | 企业签/契约/支付扩展适配器 | 第三方协议 | +| `FND-005-I` | 所有适配器的超时、重试、脱敏和 mock | A-H | + +### FND-006 历史数据迁移程序 + +| 工作包 | 细化内容 | +|---|---| +| `FND-006-A` | 迁移命令、配置、dry-run、断点和日志 | 独立命令入口 | +| `FND-006-B` | 主系统表迁移 | 用户、角色、菜单、API、部门、字典、日志 | +| `FND-006-C` | 系统扩展表迁移 | 公司、平台、黑名单、活跃池、CP 配置 | +| `FND-006-D` | 业务库表迁移 | 按 db alias 分批迁移 | +| `FND-006-E` | 主键/外键/关系表校验 | 映射表和孤儿检查 | +| `FND-006-F` | 金额/状态/时间/软删除校验 | 汇总及抽样报告 | +| `FND-006-G` | 增量追平和回滚演练 | 两次以上完整演练 | + +## 17. 阶段 2 细化:系统能力合并 + +### SYS-001 用户、登录与账号生命周期 + +| 工作包 | OA 文件/能力范围 | 依赖 | +|---|---|---| +| `SYS-001-A` | `model/system/sys_user.go`、用户 PO/DO 字段扩展 | FND-001 | +| `SYS-001-B` | 用户扩展列 migration、索引、默认值、回填 | A | +| `SYS-001-C` | `/base/login` 账号密码登录 | FND-002/003 | +| `SYS-001-D` | `/base/register`、`/base/phoneLogin`、短信验证码 | FND-005-C/D | +| `SYS-001-E` | `/base/tgPhoneLogin` 会长登录及用户状态 | FND-003-F | +| `SYS-001-F` | `/base/forgetOnChangePassword`、重置/修改密码 | C | +| `SYS-001-G` | `/user/getUserInfo`、个人资料、主题、头像 | A/C | +| `SYS-001-H` | 用户列表、筛选、导出、批量角色/部门 | SYS-002/003 | +| `SYS-001-I` | 公司子账号、公司切换、公司用户列表 | SYS-003 | +| `SYS-001-J` | `sys_user_blacklist`、注销、实名认证、敏感字段 | FND-003-H、FND-005-E | +| `SYS-001-K` | IM 客服字段、状态、容量和会话数 | IM-001 前置接口先定义 | +| `SYS-001-L` | `sys_user_menus_often`、管理后台用户页面与按钮权限 | C-H | +| `SYS-001-M` | 用户全量差异回归和数据迁移验收 | A-L | + +### SYS-002 角色、菜单、API、按钮和数据权限 + +| 工作包 | OA 文件/能力范围 | 依赖 | +|---|---|---| +| `SYS-002-A` | `sys_authority` 字段和角色状态 | FND-001 | +| `SYS-002-B` | 角色 CRUD、树、复制、默认路由 | A | +| `SYS-002-C` | 多角色关联和 JWT authority 列表 | SYS-001-C | +| `SYS-002-D` | 菜单树、菜单参数、`sys_menu_btn`、动态路由 | FND-002 | +| `SYS-002-E` | API 表、运行时路由同步、API 分组 | MIG-000-C | +| `SYS-002-F` | `sys_casbin`、Casbin 策略、`sys_authority_btn`、按钮权限、角色菜单 | C-E | +| `SYS-002-G` | 角色平台关系和数据范围 | SYS-003、PLT-001 | +| `SYS-002-H` | KRA 后台角色/菜单/API/按钮页面 | B-F | +| `SYS-002-I` | 超管/普通角色/多角色/无权限回归 | B-G | + +### SYS-003 部门、公司、岗位和通讯录 + +| 工作包 | OA 文件/能力范围 | 依赖 | +|---|---|---| +| `SYS-003-A` | OA `sys_depart` 与 KRA `sys_departments` 字段映射 | FND-001 | +| `SYS-003-B` | 部门扩展列 migration 和祖先链回填 | A | +| `SYS-003-C` | `sys_user_depart` 到 `sys_user_departments` 关系转换 | A/B | +| `SYS-003-D` | 公司/顶级部门/子部门树和范围 | B | +| `SYS-003-E` | 岗位和用户岗位关系 | KRA position | +| `SYS-003-F` | 部门 CRUD、移动、删除、批量分配 | C-E | +| `SYS-003-G` | 通讯录、客服部门、账号数量、企业微信字段 | D-F | +| `SYS-003-H` | 部门/公司/岗位后台页面 | F/G | +| `SYS-003-I` | 祖先链、孤儿关系、公司权限和用户查询回归 | A-H | + +### SYS-004 系统配置、日志、文件、导出与遗留工具 + +| 工作包 | 范围 | 依赖 | +|---|---|---| +| `SYS-004-A` | `sysConfig`/`sys_system` 兼容门面、参数和集成配置映射 | FND-002 | +| `SYS-004-B` | 操作记录、`sys_user_action_log`、登录日志 | FND-003-H | +| `SYS-004-C` | `sys_jwt_blacklist`、`sys_member_blacklist`、`sys_member_blacklist_pay` | SYS-001 | +| `SYS-004-D` | 文件上传下载、断点续传、媒体库、附件分类 | FND-005 | +| `SYS-004-E` | Excel 导入导出和模板 | FND-002-E | +| `SYS-004-F` | `sys_auto_code`、`sys_auto_code_history`、`sys_autocode_history` 和生成器使用性评估 | MIG-000-H | +| `SYS-004-G` | `exa_breakpoint_continue`、`exa_customer`、`exa_file_upload_download`、GitHub/example/test 页面使用性评估 | MIG-000-H | +| `SYS-004-H` | `sys_dictionary`、`sys_dictionary_detail` 兼容复核及后台监控、日志、媒体和配置入口 | A-E | + +## 18. 阶段 3 细化:平台、游戏、渠道和打包 + +### PLT-001 联运平台、公司和商务身份 + +| 工作包 | OA 表/文件范围 | 依赖 | +|---|---|---| +| `PLT-001-A` | `sys_platform` 平台主数据 | SYS-003 | +| `PLT-001-B` | `sys_authority_platform` 角色平台关系 | SYS-002 | +| `PLT-001-C` | `sys_user_company` 公司关系 | SYS-001/003 | +| `PLT-001-D` | `sys_platform_cp_session`、`sys_platform_cp_support`、`sys_platform_cp_gift_config`、gift 记录 | A | +| `PLT-001-E` | SW `business_user_api`、`sys_user_role`、`sys_user_preference`、`sys_user_bind_platform` | SYS-001 | +| `PLT-001-F` | 平台/公司/商务后台页面和权限 | A-E | +| `PLT-001-G` | `sys_platform_promote_active_pool`、`sys_promote_active_pool_batch*` 和当前平台/业务库映射回归 | FND-004 | + +### GAM-001 游戏目录与基础配置拆分 + +| 工作包 | OA 表/文件范围 | 主要接口/页面 | +|---|---|---| +| `GAM-001-A` | `tab_game_type` | 游戏类型 CRUD | +| `GAM-001-B` | `tab_game_cp` | CP 商 CRUD | +| `GAM-001-C` | `tab_game` 基础字段 | 游戏 CRUD、列表、详情 | +| `GAM-001-D` | 游戏状态、配置 JSON、数据字段 | `changeStatus`、`setGameConfig`、`setGameDataField` | +| `GAM-001-E` | 游戏改名和 `tab_game_change_name_log` | 改名、历史引用、日志 | +| `GAM-001-F` | `tab_game_set`、`tab_game_source` | 对接设置、原包管理 | +| `GAM-001-G` | `tab_game_server`、`tab_game_server_merge` | 区服、合服 | +| `GAM-001-H` | `tab_game_interflow` | 互通关联和跨游戏关系 | +| `GAM-001-I` | `tab_game_ban_set`、`tab_game_ban_support` | 禁推/禁扶持 | +| `GAM-001-J` | `tab_app`、`tab_platform_config` | APP 包和平台配置 | +| `GAM-001-K` | `tab_pay_config`、`sys_option` | 支付配置和系统选项 | +| `GAM-001-L` | `tab_platform_qys_pay_log`、`tab_platform_qys_consume_log`、`art_opus_index_order` | 企业签日志、排序 | +| `GAM-001-M` | 游戏后台页面、菜单、按钮和导出 | A-L | +| `GAM-001-N` | 游戏表迁移、状态机和跨表回归 | A-M | + +每个 `GAM-001-*` 工作包必须独立完成 P01-P12;`GAM-001-C` 和 `GAM-001-D` 不允许并行修改同一个 `tab_game` PO、migration 或状态常量。 + +### CHN-001 渠道、商务、合同和公告拆分 + +| 工作包 | OA 表/文件范围 | 主要业务 | +|---|---|---| +| `CHN-001-A` | `tab_promote` 主表和层级关系 | 渠道 CRUD、上下级、状态 | +| `CHN-001-B` | 渠道审核、驳回、冻结、解冻 | 状态机和审计 | +| `CHN-001-C` | `tab_promote_business` | 商务归属、批量改派 | +| `CHN-001-D` | `tab_promote_config` | 渠道推广配置 | +| `CHN-001-E` | `tab_promote_login_record` | 登录记录和连续未登录 | +| `CHN-001-F` | `tab_promote_contract` | 合同列表、签署、状态 | +| `CHN-001-G` | `tab_promote_notice` | 渠道公告 | +| `CHN-001-H` | `tab_promote_deposit`、`tab_promote_coin`、平台币/渠道币 | 押金、余额和币种 | +| `CHN-001-I` | 渠道导出、后台页面和权限 | 前端全量迁移 | +| `CHN-001-J` | 层级、商务、状态和快照回归 | 全量差异测试 | + +### PKG-001 渠道申请与多端打包拆分 + +| 工作包 | OA 表/文件范围 | 主要业务 | +|---|---|---| +| `PKG-001-A` | `tab_promote_apply` 基础申请 | 申请 CRUD、审核基础字段 | +| `PKG-001-B` | 申请审核、分成比例、可见性 | 审核、比例、隐藏游戏 | +| `PKG-001-C` | Android 普通包状态机 | `allPackage`、下载、失败重试 | +| `PKG-001-D` | PC 重打包状态机 | `allPcPackage`、外部任务、轮询 | +| `PKG-001-E` | H5/超级签/免分包 | URL、分享显示、渠道参数 | +| `PKG-001-F` | `tab_promote_app` 包自定义 | 图标、名称、启动图 | +| `PKG-001-G` | 快手/抖音配置 | 保存、查询、校验 | +| `PKG-001-H` | 打包下载、删除、导出 | 文件和权限 | +| `PKG-001-I` | 后台打包管理页面和任务中心入口 | 页面、按钮、进度 | +| `PKG-001-J` | 状态机、幂等、异步失败和下载回归 | 全量验收 | + +## 19. 阶段 4 细化:玩家、订单、扶持和财务 + +### PLY-001 玩家、角色和账号关联拆分 + +| 工作包 | OA 表/文件范围 | 主要业务 | +|---|---|---| +| `PLY-001-A` | `tab_user` 玩家主表 | 玩家列表、详情、编辑 | +| `PLY-001-B` | 玩家注册归因、设备、IP、补链 | 渠道归因和 `buLian` | +| `PLY-001-C` | `tab_user_play`、`tab_user_play_info` | 角色列表、角色详情 | +| `PLY-001-D` | `tab_user_login_record` | 玩家登录历史和导出 | +| `PLY-001-E` | 玩家补单 `tab_user_mend` | 补单申请、处理和状态 | +| `PLY-001-F` | `tab_user_agree_record`、`tab_sms_log` | 协议记录、短信查询 | +| `PLY-001-G` | 敏感信息脱敏和 reveal | 权限、审计、字段展示 | +| `PLY-001-H` | 玩家批量添加、导出和小号 | 批量导入、列表导出 | +| `PLY-001-I` | `tab_user_deduct_bind`、余额调整 | 绑币回收、平台币余额 | +| `PLY-001-J` | 玩家后台页面、筛选、按钮 | 前端全量迁移 | +| `PLY-001-K` | 归因、补链、角色和敏感字段回归 | 数据/接口验收 | + +### ORD-001 充值、绑币、平台币和交易记录拆分 + +| 工作包 | OA 表/文件范围 | 主要业务 | +|---|---|---| +| `ORD-001-A` | `tab_spend` 订单主表 | 列表、详情、CRUD | +| `ORD-001-B` | 支付状态和游戏通知状态 | 状态机、回调、查询 | +| `ORD-001-C` | 订单绑定和补单 | `bind`、`repair`、幂等 | +| `ORD-001-D` | `tab_spend_bind`、`tab_promote_bind_update_record` | 绑币充值、换绑和变更记录 | +| `ORD-001-E` | `tab_spend_provide` | 平台币发放 | +| `ORD-001-F` | `tab_spend_balance`、`tab_spend_promote_coin` | 余额/渠道币变更 | +| `ORD-001-G` | 代金券记录和 QYS 交易日志 | `tab_coupon_record`、企业签 | +| `ORD-001-H` | 支付适配器与回调兼容 | 支付渠道、签名、ACK | +| `ORD-001-I` | 订单导出、后台页面、权限 | 前端和导出 | +| `ORD-001-J` | 金额、唯一性、回调、补单、到账回归 | 全量验收 | + +### SUP-001 扶持、福利和 CP 发放拆分 + +| 工作包 | OA 表/文件范围 | 主要业务 | +|---|---|---| +| `SUP-001-A` | `tab_support` | 扶持申请、审核、状态 | +| `SUP-001-B` | `tab_promote_support` | 渠道扶持 | +| `SUP-001-C` | `tab_support_beishu`、`tab_support_zero_log` | 倍数、清零、日志 | +| `SUP-001-D` | `tab_fuli_config`、`tab_fuli_prop` | 福利配置、道具 | +| `SUP-001-E` | `tab_fuli_record` | 福利记录、批量审核、发放 | +| `SUP-001-F` | CP 礼包配置和发放 | `sys_platform_cp_gift*` | +| `SUP-001-G` | CP 外部发放适配器 | 996、盛和等业务站 | +| `SUP-001-H` | 自动审核、自动扶持、自动福利 | 任务和幂等 | +| `SUP-001-I` | 扶持/福利后台页面和权限 | 前端全量迁移 | +| `SUP-001-J` | 到账、失败、补偿、重复发放回归 | 全量验收 | + +### FIN-001 结算、提现、代充和预支拆分 + +| 工作包 | OA 表/文件范围 | 主要业务 | +|---|---|---| +| `FIN-001-A` | `tab_promote_settlement_time` | 结算时间配置 | +| `FIN-001-B` | `tab_promote_settlement` | 结算单、汇总、确认 | +| `FIN-001-C` | `tab_promote_settlement_period` | 周期单和详情 | +| `FIN-001-D` | `tab_promote_withdraw`、`tab_promote_withdraw_ip`、`tab_user_hzb_withdraw` | 渠道/玩家提现、风控、状态 | +| `FIN-001-E` | `tab_promote_agent`、`tab_promote_bind` | 代充折扣、绑币订单 | +| `FIN-001-F` | `tab_promote_advance_whitelist`、`tab_promote_advance_game` | 预支白名单和游戏 | +| `FIN-001-G` | `tab_promote_advance_limit_log`、`tab_promote_advance_return_log` | 预支额度、还款、限额和返还日志 | +| `FIN-001-H` | 余额、结算、提现后台页面和权限 | 前端全量迁移 | +| `FIN-001-I` | 金额、状态、重复付款、额度恢复回归 | 全量验收 | + +## 20. 阶段 5 细化:TG、IM、SDK 和客户端 + +### TG-001 会长/渠道移动端拆分 + +| 工作包 | API 族 | 依赖 | +|---|---|---| +| `TG-001-A` | `/tg/promote/info`、`base_info`、`update` | SYS-001、CHN-001 | +| `TG-001-B` | `/tg/promote/getAll`、`switch`、当前平台 | FND-003-F、PLT-001 | +| `TG-001-C` | `data_index`、`summary`、统计口径 | GAM/CHN/PLY/ORD | +| `TG-001-D` | `period`、`confirmPeriod`、`showDetail` | FIN-001-B/C | +| `TG-001-E` | `withdraw`、`withdraw_info`、`bank` | FIN-001-D | +| `TG-001-F` | `profit`、`profitRecord`、`recharge`、`register` | ORD/FIN | +| `TG-001-G` | `bindPay*`、预支代充 | FIN-001-E/F/G | +| `TG-001-H` | 福利、扶持和申请接口 | SUP-001、PKG-001 | +| `TG-001-I` | `tab_promote_sms`、TG 菜单、层级限制、短信和错误返回 | FND-003-F | +| `TG-001-J` | 移动端全量请求回放和字段回归 | A-I | + +### IM-001 管理后台客服 IM 拆分 + +| 工作包 | OA 表/API 族 | 主要业务 | +|---|---|---| +| `IM-001-A` | `im_group`、`im_group_member`、群组 CRUD | 后台群组和成员管理 | +| `IM-001-B` | `im_groups`、`im_messages`、`im_messages_content` | 客服群、消息、平台和玩家关系 | +| `IM-001-C` | `im_groups_contact`、`im_robots` | 会话联系、机器人和状态 | +| `IM-001-D` | 客服分配、转接、结束 | 用户 IM 字段和并发计数 | +| `IM-001-E` | `im_collect`、`im_collect_tag`、`im_collect_rel_tag`、`im_shortcut`、`im_shortcut_group` | 收藏、标签、快捷语和分组 | +| `IM-001-F` | RabbitMQ 入群队列 | 发布、消费、ack/requeue | +| `IM-001-G` | 腾讯 IM 同步、群事件和消息 | 第三方适配器 | +| `IM-001-H` | 后台 IM 页面、WebSocket、权限 | 前端和实时通信 | +| `IM-001-I` | 并发分配、断线、转接和会话回归 | 全量验收 | + +### IM-002 玩家端和 SDK IM 拆分 + +| 工作包 | API 族 | 主要业务 | +|---|---|---| +| `IM-002-A` | 玩家注册、注册登录、账号登录 | 玩家 JWT | +| `IM-002-B` | 玩家密码、登出、userinfo | 玩家会话 | +| `IM-002-C` | `im_play_users`、`im_play_users_bind_platform` | 平台账号绑定/解绑/列表 | +| `IM-002-D` | `im_play_group`、`im_play_group_tag`、游戏/角色/绑定列表 | 玩家群和跨业务库查询 | +| `IM-002-E` | enterGroup、enterContact、评价 | 客服会话 | +| `IM-002-F` | `im_call_back`、腾讯 IM callback | 消息/群事件/ACK/幂等 | +| `IM-002-G` | `/sdk/im/saveUser`、`userSig` | SDK 基础接口 | +| `IM-002-H` | SDK 群组成员和群主操作 | create/add/del/change | +| `IM-002-I` | SDK 消息修改、未读数、评价 | 消息能力 | +| `IM-002-J` | OCR 和 SDK 错误兼容 | 签名、敏感数据 | +| `IM-002-K` | 移动端/PC 端请求回放 | A-J | + +### SDK-001 其他签名 SDK + +| 工作包 | API 族 | 依赖 | +|---|---|---| +| `SDK-001-A` | `/sdk/support/list` | SUP-001 | +| `SDK-001-B` | `/sdk/support/update` | SUP-001 | +| `SDK-001-C` | 旧签名算法和字段排序 | FND-003-G | +| `SDK-001-D` | 幂等、重放、错误和审计 | A-C | +| `SDK-001-E` | 外部客户端回放 | D | + +## 21. 阶段 6 细化:Issue 和报表 + +### ISS-001 Issue 分发系统 + +| 工作包 | OA 文件/表范围 | 主要业务 | +|---|---|---| +| `ISS-001-A` | `tab_issue_user` | 分发玩家 | +| `ISS-001-B` | `sys_issue_user_mend` | 分发补单 | +| `ISS-001-C` | `tab_issue_spend` | 分发充值 | +| `ISS-001-D` | `tab_issue_user_play_role` | 分发角色 | +| `ISS-001-E` | `tab_issue_data` | 数据分发和导出 | +| `ISS-001-F` | 后台页面、权限和接口回归 | A-E | + +### REP-001 报表 + +| 工作包 | OA 文件/表范围 | 主要业务 | +|---|---|---| +| `REP-001-A` | `tab_datareport_top_pid` | Top PID | +| `REP-001-B` | `tab_datareport_every_pid` | 每日 PID | +| `REP-001-C` | 动态 SQL、跨库聚合和时间边界 | 报表查询引擎 | +| `REP-001-D` | 报表页面、导出、权限和差异回归 | 全量验收 | + +## 22. 阶段 7 细化:定时任务、运维和切换 + +### OPS-001 定时任务逐项迁移 + +每个任务都独立建工作包,不能把所有 cron 放在一个提交中: + +| 工作包 | OA 任务 | 目标模块 | 关键验收 | +|---|---|---|---| +| `OPS-001-A` | `ClearDB` | System | 操作日志/JWT/任务日志清理范围一致 | +| `OPS-001-B` | `CloseChat` | IM | 超 30 分钟会话、计数和状态 | +| `OPS-001-C` | `UnsubscribeSysUser` | System | 15 天注销窗口、软删除和回滚 | +| `OPS-001-D` | `AutoSendSupport` | Support | 多业务库、外部发放、失败重试 | +| `OPS-001-E` | `AutoSendGift` | Support | 礼包状态和重复发放 | +| `OPS-001-F` | `PromoteActivePoolScan` | Platform/Channel | 活跃池扫描和渠道状态 | +| `OPS-001-G` | `PcRepackageScan` | Packaging | 外部任务轮询和失败回收 | +| `OPS-001-H` | `LockChannelOverThreeMonth` | Channel | 登录时间、上下级锁定和原因 | +| `OPS-001-I` | `AutoSendFuliRecord` | Welfare | 审核/待发放记录和幂等 | +| `OPS-001-J` | `AutoSendWeekCard` | Welfare | 周卡/月卡周期和重复执行 | +| `OPS-001-K` | `AutoBuLianInactiveUsers` | Player | inactive 条件、补链目标和跨库 | +| `OPS-001-L` | `AutoAuditSupport` | Support | dbNames、limit、审核状态和日志 | +| `OPS-001-M` | 调度总体验收 | Worker | 启停、手动触发、并发防重、告警 | + +### OPS-002 可观测性与故障恢复 + +| 工作包 | 细化内容 | +|---|---| +| `OPS-002-A` | 请求、任务、外部调用和消息统一 request/trace ID | +| `OPS-002-B` | 订单、发放、提现、打包、IM 回调幂等查询 | +| `OPS-002-C` | MQ、第三方、数据库、任务积压监控 | +| `OPS-002-D` | 敏感字段脱敏和审计检查 | +| `OPS-002-E` | OA 维护文档故障场景逐项演练 | +| `OPS-002-F` | 运维手册、告警阈值和应急联系人交接 | + +### CUT-001 全量接口和后台回归 + +| 工作包 | 细化内容 | +|---|---| +| `CUT-001-A` | 后台公共/登录/RBAC/多库接口全量回归 | +| `CUT-001-B` | 游戏/渠道/玩家/订单/扶持/财务接口全量回归 | +| `CUT-001-C` | TG 移动端全量回放 | +| `CUT-001-D` | IM 玩家端、客服端和 SDK 全量回放 | +| `CUT-001-E` | PC/Android/iOS/H5 下载、打包和回调回归 | +| `CUT-001-F` | KRA 后台页面、菜单、按钮、角色回归 | +| `CUT-001-G` | 未注册/未迁移/已批准废弃项审计 | + +### CUT-002 数据迁移演练 + +| 工作包 | 细化内容 | +|---|---| +| `CUT-002-A` | 主系统库全量迁移演练 | +| `CUT-002-B` | 各业务库全量迁移演练 | +| `CUT-002-C` | 增量追平、停写窗口和耗时测量 | +| `CUT-002-D` | 行数、金额、状态、关系、软删除抽样 | +| `CUT-002-E` | 失败恢复、断点续跑和回滚演练 | + +### CUT-003 灰度与正式切换 + +| 工作包 | 细化内容 | +|---|---| +| `CUT-003-A` | 影子读和请求回放,不产生业务写入 | +| `CUT-003-B` | 只读接口灰度和差异阈值 | +| `CUT-003-C` | 管理后台写接口灰度 | +| `CUT-003-D` | TG/玩家/SDK/回调写接口灰度 | +| `CUT-003-E` | 停止 OA 定时任务、消费者和外部回调入口 | +| `CUT-003-F` | KRA 正式切换、DNS/网关/配置切换 | +| `CUT-003-G` | 回滚到 OA 的数据点、路由和外部系统切换 | +| `CUT-003-H` | 切换后 24 小时、72 小时和 7 天观察 | + +## 23. 后台页面与 API 迁移批次 + +后台页面必须和接口工作包绑定,不能只迁后端。以下批次是 KRA 后台新增页面的建议交付顺序: + +| 批次 | OA 页面/API 族 | 绑定任务 | 页面交付 | +|---|---|---|---| +| `WEB-001` | 用户、角色、菜单、API、按钮、部门、岗位 | SYS-001/002/003 | 登录后基础管理闭环 | +| `WEB-002` | 平台、公司、商务、黑名单、配置、日志 | SYS-004/PLT-001/CHN-001 | 系统运维闭环 | +| `WEB-003` | 游戏类型、CP、游戏、设置、原包、区服、合服 | GAM-001-A-H | 游戏主数据闭环 | +| `WEB-004` | 游戏禁推、APP、平台配置、支付、企业签日志 | GAM-001-I-L | 游戏扩展闭环 | +| `WEB-005` | 渠道、商务、推广配置、合同、公告、登录记录 | CHN-001 | 渠道主数据闭环 | +| `WEB-006` | 渠道申请、打包、APP 包、快手抖音 | PKG-001 | 打包闭环 | +| `WEB-007` | 玩家、角色、补单、协议、短信、敏感信息 | PLY-001 | 玩家运营闭环 | +| `WEB-008` | 订单、绑币、平台币、代金券、支付日志 | ORD-001 | 交易闭环 | +| `WEB-009` | 扶持、福利、CP 礼包、自动审核 | SUP-001 | 发放闭环 | +| `WEB-010` | 结算、周期、提现、代充、预支 | FIN-001 | 财务闭环 | +| `WEB-011` | 后台客服 IM、收藏、快捷语、会话 | IM-001 | 客服闭环 | +| `WEB-012` | Issue、报表和导出 | ISS-001/REP-001 | 分析闭环 | +| `WEB-013` | 定时任务、运行日志、错误日志、版本和集成配置 | SYS-004/OPS-001/002 | 运维闭环 | + +每个 WEB 批次必须同时完成:API 文件、页面组件、动态菜单、按钮权限、列表/表单/详情/导出/批量操作、loading/空态/错误态和角色权限验证。 + +## 24. 推荐的任务分派方式 + +为了避免多个任务互相覆盖,建议按以下角色边界分派: + +| 角色 | 负责范围 | 不应直接修改 | +|---|---|---| +| 基础设施任务 | FND、MIG、公共响应、认证、多库、迁移工具 | 业务模块状态机 | +| 系统任务 | SYS、平台、用户权限和后台基础页面 | 游戏/订单业务表 | +| 主数据任务 | 游戏、平台、渠道、打包 | 玩家余额和结算核心逻辑 | +| 玩家任务 | 玩家、角色、补链、敏感字段 | 渠道结算状态机 | +| 交易任务 | 订单、支付、绑币、余额 | IM 会话分配 | +| 发放财务任务 | 扶持、福利、结算、提现、预支 | 登录和权限核心表 | +| 端适配任务 | TG、玩家端、SDK、PC/移动端兼容 | KRA 系统表 schema | +| IM 集成任务 | 腾讯 IM、RabbitMQ、WebSocket、客服 | 订单金额计算 | +| 运维切换任务 | 定时任务、观测、数据演练、灰度 | 未经模块负责人确认的业务代码 | + +同一文件、同一 PO、同一 migration、同一状态常量只能有一个任务负责人。跨任务修改必须先登记依赖变更,不允许通过后续冲突解决“顺手合并”。 + +## 25. 细化后的验收批次 + +每完成一个工作包,按以下批次保存证据: + +| 批次 | 验收内容 | 必须回答的问题 | +|---|---|---| +| `V-A` 文件 | OA 文件是否逐一处理 | 是否还有未归类文件?是否误删逻辑? | +| `V-B` Schema | 表和字段是否兼容 | 是否保留主键、索引、默认值、软删除和状态? | +| `V-C` Request | 请求是否兼容 | 方法、路径、参数位置、必填、别名是否一致? | +| `V-D` Response | 响应是否兼容 | code、msg、字段、类型、空值、分页和 Header 是否一致? | +| `V-E` Logic | 业务是否一致 | 状态转换、事务、幂等、跨表更新是否一致? | +| `V-F` Side Effect | 副作用是否一致 | Redis、MQ、第三方调用、日志和任务是否一致? | +| `V-G` Web | 后台是否完整 | 菜单、按钮、列表、表单、导出和权限是否完整? | +| `V-H` Data | 历史数据是否正确 | 行数、金额、状态、关系、抽样是否通过? | +| `V-I` Ops | 能否运行和恢复 | 监控、告警、重试、补偿、回滚是否可执行? | + +只有 `V-A` 到 `V-I` 全部有证据,工作包才可以进入 `REVIEW`。 + +## 26. 细化后的并行规则 + +### 允许并行 + +- `MIG-000-A/B/C/E/F` 可以并行;`MIG-000-D` 在文件清单稳定后开始。 +- `FND-005-A/B/C/D/E` 可由不同集成任务并行,但共享的配置加载和错误规范由一个负责人维护。 +- `GAM-001-A/B` 可并行;`GAM-001-C/D/E` 必须按游戏主表、状态、改名顺序执行。 +- `CHN-001-C/D/E/F/G` 可在 `CHN-001-A` 的主键和状态冻结后并行。 +- `PLY-001-C/D/F` 可在玩家主表冻结后并行。 +- `WEB-001` 之后,各业务 WEB 批次可在相应 API 契约冻结后并行开发。 + +### 禁止并行 + +- 不允许同时修改同一张表的 migration 和历史迁移脚本。 +- 不允许同时修改同一个 API 的旧响应 DTO 和前端调用封装。 +- 不允许在 `SYS-001/002/003` 未完成前迁移用户、角色、部门关系数据。 +- 不允许在 `GAM-001-C` 未完成前迁移渠道申请、玩家归因和订单外键。 +- 不允许在 `ORD-001` 未完成前迁移结算、提现、预支金额数据。 +- 不允许在 `IM-001-F/G` 未完成前启用真实 RabbitMQ 消费和腾讯 IM 回调。 +- 不允许在 `CUT-003-E` 完成前关闭 OA 的任务/消费者,也不允许双系统同时处理同一回调。 + +## 27. 细化后的第一轮实际执行顺序 + +第一轮不要直接进入全部业务,按以下 20 个工作包顺序执行: + +1. `MIG-000-A` 后端源文件登记。 +2. `MIG-000-B` 前端 API/页面登记。 +3. `MIG-000-C` 运行时路由导出。 +4. `MIG-000-D` 数据表字段目录。 +5. `MIG-000-G` 移动端/PC/后台请求样本冻结。 +6. `MIG-001-A/B/C` 差异测试基础框架。 +7. `MIG-002-A/B/E` 模块接入样板。 +8. `FND-001-A/B/G` 增量迁移最小闭环。 +9. `FND-002-A/B/C/D` 响应和 token 兼容闭环。 +10. `FND-003-A/B/C/D` 后台路由和 JWT/Casbin 闭环。 +11. `FND-004-A/B/C` 多库和平台上下文闭环。 +12. `SYS-001-A/B` 用户表扩展和 migration 评审。 +13. `SYS-003-A/B/C` 部门表和关系表映射评审。 +14. `SYS-002-A/C/D/E` 角色、多角色、菜单和 API 关系。 +15. `SYS-001-C/G` 登录和个人信息兼容接口。 +16. `SYS-003-D/F` 公司/部门接口。 +17. `WEB-001` 系统基础后台页面。 +18. `MIG-001-G` 登录、菜单、用户、部门差异回归。 +19. `FND-006-B` 主系统表迁移演练。 +20. 阶段门禁评审,确认是否进入 `PLT/GAM/CHN` 并行开发。 + +## 28. 任务记录模板 + +后续每个任务建议按以下格式记录,避免只提交代码而没有迁移证据: + +```markdown +# <任务名称> + +## 任务状态 +- 状态:READY / DOING / REVIEW / BLOCKED / DONE +- 负责人: +- 评审人: +- 前置任务: +- 影响任务: + +## 范围 +- OA 文件: +- OA 路由/API: +- OA 表: +- KRA 目标文件: +- KRA 后台页面: + +## 实施清单 +- [ ] P01 源文件登记 +- [ ] P02 路由冻结 +- [ ] P03 请求冻结 +- [ ] P04 响应冻结 +- [ ] P05 数据表冻结 +- [ ] P06 状态机冻结 +- [ ] P07 领域实现 +- [ ] P08 数据实现 +- [ ] P09 兼容接口实现 +- [ ] P10 前端/后台实现 +- [ ] P11 对照验证 +- [ ] P12 文件签收 + +## 验收证据 +- [ ] V-A 文件 +- [ ] V-B Schema +- [ ] V-C Request +- [ ] V-D Response +- [ ] V-E Logic +- [ ] V-F Side Effect +- [ ] V-G Web +- [ ] V-H Data +- [ ] V-I Ops + +## 已知差异与批准 +| 差异 | 原因 | 影响 | 批准人 | 是否阻塞 | +|---|---|---|---|---| +``` + +## 29. OA 前端 API 和页面精确归属索引 + +本索引用于 `MIG-000-B` 的初始归类。最终仍以实际 import、页面调用和运行时请求为准;同名 API 文件不能仅凭文件名判断已完成。 + +### 29.1 系统基础前端 + +| OA `web/src/api` 文件 | 目标任务 | 目标页面/说明 | +|---|---|---| +| `user.js` | SYS-001 | 用户、登录和个人信息 | +| `jwt.js` | SYS-001 / FND-003 | 登出、黑名单和 token | +| `authority.js` | SYS-002 | 角色管理 | +| `authorityBtn.js` | SYS-002 | 按钮权限 | +| `menu.js` | SYS-002 | 菜单与动态路由 | +| `api.js` | SYS-002 | API 管理 | +| `casbin.js` | SYS-002 | Casbin 策略 | +| `sysDepart.js` | SYS-003 | OA 部门兼容接口 | +| `sysUserDepart.js` | SYS-003 | 用户部门关系 | +| `sysUserMenusOften.js` | SYS-001-L | 常用菜单 | +| `sysDictionary.js` | SYS-004-H | 字典 | +| `sysDictionaryDetail.js` | SYS-004-H | 字典项 | +| `sysOperationRecord.js` | SYS-004-B | 操作记录 | +| `sysUserActionLog.js` | SYS-004-B | 用户行为日志 | +| `sysUserBlacklist.js` | SYS-001-J / SYS-004-C | 用户黑名单 | +| `system.js` | SYS-004-A | 系统配置兼容门面 | +| `initdb.js` | FND-003-I | 数据库初始化 | + +对应 OA 页面目录:`login`、`person`、`superAdmin`、`sysDepart`、`sysUserDepart`、`sysUserMenusOften`、`sysUserActionLog`、`sysUserBlacklist`、`system`、`systemTools`。 + +### 29.2 平台和公司前端 + +| OA `web/src/api` 文件 | 目标任务 | 目标页面目录 | +|---|---|---| +| `sysPlatform.js` | PLT-001-A | `sysPlatform` | +| `sysAuthorityPlatform.js` | PLT-001-B | `sysAuthorityPlatform` | +| `sysPlatformCpSession.js` | PLT-001-D | `sysPlatformCpSession` | +| `sysPlatformCpSupport.js` | PLT-001-D | `sysPlatformCpSupport` | + +### 29.3 游戏主数据前端 + +| OA `web/src/api` 文件 | 目标任务 | 目标页面目录 | +|---|---|---| +| `tabGameType.js` | GAM-001-A | `tabGameType` | +| `tabGameCp.js` | GAM-001-B | `tabGameCp` | +| `tabGame.js` | GAM-001-C/D/E | `tabGame` | +| `tabGameSet.js` | GAM-001-F | `tabGameSet` | +| `tabGameSource.js` | GAM-001-F | `tabGameSource` | +| `tabGameServer.js` | GAM-001-G | `tabGameServer` | +| `tabGameServerMerge.js` | GAM-001-G | `tabGameServerMerge` | +| `tabGameBanSet.js` | GAM-001-I | `tabGameBanSet` | +| `tabGameChangeNameLog.js` | GAM-001-E | `tabGameChangeNameLog` | +| `tabApp.js` | GAM-001-J / PKG-001-F | `tabApp` | +| `tabPlatformConfig.js` | GAM-001-J | `tabPlatformConfig` | +| `tabPlatformQysPayLog.js` | GAM-001-L / ORD-001-G | `tabPlatformQysPayLog` | +| `tabPlatformQysConsumeLog.js` | GAM-001-L / ORD-001-G | `tabPlatformQysConsumeLog` | + +### 29.4 渠道、打包和财务前端 + +| OA `web/src/api` 文件 | 目标任务 | 目标页面目录 | +|---|---|---| +| `tabPromote.js` | CHN-001-A/B/H | `tabPromote` | +| `tabPromoteBusiness.js` | CHN-001-C | `tabPromoteBusiness` | +| `tabPromoteConfig.js` | CHN-001-D | `tabPromoteConfig` | +| `tabPromoteLoginRecord.js` | CHN-001-E | `tabPromoteLoginRecord` | +| `tabPromoteContract.js` | CHN-001-F | `tabPromoteContract` | +| `tabPromoteApply.js` | PKG-001 | `tabPromoteApply` | +| `tabPromoteSettlement.js` | FIN-001-B | `tabPromoteSettlement` | +| `tabPromoteSettlementTime.js` | FIN-001-A | `tabPromoteSettlementTime` | +| `tabPromoteSupport.js` | SUP-001-B | `tabPromoteSupport` | + +### 29.5 玩家、订单和扶持前端 + +| OA `web/src/api` 文件 | 目标任务 | 目标页面目录 | +|---|---|---| +| `tabUser.js` | PLY-001-A/B/G/H | `tabUser` | +| `tabUserMend.js` | PLY-001-E | `tabUserMend` | +| `tabUserPlayInfo.js` | PLY-001-C | `tabUserPlayInfo` | +| `tabSmsLog.js` | PLY-001-F | `tabSmsLog` | +| `tabSpend.js` | ORD-001-A/B/C/I | `tabSpend` | +| `tabCouponRecord.js` | ORD-001-G | `tabCouponRecord` | +| `tabSupport.js` | SUP-001-A | `tabSupport` | + +### 29.6 IM、公共能力和遗留前端 + +| OA `web/src/api` 文件 | 目标任务 | 处理原则 | +|---|---|---| +| `imGroupMember.js` | IM-001-A | 迁移接口和 `imGroupMember` 页面 | +| `fileUploadAndDownload.js` | SYS-004-D | 对接 KRA 媒体库并保持旧协议 | +| `breakpoint.js` | SYS-004-D / SYS-004-G | 有调用则迁移,未调用则审批废弃 | +| `email.js` | SYS-004-A | 复用 KRA 邮件模块,保持旧 API | +| `autoCode.js` | SYS-004-F | 使用性评估后迁移或废弃 | +| `customer.js` | SYS-004-G | 使用性评估后迁移或废弃 | +| `github.js` | SYS-004-G | 使用性评估后迁移或废弃 | + +### 29.7 无独立 OA 页面但仍必须迁移的接口 + +以下能力不能因为当前 `web/src/view` 中没有对应目录而漏迁: + +- `/tg/*`:移动会长端使用,归 `TG-001`; +- `/imserver/*`:移动/PC 玩家端使用,归 `IM-002`; +- `/sdk/*`:游戏 SDK 和外部服务使用,归 `IM-002`、`SDK-001`; +- Issue 全部接口:可能由外部后台或独立页面使用,归 `ISS-001`; +- Report 全部接口:归 `REP-001`; +- SW 商务用户接口:归 `PLT-001-E`; +- OA 当前未展示页面的 Game/User/System 接口:仍按运行时路由和调用日志迁移。 + +## 30. OA 后端源文件覆盖检查规则 + +为了保证“逐文件确认”可以自动执行,在 `MIG-000-A` 中增加以下检查: + +1. 扫描 `server/model//*.go`,每个文件基名必须出现在 source ledger 或本计划的任务归属中。 +2. 扫描 `server/model//request/*.go` 和 `response/*.go`,必须关联至少一个 API contract。 +3. 扫描 `server/api/v1//*.go`,必须关联 handler 工作包或批准废弃项。 +4. 扫描 `server/router//*.go`,必须关联运行时有效路由或明确未注册。 +5. 扫描 `server/service//*.go`,必须关联业务用例、集成适配器或批准废弃项。 +6. 扫描 `web/src/api/*.js`,必须关联页面、外部客户端或批准废弃项。 +7. 扫描 `web/src/view/**`,必须关联一个 `WEB-*` 批次或批准废弃项。 +8. 扫描配置、中间件、定时任务和第三方工具目录,必须关联 `FND-*`、`OPS-*` 或业务任务。 + +自动检查输出至少包含: + +```text +source_path +source_type +domain +owner_task_id +target_path +decision=migrate|replace|retire +contract_ids +verification_status +``` + +合并门禁:`owner_task_id`、`decision`、`target_path` 或 `verification_status` 任何一项为空,迁移覆盖检查失败。 diff --git a/docs/system-pkg-audit.md b/docs/system-pkg-audit.md index 4c5edf8..c0353a3 100644 --- a/docs/system-pkg-audit.md +++ b/docs/system-pkg-audit.md @@ -13,7 +13,7 @@ | 支付 provider/mode 标识 | `pkg/paymentkit` | provider 常量、支持列表、金额/签名/JSON 等跨模块协议;system `biz` 只保留兼容别名。 | | 支付回调 ACK | `pkg/paymentkit` | 回调应答、失败包装和默认 provider 应答;具体渠道 SDK 仍留在 system integration。 | | WebSocket 通用收发 | `pkg/websocket` | Melody 的连接、事件、点对点发送、广播和会话查询封装;system integration 管理配置与生命周期。 | -| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口;EMQX/Paho 客户端由 system integration 管理。 | +| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口;EMQX/Paho 与 RabbitMQ/AMQP 客户端由 system integration 管理。 | | 模块、任务和迁移协议 | `pkg/module`、`pkg/task`、`pkg/database/migration` | 供不同业务模块注册贡献,不带 system 业务语义。 | ## system 内部保留边界 @@ -23,7 +23,7 @@ - `conf`:system 配置 proto、运行时快照和生成代码。 - `data`:数据库连接、PO、仓储、system 表、支付持久化和配置 watcher。 - `initialize`:首次安装、配置迁移、种子编排和运行时重载。 -- `integration`:Redis、邮件、存储、支付、WebSocket 和 EMQX 的 provider 生命周期。 +- `integration`:Redis、邮件、存储、支付、WebSocket、EMQX 和 RabbitMQ 的 provider 生命周期。 - `security`:JWT claims、签发/解析和后台安全实现。 - `service`:HTTP DTO(`service/dto`)、DTO 与 DO 转换、应用服务和路由元数据。 - `server`:Gin 生命周期;handler、middleware、router、HTTP 适配按子包维护。 diff --git a/go.mod b/go.mod index e8ccad2..e895719 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/mojocn/base64Captcha v1.3.8 github.com/olahol/melody v1.4.0 github.com/qiniu/go-sdk/v7 v7.25.2 + github.com/rabbitmq/amqp091-go v1.14.0 github.com/redis/go-redis/v9 v9.7.0 github.com/robfig/cron/v3 v3.0.1 github.com/shirou/gopsutil/v4 v4.25.7 @@ -143,7 +144,6 @@ require ( github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/rabbitmq/amqp091-go v1.14.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/internal/README.md b/internal/README.md index b2c4254..f33eb44 100644 --- a/internal/README.md +++ b/internal/README.md @@ -5,10 +5,10 @@ - `app`:组合根、system 模块定义和任务/路由运行时组合 - `biz`:系统领域对象、用例和仓储接口 -- `conf`:配置 proto 与运行时配置解析 +- `conf`:基础配置 proto 与运行时配置解析 - `data`:数据库生命周期、系统仓储、系统表和支付持久化 - `initialize`:数据库首次初始化和系统种子数据编排 -- `integration`:Redis、邮件、对象存储、支付、WebSocket 和 EMQX 适配器 +- `integration`:Redis、邮件、对象存储、支付、WebSocket、EMQX 和 RabbitMQ 适配器 - `security`:后台 JWT 等安全实现 - `server`:Gin server 组合与生命周期;横切 HTTP 代码按子包维护: `server/handler`、`server/middleware`、`server/router`、`server/httpx` diff --git a/internal/app/definition.go b/internal/app/definition.go index f3f6f3e..216dd98 100644 --- a/internal/app/definition.go +++ b/internal/app/definition.go @@ -9,10 +9,22 @@ import ( // Definition describes the built-in system contribution to the application // catalog. Other business modules can expose the same shape independently. func Definition() module.Definition { + surface := datapayment.AdminSurface() + communication := module.Surface{ + Menus: []module.Menu{{Name: "integrationConfig", Path: "integrationConfig", ParentName: "extensions", Component: "view/systemTools/integration/config.vue", Title: "通信集成", Icon: "connection", Sort: 8}}, + APIs: []module.API{ + {Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"}, + {Path: "/integration/configs/:kind/:provider", Method: "GET", Group: "集成配置", Description: "获取指定集成配置"}, + {Path: "/integration/configs/:kind/:provider", Method: "PUT", Group: "集成配置", Description: "保存集成配置"}, + {Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"}, + }, + } + surface.Menus = append(surface.Menus, communication.Menus...) + surface.APIs = append(surface.APIs, communication.APIs...) return module.Definition{ Name: "system", Migrations: append(datasystem.Migrations(), datapayment.Migrations()...), - Surface: datapayment.AdminSurface(), + Surface: surface, TimedTasks: []module.TimedTask{ {Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", MethodName: "ClearDB", Enabled: true}, {Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true}, diff --git a/internal/app/definition_test.go b/internal/app/definition_test.go new file mode 100644 index 0000000..6c8601d --- /dev/null +++ b/internal/app/definition_test.go @@ -0,0 +1,27 @@ +package app + +import "testing" + +func TestDefinitionIncludesCommunicationIntegrationSurface(t *testing.T) { + surface := Definition().Surface + menuFound := false + for _, menu := range surface.Menus { + if menu.Name == "integrationConfig" { + menuFound = menu.ParentName == "extensions" && menu.Component == "view/systemTools/integration/config.vue" + break + } + } + if !menuFound { + t.Fatal("communication integration menu is missing") + } + apiFound := false + for _, api := range surface.APIs { + if api.Method == "PUT" && api.Path == "/integration/configs/:kind/:provider" { + apiFound = true + break + } + } + if !apiFound { + t.Fatal("communication integration save API is missing") + } +} diff --git a/internal/biz/integration_config.go b/internal/biz/integration_config.go index cc237e6..f7c2373 100644 --- a/internal/biz/integration_config.go +++ b/internal/biz/integration_config.go @@ -8,6 +8,7 @@ import ( "sort" "strconv" "strings" + "time" ) const ( @@ -195,8 +196,8 @@ func validateCommunicationIntegrationConfig(kind, provider string, values map[st return errors.New("rabbitmq port 必须在 1-65535 之间") } exchangeType := strings.ToLower(integrationText(values, "exchange_type")) - if exchangeType != "direct" && exchangeType != "fanout" && exchangeType != "topic" && exchangeType != "headers" { - return errors.New("rabbitmq exchange_type 必须是 direct、fanout、topic 或 headers") + if exchangeType != "direct" && exchangeType != "fanout" && exchangeType != "topic" { + return errors.New("rabbitmq exchange_type 必须是 direct、fanout 或 topic") } if integrationInt64(values, "prefetch_count", -1) < 0 { return errors.New("rabbitmq prefetch_count 不能小于 0") diff --git a/internal/biz/integration_config_communication_test.go b/internal/biz/integration_config_communication_test.go new file mode 100644 index 0000000..4ea7a4b --- /dev/null +++ b/internal/biz/integration_config_communication_test.go @@ -0,0 +1,32 @@ +package biz + +import "testing" + +func TestCommunicationIntegrationDefinitionsAndValidation(t *testing.T) { + for _, target := range []struct{ kind, provider string }{ + {IntegrationKindMQ, "emqx"}, + {IntegrationKindMQ, "rabbitmq"}, + {IntegrationKindWebSocket, "melody"}, + } { + values := DefaultIntegrationConfig(target.kind, target.provider) + if len(values) == 0 { + t.Fatalf("default config missing for %s/%s", target.kind, target.provider) + } + if err := ValidateIntegrationConfig(target.kind, target.provider, values); err != nil { + t.Fatalf("default config invalid for %s/%s: %v", target.kind, target.provider, err) + } + } +} + +func TestCommunicationIntegrationValidationRejectsInvalidValues(t *testing.T) { + rabbit := DefaultIntegrationConfig(IntegrationKindMQ, "rabbitmq") + rabbit["port"] = 0 + if err := ValidateIntegrationConfig(IntegrationKindMQ, "rabbitmq", rabbit); err == nil { + t.Fatal("invalid rabbitmq port was accepted") + } + websocket := DefaultIntegrationConfig(IntegrationKindWebSocket, "melody") + websocket["path"] = "ws" + if err := ValidateIntegrationConfig(IntegrationKindWebSocket, "melody", websocket); err == nil { + t.Fatal("invalid websocket path was accepted") + } +} diff --git a/internal/biz/integration_config_definition.go b/internal/biz/integration_config_definition.go index 0689809..ab1339d 100644 --- a/internal/biz/integration_config_definition.go +++ b/internal/biz/integration_config_definition.go @@ -113,7 +113,7 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{ {Key: "password", Label: "密码", Type: "password", Required: true, Secret: true}, {Key: "vhost", Label: "Virtual Host", Type: "text", Required: true, Placeholder: "/"}, {Key: "exchange", Label: "Exchange", Type: "text", Required: true, Placeholder: "kra"}, - {Key: "exchange_type", Label: "Exchange 类型", Type: "select", Required: true, Options: []IntegrationConfigOption{{Label: "topic", Value: "topic"}, {Label: "direct", Value: "direct"}, {Label: "fanout", Value: "fanout"}, {Label: "headers", Value: "headers"}}}, + {Key: "exchange_type", Label: "Exchange 类型", Type: "select", Required: true, Options: []IntegrationConfigOption{{Label: "topic", Value: "topic"}, {Label: "direct", Value: "direct"}, {Label: "fanout", Value: "fanout"}}}, {Key: "queue", Label: "Queue", Type: "text", Required: true, Placeholder: "kra"}, {Key: "routing_key", Label: "默认 Routing Key", Type: "text", Required: true, Placeholder: "#", Description: "业务未指定订阅键时使用;topic 类型支持 * 和 #。"}, {Key: "durable", Label: "持久化", Type: "switch"}, diff --git a/internal/data/config_store.go b/internal/data/config_store.go index 3b0dba9..037ed57 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -194,8 +194,6 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con } deleteYAMLMapping(&document, "admin", "storage") deleteYAMLMapping(&document, "admin", "email") - deleteYAMLMapping(&document, "admin", "websocket") - deleteYAMLMapping(&document, "admin", "mq") if adminConfig.System != nil { if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil { return err @@ -255,8 +253,6 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey st } deleteYAMLMapping(&document, "admin", "storage") deleteYAMLMapping(&document, "admin", "email") - deleteYAMLMapping(&document, "admin", "websocket") - deleteYAMLMapping(&document, "admin", "mq") return writeConfigDocument(configPath, &document) } @@ -275,13 +271,11 @@ func (d *Data) removeIntegrationConfigFromFile() error { if err = yaml.Unmarshal(raw, &document); err != nil { return err } - if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil && yamlMappingValue(&document, "admin", "websocket") == nil && yamlMappingValue(&document, "admin", "mq") == nil { + if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil { return nil } deleteYAMLMapping(&document, "admin", "storage") deleteYAMLMapping(&document, "admin", "email") - deleteYAMLMapping(&document, "admin", "websocket") - deleteYAMLMapping(&document, "admin", "mq") return writeConfigDocument(configPath, &document) } diff --git a/internal/data/data.go b/internal/data/data.go index fa6a8c6..d598698 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -14,13 +14,14 @@ import ( "kra/internal/conf" datapayment "kra/internal/data/payment" datasystem "kra/internal/data/repository" + "kra/internal/integration/runtimeconfig" "kra/internal/integration/storage" - "kra/internal/integrationruntime" "kra/pkg/module" ) var ProviderSet = wire.NewSet( NewData, + NewIntegrationRuntime, wire.Bind(new(datasystem.Provider), new(*Data)), wire.Bind(new(datasystem.DatabaseProvider), new(*Data)), wire.Bind(new(datapayment.Provider), new(*Data)), @@ -34,6 +35,13 @@ var ProviderSet = wire.NewSet( datasystem.NewIntegrationConfigRepo, ) +func NewIntegrationRuntime(data *Data) *runtimeconfig.Store { + if data == nil { + return runtimeconfig.NewStore() + } + return data.IntegrationRuntime() +} + type Data struct { initMu sync.Mutex configMu sync.Mutex @@ -42,7 +50,7 @@ type Data struct { redis *reloadableRedis mongo *reloadableMongo runtime *conf.Runtime - integrations *integrationruntime.Store + integrations *runtimeconfig.Store storage *storage.Reloadable dbListMu sync.RWMutex dbList map[string]*gorm.DB @@ -77,7 +85,7 @@ func (d *Data) Runtime() *conf.Runtime { // IntegrationRuntime exposes database-backed integration configuration to // long-lived adapters without making config.yaml part of their lifecycle. -func (d *Data) IntegrationRuntime() *integrationruntime.Store { +func (d *Data) IntegrationRuntime() *runtimeconfig.Store { if d == nil { return nil } @@ -167,7 +175,7 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor // and /init/initdb remain available. c.Database = &conf.Data_Database{} } - d := &Data{runtime: runtime, integrations: integrationruntime.NewStore(), appLogger: appLogger, storage: storageManager, catalog: catalog} + d := &Data{runtime: runtime, integrations: runtimeconfig.NewStore(), appLogger: appLogger, storage: storageManager, catalog: catalog} usingFallback := !databaseConnectionConfigured(c.Database) var db *gorm.DB var err error diff --git a/internal/data/integration_config_test.go b/internal/data/integration_config_test.go index c018823..8a8634d 100644 --- a/internal/data/integration_config_test.go +++ b/internal/data/integration_config_test.go @@ -123,25 +123,6 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) { } } -func TestMQIntegrationConfigRoundTrip(t *testing.T) { - db := openIntegrationConfigTestDB(t) - legacy := &conf.AdminBackend_MQ{Enabled: true, Broker: "mqtt://emqx.example.com:1883", ClientId: "system", Username: "app", Password: "secret", KeepAlive: 45, CleanSession: true, ConnectTimeout: 12} - if err := saveMQIntegrationConfig(db, legacy); err != nil { - t.Fatal(err) - } - loaded, found, err := loadMQIntegrationConfig(db) - if err != nil || !found { - t.Fatalf("found=%v err=%v", found, err) - } - if loaded.Broker != legacy.Broker || loaded.Password != legacy.Password || loaded.KeepAlive != 45 { - t.Fatalf("loaded mq = %#v", loaded) - } - loaded, err = resolveMQIntegrationConfig(db, &conf.AdminBackend_MQ{Broker: "must-not-replace"}) - if err != nil || loaded.Broker != legacy.Broker { - t.Fatalf("database mq was replaced: %#v err=%v", loaded, err) - } -} - func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { db := openIntegrationConfigTestDB(t) legacy := &conf.AdminBackend_Storage{ @@ -205,7 +186,7 @@ func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") - input := []byte("data: {}\nadmin:\n router_prefix: /old\n storage:\n type: qiniu\n qiniu:\n secret_key: legacy-secret\n email:\n host: smtp.legacy.example.com\n secret: legacy-email-secret\n mq:\n enabled: true\n broker: tcp://localhost:1883\n password: legacy-mq-secret\n extension_key: retained\n") + input := []byte("data: {}\nadmin:\n router_prefix: /old\n storage:\n type: qiniu\n qiniu:\n secret_key: legacy-secret\n email:\n host: smtp.legacy.example.com\n secret: legacy-email-secret\n extension_key: retained\n") if err := os.WriteFile(path, input, 0o600); err != nil { t.Fatal(err) } @@ -218,7 +199,6 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) { Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-secret"}, }, Email: &conf.AdminBackend_Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"}, - Mq: &conf.AdminBackend_MQ{Enabled: true, Broker: "tcp://emqx:1883", Password: "database-only-mq-secret"}, } if err := d.persistConfigValues(&conf.Data{}, admin); err != nil { t.Fatal(err) @@ -242,9 +222,6 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) { if _, exists := adminValue["email"]; exists { t.Fatalf("email remained in YAML: %s", raw) } - if _, exists := adminValue["mq"]; exists { - t.Fatalf("mq remained in YAML: %s", raw) - } if adminValue["extension_key"] != "retained" { t.Fatalf("extension key was not retained: %#v", adminValue) } diff --git a/internal/data/integration_defaults.go b/internal/data/integration_defaults.go new file mode 100644 index 0000000..7f2177d --- /dev/null +++ b/internal/data/integration_defaults.go @@ -0,0 +1,39 @@ +package data + +import ( + "encoding/json" + "errors" + + "kra/internal/biz" + + "gorm.io/gorm" +) + +func ensureCommunicationIntegrationConfigs(db *gorm.DB) error { + defaults := []struct { + kind string + provider string + }{ + {kind: biz.IntegrationKindMQ, provider: "emqx"}, + {kind: biz.IntegrationKindMQ, provider: "rabbitmq"}, + {kind: biz.IntegrationKindWebSocket, provider: "melody"}, + } + for _, item := range defaults { + var row integrationConfigPO + err := db.Where("kind = ? AND provider = ?", item.kind, item.provider).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + values, marshalErr := json.Marshal(biz.DefaultIntegrationConfig(item.kind, item.provider)) + if marshalErr != nil { + return marshalErr + } + if err = db.Create(&integrationConfigPO{Kind: item.kind, Provider: item.provider, Enabled: false, Config: string(values)}).Error; err != nil { + return err + } + continue + } + if err != nil { + return err + } + } + return nil +} diff --git a/internal/data/integration_runtime.go b/internal/data/integration_runtime.go index fd359ca..0646d4b 100644 --- a/internal/data/integration_runtime.go +++ b/internal/data/integration_runtime.go @@ -3,12 +3,12 @@ package data import ( "errors" - "kra/internal/integrationruntime" + "kra/internal/integration/runtimeconfig" "gorm.io/gorm" ) -func readIntegrationRuntime(db *gorm.DB) ([]integrationruntime.Config, error) { +func readIntegrationRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) { if db == nil || !db.Migrator().HasTable(&integrationConfigPO{}) { return nil, nil } @@ -19,9 +19,9 @@ func readIntegrationRuntime(db *gorm.DB) ([]integrationruntime.Config, error) { Find(&rows).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } - configs := make([]integrationruntime.Config, 0, len(rows)) + configs := make([]runtimeconfig.Config, 0, len(rows)) for _, row := range rows { - configs = append(configs, integrationruntime.Config{Kind: row.Kind, Provider: row.Provider, Enabled: row.Enabled, Values: []byte(row.Config)}) + configs = append(configs, runtimeconfig.Config{Kind: row.Kind, Provider: row.Provider, Enabled: row.Enabled, Values: []byte(row.Config)}) } return configs, nil } diff --git a/internal/data/migrations.go b/internal/data/migrations.go index 287f339..42b16ed 100644 --- a/internal/data/migrations.go +++ b/internal/data/migrations.go @@ -10,12 +10,15 @@ import ( ) func InfrastructureMigrations() []migration.Step { - return []migration.Step{{ - ID: "202608200001_data_infrastructure", - Migrate: func(db *gorm.DB) error { - return migration.CreateMissingTables(db, &integrationConfigPO{}) + return []migration.Step{ + { + ID: "202608200001_data_infrastructure", + Migrate: func(db *gorm.DB) error { + return migration.CreateMissingTables(db, &integrationConfigPO{}) + }, }, - }} + {ID: "202608210001_communication_integration_defaults", Migrate: ensureCommunicationIntegrationConfigs}, + } } // migrateAll is the single data-layer migration entry point. Module-specific diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index b7d2760..29ca0ca 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -26,8 +26,20 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { if err = db.Table(migration.TableName).Count(&versions).Error; err != nil { t.Fatal(err) } - if versions != 4 { - t.Fatalf("migration versions = %d, want 4", versions) + if versions != 6 { + t.Fatalf("migration versions = %d, want 6", versions) + } + var communicationRows []integrationConfigPO + if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil { + t.Fatal(err) + } + if len(communicationRows) != 3 { + t.Fatalf("communication integration rows = %d, want 3", len(communicationRows)) + } + for _, row := range communicationRows { + if row.Enabled || row.Config == "" { + t.Fatalf("default communication integration = %#v", row) + } } for _, table := range []string{"sys_users", "sys_base_menus", "sys_apis"} { var count int64 diff --git a/internal/data/payment/migrations.go b/internal/data/payment/migrations.go index fb2f10e..bf27f09 100644 --- a/internal/data/payment/migrations.go +++ b/internal/data/payment/migrations.go @@ -26,12 +26,16 @@ func AdminSurface() platformmodule.Surface { {Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7}, }, APIs: []platformmodule.API{ - {Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"}, - {Path: "/integration/configs/:kind/:provider", Method: "GET", Group: "集成配置", Description: "获取指定集成配置"}, - {Path: "/integration/configs/:kind/:provider", Method: "PUT", Group: "集成配置", Description: "保存集成配置"}, - {Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"}, {Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"}, {Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"}, + {Path: "/payment/orders/:provider/:tradeNo", Method: "GET", Group: "支付", Description: "按路径查询支付订单"}, + {Path: "/payment/create", Method: "POST", Group: "支付", Description: "创建支付订单"}, + {Path: "/payment/query", Method: "POST", Group: "支付", Description: "同步支付订单状态"}, + {Path: "/payment/refund", Method: "POST", Group: "支付", Description: "申请支付订单退款"}, + {Path: "/payment/orders/:provider/:tradeNo/refund", Method: "POST", Group: "支付", Description: "按路径申请支付订单退款"}, + {Path: "/payment/fulfill", Method: "POST", Group: "支付", Description: "重试支付订单发货"}, + {Path: "/payment/orders/:provider/:tradeNo/fulfill", Method: "POST", Group: "支付", Description: "按路径重试支付订单发货"}, + {Path: "/payment/providers/:provider/test", Method: "POST", Group: "支付", Description: "测试支付渠道"}, }, } } diff --git a/internal/data/repository/integration_config.go b/internal/data/repository/integration_config.go index 6926c69..55e2ef1 100644 --- a/internal/data/repository/integration_config.go +++ b/internal/data/repository/integration_config.go @@ -8,7 +8,7 @@ import ( "time" "kra/internal/biz" - "kra/internal/integrationruntime" + "kra/internal/integration/runtimeconfig" "gorm.io/gorm" ) @@ -27,6 +27,10 @@ func (integrationConfigPO) TableName() string { return "sys_integration_configs" type integrationConfigRepo struct{ data Provider } +type integrationRuntimeProvider interface { + IntegrationRuntime() *runtimeconfig.Store +} + func NewIntegrationConfigRepo(data Provider) biz.IntegrationConfigRepo { return &integrationConfigRepo{data: data} } @@ -93,18 +97,25 @@ func (r *integrationConfigRepo) DeleteIntegrationConfig(ctx context.Context, kin if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).Delete(&integrationConfigPO{}).Error; err != nil { return err } - if runtime := r.data.IntegrationRuntime(); runtime != nil { + if runtime := integrationRuntime(r.data); runtime != nil { runtime.Delete(kind, provider) } return nil } func (r *integrationConfigRepo) publish(kind, provider string, enabled bool, values []byte) { - if runtime := r.data.IntegrationRuntime(); runtime != nil { - runtime.Set(integrationruntime.Config{Kind: kind, Provider: provider, Enabled: enabled, Values: values}) + if runtime := integrationRuntime(r.data); runtime != nil { + runtime.Set(runtimeconfig.Config{Kind: kind, Provider: provider, Enabled: enabled, Values: values}) } } +func integrationRuntime(provider Provider) *runtimeconfig.Store { + if value, ok := provider.(integrationRuntimeProvider); ok { + return value.IntegrationRuntime() + } + return nil +} + func integrationConfigFromPO(row integrationConfigPO) *biz.IntegrationConfig { values := integrationObject(json.RawMessage(row.Config)) maskIntegrationSecrets(row.Kind, row.Provider, values) diff --git a/internal/data/repository/integration_config_runtime_test.go b/internal/data/repository/integration_config_runtime_test.go new file mode 100644 index 0000000..ff2c989 --- /dev/null +++ b/internal/data/repository/integration_config_runtime_test.go @@ -0,0 +1,53 @@ +package system + +import ( + "context" + "encoding/json" + "testing" + + "kra/internal/biz" + "kra/internal/integration/runtimeconfig" +) + +type integrationRuntimeTestProvider struct { + *Data + store *runtimeconfig.Store +} + +func (p *integrationRuntimeTestProvider) IntegrationRuntime() *runtimeconfig.Store { return p.store } + +func TestIntegrationConfigSavePublishesUnmaskedRuntimeValues(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&integrationConfigPO{}); err != nil { + t.Fatal(err) + } + provider := &integrationRuntimeTestProvider{Data: &Data{gormDB: newReloadableDB(db, nil)}, store: runtimeconfig.NewStore()} + repo := &integrationConfigRepo{data: provider} + + values := biz.DefaultIntegrationConfig(biz.IntegrationKindMQ, "rabbitmq") + values["password"] = "runtime-secret" + raw, _ := json.Marshal(values) + if err = repo.SaveIntegrationConfig(context.Background(), &biz.IntegrationConfig{Kind: biz.IntegrationKindMQ, Provider: "rabbitmq", Enabled: true, Values: raw}); err != nil { + t.Fatal(err) + } + + values["password"] = "******" + raw, _ = json.Marshal(values) + if err = repo.SaveIntegrationConfig(context.Background(), &biz.IntegrationConfig{Kind: biz.IntegrationKindMQ, Provider: "rabbitmq", Enabled: true, Values: raw}); err != nil { + t.Fatal(err) + } + active, ok := provider.store.Get(biz.IntegrationKindMQ, "rabbitmq") + if !ok || !active.Enabled { + t.Fatalf("runtime config = %#v, ok=%v", active, ok) + } + stored := map[string]any{} + if err = json.Unmarshal(active.Values, &stored); err != nil { + t.Fatal(err) + } + if stored["password"] != "runtime-secret" { + t.Fatalf("runtime password = %#v", stored["password"]) + } +} diff --git a/internal/data/repository/provider.go b/internal/data/repository/provider.go index 218c1a4..e8f1b80 100644 --- a/internal/data/repository/provider.go +++ b/internal/data/repository/provider.go @@ -2,7 +2,6 @@ package system import ( "kra/internal/conf" - "kra/internal/integrationruntime" "gorm.io/gorm" ) @@ -14,5 +13,4 @@ type Provider interface { Database(name string) (*gorm.DB, error) DatabaseReady() bool Runtime() *conf.Runtime - IntegrationRuntime() *integrationruntime.Store } diff --git a/internal/integration/README.md b/internal/integration/README.md index 092f52f..4adba5c 100644 --- a/internal/integration/README.md +++ b/internal/integration/README.md @@ -8,7 +8,7 @@ state, or translate provider-specific protocols into `biz` interfaces. - `cache`: Redis-backed cache with an in-memory fallback. - `email`: SMTP email repository. -- `mq`: reloadable EMQX/MQTT client exposed through the shared `pkg/mq` interface. +- `mq`: reloadable EMQX/MQTT and RabbitMQ/AMQP clients exposed through the shared `pkg/mq` interface. - `payment`: payment-channel SDKs and callback/signature handling. - `storage`: local and object-storage implementations of `biz.FileStorage`. - `websocket`: reloadable Melody endpoint exposed through the shared @@ -28,7 +28,7 @@ Business modules depend on `websocket.Hub` and `mq.Client` from the shared `pkg/websocket` and `pkg/mq` packages; they do not construct Melody or Paho clients and do not read system configuration directly. The system integration packages own runtime refresh and shutdown. Registered -WebSocket handlers and MQTT subscriptions are retained when database-backed +WebSocket handlers and broker subscriptions are retained when database-backed configuration replaces a live client. ```go @@ -45,7 +45,8 @@ Integration configuration is stored in `sys_integration_configs`: - WebSocket: `kind=websocket`, `provider=melody` - EMQX: `kind=mq`, `provider=emqx` +- RabbitMQ: `kind=mq`, `provider=rabbitmq` -The YAML values are only migration/bootstrap inputs. After the integration -table exists, the database is authoritative and runtime updates are applied -without restarting the process. The WebSocket public path defaults to `/ws`. +These three integrations are stored only in `sys_integration_configs`; they do +not come from `config.yaml`. Runtime updates are applied without restarting +the process. The WebSocket public path defaults to `/ws`. diff --git a/internal/integration/mq/emqx.go b/internal/integration/mq/emqx.go index b648715..fe5ab6f 100644 --- a/internal/integration/mq/emqx.go +++ b/internal/integration/mq/emqx.go @@ -2,140 +2,294 @@ package mq import ( "context" + "encoding/json" + "fmt" "log/slog" + "strconv" + "strings" "sync" "time" - "kra/internal/conf" - "kra/pkg/mq" + "kra/internal/integration/runtimeconfig" + platformmq "kra/pkg/mq" ) -// Reloadable follows the runtime snapshot and keeps a single shared EMQX -// connection for all modules in this process. +const ( + ProviderEMQX = "emqx" + ProviderRabbitMQ = "rabbitmq" +) + +// Reloadable owns the process-wide message clients. Configuration comes only +// from sys_integration_configs through runtimeconfig.Store. type Reloadable struct { mu sync.RWMutex opMu sync.Mutex - current mq.Client - subscriptions map[string]subscription - stop func() + clients map[string]platformmq.Client + subscriptions map[string]map[string]subscription + stop []func() logger *slog.Logger closed bool } type subscription struct { qos byte - handler mq.Handler + handler platformmq.Handler } -func New(runtime *conf.Runtime, logger *slog.Logger) (*Reloadable, func(), error) { +type namedClient struct { + owner *Reloadable + provider string +} + +func New(store *runtimeconfig.Store, logger *slog.Logger) (*Reloadable, func(), error) { if logger == nil { logger = slog.Default() } - r := &Reloadable{logger: logger, subscriptions: make(map[string]subscription)} - if runtime != nil { - var config *conf.AdminBackend_MQ - if admin := runtime.Admin(); admin != nil { - config = admin.GetMq() - } - r.replace(config) - r.stop = runtime.Subscribe(func(_ *conf.Data, admin *conf.AdminBackend) { - if admin != nil { - r.replace(admin.GetMq()) - } - }) + r := &Reloadable{ + clients: make(map[string]platformmq.Client), + subscriptions: make(map[string]map[string]subscription), + logger: logger, + } + if store != nil { + r.apply(ProviderEMQX, storeConfig(store, ProviderEMQX)) + r.apply(ProviderRabbitMQ, storeConfig(store, ProviderRabbitMQ)) + r.stop = append(r.stop, + store.Subscribe("mq", ProviderEMQX, func(config runtimeconfig.Config) { r.apply(ProviderEMQX, config) }), + store.Subscribe("mq", ProviderRabbitMQ, func(config runtimeconfig.Config) { r.apply(ProviderRabbitMQ, config) }), + ) } cleanup := func() { - if r.stop != nil { - r.stop() + for _, stop := range r.stop { + stop() } _ = r.Close() } return r, cleanup, nil } -func (r *Reloadable) replace(config *conf.AdminBackend_MQ) { +func storeConfig(store *runtimeconfig.Store, provider string) runtimeconfig.Config { + config, _ := store.Get("mq", provider) + return config +} + +func (r *Reloadable) apply(provider string, config runtimeconfig.Config) { r.opMu.Lock() defer r.opMu.Unlock() if r.closed { return } - if config == nil { - config = &conf.AdminBackend_MQ{} - } - cfg := mq.Config{Enabled: config.Enabled, Broker: config.Broker, ClientID: config.ClientId, Username: config.Username, Password: config.Password, CleanSession: config.CleanSession} - if config.KeepAlive > 0 { - cfg.KeepAlive = time.Duration(config.KeepAlive) * time.Second - } - if config.ConnectTimeout > 0 { - cfg.ConnectTimeout = time.Duration(config.ConnectTimeout) * time.Second - } - client, err := mq.NewMQTT(cfg) - if err != nil { - r.logger.Warn("emqx unavailable", "mod", "mq", "error", err) + if !config.Enabled { + r.replaceClientLocked(provider, nil) return } - if config.Enabled { - if err = r.restoreSubscriptions(context.Background(), client); err != nil { - _ = client.Close() - r.logger.Warn("restore emqx subscriptions failed", "mod", "mq", "error", err) - return - } + client, err := newProviderClient(provider, config.Values) + if err != nil { + r.logger.Warn("message integration unavailable", "mod", "mq", "provider", provider, "error", err) + return } + if err = r.restoreSubscriptionsLocked(provider, client); err != nil { + _ = client.Close() + r.logger.Warn("restore message subscriptions failed", "mod", "mq", "provider", provider, "error", err) + return + } + r.replaceClientLocked(provider, client) +} + +func newProviderClient(provider string, raw json.RawMessage) (platformmq.Client, error) { + values := map[string]any{} + if err := json.Unmarshal(raw, &values); err != nil { + return nil, fmt.Errorf("decode %s configuration: %w", provider, err) + } + switch provider { + case ProviderEMQX: + return platformmq.NewMQTT(platformmq.Config{ + Enabled: true, + Broker: configText(values, "broker"), + ClientID: configText(values, "client_id"), + Username: configText(values, "username"), + Password: configText(values, "password"), + KeepAlive: configSeconds(values, "keep_alive"), + CleanSession: configBool(values, "clean_session"), + ConnectTimeout: configSeconds(values, "connect_timeout"), + }) + case ProviderRabbitMQ: + return platformmq.NewRabbitMQ(platformmq.RabbitMQConfig{ + Enabled: true, + Host: configText(values, "host"), + Port: configInt(values, "port"), + Username: configText(values, "username"), + Password: configText(values, "password"), + VHost: configText(values, "vhost"), + Exchange: configText(values, "exchange"), + ExchangeType: configText(values, "exchange_type"), + Queue: configText(values, "queue"), + RoutingKey: configText(values, "routing_key"), + Durable: configBool(values, "durable"), + AutoDelete: configBool(values, "auto_delete"), + PrefetchCount: configInt(values, "prefetch_count"), + Heartbeat: configSeconds(values, "heartbeat"), + ConnectTimeout: configSeconds(values, "connect_timeout"), + TLS: configBool(values, "tls"), + }) + default: + return nil, fmt.Errorf("unsupported message provider %q", provider) + } +} + +func configText(values map[string]any, key string) string { + value, ok := values[key] + if !ok || value == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(value)) +} + +func configInt(values map[string]any, key string) int { + switch value := values[key].(type) { + case float64: + return int(value) + case int: + return value + case json.Number: + parsed, _ := strconv.Atoi(string(value)) + return parsed + default: + parsed, _ := strconv.Atoi(configText(values, key)) + return parsed + } +} + +func configSeconds(values map[string]any, key string) time.Duration { + seconds := configInt(values, key) + if seconds <= 0 { + return 0 + } + return time.Duration(seconds) * time.Second +} + +func configBool(values map[string]any, key string) bool { + value, _ := values[key].(bool) + return value +} + +func (r *Reloadable) replaceClientLocked(provider string, next platformmq.Client) { r.mu.Lock() - old := r.current - r.current = client + old := r.clients[provider] + if next == nil { + delete(r.clients, provider) + } else { + r.clients[provider] = next + } r.mu.Unlock() if old != nil { _ = old.Close() } } -func (r *Reloadable) restoreSubscriptions(ctx context.Context, client mq.Client) error { - for topic, item := range r.subscriptions { - if err := client.Subscribe(ctx, topic, item.qos, item.handler); err != nil { +func (r *Reloadable) restoreSubscriptionsLocked(provider string, client platformmq.Client) error { + for topic, item := range r.subscriptions[provider] { + if err := client.Subscribe(context.Background(), topic, item.qos, item.handler); err != nil { return err } } return nil } -func (r *Reloadable) client() mq.Client { r.mu.RLock(); defer r.mu.RUnlock(); return r.current } -func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error { - c := r.client() - if c == nil { - return mq.ErrUnavailable - } - return c.Publish(ctx, topic, payload, qos, retain) +func (r *Reloadable) client(provider string) platformmq.Client { + provider = strings.ToLower(strings.TrimSpace(provider)) + r.mu.RLock() + defer r.mu.RUnlock() + return r.clients[provider] } -func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler mq.Handler) error { + +func (r *Reloadable) Client(provider string) platformmq.Client { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider != ProviderEMQX && provider != ProviderRabbitMQ { + return nil + } + return &namedClient{owner: r, provider: provider} +} + +func (c *namedClient) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error { + return c.owner.PublishTo(ctx, c.provider, topic, payload, qos, retain) +} +func (c *namedClient) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error { + return c.owner.SubscribeTo(ctx, c.provider, topic, qos, handler) +} +func (c *namedClient) Unsubscribe(ctx context.Context, topics ...string) error { + return c.owner.UnsubscribeFrom(ctx, c.provider, topics...) +} +func (c *namedClient) Connected() bool { return c.owner.ConnectedTo(c.provider) } +func (*namedClient) Close() error { return nil } + +func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error { + return r.PublishTo(ctx, ProviderEMQX, topic, payload, qos, retain) +} + +func (r *Reloadable) PublishTo(ctx context.Context, provider, topic string, payload []byte, qos byte, retain bool) error { + provider = strings.ToLower(strings.TrimSpace(provider)) + r.mu.RLock() + defer r.mu.RUnlock() + client := r.clients[provider] + if client == nil { + return platformmq.ErrUnavailable + } + return client.Publish(ctx, topic, payload, qos, retain) +} + +func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error { + return r.SubscribeTo(ctx, ProviderEMQX, topic, qos, handler) +} + +func (r *Reloadable) SubscribeTo(ctx context.Context, provider, topic string, qos byte, handler platformmq.Handler) error { + provider = strings.ToLower(strings.TrimSpace(provider)) r.opMu.Lock() defer r.opMu.Unlock() - c := r.client() - if c == nil { - return mq.ErrUnavailable + client := r.client(provider) + if client == nil { + return platformmq.ErrUnavailable } - if err := c.Subscribe(ctx, topic, qos, handler); err != nil { + if err := client.Subscribe(ctx, topic, qos, handler); err != nil { return err } - r.subscriptions[topic] = subscription{qos: qos, handler: handler} + if r.subscriptions[provider] == nil { + r.subscriptions[provider] = make(map[string]subscription) + } + r.subscriptions[provider][topic] = subscription{qos: qos, handler: handler} return nil } + func (r *Reloadable) Unsubscribe(ctx context.Context, topics ...string) error { + return r.UnsubscribeFrom(ctx, ProviderEMQX, topics...) +} + +func (r *Reloadable) UnsubscribeFrom(ctx context.Context, provider string, topics ...string) error { + provider = strings.ToLower(strings.TrimSpace(provider)) r.opMu.Lock() defer r.opMu.Unlock() - c := r.client() - if c == nil { - return mq.ErrUnavailable + client := r.client(provider) + if client == nil { + return platformmq.ErrUnavailable } - if err := c.Unsubscribe(ctx, topics...); err != nil { + if err := client.Unsubscribe(ctx, topics...); err != nil { return err } for _, topic := range topics { - delete(r.subscriptions, topic) + delete(r.subscriptions[provider], topic) } return nil } -func (r *Reloadable) Connected() bool { c := r.client(); return c != nil && c.Connected() } + +func (r *Reloadable) Connected() bool { return r.ConnectedTo(ProviderEMQX) } + +func (r *Reloadable) ConnectedTo(provider string) bool { + provider = strings.ToLower(strings.TrimSpace(provider)) + r.mu.RLock() + defer r.mu.RUnlock() + client := r.clients[provider] + return client != nil && client.Connected() +} + func (r *Reloadable) Close() error { r.opMu.Lock() defer r.opMu.Unlock() @@ -144,11 +298,16 @@ func (r *Reloadable) Close() error { } r.closed = true r.mu.Lock() - old := r.current - r.current = nil + clients := make([]platformmq.Client, 0, len(r.clients)) + for provider, client := range r.clients { + clients = append(clients, client) + delete(r.clients, provider) + } r.mu.Unlock() - if old != nil { - return old.Close() + for _, client := range clients { + if client != nil { + _ = client.Close() + } } return nil } diff --git a/internal/integration/mq/emqx_test.go b/internal/integration/mq/emqx_test.go index 06b9b8a..53615d3 100644 --- a/internal/integration/mq/emqx_test.go +++ b/internal/integration/mq/emqx_test.go @@ -26,28 +26,33 @@ func (*fakeClient) Close() error { return nil } func TestReloadableTracksSubscriptions(t *testing.T) { client := &fakeClient{} - r := &Reloadable{current: client, subscriptions: make(map[string]subscription)} + r := &Reloadable{ + clients: map[string]platformmq.Client{ProviderEMQX: client}, + subscriptions: make(map[string]map[string]subscription), + } handler := func(context.Context, platformmq.Message) {} if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil { t.Fatal(err) } - if _, ok := r.subscriptions["orders/+/paid"]; !ok { + if _, ok := r.subscriptions[ProviderEMQX]["orders/+/paid"]; !ok { t.Fatal("subscription was not retained for configuration reload") } if err := r.Unsubscribe(context.Background(), "orders/+/paid"); err != nil { t.Fatal(err) } - if _, ok := r.subscriptions["orders/+/paid"]; ok { + if _, ok := r.subscriptions[ProviderEMQX]["orders/+/paid"]; ok { t.Fatal("unsubscribed topic remained in the reload registry") } } func TestReloadableRestoresSubscriptions(t *testing.T) { client := &fakeClient{} - r := &Reloadable{subscriptions: map[string]subscription{ - "orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}}, + r := &Reloadable{subscriptions: map[string]map[string]subscription{ + ProviderEMQX: { + "orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}}, + }, }} - if err := r.restoreSubscriptions(context.Background(), client); err != nil { + if err := r.restoreSubscriptionsLocked(ProviderEMQX, client); err != nil { t.Fatal(err) } if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" { diff --git a/internal/integration/provider.go b/internal/integration/provider.go index bd5a4d6..4b38b60 100644 --- a/internal/integration/provider.go +++ b/internal/integration/provider.go @@ -22,6 +22,7 @@ var ProviderSet = wire.NewSet( storage.NewFileStorage, mqintegration.New, wire.Bind(new(mq.Client), new(*mqintegration.Reloadable)), + wire.Bind(new(mq.Registry), new(*mqintegration.Reloadable)), websocketintegration.New, wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)), wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)), diff --git a/internal/integrationruntime/store.go b/internal/integration/runtimeconfig/store.go similarity index 95% rename from internal/integrationruntime/store.go rename to internal/integration/runtimeconfig/store.go index 7c7b529..4d399ac 100644 --- a/internal/integrationruntime/store.go +++ b/internal/integration/runtimeconfig/store.go @@ -1,6 +1,6 @@ -// Package integrationruntime keeps the active database-backed integration -// settings and notifies long-lived provider clients when they change. -package integrationruntime +// Package runtimeconfig keeps the active database-backed integration settings +// and notifies long-lived provider clients when they change. +package runtimeconfig import ( "encoding/json" diff --git a/internal/integrationruntime/store_test.go b/internal/integration/runtimeconfig/store_test.go similarity index 96% rename from internal/integrationruntime/store_test.go rename to internal/integration/runtimeconfig/store_test.go index 4be9dd7..964da35 100644 --- a/internal/integrationruntime/store_test.go +++ b/internal/integration/runtimeconfig/store_test.go @@ -1,4 +1,4 @@ -package integrationruntime +package runtimeconfig import ( "encoding/json" diff --git a/internal/integration/websocket/server.go b/internal/integration/websocket/server.go index fef3ca3..189a570 100644 --- a/internal/integration/websocket/server.go +++ b/internal/integration/websocket/server.go @@ -1,19 +1,23 @@ package websocket import ( + "encoding/json" "errors" + "fmt" "net/http" + "strconv" "strings" "sync" "time" melody "github.com/olahol/melody" - "kra/internal/conf" + "kra/internal/integration/runtimeconfig" platformws "kra/pkg/websocket" ) -// Server is the system-owned WebSocket endpoint. Business modules can attach -// handlers and publish messages without depending on Gin or Melody directly. +const ProviderMelody = "melody" + +// Server owns the database-configured WebSocket endpoint. type Server struct { mu sync.RWMutex current *platformws.Server @@ -22,24 +26,25 @@ type Server struct { binaryHandlers []func(*melody.Session, []byte) connectHandlers []func(*melody.Session) disconnectHandlers []func(*melody.Session) + stop func() closed bool } -func New(runtime *conf.Runtime) (*Server, func(), error) { +func New(store *runtimeconfig.Store) (*Server, func(), error) { s := &Server{} - s.Replace(runtime) - var unsubscribe func() - if runtime != nil { - unsubscribe = runtime.Subscribe(func(_ *conf.Data, _ *conf.AdminBackend) { s.Replace(runtime) }) + if store != nil { + s.apply(storeConfig(store)) + s.stop = store.Subscribe("websocket", ProviderMelody, func(config runtimeconfig.Config) { s.apply(config) }) } return s, func() { - if unsubscribe != nil { - unsubscribe() + if s.stop != nil { + s.stop() } s.mu.Lock() s.closed = true current := s.current s.current = nil + s.path = "" s.mu.Unlock() if current != nil { _ = current.Close() @@ -47,13 +52,20 @@ func New(runtime *conf.Runtime) (*Server, func(), error) { }, nil } -func (s *Server) Replace(runtime *conf.Runtime) { - var config *conf.AdminBackend_WebSocket - if runtime != nil && runtime.Admin() != nil { - config = runtime.Admin().Websocket +func storeConfig(store *runtimeconfig.Store) runtimeconfig.Config { + config, _ := store.Get("websocket", ProviderMelody) + return config +} + +func (s *Server) apply(config runtimeconfig.Config) { + if s == nil { + return } - if config == nil { - config = &conf.AdminBackend_WebSocket{} + values := map[string]any{} + if len(config.Values) > 0 { + if err := json.Unmarshal(config.Values, &values); err != nil { + return + } } s.mu.Lock() if s.closed { @@ -70,14 +82,21 @@ func (s *Server) Replace(runtime *conf.Runtime) { } return } + path := text(values, "path") + if path == "" { + path = "/ws" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } next := platformws.New(platformws.Config{ - WriteWait: duration(config.WriteWait, 10*time.Second), - PongWait: duration(config.PongWait, 60*time.Second), - PingPeriod: duration(config.PingPeriod, 54*time.Second), - MaxMessageSize: config.MaxMessageSize, - MessageBufferSize: int(config.MessageBufferSize), - ConcurrentMessageHandling: config.ConcurrentMessageHandling, - AllowOrigins: config.AllowOrigins, + WriteWait: durationValue(values, "write_wait", 10*time.Second), + PongWait: durationValue(values, "pong_wait", 60*time.Second), + PingPeriod: durationValue(values, "ping_period", 54*time.Second), + MaxMessageSize: int64Value(values, "max_message_size"), + MessageBufferSize: int(intValue(values, "message_buffer_size")), + ConcurrentMessageHandling: boolValue(values, "concurrent_message_handling"), + AllowOrigins: stringList(values, "allow_origins"), }) for _, handler := range s.messageHandlers { next.OnMessage(handler) @@ -91,27 +110,65 @@ func (s *Server) Replace(runtime *conf.Runtime) { for _, handler := range s.disconnectHandlers { next.OnDisconnect(handler) } - s.path = strings.TrimSpace(config.Path) - if s.path == "" { - s.path = "/ws" - } else if !strings.HasPrefix(s.path, "/") { - s.path = "/" + s.path - } s.current = next + s.path = path s.mu.Unlock() if previous != nil { _ = previous.Close() } } -func duration(value interface{ AsDuration() time.Duration }, fallback time.Duration) time.Duration { - if value == nil { - return fallback +func text(values map[string]any, key string) string { + value, ok := values[key] + if !ok || value == nil { + return "" } - if result := value.AsDuration(); result > 0 { + return strings.TrimSpace(fmt.Sprint(value)) +} + +func intValue(values map[string]any, key string) int64 { + switch value := values[key].(type) { + case float64: + return int64(value) + case int: + return int64(value) + case json.Number: + parsed, _ := strconv.ParseInt(string(value), 10, 64) + return parsed + default: + parsed, _ := strconv.ParseInt(text(values, key), 10, 64) + return parsed + } +} + +func int64Value(values map[string]any, key string) int64 { return intValue(values, key) } +func boolValue(values map[string]any, key string) bool { value, _ := values[key].(bool); return value } +func stringList(values map[string]any, key string) []string { + value, ok := values[key].([]any) + if ok { + result := make([]string, 0, len(value)) + for _, item := range value { + if item != nil && strings.TrimSpace(fmt.Sprint(item)) != "" { + result = append(result, strings.TrimSpace(fmt.Sprint(item))) + } + } return result } - return fallback + if value, ok := values[key].([]string); ok { + return append([]string(nil), value...) + } + return nil +} +func durationValue(values map[string]any, key string, fallback time.Duration) time.Duration { + value := text(values, key) + if value == "" { + return fallback + } + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + return fallback + } + return parsed } func (s *Server) Enabled() bool { @@ -131,14 +188,9 @@ func (s *Server) Path() string { return s.path } func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) error { - if s == nil { - return errors.New("websocket server is disabled") - } - s.mu.RLock() - current := s.current - s.mu.RUnlock() - if current == nil { - return errors.New("websocket server is disabled") + current, err := s.active() + if err != nil { + return err } return current.HandleRequest(w, r) } @@ -150,26 +202,16 @@ func (s *Server) HandleRequestWithKeys(w http.ResponseWriter, r *http.Request, k return current.HandleRequestWithKeys(w, r, keys) } func (s *Server) Broadcast(message []byte) error { - if s == nil { - return errors.New("websocket server is disabled") - } - s.mu.RLock() - current := s.current - s.mu.RUnlock() - if current == nil { - return errors.New("websocket server is disabled") + current, err := s.active() + if err != nil { + return err } return current.Broadcast(message) } func (s *Server) BroadcastBinary(message []byte) error { - if s == nil { - return errors.New("websocket server is disabled") - } - s.mu.RLock() - current := s.current - s.mu.RUnlock() - if current == nil { - return errors.New("websocket server is disabled") + current, err := s.active() + if err != nil { + return err } return current.BroadcastBinary(message) } @@ -231,8 +273,9 @@ func (s *Server) OnBinaryMessage(handler func(*melody.Session, []byte)) { } s.mu.Lock() s.binaryHandlers = append(s.binaryHandlers, handler) - if s.current != nil { - s.current.OnBinaryMessage(handler) + current := s.current + if current != nil { + current.OnBinaryMessage(handler) } s.mu.Unlock() } @@ -242,8 +285,9 @@ func (s *Server) OnConnect(handler func(*melody.Session)) { } s.mu.Lock() s.connectHandlers = append(s.connectHandlers, handler) - if s.current != nil { - s.current.OnConnect(handler) + current := s.current + if current != nil { + current.OnConnect(handler) } s.mu.Unlock() } @@ -253,8 +297,9 @@ func (s *Server) OnDisconnect(handler func(*melody.Session)) { } s.mu.Lock() s.disconnectHandlers = append(s.disconnectHandlers, handler) - if s.current != nil { - s.current.OnDisconnect(handler) + current := s.current + if current != nil { + current.OnDisconnect(handler) } s.mu.Unlock() } diff --git a/internal/server/gin.go b/internal/server/gin.go index 8f44263..27e089d 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -65,6 +65,12 @@ func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessContro } registerSwagger(engine, prefix, version, logger) registerLocalStorage(engine, runtime) + if logger != nil { + for _, route := range engine.Routes() { + logger.Info("router registered", "method", route.Method, "path", route.Path) + } + logger.Info("router register success", "route_count", len(engine.Routes())) + } engine.NoRoute(func(c *gin.Context) { if ws != nil && ws.Enabled() && c.Request.Method == http.MethodGet && c.Request.URL.Path == ws.Path() { diff --git a/internal/server/middleware/access_log.go b/internal/server/middleware/access_log.go index a5c7997..afb8f12 100644 --- a/internal/server/middleware/access_log.go +++ b/internal/server/middleware/access_log.go @@ -2,10 +2,14 @@ package middleware import ( "bytes" + "crypto/sha256" + "encoding/hex" "errors" "io" "log/slog" + "mime" "net/http" + "strconv" "strings" "time" @@ -67,14 +71,26 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 { logLimit = int(config.Zap.AccessLogMaxBytes) } + paymentCallback := isPaymentCallbackPath(c.Request.URL.Path) + paymentConfigWrite := isPaymentIntegrationConfigWrite(c.Request.Method, c.Request.URL.Path) requestText := "" - if multipart { + if paymentCallback { + requestText = paymentCallbackSummary(requestBody, c.GetHeader("Content-Type")) + } else if paymentConfigWrite { + requestText = paymentConfigSummary(requestBody) + } else if multipart { requestText = "[文件]" } else { requestText = redactJSON(requestBody, c.GetHeader("Content-Type"), logLimit) } c.Set(ctxReqBodyKey, requestText) - c.Set(ctxRespBufferKey, &writer.body) + if paymentCallback { + // Callback acknowledgements and provider payloads must not flow into + // the generic response/error audit pipeline. + c.Set(ctxRespBufferKey, &bytes.Buffer{}) + } else { + c.Set(ctxRespBufferKey, &writer.body) + } if !requestReadFailed { c.Next() } @@ -82,6 +98,9 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H return } responseText := redactJSON(writer.body.Bytes(), c.Writer.Header().Get("Content-Type"), logLimit) + if paymentCallback { + responseText = "[支付回调响应已省略]" + } userID, authorityID := uint(0), uint(0) if claims := Claims(c); claims != nil { userID, authorityID = claims.ID, claims.AuthorityID @@ -95,22 +114,36 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H bytesOut = 0 } privateErrors := strings.TrimRight(c.Errors.ByType(gin.ErrorTypePrivate).String(), "\n") - attributes := []any{ - "mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route, - "http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(), - "request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"), - "bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID, - "error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(), "req_query", c.Request.URL.RawQuery} - if config != nil && config.Zap != nil && config.Zap.AccessReqHeaders { + var attributes []any + if paymentCallback { + attributes = []any{ + "mod", "payment-callback", "payment_provider", paymentCallbackProvider(c.Request.URL.Path), + "http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(), + "request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"), + "bytes_in", bytesIn, "bytes_out", bytesOut, + "error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", + "payment_callback", true, "payment_callback_summary", requestText, + } + } else { + attributes = []any{ + "mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route, + "http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(), + "request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"), + "bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID, + "error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(), + "req_query", c.Request.URL.RawQuery, + } + } + if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqHeaders { attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header)) } - if config != nil && config.Zap != nil && config.Zap.AccessReqBody { + if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqBody { attributes = append(attributes, "req_body", requestText) } - if config != nil && config.Zap != nil && config.Zap.AccessRespData { + if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessRespData { attributes = append(attributes, "resp_data", responseText) } - if privateErrors != "" { + if !paymentCallback && privateErrors != "" { attributes = append(attributes, "error_msg", privateErrors) } logger.InfoContext(c.Request.Context(), "请求完成", attributes...) @@ -122,6 +155,56 @@ func isMediaUploadRoute(route string) bool { strings.HasSuffix(route, "/mediaUpload/chunk") } +func isPaymentCallbackPath(path string) bool { + parts := strings.Split(strings.Trim(path, "/"), "/") + for index := 0; index+1 < len(parts); index++ { + if parts[index] == "payment" && parts[index+1] == "callback" { + return true + } + } + return false +} + +func paymentCallbackProvider(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + for index := 0; index+2 < len(parts); index++ { + if parts[index] == "payment" && parts[index+1] == "callback" { + return parts[index+2] + } + } + return "unknown" +} + +func paymentCallbackSummary(body []byte, contentType string) string { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType == "" { + mediaType = strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]) + } + if mediaType == "" { + mediaType = "unknown" + } + digest := sha256.Sum256(body) + return "[支付回调正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + " content_type=" + mediaType + "]" +} + +func isPaymentIntegrationConfigWrite(method, path string) bool { + if method != http.MethodPut { + return false + } + parts := strings.Split(strings.Trim(path, "/"), "/") + for index := 0; index+3 < len(parts); index++ { + if parts[index] == "integration" && parts[index+1] == "configs" && parts[index+2] == "payment" && parts[index+3] != "" { + return true + } + } + return false +} + +func paymentConfigSummary(body []byte) string { + digest := sha256.Sum256(body) + return "[支付配置正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + "]" +} + func redactHeaders(headers map[string][]string) map[string]string { out := make(map[string]string, len(headers)) for key, values := range headers { diff --git a/internal/server/middleware/access_log_test.go b/internal/server/middleware/access_log_test.go index 67bd9bc..f3fabf7 100644 --- a/internal/server/middleware/access_log_test.go +++ b/internal/server/middleware/access_log_test.go @@ -1,7 +1,9 @@ package middleware import ( + "bytes" "io" + "log/slog" "net/http" "net/http/httptest" "strings" @@ -63,3 +65,60 @@ func TestAccessLogAllowsMediaLimitOnlyOnUploadRoute(t *testing.T) { t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String()) } } + +func TestAccessLogRedactsPaymentCallbackPayloadAndHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + var logs bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo})) + runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}}) + engine := gin.New() + engine.Use(AccessLog(runtime, logger, "test")) + engine.POST("/api/payment/callback/:provider", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"body": "callback-response-secret"}) + }) + + request := httptest.NewRequest(http.MethodPost, "/api/payment/callback/alipay?signature=query-secret", strings.NewReader("payment-body-secret")) + request.Header.Set("Content-Type", "application/json; boundary=credential-secret") + request.Header.Set("Authorization", "Bearer header-secret") + request.Header.Set("X-Alipay-Signature", "signature-secret") + response := httptest.NewRecorder() + + engine.ServeHTTP(response, request) + logText := logs.String() + for _, secret := range []string{"payment-body-secret", "query-secret", "header-secret", "signature-secret", "callback-response-secret", "credential-secret"} { + if strings.Contains(logText, secret) { + t.Fatalf("payment callback secret leaked into access log: %q in %s", secret, logText) + } + } + for _, marker := range []string{"payment_callback=true", "payment_provider=alipay", "body_sha256=", "http_status=200"} { + if !strings.Contains(logText, marker) { + t.Fatalf("payment callback access summary missing %q: %s", marker, logText) + } + } +} + +func TestAccessLogOmitsPaymentIntegrationConfigBody(t *testing.T) { + gin.SetMode(gin.TestMode) + var logs bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo})) + runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}}) + engine := gin.New() + engine.Use(AccessLog(runtime, logger, "test")) + engine.PUT("/api/integration/configs/:kind/:provider", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0}) }) + + body := `{"enabled":true,"config":{"key":"saobei-secret","certificate_blob":"certificate-secret","unknown_credential":"credential-secret"}}` + request := httptest.NewRequest(http.MethodPut, "/api/integration/configs/payment/saobei", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + + engine.ServeHTTP(response, request) + logText := logs.String() + for _, secret := range []string{"saobei-secret", "certificate-secret", "credential-secret"} { + if strings.Contains(logText, secret) { + t.Fatalf("payment configuration secret leaked into access log: %q in %s", secret, logText) + } + } + if !strings.Contains(logText, "支付配置正文已省略") || !strings.Contains(logText, "body_sha256=") { + t.Fatalf("payment configuration summary missing: %s", logText) + } +} diff --git a/internal/server/middleware/audit.go b/internal/server/middleware/audit.go index e482bf6..0164090 100644 --- a/internal/server/middleware/audit.go +++ b/internal/server/middleware/audit.go @@ -81,7 +81,11 @@ func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin. responseBody = "[超出记录长度]" } errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String() - if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil { + operationBody := operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes) + if isPaymentIntegrationConfigWrite(c.Request.Method, path) { + operationBody = paymentConfigSummary(requestBody) + } + if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationBody, Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil { // Preserve the business response, but expose audit persistence failures // to the global access/error logging pipeline. c.Set(ctxOperationAuditPersistFailedKey, true) @@ -128,7 +132,7 @@ func maskOperationBody(value any) { case map[string]any: for key, item := range current { normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", "")) - if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" { + if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" || normalized == "appkey" || normalized == "mchkey" || normalized == "apiv3key" || normalized == "clientcert" || normalized == "clientkey" || normalized == "platformcert" || normalized == "platformserialno" || normalized == "credentialcode" || normalized == "certfile" || normalized == "keyfile" || normalized == "publickey" || normalized == "rootcert" || normalized == "appcert" || normalized == "webhookid" { current[key] = "***" continue } @@ -157,17 +161,49 @@ func isDownloadResponse(c *gin.Context) bool { // recordsOperation mirrors the routes on which operation records are enabled. // Matching by suffix keeps the behavior stable when router-prefix is configured. func recordsOperation(method, path string) bool { - _, ok := operationRoutes[method+" "+routeSuffix(path)] - return ok + for route := range operationRoutes { + parts := strings.SplitN(route, " ", 2) + if len(parts) != 2 || parts[0] != method || !operationPathMatches(parts[1], path) { + continue + } + return true + } + return false +} + +func operationPathMatches(pattern, path string) bool { + patternParts := strings.Split(strings.Trim(pattern, "/"), "/") + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) < len(patternParts) { + return false + } + pathParts = pathParts[len(pathParts)-len(patternParts):] + for index, patternPart := range patternParts { + if strings.HasPrefix(patternPart, "*") { + return index <= len(pathParts) + } + if index >= len(pathParts) || (strings.HasPrefix(patternPart, ":") == false && patternPart != pathParts[index]) { + return false + } + } + return len(patternParts) == len(pathParts) } func routeSuffix(path string) string { - for _, marker := range []string{"/user/", "/api/", "/casbin/", "/authority/", "/menu/", "/department/", "/position/", "/sysDictionary/", "/sysDictionaryDetail/", "/sysParams/", "/securityConfig/", "/system/", "/sysApiToken/", "/sysVersion/", "/sysExportTemplate/", "/sysError/", "/sysLoginLog/", "/sysOperationRecord/", "/dataAccessLog/", "/timedTask/", "/info/", "/email/"} { + bestIndex := -1 + bestPath := path + for _, marker := range []string{"/user/", "/api/", "/casbin/", "/authority/", "/menu/", "/department/", "/position/", "/sysDictionary/", "/sysDictionaryDetail/", "/sysParams/", "/securityConfig/", "/system/", "/sysApiToken/", "/sysVersion/", "/sysExportTemplate/", "/sysError/", "/sysLoginLog/", "/sysOperationRecord/", "/dataAccessLog/", "/timedTask/", "/info/", "/email/", "/integration/", "/payment/"} { if index := strings.Index(path, marker); index >= 0 { - return path[index:] + // Router prefixes may themselves contain a registered route marker + // (for example /api/integration/...). Keep the deepest match so the + // policy and audit route remain the actual application endpoint. + if index > bestIndex { + bestIndex = index + bestPath = path[index:] + } } } - return path + return bestPath } var operationRoutes = func() map[string]struct{} { @@ -189,6 +225,8 @@ var operationRoutes = func() map[string]struct{} { "DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds", "POST /timedTask/createTimedTask", "PUT /timedTask/updateTimedTask", "DELETE /timedTask/deleteTimedTask", "POST /timedTask/toggleTimedTask", "POST /timedTask/triggerTimedTask", "POST /info/createInfo", "DELETE /info/deleteInfo", "DELETE /info/deleteInfoByIds", "PUT /info/updateInfo", "POST /email/emailTest", "POST /email/sendEmail", + "PUT /integration/configs/:kind/:provider", "DELETE /integration/configs/:kind/:provider", + "POST /payment/create", "POST /payment/query", "POST /payment/refund", "POST /payment/orders/:provider/:tradeNo/refund", "POST /payment/fulfill", "POST /payment/orders/:provider/:tradeNo/fulfill", "POST /payment/providers/:provider/test", } out := make(map[string]struct{}, len(values)) for _, value := range values { diff --git a/internal/server/middleware/payment_audit_test.go b/internal/server/middleware/payment_audit_test.go new file mode 100644 index 0000000..60b7012 --- /dev/null +++ b/internal/server/middleware/payment_audit_test.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestPaymentIntegrationSecretsAreRedacted(t *testing.T) { + raw := []byte(`{"enabled":true,"config":{"app_id":"app","mch_key":"merchant-secret","api_v3_key":"v3-secret","client_cert":"certificate","client_key":"private-key","platform_cert":"platform-certificate","credential_code":"credential","webhook_id":"webhook"}}`) + redacted := redactJSON(raw, "application/json", 4096) + var payload struct { + Config map[string]string `json:"config"` + } + if err := json.Unmarshal([]byte(redacted), &payload); err != nil { + t.Fatalf("decode redacted payload: %v", err) + } + for _, key := range []string{"mch_key", "api_v3_key", "client_cert", "client_key", "platform_cert", "credential_code", "webhook_id"} { + if payload.Config[key] != "***" { + t.Fatalf("payment secret %q was not redacted: %s", key, redacted) + } + } + if !strings.Contains(redacted, `"app_id":"app"`) { + t.Fatalf("non-secret integration field was removed: %s", redacted) + } +} + +func TestPaymentOperationsAreAuditedWithRouterPrefix(t *testing.T) { + for _, route := range []struct { + method string + path string + }{ + {method: "PUT", path: "/api/integration/configs/payment/alipay"}, + {method: "POST", path: "/api/payment/refund"}, + {method: "POST", path: "/api/payment/fulfill"}, + {method: "POST", path: "/api/payment/providers/alipay/test"}, + } { + if !recordsOperation(route.method, route.path) { + t.Fatalf("payment operation was not audited: %s %s", route.method, route.path) + } + } +} + +func TestPaymentIntegrationConfigUsesRouteLevelSummary(t *testing.T) { + raw := []byte(`{"enabled":true,"config":{"key":"secret","custom_certificate":"certificate"}}`) + summary := paymentConfigSummary(raw) + if strings.Contains(summary, "secret") || strings.Contains(summary, "certificate") { + t.Fatalf("payment configuration summary leaked payload: %s", summary) + } + if !isPaymentIntegrationConfigWrite("PUT", "/api/integration/configs/payment/saobei") { + t.Fatal("payment configuration write route was not recognized") + } + if isPaymentIntegrationConfigWrite("PUT", "/api/integration/configs/mq/emqx") { + t.Fatal("non-payment integration was treated as payment configuration") + } +} diff --git a/internal/service/route_metadata.go b/internal/service/route_metadata.go index 6f6fe96..55b7cb4 100644 --- a/internal/service/route_metadata.go +++ b/internal/service/route_metadata.go @@ -47,6 +47,13 @@ var apiMetadata = map[string]apiMetadataValue{ "GET /integration/configs/:kind": {group: "集成配置", description: "按类型获取集成配置"}, "GET /integration/configs/:kind/:provider": {group: "集成配置", description: "获取指定集成配置"}, "GET /payment/orders": {group: "支付", description: "分页查询支付订单"}, + "GET /payment/orders/:provider/:tradeNo": {group: "支付", description: "按路径查询支付订单"}, + "POST /payment/create": {group: "支付", description: "创建支付订单"}, + "POST /payment/query": {group: "支付", description: "同步支付订单状态"}, + "POST /payment/refund": {group: "支付", description: "申请支付订单退款"}, + "POST /payment/orders/:provider/:tradeNo/refund": {group: "支付", description: "按路径申请支付订单退款"}, + "POST /payment/fulfill": {group: "支付", description: "重试支付订单发货"}, + "POST /payment/orders/:provider/:tradeNo/fulfill": {group: "支付", description: "按路径重试支付订单发货"}, "POST /payment/providers/:provider/test": {group: "支付", description: "测试支付渠道配置与沙箱交易链路"}, "GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"}, "GET /sysDictionary/exportSysDictionary": {group: "系统字典", description: "导出字典JSON"}, diff --git a/pkg/mq/rabbitmq.go b/pkg/mq/rabbitmq.go new file mode 100644 index 0000000..6a3fc62 --- /dev/null +++ b/pkg/mq/rabbitmq.go @@ -0,0 +1,447 @@ +package mq + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type RabbitMQConfig struct { + Enabled bool + Host string + Port int + Username string + Password string + VHost string + Exchange string + ExchangeType string + Queue string + RoutingKey string + Durable bool + AutoDelete bool + PrefetchCount int + Heartbeat time.Duration + ConnectTimeout time.Duration + TLS bool +} + +type rabbitSubscription struct { + qos byte + handler Handler +} + +// RabbitMQ adapts AMQP exchanges and routing keys to the shared topic-based +// Client contract. All subscriptions share the configured queue and a single +// consumer; deliveries are dispatched to matching handlers locally. +type RabbitMQ struct { + mu sync.RWMutex + opMu sync.Mutex + publishMu sync.Mutex + consumeMu sync.Mutex + connection *amqp.Connection + publishChannel *amqp.Channel + consumeChannel *amqp.Channel + config RabbitMQConfig + subscriptions map[string]rabbitSubscription + consumerTag string + consuming bool + stop chan struct{} + closed bool +} + +func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) { + client := &RabbitMQ{subscriptions: make(map[string]rabbitSubscription), stop: make(chan struct{})} + if !config.Enabled { + return client, nil + } + config = defaultRabbitMQConfig(config) + if err := validateRabbitMQConfig(config); err != nil { + return nil, err + } + + scheme := "amqp" + if config.TLS { + scheme = "amqps" + } + address := amqp.URI{ + Scheme: scheme, Host: config.Host, Port: config.Port, + Username: config.Username, Password: config.Password, Vhost: config.VHost, + ConnectionTimeout: int(config.ConnectTimeout.Milliseconds()), + }.String() + connection, err := amqp.DialConfig(address, amqp.Config{ + Heartbeat: config.Heartbeat, + Recovery: &amqp.Recovery{}, + }) + if err != nil { + return nil, fmt.Errorf("connect rabbitmq: %w", err) + } + + publishChannel, err := connection.Channel() + if err != nil { + _ = connection.Close() + return nil, fmt.Errorf("open rabbitmq publish channel: %w", err) + } + consumeChannel, err := connection.Channel() + if err != nil { + _ = publishChannel.Close() + _ = connection.Close() + return nil, fmt.Errorf("open rabbitmq consume channel: %w", err) + } + closeOnError := func() { + _ = consumeChannel.Close() + _ = publishChannel.Close() + _ = connection.Close() + } + if err = consumeChannel.ExchangeDeclare(config.Exchange, config.ExchangeType, config.Durable, config.AutoDelete, false, false, nil); err != nil { + closeOnError() + return nil, fmt.Errorf("declare rabbitmq exchange: %w", err) + } + if _, err = consumeChannel.QueueDeclare(config.Queue, config.Durable, config.AutoDelete, false, false, nil); err != nil { + closeOnError() + return nil, fmt.Errorf("declare rabbitmq queue: %w", err) + } + if config.PrefetchCount > 0 { + if err = consumeChannel.Qos(config.PrefetchCount, 0, false); err != nil { + closeOnError() + return nil, fmt.Errorf("configure rabbitmq qos: %w", err) + } + } + client.connection = connection + client.publishChannel = publishChannel + client.consumeChannel = consumeChannel + client.config = config + client.consumerTag = fmt.Sprintf("kra-%d", time.Now().UnixNano()) + return client, nil +} + +func defaultRabbitMQConfig(config RabbitMQConfig) RabbitMQConfig { + if config.Port <= 0 { + if config.TLS { + config.Port = 5671 + } else { + config.Port = 5672 + } + } + if config.Username == "" { + config.Username = "guest" + } + if config.Password == "" { + config.Password = "guest" + } + if config.VHost == "" { + config.VHost = "/" + } + if config.ExchangeType == "" { + config.ExchangeType = "topic" + } + if config.RoutingKey == "" { + config.RoutingKey = "#" + } + if config.Heartbeat <= 0 { + config.Heartbeat = 10 * time.Second + } + if config.ConnectTimeout <= 0 { + config.ConnectTimeout = 10 * time.Second + } + return config +} + +func validateRabbitMQConfig(config RabbitMQConfig) error { + if strings.TrimSpace(config.Host) == "" { + return errors.New("rabbitmq host is empty") + } + if config.Port < 1 || config.Port > 65535 { + return fmt.Errorf("invalid rabbitmq port %d", config.Port) + } + if strings.TrimSpace(config.Exchange) == "" { + return errors.New("rabbitmq exchange is empty") + } + if strings.TrimSpace(config.Queue) == "" { + return errors.New("rabbitmq queue is empty") + } + switch strings.ToLower(config.ExchangeType) { + case "direct", "fanout", "topic": + return nil + default: + return fmt.Errorf("invalid rabbitmq exchange type %q", config.ExchangeType) + } +} + +func (c *RabbitMQ) Publish(ctx context.Context, topic string, payload []byte, qos byte, _ bool) error { + if c == nil { + return ErrUnavailable + } + if c != nil && strings.TrimSpace(topic) == "" { + topic = c.config.RoutingKey + } + if strings.TrimSpace(topic) == "" { + return errors.New("rabbitmq routing key is empty") + } + if qos > AtLeastOnce { + return fmt.Errorf("rabbitmq supports qos 0 or 1, got %d", qos) + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + c.publishMu.Lock() + defer c.publishMu.Unlock() + c.mu.RLock() + if c.closed || c.publishChannel == nil || c.publishChannel.IsClosed() { + c.mu.RUnlock() + return ErrUnavailable + } + channel := c.publishChannel + exchange := c.config.Exchange + c.mu.RUnlock() + deliveryMode := amqp.Transient + if qos >= AtLeastOnce { + deliveryMode = amqp.Persistent + } + return channel.PublishWithContext(ctx, exchange, topic, false, false, amqp.Publishing{ + ContentType: "application/octet-stream", + DeliveryMode: deliveryMode, + Timestamp: time.Now(), + Body: append([]byte(nil), payload...), + }) +} + +func (c *RabbitMQ) Subscribe(ctx context.Context, topic string, qos byte, handler Handler) error { + if c == nil { + return ErrUnavailable + } + if c != nil && strings.TrimSpace(topic) == "" { + topic = c.config.RoutingKey + } + if strings.TrimSpace(topic) == "" { + return errors.New("rabbitmq routing key is empty") + } + if qos > AtLeastOnce { + return fmt.Errorf("rabbitmq supports qos 0 or 1, got %d", qos) + } + if handler == nil { + return errors.New("rabbitmq handler is nil") + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + c.opMu.Lock() + defer c.opMu.Unlock() + c.consumeMu.Lock() + defer c.consumeMu.Unlock() + c.mu.RLock() + if c.closed || c.consumeChannel == nil || c.consumeChannel.IsClosed() { + c.mu.RUnlock() + return ErrUnavailable + } + channel := c.consumeChannel + config := c.config + _, exists := c.subscriptions[topic] + c.mu.RUnlock() + if !exists { + if err := channel.QueueBind(config.Queue, topic, config.Exchange, false, nil); err != nil { + return fmt.Errorf("bind rabbitmq queue: %w", err) + } + } + + c.mu.Lock() + c.subscriptions[topic] = rabbitSubscription{qos: qos, handler: handler} + shouldStart := !c.consuming + c.mu.Unlock() + if !shouldStart { + return nil + } + deliveries, err := channel.Consume(config.Queue, c.consumerTag, false, false, false, false, nil) + if err != nil { + c.mu.Lock() + delete(c.subscriptions, topic) + c.mu.Unlock() + if !exists { + _ = channel.QueueUnbind(config.Queue, topic, config.Exchange, nil) + } + return fmt.Errorf("consume rabbitmq queue: %w", err) + } + c.mu.Lock() + c.consuming = true + c.mu.Unlock() + go c.consume(deliveries) + return nil +} + +func (c *RabbitMQ) consume(deliveries <-chan amqp.Delivery) { + for { + select { + case <-c.stop: + return + case delivery, ok := <-deliveries: + if !ok { + c.mu.Lock() + c.consuming = false + c.mu.Unlock() + return + } + subscriptions := c.subscriptionSnapshot() + for pattern, item := range subscriptions { + if rabbitRoutingKeyMatches(c.config.ExchangeType, pattern, delivery.RoutingKey) { + item.handler(context.Background(), Message{Topic: delivery.RoutingKey, Payload: append([]byte(nil), delivery.Body...), QoS: item.qos}) + } + } + c.consumeMu.Lock() + _ = delivery.Ack(false) + c.consumeMu.Unlock() + } + } +} + +func (c *RabbitMQ) subscriptionSnapshot() map[string]rabbitSubscription { + c.mu.RLock() + defer c.mu.RUnlock() + result := make(map[string]rabbitSubscription, len(c.subscriptions)) + for topic, item := range c.subscriptions { + result[topic] = item + } + return result +} + +func (c *RabbitMQ) Unsubscribe(ctx context.Context, topics ...string) error { + if c == nil { + return ErrUnavailable + } + if len(topics) == 0 { + return errors.New("rabbitmq routing keys are empty") + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + c.opMu.Lock() + defer c.opMu.Unlock() + c.consumeMu.Lock() + defer c.consumeMu.Unlock() + c.mu.RLock() + if c.closed || c.consumeChannel == nil || c.consumeChannel.IsClosed() { + c.mu.RUnlock() + return ErrUnavailable + } + channel := c.consumeChannel + config := c.config + c.mu.RUnlock() + for _, topic := range topics { + c.mu.RLock() + _, exists := c.subscriptions[topic] + c.mu.RUnlock() + if !exists { + continue + } + if err := channel.QueueUnbind(config.Queue, topic, config.Exchange, nil); err != nil { + return fmt.Errorf("unbind rabbitmq queue: %w", err) + } + c.mu.Lock() + delete(c.subscriptions, topic) + c.mu.Unlock() + } + c.mu.RLock() + empty := len(c.subscriptions) == 0 + consuming := c.consuming + c.mu.RUnlock() + if empty && consuming { + if err := channel.Cancel(c.consumerTag, false); err != nil { + return fmt.Errorf("cancel rabbitmq consumer: %w", err) + } + c.mu.Lock() + c.consuming = false + c.mu.Unlock() + } + return nil +} + +func (c *RabbitMQ) Connected() bool { + if c == nil { + return false + } + c.mu.RLock() + defer c.mu.RUnlock() + return !c.closed && c.connection != nil && !c.connection.IsClosed() +} + +func (c *RabbitMQ) Close() error { + if c == nil { + return nil + } + c.opMu.Lock() + defer c.opMu.Unlock() + c.publishMu.Lock() + defer c.publishMu.Unlock() + c.consumeMu.Lock() + defer c.consumeMu.Unlock() + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil + } + c.closed = true + close(c.stop) + publishChannel := c.publishChannel + consumeChannel := c.consumeChannel + connection := c.connection + c.publishChannel = nil + c.consumeChannel = nil + c.connection = nil + c.mu.Unlock() + var result error + if consumeChannel != nil { + result = errors.Join(result, consumeChannel.Close()) + } + if publishChannel != nil { + result = errors.Join(result, publishChannel.Close()) + } + if connection != nil { + result = errors.Join(result, connection.Close()) + } + return result +} + +func rabbitRoutingKeyMatches(exchangeType, pattern, routingKey string) bool { + switch strings.ToLower(exchangeType) { + case "fanout": + return true + case "direct": + return pattern == routingKey + } + patternParts := strings.Split(pattern, ".") + routingParts := strings.Split(routingKey, ".") + for len(patternParts) > 0 { + head := patternParts[0] + patternParts = patternParts[1:] + if head == "#" { + if len(patternParts) == 0 { + return true + } + for index := 0; index <= len(routingParts); index++ { + if rabbitTopicPartsMatch(patternParts, routingParts[index:]) { + return true + } + } + return false + } + if len(routingParts) == 0 || (head != "*" && head != routingParts[0]) { + return false + } + routingParts = routingParts[1:] + } + return len(routingParts) == 0 +} + +func rabbitTopicPartsMatch(patternParts, routingParts []string) bool { + return rabbitRoutingKeyMatches("topic", strings.Join(patternParts, "."), strings.Join(routingParts, ".")) +} diff --git a/pkg/mq/rabbitmq_test.go b/pkg/mq/rabbitmq_test.go new file mode 100644 index 0000000..d3cd86d --- /dev/null +++ b/pkg/mq/rabbitmq_test.go @@ -0,0 +1,48 @@ +package mq + +import ( + "context" + "errors" + "testing" +) + +func TestDisabledRabbitMQIsSafeAndUnavailable(t *testing.T) { + client, err := NewRabbitMQ(RabbitMQConfig{}) + if err != nil { + t.Fatal(err) + } + if client.Connected() { + t.Fatal("disabled rabbitmq reported connected") + } + if err = client.Publish(context.Background(), "orders.paid", []byte("test"), AtLeastOnce, false); !errors.Is(err, ErrUnavailable) { + t.Fatalf("publish error = %v", err) + } + if err = client.Close(); err != nil { + t.Fatal(err) + } +} + +func TestEnabledRabbitMQRequiresTopology(t *testing.T) { + if _, err := NewRabbitMQ(RabbitMQConfig{Enabled: true}); err == nil { + t.Fatal("enabled rabbitmq without host and topology should fail") + } +} + +func TestRabbitRoutingKeyMatches(t *testing.T) { + tests := []struct { + pattern string + key string + want bool + }{ + {pattern: "orders.*.paid", key: "orders.cn.paid", want: true}, + {pattern: "orders.#", key: "orders.cn.created", want: true}, + {pattern: "#.paid", key: "orders.cn.paid", want: true}, + {pattern: "orders.*", key: "orders.cn.paid", want: false}, + {pattern: "orders.created", key: "orders.paid", want: false}, + } + for _, test := range tests { + if got := rabbitRoutingKeyMatches("topic", test.pattern, test.key); got != test.want { + t.Fatalf("match(%q, %q) = %v, want %v", test.pattern, test.key, got, test.want) + } + } +} diff --git a/web/src/api/payment.js b/web/src/api/payment.js index 6af848b..e714be5 100644 --- a/web/src/api/payment.js +++ b/web/src/api/payment.js @@ -3,25 +3,36 @@ import service from '@/utils/request' export const getPaymentOrders = (params) => service({ url: '/payment/orders', method: 'get', - params + params, + donNotShowLoading: true }) export const getPaymentOrder = (data) => service({ url: '/payment/order', method: 'post', - data + data, + donNotShowLoading: true }) export const queryPaymentOrder = (data) => service({ url: '/payment/query', method: 'post', - data + data, + donNotShowLoading: true }) export const refundPaymentOrder = (data) => service({ url: '/payment/refund', method: 'post', - data + data, + donNotShowLoading: true +}) + +export const fulfillPaymentOrder = (data) => service({ + url: '/payment/fulfill', + method: 'post', + data, + donNotShowLoading: true }) export const testPaymentProvider = (provider) => service({ @@ -34,5 +45,6 @@ export const testPaymentProvider = (provider) => service({ ? { baseURL: `${window.location.origin}/` } : {}), url: `/payment/providers/${encodeURIComponent(provider)}/test`, - method: 'post' + method: 'post', + donNotShowLoading: true }) diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index e26b714..dd1e10f 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -57,6 +57,8 @@ "/src/view/system/state.vue": "State", "/src/view/systemTools/integration/config.vue": "IntegrationConfig", "/src/view/systemTools/logViewer/index.vue": "LogViewer", + "/src/view/systemTools/payment/config.vue": "PaymentConfig", + "/src/view/systemTools/payment/orders.vue": "PaymentOrders", "/src/view/systemTools/sysError/sysError.vue": "SysError", "/src/view/systemTools/system/system.vue": "Config", "/src/view/systemTools/timedTask/index.vue": "TimedTask", diff --git a/web/src/view/systemTools/integration/config.vue b/web/src/view/systemTools/integration/config.vue index 8246360..34eb100 100644 --- a/web/src/view/systemTools/integration/config.vue +++ b/web/src/view/systemTools/integration/config.vue @@ -32,7 +32,7 @@ {{ providerMeta(item).protocol }} - {{ item.enabled ? '运行中' : '已停用' }} + {{ item.enabled ? '已启用' : '已停用' }} @@ -390,6 +390,11 @@ const hasMaskedSecret = (item) => const markSaved = (item) => { item.configured = true + for (const field of item.fields || []) { + if (field.secret && !isMissing(item.config[field.key])) { + item.config[field.key] = '******' + } + } item._savedEnabled = item.enabled item._savedConfig = cloneConfig(item.config) } diff --git a/web/src/view/systemTools/payment/config.vue b/web/src/view/systemTools/payment/config.vue index b15d8c8..e0b569c 100644 --- a/web/src/view/systemTools/payment/config.vue +++ b/web/src/view/systemTools/payment/config.vue @@ -1,107 +1,432 @@ diff --git a/web/src/view/systemTools/payment/orders.vue b/web/src/view/systemTools/payment/orders.vue index eaf70dc..242444e 100644 --- a/web/src/view/systemTools/payment/orders.vue +++ b/web/src/view/systemTools/payment/orders.vue @@ -1,68 +1,524 @@