From 653414a1da412abad9be114bcb8164523312b915 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Thu, 27 Aug 2026 16:20:37 +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 --- .gitignore | 4 + Makefile | 7 - buf.gen.yaml | 21 - buf.lock | 6 - buf.yaml | 5 - cmd/main.go | 19 +- cmd/wire.go | 6 +- cmd/wire_gen.go | 12 +- docs/ADMIN_BACKEND.md | 52 - docs/OA_MIGRATION_PLAN.md | 1355 ----------------- docs/PAYMENT.md | 410 ----- docs/system-pkg-audit.md | 87 -- docs/system-structure-audit.md | 63 - internal/app/runtime.go | 31 - internal/app/runtime_test.go | 55 - internal/biz/system/cache.go | 15 + internal/biz/system/infrastructure.go | 114 -- internal/biz/system/settings.go | 43 + internal/biz/system/storage.go | 26 + internal/biz/system/token.go | 48 + internal/config/clone.go | 49 +- internal/config/document.go | 209 +++ internal/config/document_test.go | 39 + internal/config/runtime.go | 206 +-- internal/data/config_helpers.go | 12 - internal/data/config_store.go | 288 +--- internal/data/data.go | 48 +- internal/data/data_scope_audit.go | 2 +- internal/data/data_scope_audit_test.go | 2 +- internal/data/initialization_backend.go | 7 +- .../data/integration/integration_config.go | 67 + internal/data/integration/migrations.go | 2 +- internal/data/integration/storage_email.go | 219 +++ internal/data/integration_config.go | 274 +--- internal/data/integration_config_test.go | 32 +- internal/data/integration_runtime.go | 22 - internal/data/migrations.go | 15 - internal/data/runtime_clients.go | 206 ++- internal/data/runtime_clients_test.go | 38 + internal/initialize/backend.go | 20 - internal/initialize/configuration.go | 185 +-- internal/initialize/configuration_test.go | 6 +- internal/initialize/initialize.go | 38 +- internal/modules/catalog_test.go | 9 +- internal/routecatalog/catalog.go | 124 +- internal/routecatalog/catalog_test.go | 9 + internal/server/gin.go | 4 +- internal/server/handler/http.go | 5 +- internal/worker/task_registry.go | 5 +- pkg/module/module.go | 46 - pkg/module/module_test.go | 27 +- pkg/protoutil/json.go | 80 - pkg/task/registry.go | 18 - pkg/task/registry_test.go | 15 - 54 files changed, 1257 insertions(+), 3450 deletions(-) delete mode 100644 buf.gen.yaml delete mode 100644 buf.lock delete mode 100644 buf.yaml delete mode 100644 docs/ADMIN_BACKEND.md delete mode 100644 docs/OA_MIGRATION_PLAN.md delete mode 100644 docs/PAYMENT.md delete mode 100644 docs/system-pkg-audit.md delete mode 100644 docs/system-structure-audit.md delete mode 100644 internal/app/runtime.go delete mode 100644 internal/app/runtime_test.go create mode 100644 internal/biz/system/cache.go delete mode 100644 internal/biz/system/infrastructure.go create mode 100644 internal/biz/system/settings.go create mode 100644 internal/biz/system/storage.go create mode 100644 internal/biz/system/token.go create mode 100644 internal/config/document.go create mode 100644 internal/config/document_test.go delete mode 100644 internal/data/config_helpers.go create mode 100644 internal/data/integration/storage_email.go delete mode 100644 internal/data/integration_runtime.go delete mode 100644 internal/data/migrations.go create mode 100644 internal/data/runtime_clients_test.go delete mode 100644 internal/initialize/backend.go delete mode 100644 pkg/protoutil/json.go diff --git a/.gitignore b/.gitignore index a56862b..c108be9 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ bin/ web/dist/ web/node_modules/ .pnpm-store/ + +# Local reference sources and desktop-only tooling state +/gva/ +/.claude/settings.local.json diff --git a/Makefile b/Makefile index d108ed4..e0c0aed 100644 --- a/Makefile +++ b/Makefile @@ -6,12 +6,6 @@ VERSION=$(shell git describe --tags --always) # init env init: go install github.com/google/wire/cmd/wire@latest - go install github.com/bufbuild/buf/cmd/buf@latest - -.PHONY: api -# generate api proto -api: - buf generate --template buf.gen.yaml .PHONY: build # build @@ -27,7 +21,6 @@ generate: .PHONY: all # generate all all: - make api make generate # show help diff --git a/buf.gen.yaml b/buf.gen.yaml deleted file mode 100644 index 7e92649..0000000 --- a/buf.gen.yaml +++ /dev/null @@ -1,21 +0,0 @@ -version: v2 -inputs: - - directory: api -plugins: - - local: ["go", "run", "google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11"] - out: api - opt: paths=source_relative - - local: ["go", "run", "google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2"] - out: api - opt: paths=source_relative - - local: ["go", "run", "github.com/go-kratos/kratos/cmd/protoc-gen-go-http/v3@v3.0.0-20260526000039-30da04b769dc"] - out: api - opt: paths=source_relative - - local: ["go", "run", "go.einride.tech/protoc-gen-typescript-http@v0.10.0"] - out: web/src/services - - local: ["go", "run", "github.com/google/gnostic/cmd/protoc-gen-openapi@v0.7.1"] - out: . - strategy: all - opt: - - fq_schema_naming=true - - default_response=false diff --git a/buf.lock b/buf.lock deleted file mode 100644 index 8447589..0000000 --- a/buf.lock +++ /dev/null @@ -1,6 +0,0 @@ -# Generated by buf. DO NOT EDIT. -version: v2 -deps: - - name: buf.build/googleapis/googleapis - commit: c17df5b2beca46928cc87d5656bd5343 - digest: b5:648a01e0170d4512dea7d564016165decd1ed6e34bef79fe54753e51ad7e27545709ad9157d7551270147d551155c595a2fb0bf5bb33b1c83040ddbce915c604 diff --git a/buf.yaml b/buf.yaml deleted file mode 100644 index e222284..0000000 --- a/buf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -version: v2 -modules: - - path: api -deps: - - buf.build/googleapis/googleapis diff --git a/cmd/main.go b/cmd/main.go index c096979..323db7a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -8,7 +8,6 @@ import ( "os" "strings" - "kra/internal/app" "kra/internal/config" "kra/internal/server/router" "kra/internal/service" @@ -50,13 +49,17 @@ func init() { flag.StringVar(&flagconf, "conf", "./configs", "config path, eg: -conf config.yaml") } -// runtimeContributions is the binary-level list of modules with constructed -// route or task dependencies. Adding another runtime module is explicit here. -func runtimeContributions(systemRoutes *router.Routes, systemTasks *worker.TaskMethods) app.Composition { - return app.Composition{ - Routes: []module.RouteRegistrar{systemRoutes}, - Tasks: []platformtask.Contributor{systemTasks}, - } +// taskRegistry builds the process-wide registry from the dependency-free task +// methods the static module catalog declares. Methods whose handlers need +// constructed usecases are added later by buildRuntime. +func taskRegistry() *platformtask.Registry { return platformtask.NewRegistry() } + +// buildRuntime is the binary-level list of modules with constructed route or +// task dependencies: it activates their task methods and composes their routes. +// Adding another runtime module is explicit here. +func buildRuntime(registry *platformtask.Registry, systemRoutes *router.Routes, systemTasks *worker.TaskMethods) module.RouteRegistrar { + systemTasks.RegisterTasks(registry) + return systemRoutes } func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler, audit *service.AuditRecorder, loggerControl *logging.ReloadableLogger, _ mq.Client) *kratos.App { diff --git a/cmd/wire.go b/cmd/wire.go index 5172fca..446f806 100644 --- a/cmd/wire.go +++ b/cmd/wire.go @@ -8,7 +8,6 @@ package main import ( "log/slog" - "kra/internal/app" "kra/internal/biz" taskbiz "kra/internal/biz/task" "kra/internal/config" @@ -38,9 +37,8 @@ func wireApp(*config.Server, *config.Store, *slog.Logger, *logging.ReloadableLog router.ProviderSet, worker.ProviderSet, modules.Catalog, - app.TaskRegistry, - runtimeContributions, - app.Build, + taskRegistry, + buildRuntime, data.ProviderSet, integration.ProviderSet, initialize.ProviderSet, diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 348b320..a75bd90 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -8,7 +8,6 @@ package main import ( "github.com/go-kratos/kratos/v3" - "kra/internal/app" integration3 "kra/internal/biz/integration" payment2 "kra/internal/biz/payment" system2 "kra/internal/biz/system" @@ -75,6 +74,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg v2 := system3.NewSecurityService(securityUsecase) auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo) v3 := system3.NewAuditRecorder(auditRecorderUsecase) + registry := taskRegistry() authorityUsecase := system2.NewAuthorityUsecase(authorityAccessRepo) v4 := system3.NewAuthorityService(authorityUsecase) authority := handler.NewAuthority(v4) @@ -111,7 +111,6 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg v12 := payment3.NewPaymentService(paymentUsecase) handlerPayment := handler.NewPayment(v12) taskRepo := task.NewTaskRepo(dataData) - registry := app.TaskRegistry(catalog) taskUsecase := task2.NewTaskUsecaseWithRegistry(taskRepo, registry) taskExecutor := worker.NewTaskExecutorWithRegistry(taskUsecase, registry) taskScheduler := worker.NewTaskScheduler(taskUsecase, authorityUsecase, taskExecutor, logger) @@ -192,14 +191,13 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg maintenanceRepo := system.NewMaintenanceRepo(dataData) maintenanceUsecase := system2.NewMaintenanceUsecase(maintenanceRepo) taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store) - composition := runtimeContributions(routes, taskMethods) - runtime := app.Build(composition, registry) + routeRegistrar := buildRuntime(registry, routes, taskMethods) websocketServer, cleanup2, err := websocket.New(runtimeconfigStore) if err != nil { cleanup() return nil, nil, err } - engine := server.NewGinEngineWithRuntime(store, v, authService, v2, v3, logger, string2, runtime, websocketServer) + engine := server.NewGinEngineWithRuntime(store, v, authService, v2, v3, logger, string2, routeRegistrar, websocketServer) httpServer := server.NewGinServer(configServer, engine) mqReloadable, cleanup3, err := mq.New(runtimeconfigStore, logger) if err != nil { @@ -207,8 +205,8 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg cleanup() return nil, nil, err } - kratosApp := newApp(logger, httpServer, taskScheduler, v3, reloadableLogger, mqReloadable) - return kratosApp, func() { + app := newApp(logger, httpServer, taskScheduler, v3, reloadableLogger, mqReloadable) + return app, func() { cleanup3() cleanup2() cleanup() diff --git a/docs/ADMIN_BACKEND.md b/docs/ADMIN_BACKEND.md deleted file mode 100644 index 4f18ae4..0000000 --- a/docs/ADMIN_BACKEND.md +++ /dev/null @@ -1,52 +0,0 @@ -# Kra 管理后台 - -## 范围 - -Kra 管理后台由 Kratos 应用承载生命周期,Gin 提供兼容管理端的 HTTP 路由。后端按 `service -> biz -> data` 分层,前端位于 `web/`。 - -保留的业务模块: - -- 初始化、认证、用户和个人中心 -- 角色、菜单、API、Casbin 和按钮权限 -- 部门、岗位和数据范围 -- 字典、字典项和系统参数 -- 安全策略、系统配置和 API Token -- Excel 导入导出与版本管理 -- 操作日志、登录日志、数据访问日志、错误日志和文件日志 -- 定时任务、SSE、媒体上传和附件分类 -- 公告管理与邮件发送 - -不包含代码生成、智能辅助、模块安装发布、客户示例和测试页面。 - -## 目录 - -| 目录 | 职责 | -|---|---| -| `internal/server` | Gin 路由、中间件和 Kratos transport 生命周期 | -| `internal/service` | HTTP DTO 与领域对象转换、用例编排 | -| `internal/biz` | 领域对象、业务规则和仓储接口 | -| `internal/data` | GORM、MySQL、Redis 和本地文件存储实现 | -| `web` | Vue 3 管理端 | - -## 兼容约定 - -- HTTP 响应为 `{code, data, msg}`,成功码为 `0`。 -- JWT 接受 `x-token` Header 和同名 Cookie。 -- 初始化创建后台数据表、管理员、菜单,并从 Gin 路由同步 API 权限数据。 -- 超级管理员角色 `888` 保留全权限旁路,其他角色由 Casbin 控制。 -- SMTP 配置位于 `admin.email`;未配置时错误邮件告警保持关闭。 -- 媒体文件默认使用本地 `uploads/file` 存储。 - -## 验证 - -```bash -go generate ./cmd -go test ./... -go vet ./... -go build ./... - -cd web -pnpm install -pnpm run lint -pnpm run build -``` diff --git a/docs/OA_MIGRATION_PLAN.md b/docs/OA_MIGRATION_PLAN.md deleted file mode 100644 index 772c114..0000000 --- a/docs/OA_MIGRATION_PLAN.md +++ /dev/null @@ -1,1355 +0,0 @@ -# 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/system`、`internal/data/system`、`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/PAYMENT.md b/docs/PAYMENT.md deleted file mode 100644 index fbd1582..0000000 --- a/docs/PAYMENT.md +++ /dev/null @@ -1,410 +0,0 @@ -# 支付接入设计 - -## 代码分层 - -支付渠道适配器统一放在 `internal/data/payment`,订单仓储和运行时入口保留在 -`internal/data`;这与 `internal/data/storage` 集中管理 OSS provider 的方式一致, -避免各渠道协议散落在 data 根目录。它们仍属于 data 层,因为需要读取 -`sys_integration_configs`、调用运行时客户端、转换 `biz` 对象并在数据库事务中推进 -`pay_orders` 状态。无状态的协议基础能力已下沉到领域化公共包: - -- `pkg/paymentkit`:金额整数化、支付状态归一化、JSON 路径读取、微信 v2 XML、 - 微信 v2/聚合渠道签名等纯函数;不依赖 `biz`、`data` 或数据库。 -- `internal/data/payment`:GoPay 和配置驱动渠道的 adapter、SDK 配置映射、 - 回调验签/解密以及 DO 结果归一化;GoPay/driver 类型不越过 data 边界。 -- `pkg/osskit`:跨对象存储 provider 的流式合并和 MD5 计算;不依赖具体 OSS SDK。 -- `internal/data/storage`:本地磁盘、七牛、阿里云 OSS、华为 OBS、腾讯 COS、 - S3/MinIO/R2 的客户端创建、配置映射和 `biz.FileStorage` 适配仍保留在 data。 - -因此不能把整个 `internal/data` 或所有 OSS provider 直接移动到 `pkg`:那会让公共包 -反向依赖内部领域模型和运行时配置,破坏分层。后续新增 provider 时,优先把纯签名、 -金额和报文转换放入 `pkg/paymentkit`,把配置、HTTP 客户端、数据库和业务结果转换留在 -`internal/data`。 - -支付模块创建一张 `pay_orders` 表,用于记录一次支付尝试的金额、渠道状态、发货状态、退款状态和并发租约。业务模块继续拥有自己的业务订单,通过 `business_type + business_id` 关联支付订单。渠道配置继续使用已有的 `sys_integration_configs`,`kind = payment`;不创建独立回调事件表或支付日志表。 - -## 订单表边界 - -`pay_orders` 一行代表一次支付尝试,而不是业务订单: - -- `(provider, trade_no)` 唯一,同一个业务订单可以创建多个不同渠道或不同批次的支付尝试。 -- `(provider, provider_trade_no)` 唯一,禁止一个平台交易绑定两个本地订单。 -- `request_fingerprint` 防止相同商户订单号被不同金额、币种或业务对象重复使用。 -- `payment_status`、`fulfillment_status`、`refund_status` 相互独立,退款不会抹掉原始支付和发货事实。 -- `original_amount` 是业务原价;`amount` 是提交给第三方平台的订单总额,二者均使用币种最小单位整数。 -- `payer_paid_amount` 是用户实际支付金额;`cash_paid_amount` / `point_paid_amount` 分别表示现金和积分/平台资产部分;`discount_amount` 是订单总额减用户实付。 -- `provider_discount_amount` / `merchant_discount_amount` 只有渠道明确返回出资拆分时才填写;未知字段不能推测。`settlement_amount` 是渠道最终结算商户金额,不等于用户实付。 -- `amount_breakdown_known` 只有在上述核心金额满足守恒校验时才为 `true`,否则仍保留总额校验,但不伪造优惠拆分。 -- `confirmation_id` 根据 `provider + trade_no` 稳定生成,是业务发货最终幂等键。 -- 只保存平台响应哈希和必要状态;不保存完整回调原文、签名密钥或请求头。 -- `fulfillment_token` / `refund_token` 和租约时间用于跨实例互斥,进程退出后租约到期可安全重试。 - -## GoPay 渠道矩阵(v1.5.122) - -本分支固定使用 `github.com/go-pay/gopay v1.5.122`。下面的九行是 GoPay -稳定版本中可复用的渠道族;微信在本地拆成 `wechat-v2` 和 `wechat-v3` 两个 -provider,因此渠道族仍然是九个,而不是把微信重复计算成两个渠道。 - -| GoPay 渠道族 | 本地 provider | 当前适配的 GoPay 能力 | 回调/退款边界 | -| --- | --- | --- | --- | -| Alipay(旧网关协议) | `alipay` | `TradeCreate`、`TradePay`、`TradePrecreate`、`TradeAppPay`、`TradePagePay`、`TradeWapPay`、查单、退款、通知验签 | 支持服务端验签后主动查单 | -| Alipay V3 | `alipay-v3` | GoPay V3 `TradeCreate`、`TradePay`、`TradePrecreate`、`TradeAppPay`、`TradePagePay`、`TradeWapPay`、查单、退款、证书响应验签、通知证书验签 | 必须配置应用公钥证书、支付宝根证书和支付宝公钥证书;确认仍以主动查单为准 | -| WeChat | `wechat-v2`、`wechat-v3` | v2 `UnifiedOrder`/`Micropay`/`QueryOrder`/`Refund`;v3 JSAPI、App、Native、H5、CodePay 付款码下单、查单、退款、通知验签/解密 | v2 使用商户密钥;v3 使用平台证书和 API v3 key | -| Apple | `apple-iap` | App Store Server API 交易查询、JWS/证书链处理 | 下单由客户端 StoreKit 驱动;服务端不提供主动退款 | -| PayPal | `paypal` | `CreateOrder`、`OrderDetail`、`OrderCapture`、`PaymentCaptureRefund`、Webhook 验签 | 买家批准后由查单链路捕获;退款需要已持久化的 capture ID | -| Douyin | `douyin` | App、JSAPI、H5、Native 下单,按商户订单号查单、退款、通知验签和解密 | 平台证书必须配置 | -| QQ | `qq` | `UnifiedOrder`、`OrderQuery`、`Refund`、通知解析和验签 | 退款需要商户证书、私钥或 PKCS#12 | -| AllinPay | `allinpay` | `Pay`、`ScanPay`、`NativePay`、`Query`、`Refund` | 当前不接收通知;使用主动查单/对账确认 | -| Lakala | `lakala` | JSAPI、H5、小程序、Native/二维码、Native JSAPI、SDK、Web Gateway、线下条码/二维码下单,`OrderStatus`、`Refund`、通知解析和验签 | 回调字段以 GoPay 返回模型为准 | -| Saobei | `saobei` | `MiniPay`、`BarcodePay`、`Query`、`Refund` | 当前不接收通知;使用主动查单/对账确认 | - -`CMB`(招商银行)不在固定的 v1.5.122 模块中,不能作为本分支已接入渠道; -上游未发布版本中的目录或提交不构成稳定依赖,故这里明确排除。 - -AllinPay 和 Saobei 的 adapter 会拒绝回调入口,因为固定版本没有可复用且能在 -本项目边界内完成验签的通知路径。对这两个渠道,生产流程必须依赖主动查单、 -定时对账和幂等状态推进,不得把未验签的通知当作支付事实。 - -本矩阵只说明代码和 GoPay 方法的接入情况,不代表真实商户沙箱或生产联调已经完成。 -当前仓库仅做单元测试和本地 mock/HTTP 响应验证;上线前仍须使用实际商户凭证、 -证书、平台回调和退款报文逐项联调。 - -统一 adapter 面向普通商户的核心下单、查单、退款和回调流程。微信服务商/合单、 -PayPal `AUTHORIZE` 意图后的授权捕获,以及账单、分账、转账等能力需要不同的业务 -状态机和持久化字段,不能仅靠透传 `extra` 安全接入;这些扩展应按实际业务合同 -单独建模,不属于“稳定渠道族已接入”的含义。 - -## 其他已保留渠道 - -| Provider | 实现方式 | -| --- | --- | -| `alipay`、`alipay-v3`、`wechat-v2`、`wechat-v3`、`apple-iap`、`paypal`、`douyin`、`qq`、`allinpay`、`lakala`、`saobei` | 统一走上面的 GoPay v1.5.122 adapter;业务层只接收 `paymentbiz.PaymentResult` | -| `chinaums` | 配置驱动的银联商务 JSON 签名适配器 | -| `sft` | 配置驱动的商福通 JSON/MD5 适配器 | -| `supper-pay` | 配置驱动的 Supper Pay HMAC 适配器 | -| `wechat-game-pay` | 配置驱动的微信小游戏虚拟支付 2.0 适配器,支持 access token | -| `douyin-game-pay` | 配置驱动的抖音小游戏支付签名适配器 | - -上表后五个 provider 不是 GoPay v1.5.122 的九个稳定渠道族,仍保留现有的 -配置驱动协议适配器。它们的协议字段依商户合同和产品版本不同,因此没有强行 -假设某一个固定请求格式,必须在 `sys_integration_configs.config` 中声明查单 -字段和金额单位。 - -## 回调安全流程 - -创建支付按以下顺序处理: - -1. 根据 `business_type` 调用业务模块注册的 `PaymentOrderSource`。 -2. 业务模块返回可信的金额、币种、标题、业务 ID 和商户订单号;客户端提交的金额不是权威数据。 -3. 先插入 `pay_orders`,利用唯一索引和请求指纹实现本地下单幂等。 -4. 再调用支付平台创建订单;平台创建响应只会把本地状态推进到 `pending`,不能直接认定已支付。 -5. 平台调用成功后保存客户端后续支付所需的创建响应。数据库记录失败时,可使用相同商户订单号安全重试平台创建接口。 - -每次支付回调按以下顺序处理: - -1. 根据渠道配置加载适配器。 -2. 校验渠道签名、证书/JWS/通知解密,并校验应用号、商户号等身份字段。 -3. 从已验签的通知中提取商户订单号。 -4. **主动调用对应平台查单接口**,不以回调中的支付状态、金额或币种作为最终依据。 -5. 查单状态只允许 `success`、`pending`、`failed`;未知状态直接失败。成功状态必须同时包含商户订单号、平台交易号、正整数金额和币种。 -6. 查单状态不是成功时,不发货;后续回调或业务主动查询可以再次确认。 -7. 从 `pay_orders` 读取本地权威金额、币种、业务类型和业务 ID。 -8. 严格比较渠道、商户订单号、平台交易号、金额、币种;任一关键字段缺失或不一致都拒绝更新为已支付。 -9. 在数据库事务中把支付状态推进到 `paid`,再竞争发货租约。 -10. 根据 `business_type` 查找 `PaymentFulfillmentHandler`,使用稳定 `confirmation_id` 执行业务发货。 -11. 发货成功后将 `fulfillment_status` 更新为 `succeeded`;失败记录为 `failed` 并允许重试。重复回调和主动查询不会重复执行已完成发货。 - -支付模块不会接受客户端传入的金额、标题或币种作为权威订单数据。持久化流程启用后,未注册 `PaymentOrderSource` 的业务类型会直接拒绝下单。 - -## 业务扩展接口 - -业务侧必须把同一个实现作为 `PaymentBusinessModule` 注册。该接口同时要求实现 -`PaymentOrderSource`(返回权威金额、币种和标题)、`PaymentFulfillmentHandler` -(按 `confirmation_id` 幂等发货)和 `PaymentRefundAuthorizer`(校验业务订单是否 -允许退款)。应用组合阶段调用 `PaymentUsecase.RegisterBusinessModule(...)`; -只注册支付 adapter 而不注册业务模块是不完整的接入。 - -当前仓库没有注册任何生产业务模块,因此直接调用持久化支付创建流程会返回 -`支付业务订单来源未注册`。这不是渠道配置错误,接入具体商品、订单或订阅业务时 -必须在应用启动组装处完成注册。 - -每个业务模块先实现可信订单来源: - -```go -import paymentbiz "kra/internal/biz/payment" - -type GameItemPayment struct { - orders GameItemOrderRepo -} - -func (GameItemPayment) Type() string { return "game_item" } - -func (p GameItemPayment) PreparePayment(ctx context.Context, provider, tradeNo, businessID string) (*paymentbiz.PaymentIntent, error) { - order, err := p.orders.FindPayable(ctx, businessID) - if err != nil { - return nil, err - } - return &paymentbiz.PaymentIntent{ - Provider: provider, TradeNo: tradeNo, - BusinessType: "game_item", BusinessID: businessID, - Subject: order.Title, Amount: order.PayableAmount, Currency: order.Currency, - }, nil -} -``` - -然后注册按业务类型分发的发货处理器: - -```go -func (p GameItemPayment) Fulfill(ctx context.Context, c *paymentbiz.PaymentConfirmation) error { - return p.orders.Transaction(ctx, func(tx GameItemOrderTx) error { - // confirmation_id 必须有唯一约束。已处理时直接返回 nil。 - if tx.HasPaymentConfirmation(c.ID) { - return nil - } - if err := tx.Deliver(c.BusinessID); err != nil { - return err - } - return tx.SavePaymentConfirmation(c.ID) - }) -} - -func (p GameItemPayment) AuthorizeRefund(ctx context.Context, order *paymentbiz.PaymentOrder, amount int64) error { - return p.orders.CheckRefundable(ctx, order.BusinessID, amount) -} -``` - -启动时将同一个实现分别注册到 `PaymentOrderSourceRegistry` 和 `PaymentFulfillmentRegistry`。`PaymentConfirmation.ID` 同一支付尝试永远不变。业务处理器必须在自己的事务中以该 ID 建唯一约束,才能覆盖“业务已发货但进程在更新 `pay_orders` 前退出”的极端窗口。 - -推荐直接注册完整业务模块: - -```go -if err := paymentUsecase.RegisterBusinessModule(GameItemPayment{orders: gameOrders}); err != nil { - return err -} -``` - -## 一致性边界 - -- 本地下单:唯一索引和 `request_fingerprint` 保证同一支付号不可换金额或业务对象。 -- 平台确认:只有验签后的主动查单结果可以把订单推进到 `paid`,状态不会从已支付回退到待支付或失败。 -- 多实例发货:数据库行锁、处理令牌和租约保证同一时刻只有一个实例执行发货。 -- 最终发货幂等:业务模块必须在自己的事务中对 `confirmation_id` 建唯一约束。 -- 退款:一次只允许一个在途退款;每次退款都有持久化 `refund_no`,网络超时会复用同一个退款号重试。渠道响应提供退款号回显时,adapter 必须校验其与本地 `refund_no` 一致,并要求独立的平台退款号非空;平台接受退款后状态为 `pending`,只有携带匹配 `refund_no` 的退款通知或对账任务调用 `ConfirmRefund` 后才增加 `refunded_amount`。 -- 退款授权:业务模块必须实现 `AuthorizeRefund`,支付模块不会仅凭渠道、订单号和金额执行退款。 -- 外部平台调用和本地数据库无法组成单个 ACID 事务,因此采用“本地先落单、平台接口幂等重试、主动查单、数据库状态机、业务最终幂等”的组合保证最终一致性。 - -## 配置示例 - -支付宝: - -```json -{"app_id":"","private_key":"PEM","public_key":"PEM","environment":"production","sign_type":"RSA2","gateway_url":"https://openapi.alipay.com/gateway.do","method":"alipay.trade.create"} -``` - -`method` 可选 `alipay.trade.create`、`alipay.trade.pay`、`alipay.trade.precreate`、 -`alipay.trade.app.pay`、`alipay.trade.page.pay` 或 `alipay.trade.wap.pay`。 -付款码支付可使用 `barcode` / `micropay` 别名,并在订单 `extra.auth_code` 中传入付款码。 - -支付宝 V3: - -```json -{"app_id":"","private_key":"PEM","app_cert":"PEM 或文件路径","root_cert":"PEM 或文件路径","public_cert":"PEM 或文件路径","environment":"production","api_base_url":"https://openapi.alipay.com","gateway_url":"https://openapi.alipay.com/gateway.do","method":"alipay.trade.create"} -``` - -`alipay-v3` 独立于旧 `alipay` provider。`app_cert`、`root_cert` 和 `public_cert` -分别对应 GoPay `ClientV3.SetCert` 的应用公钥证书、支付宝根证书和支付宝公钥证书; -也可使用 `*_content` / `*_path` 以及 `alipay_root_cert*`、`alipay_public_cert*` -兼容别名。V3 HTTP 接口使用 `api_base_url`(测试代理可指向本地 mock),页面/APP -调起参数使用 GoPay 已封装的 `TradeAppPay`、`TradePagePay` 和 `TradeWapPay`; -`method` 支持 `alipay.trade.create`、`alipay.trade.pay`、`alipay.trade.precreate`、 -`alipay.trade.app.pay`、`alipay.trade.page.pay` 和 `alipay.trade.wap.pay`。V3 的 -REST 响应只有在证书验签通过后才会进入业务层,通知回调同样使用 GoPay 的证书验签。 - -微信支付 v2: - -```json -{"app_id":"","merchant_id":"","mch_key":"","sign_type":"MD5","trade_type":"NATIVE","client_cert":"PEM","client_key":"PEM"} -``` - -`client_cert` / `client_key` 在退款等双向 TLS 请求中使用;也接受 -`appid`、`mch_id`、`api_key` 等兼容别名。自定义测试端点可分别配置 -`create_url`、`query_url` 和 `refund_url`。`trade_type` 支持 `JSAPI`、`APP`、 -`NATIVE`、`MWEB`;付款码支付可使用 `micropay` / `barcode` 别名,并在订单 -`extra.auth_code`(或渠道配置同名字段)传入付款码。未知下单方式会直接拒绝; -付款码下单返回的平台交易号和金额会校验,客户端调起数据只保存在 `Payload`。 - -微信支付 v3: - -```json -{"app_id":"","merchant_id":"","serial_no":"","private_key":"PEM","api_v3_key":"32-byte key","platform_cert":"PEM","platform_serial_no":"","trade_type":"jsapi"} -``` - -`trade_type` 支持 `jsapi`、`app`、`native`、`h5` 和 `codepay` / `micropay`; -JSAPI/小程序下单还需在订单 `extra.openid` 中传入用户标识,付款码支付则需在 -订单 `extra.auth_code` 中传入用户付款码(兼容 `authcode` / `barcode` 字段名)。 - -Apple 内购: - -```json -{"issuer_id":"","key_id":"","bundle_id":"","private_key":"PEM","price_divisor":"10","environment":"production"} -``` - -Apple Server API 的交易 `price` 使用平台返回的单位;`price_divisor` 必须按业务订单使用的最小货币单位配置。金额不能整除时,支付模块拒绝确认。 - -Apple 多币种可使用 `price_divisors` 对不同 `currency` 分别配置比例;服务端不会把 Apple 的价格字段默认当作人民币分。 - -Apple 没有传统服务端“预下单”。创建接口要求 `tradeNo` 是 UUID,并把它作为 `appAccountToken` 返回给客户端;客户端发起 StoreKit 购买时必须原样传入。回调使用 `appAccountToken` 关联本地订单,使用 `transactionId` 调 Apple Server API 主动查单,两者不会混用。最终确认还会把签名载荷的 `productId` 与业务订单持久化的 `extra.product_id` 精确匹配;缺少 `appAccountToken`、Bundle ID、商品 ID 或环境不一致、JWS 算法不是 ES256、证书链校验失败时均拒绝确认。 -adapter 在调用 GoPay 解码前还会绑定 `x5c[0]` 叶子到 `x5c[1]`/`x5c[2]`,并检查 Apple App Store 签名证书扩展,避免仅凭叶子公钥验签。 - -Apple IAP 的购买流程由客户端发起,退款/撤销由 App Store 管理。本项目的 -`apple-iap` adapter 不提供商户服务端主动退款,业务侧应通过 Apple 的退款流程和 -后续通知/查询更新状态。 - -PayPal: - -```json -{"client_id":"","client_secret":"","webhook_id":"","environment":"sandbox","return_url":"https://merchant.example/paypal/return","cancel_url":"https://merchant.example/paypal/cancel","amount_scales":{"USD":"100","JPY":"1"}} -``` - -启用 PayPal 配置必须提供 `webhook_id`,回调验签会将其传给 GoPay;默认金额比例按 -PayPal 币种处理,也可使用 `amount_scales`/`currency_scales` 覆盖。`CAPTURE` 意图的 -订单在买家批准后会由查单流程调用 GoPay `OrderCapture` 完成捕获,只有捕获成功才会 -进入支付成功和发货;可显式配置 `auto_capture=false` 关闭。退款需要订单状态中已 -持久化的 capture ID。 - -抖音支付: - -```json -{"app_id":"","merchant_id":"","serial_no":"","api_key":"32-byte key","private_key":"PEM","platform_cert":"PEM","platform_serial_no":"","trade_type":"jsapi","environment":"production"} -``` - -当前 adapter 支持 `app`、`jsapi`、`h5`、`native`;固定版本的抖音客户端按 -生产接口工作,`environment` 只能使用 `production`/`prod`。平台证书序列号也接受 -GoPay 模型使用的兼容字段 `platform_cert_serial`。 - -QQ 支付: - -```json -{"mch_id":"","api_key":"","sign_type":"MD5","trade_type":"NATIVE","cert_file":"/secure/qq/apiclient_cert.pem","key_file":"/secure/qq/apiclient_key.pem","environment":"production"} -``` - -`sign_type` 可用 `MD5` 或 `HMAC-SHA256`。退款必须配置 -`cert_file` + `key_file`,或 `pkcs12_file`;也可使用对应的 `*_content` 字段。 - -通联支付(AllinPay): - -```json -{"cus_id":"","app_id":"","private_key":"PEM","public_key":"PEM","org_id":"","pay_type":"W02","query_order_type":"reqsn","currency":"CNY","environment":"production"} -``` - -`query_order_type` 只允许 `reqsn` 或 `trxid`,默认使用商户订单号 `reqsn`。 -选择 `trxid` 时,下单响应必须返回交易号并将其持久化为后续查单、退款标识; -Native 下单不会返回该标识,因此不能与 `trxid` 模式组合。该 provider 只接入 -下单、查单和退款,不接收通知;支付确认由主动查单/对账触发。 - -拉卡拉(Lakala): - -```json -{"partner_code":"","credential_code":"","channel":"Wechat","method":"jsapi","currency":"JPY","environment":"production"} -``` - -`method` 支持 `jsapi`、`h5`、`mini`、`native`、`qrcode`、`native_jsapi`、 -`sdk`、`web`、`retail` 和 `retail_qrcode`,并映射到 GoPay v1.5.122 对应的 -创建方法;未知值会直接拒绝。下单响应中的 `order_id` 是持久化查单键,二维码、 -跳转 URL 和 SDK 参数只放在创建结果 `Payload`。支持查单、退款和 GoPay 通知验签。 -当前固定版本客户端只允许生产环境配置。 - -扫呗(Saobei): - -```json -{"inst_no":"","key":"","merchant_no":"","terminal_id":"","access_token":"","pay_type":"010","currency":"CNY","environment":"production"} -``` - -该 provider 只接入条码/小程序下单、查单和退款,不接收通知;支付确认由主动 -查单/对账触发。条码支付在订单 `extra.auth_no` 中传入付款码,也兼容 -`auth_code`、`authcode`、`barcode` 和 `pay_code` 字段名。 - -非 GoPay 的配置驱动聚合/小游戏渠道最少需要: - -```json -{ - "protocol_version":"以商户协议为准", - "app_id":"", - "merchant_id":"", - "create_url":"", - "query_url":"", - "refund_url":"", - "app_key":"", - "query_status_field":"data.status", - "query_success_values":"SUCCESS,PAID", - "query_trade_no_field":"data.trade_no", - "query_provider_trade_no_field":"data.transaction_id", - "query_amount_field":"data.amount", - "query_currency_field":"data.currency", - "query_amount_scale":"1", - "callback_status_field":"data.status", - "callback_success_values":"SUCCESS,PAID", - "callback_trade_no_field":"data.trade_no", - "callback_provider_trade_no_field":"data.transaction_id" -} -``` - -`query_amount_scale = 1` 表示响应已经是最小货币单位整数;`100` 表示响应是元/主货币单位并转换为分;其他值必须是 10 的幂。字段路径使用点号访问 JSON 对象。 - -聚合/小游戏渠道如需保存优惠和内部资产拆分,可额外配置: - -```json -{ - "query_payer_paid_amount_field":"data.payer_total", - "query_cash_paid_amount_field":"data.cash_fee", - "query_point_paid_amount_field":"data.point_fee", - "query_discount_amount_field":"data.discount", - "query_provider_discount_amount_field":"data.provider_discount", - "query_merchant_discount_amount_field":"data.merchant_discount", - "query_settlement_amount_field":"data.settlement_amount", - "query_payer_currency_field":"data.payer_currency", - "query_payer_paid_amount_scale":"100", - "query_cash_paid_amount_scale":"100" -} -``` - -金额拆分字段缺失时,适配器把 `amount_breakdown_known` 设为 `false`,不会将未知优惠归给平台或商户。字段存在但不能按配置的整数比例精确换算时,查单失败并要求修正渠道配置。 - -内部支付使用 `provider = internal`、`payment_mode = internal`,不调用第三方平台。业务模块实现 `PayInternal`,以 `trade_no` 原子、幂等扣减积分、余额或其他内部资产,并返回授权号和完整金额拆分;仍然走同一套 `pay_orders`、查单/确认、发货和退款状态机。内部退款必须由业务模块以同一订单幂等执行 `RefundInternal`。 - -Chinaums、SFT、Supper Pay、微信小游戏和抖音小游戏没有在代码中假设所有商户都使用同一合同版本。启用前必须拿实际商户文档和沙箱报文逐项确认请求字段、签名串、金额单位、回调字段和成功状态;缺少 `protocol_version` 或明确回调字段映射时配置校验会拒绝启用。这里提供的是严格失败的适配框架,不应在未完成渠道联调测试时标记为生产可用。 - -## 回调 ACK - -回调接口不会返回内部错误文本、平台原始查询结果或业务发货信息。支付宝、微信 v2、微信 v3 和 Apple 使用各自固定 ACK;聚合渠道默认返回纯文本 `success` / `failure`,并可按实际协议配置: - -```json -{ - "callback_success_status":"200", - "callback_success_content_type":"application/json", - "callback_success_body":"{\"code\":\"SUCCESS\"}", - "callback_failure_status":"500", - "callback_failure_content_type":"application/json", - "callback_failure_body":"{\"code\":\"FAIL\"}" -} -``` - -状态码只允许 `200-599`,Content-Type 禁止换行,响应体最大 64KiB。验签、主动查单、订单解析、金额校验或发货失败时返回失败 ACK,让支持重试的平台再次通知;查单为 `pending` / `failed` 时不发货,但通知已被安全处理,因此返回成功 ACK。 - -## HTTP 接口 - -- `GET /integration/configs/payment` -- `GET /integration/configs/payment/:provider` -- `PUT /integration/configs/payment/:provider` -- `DELETE /integration/configs/payment/:provider` -- `POST /payment/order` -- `POST /payment/create` -- `POST /payment/query` -- `POST /payment/refund` -- `POST /payment/callback/:provider` - -`amount` 使用整数,单位是业务约定的最小货币单位。`extra` 传递渠道特有字段,例如 `openid`、`trade_type`、`product_id`。 - -## 日志 - -支付日志在 `internal/biz/payment/payment_log.go` 通过 `PaymentLogger` 独立抽象,包含下单、查单失败、回调查单、金额校验、重复回调和发货结果等结构化事件。日志只记录渠道、商户订单号、业务类型、业务 ID、确认 ID 等审计字段,不记录私钥、密钥、证书内容或完整敏感回调原文。 diff --git a/docs/system-pkg-audit.md b/docs/system-pkg-audit.md deleted file mode 100644 index b550906..0000000 --- a/docs/system-pkg-audit.md +++ /dev/null @@ -1,87 +0,0 @@ -# `` 到 `pkg` 复用性审查 - -审查原则:公共包只提供跨模块稳定的机制、协议或无状态纯函数;不能依赖 -`app/*/internal`,也不承载 system 的业务表、用例、provider 客户端或运行时 -配置。 - -## 已抽取到 `pkg` - -| 能力 | 公共位置 | 说明 | -| --- | --- | --- | -| HTTP JSON 响应契约 | `pkg/httpx` | `Response`、`PageResult`、状态码和 Gin 响应助手;`server/httpx/response.go` 仅保留 system 适配。 | -| protobuf JSON 局部合并 | `pkg/protoutil` | 与业务无关的字段归一化和局部反序列化;初始化直接使用公共包。 | -| 支付 provider/mode 标识 | `pkg/paymentkit` | provider 常量、支持列表、金额/签名/JSON 等跨模块协议;`biz/payment` 与 `biz/integration` 直接复用。 | -| 支付回调 ACK | `pkg/paymentkit` | 回调应答、失败包装和默认 provider 应答;具体渠道 SDK 留在 `internal/integration/payment`。 | -| WebSocket 通用收发 | `pkg/websocket` | Melody 的连接、事件、点对点发送、广播和会话查询封装;`internal/integration` 管理配置与生命周期。 | -| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口;EMQX/Paho 与 RabbitMQ/AMQP 客户端由 `internal/integration` 管理。 | -| 模块、任务和迁移协议 | `pkg/module`、`pkg/task`、`pkg/database/migration` | 供不同业务模块注册贡献,不带 system 业务语义。 | - -## system 内部保留边界 - -- `app`:运行时组合根,汇总依赖注入后的路由和任务贡献。 -- `modules`:静态模块 catalog,汇总各模块迁移、菜单、API 和默认任务。 -- `modules/system`:system 模块的 Definition,声明系统表迁移。 -- `modules/integration`:integration 配置迁移和管理面贡献。 -- `modules/task`:定时任务迁移和默认任务贡献。 -- `modules/payment`:payment 模块的 Definition,声明支付迁移和支付管理面。 -- `biz/system`:用户、权限、菜单、审计、媒体和系统配置等系统领域模型与用例。 -- `biz/payment`:支付订单、支付流程、支付接口和支付日志。 -- `biz/integration`:支付/消息队列/WebSocket 集成配置定义与校验。 -- `biz/task`:定时任务模型、任务用例和任务注册协议。 -- `conf`:system 配置 proto、运行时快照和生成代码。 -- `data`:共享数据库生命周期与配置 watcher;PO/仓储按 `system`、`integration`、`task`、`payment` 子包隔离;后台 JWT claims 与签发/解析位于 `data/system/token.go`。 -- `initialize`:首次安装、配置迁移、种子编排和运行时重载。 -- `integration`:Redis、邮件、存储、支付、WebSocket、EMQX 和 RabbitMQ 的 provider 生命周期。 -- `routecatalog`:HTTP 公开性、操作审计、请求体策略和 API 分组/说明的统一目录。 -- `service`:HTTP DTO(`service/dto`)、DTO 与 DO 转换和应用服务。 -- `server`:Gin 生命周期;handler、middleware、router、HTTP 适配按子包维护。 -- `worker`:任务调度、执行器、SSE 订阅及其并发状态。 - -这些代码都带有 system 的 API、配置、数据表或生命周期语义,不应为了减少 -文件数量搬到 `pkg`。 - -## 目录分类约定 - -```text -internal/ - app/ # 应用组合根 - modules/ # 静态 catalog、业务模块定义及其模块级贡献 - biz/ - system/ # 系统领域 - payment/ # 支付领域 - integration/# 集成配置领域 - task/ # 定时任务领域 - config/ # Viper 配置、快照和热更新 - global/ # 进程级共享资源入口 - data/ - system/ # 系统表与系统仓储,含后台 JWT token.go - integration/# 集成配置表与仓储 - task/ # 定时任务表与仓储 - payment/ # 支付表与仓储 - initialize/ # 首次安装和配置编排 - integration/ # 外部 I/O provider - server/ # Gin 生命周期,内部按 handler/middleware/router/httpx 分类 - service/ # 应用服务;DTO 集中在 dto 子包 - worker/ # 任务运行时 -``` - -技术角色使用子目录表达,子目录内部使用资源名,例如 `handler/payment.go`、 -`router/payment.go`、`dto/payment.go`。只有少量代码且没有独立边界时不建立 -新包;同一角色文件较多时也不应全部堆在父目录。 - -## 暂不抽取的候选 - -1. token cookie 名称、SameSite 和反向代理策略:目前是 system 认证策略。 -2. operation-audit 脱敏和请求采集:包含 system context key 与审计字段。 -3. biz 查询选项和错误:当前绑定 system 的领域接口及 API reason。 - -只有当其他模块出现相同、稳定且不带 system 语义的契约时,才新增公共包;不要 -直接把单个 system 类型搬到 `pkg`。 - -## 验证 - -```text -go test ./... -``` - -当前全仓测试通过,且未发现对已删除旧路径的 Go import。 diff --git a/docs/system-structure-audit.md b/docs/system-structure-audit.md deleted file mode 100644 index 9832817..0000000 --- a/docs/system-structure-audit.md +++ /dev/null @@ -1,63 +0,0 @@ -# `internal` 目录结构优化结论 - -参考 Go Kratos 的分层方式,顶层保留 `app`、`modules`、`biz`、`config`、`global`、 -`data`、`initialize`、`integration`、`security`、`server`、`service`、`worker` 等 -稳定职责。目录不是越少越好:同一技术角色文件较多时,应在所属层下分组,避免 -一个目录堆积几十个文件。 - -## 当前结构 - -```text -internal/ - app/ # 应用组合根 - modules/ # 静态 catalog 和模块定义 - modules/payment/ # payment 模块定义 - biz/ # DO、usecase、repo interface - config/ # Viper 配置、快照和热更新 - global/ # 进程级共享资源 - data/ # PO、repo、数据库和迁移 - initialize/ # 首次安装和配置编排 - integration/ # 外部 I/O provider - security/ # JWT 和安全实现 - server/ - handler/ # Gin handler,按资源命名 - middleware/ # 认证、审计、限流、恢复等中间件 - router/ # 各资源路由及 Routes 聚合 - httpx/ # system HTTP 响应和 cookie 适配 - service/ - dto/ # HTTP 请求、响应和查询 DTO,按模块命名 - worker/ # 任务运行时 -``` - -## 本次调整 - -- DTO 从 `service` 根目录归档到 `service/dto`。 -- handler、middleware、router、HTTP helper 分别归档到 `server` 子包。 -- 子目录内文件直接使用资源名,例如 `handler/payment.go`、 - `router/payment.go`、`dto/payment.go`,不保留重复角色前缀。 -- Wire 直接装配 `handler.ProviderSet` 和 `router.ProviderSet`,server 根目录只 - 负责 Gin/Swagger 生命周期。 -- `security/adminauth/token.go` 合并为 `security/token.go`;单文件子包没有 - 独立边界时不继续拆分。 -- 删除只转发 `pkg/protoutil` 的 `utils/configutil`。 - -## `internal/app` 为什么只保留应用组合 - -`modules/catalog.go` 是静态模块注册点,负责按依赖顺序汇总各模块 -`Definition()`;`app/runtime.go` 只负责任务注册和依赖注入后的路由组合。 -这样模块定义不再和应用组合逻辑混在一起,也不能误并入 `biz`、`service` 或 -`data`。 - -Catalog 只能自动汇总静态模块贡献;新增模块若提供运行时路由或依赖型任务,仍需 -在 cmd/Wire 中显式注册。 - -## 其他目录审查 - -- `data` 与 `integration` 已按仓储或 provider 分类,边界和生命周期明确。 -- `biz`、`service` 根目录采用一资源一文件;进一步拆成资源子包会改变 Go 包 - 边界并容易引入循环依赖,本次不做纯视觉拆分。 -- `security` 当前只有一个文件,但安全实现是明确的依赖边界,后续认证机制也会 - 在此扩展,因此保留顶层包。 - -新增目录应至少满足独立依赖方向、状态生命周期或稳定技术角色之一。不要回到 -一文件一目录,也不要为了减少目录数量把大量不同角色重新铺平。 diff --git a/internal/app/runtime.go b/internal/app/runtime.go deleted file mode 100644 index dc28930..0000000 --- a/internal/app/runtime.go +++ /dev/null @@ -1,31 +0,0 @@ -// Package app is the composition root for the running administration service. -// It wires runtime objects that need constructed dependencies. Static module -// declarations live in the sibling internal/modules package. -package app - -import ( - "kra/pkg/module" - platformtask "kra/pkg/task" -) - -// TaskRegistry builds the process-wide registry from dependency-free module -// contributions. Dependency-bearing methods are added by their module runtime -// constructors after the usecases have been created. -func TaskRegistry(catalog module.Catalog) *platformtask.Registry { - registry := platformtask.NewRegistry() - registry.RegisterAll(catalog.TaskMethods()) - return registry -} - -// Composition groups runtime objects that need constructed dependencies. The -// binary composition root supplies the concrete modules. -type Composition struct { - Routes []module.RouteRegistrar - Tasks []platformtask.Contributor -} - -// Build activates dependency-bearing tasks and composes module routes. -func Build(contributions Composition, registry *platformtask.Registry) *module.Runtime { - platformtask.Apply(registry, contributions.Tasks...) - return module.NewRuntime(contributions.Routes...) -} diff --git a/internal/app/runtime_test.go b/internal/app/runtime_test.go deleted file mode 100644 index de844e4..0000000 --- a/internal/app/runtime_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package app - -import ( - "context" - "encoding/json" - "testing" - - "github.com/gin-gonic/gin" - "kra/pkg/module" - platformtask "kra/pkg/task" -) - -func TestTaskRegistryRegistersStaticModuleMethods(t *testing.T) { - method := platformtask.Method{ - Name: "test.static", - Run: func(context.Context, json.RawMessage) error { return nil }, - } - catalog := module.Catalog{Definitions: []module.Definition{{Tasks: []platformtask.Method{method}}}} - registry := TaskRegistry(catalog) - if _, ok := registry.Lookup(method.Name); !ok { - t.Fatalf("method %q was not registered", method.Name) - } -} - -type testRouteRegistrar struct{ called bool } - -func (registrar *testRouteRegistrar) RegisterRoutes(*gin.RouterGroup, *gin.RouterGroup, *gin.Engine) { - registrar.called = true -} - -type testTaskContributor struct{ name string } - -func (contributor testTaskContributor) RegisterTasks(registry *platformtask.Registry) { - registry.Register(platformtask.Method{ - Name: contributor.name, - Run: func(context.Context, json.RawMessage) error { return nil }, - }) -} - -func TestRuntimeAppliesAllContributions(t *testing.T) { - registry := platformtask.NewRegistry() - route := &testRouteRegistrar{} - runtime := Build(Composition{ - Routes: []module.RouteRegistrar{route}, - Tasks: []platformtask.Contributor{testTaskContributor{name: "test.runtime"}}, - }, registry) - - if _, ok := registry.Lookup("test.runtime"); !ok { - t.Fatal("runtime task contributor was not applied") - } - runtime.RegisterRoutes(nil, nil, nil) - if !route.called { - t.Fatal("runtime route contributor was not called") - } -} diff --git a/internal/biz/system/cache.go b/internal/biz/system/cache.go new file mode 100644 index 0000000..36bdc9a --- /dev/null +++ b/internal/biz/system/cache.go @@ -0,0 +1,15 @@ +package system + +import ( + "context" + "time" +) + +// Cache is the shared cache seam. The data implementation uses Redis when it +// is reachable and falls back to process memory for local development. +type Cache interface { + Get(context.Context, string) (string, bool, error) + Set(context.Context, string, string, time.Duration) error + Delete(context.Context, string) error + Increment(context.Context, string, time.Duration) (int64, error) +} diff --git a/internal/biz/system/infrastructure.go b/internal/biz/system/infrastructure.go deleted file mode 100644 index 8aca735..0000000 --- a/internal/biz/system/infrastructure.go +++ /dev/null @@ -1,114 +0,0 @@ -package system - -import ( - "context" - "errors" - "io" - "time" -) - -// Cache is the shared cache seam. The data implementation uses Redis when it -// is reachable and falls back to process memory for local development. -type Cache interface { - Get(context.Context, string) (string, bool, error) - Set(context.Context, string, string, time.Duration) error - Delete(context.Context, string) error - Increment(context.Context, string, time.Duration) (int64, error) -} - -type StoredFile struct { - Name string - Path string - URL string - Size int64 - LastModified time.Time - ContentType string -} - -// FileStorage owns the persistence boundary for uploaded files. -type FileStorage interface { - Put(context.Context, string, io.Reader) (*StoredFile, error) - Open(context.Context, string) (io.ReadCloser, error) - Delete(context.Context, string) error - Compose(context.Context, []string, string) (*StoredFile, string, error) - DeletePrefix(context.Context, string) error - List(context.Context, string, string, int) ([]*StoredFile, string, bool, error) -} - -type JWTSettings struct { - SigningKey string - Issuer string - Expires time.Duration - Buffer time.Duration -} - -type CaptchaSettings struct { - KeyLong int - ImageWidth int - ImageHeight int - StoreExpiration time.Duration -} - -type MediaSettings struct { - SessionTTL int - MaxFileSize int64 - ChunkDir string -} - -const DefaultMaxMediaFileSize int64 = 100 << 20 - -func (s MediaSettings) EffectiveMaxFileSize() int64 { - if s.MaxFileSize > 0 { - return s.MaxFileSize - } - return DefaultMaxMediaFileSize -} - -// RuntimeSettings exposes only the active values needed by the application. -// The data implementation resolves every call from config.Store so hot reloads -// take effect without rebuilding services. -type RuntimeSettings interface { - RouterPrefix() string - JWTSettings() JWTSettings - CaptchaSettings() CaptchaSettings - MediaSettings() MediaSettings - UseMultipoint() bool -} - -type IssuedToken struct { - Value string - ExpiresAt time.Time - TTL time.Duration -} - -type AuthClaims struct { - UUID string - ID uint - Username string - NickName string - AuthorityID uint - UserType string - BufferTime time.Duration - MustChangePwd bool - PasswordVersion int64 - Issuer string - Audience []string - IssuedAt time.Time - NotBefore time.Time - ExpiresAt time.Time -} - -var ( - ErrTokenExpired = errors.New("token expired") - ErrTokenMalformed = errors.New("token malformed") - ErrTokenSignatureInvalid = errors.New("token signature invalid") - ErrTokenNotValidYet = errors.New("token not valid yet") - ErrTokenInvalid = errors.New("token invalid") - ErrTokenDisabled = errors.New("token disabled") -) - -type TokenIssuer interface { - IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error) - ReissueToken(*AuthClaims, uint) (*IssuedToken, error) - ParseToken(string) (*AuthClaims, error) -} diff --git a/internal/biz/system/settings.go b/internal/biz/system/settings.go new file mode 100644 index 0000000..3bc4756 --- /dev/null +++ b/internal/biz/system/settings.go @@ -0,0 +1,43 @@ +package system + +import "time" + +type JWTSettings struct { + SigningKey string + Issuer string + Expires time.Duration + Buffer time.Duration +} + +type CaptchaSettings struct { + KeyLong int + ImageWidth int + ImageHeight int + StoreExpiration time.Duration +} + +type MediaSettings struct { + SessionTTL int + MaxFileSize int64 + ChunkDir string +} + +const DefaultMaxMediaFileSize int64 = 100 << 20 + +func (s MediaSettings) EffectiveMaxFileSize() int64 { + if s.MaxFileSize > 0 { + return s.MaxFileSize + } + return DefaultMaxMediaFileSize +} + +// RuntimeSettings exposes only the active values needed by the application. +// The data implementation resolves every call from config.Store so hot reloads +// take effect without rebuilding services. +type RuntimeSettings interface { + RouterPrefix() string + JWTSettings() JWTSettings + CaptchaSettings() CaptchaSettings + MediaSettings() MediaSettings + UseMultipoint() bool +} diff --git a/internal/biz/system/storage.go b/internal/biz/system/storage.go new file mode 100644 index 0000000..0ab9c85 --- /dev/null +++ b/internal/biz/system/storage.go @@ -0,0 +1,26 @@ +package system + +import ( + "context" + "io" + "time" +) + +type StoredFile struct { + Name string + Path string + URL string + Size int64 + LastModified time.Time + ContentType string +} + +// FileStorage owns the persistence boundary for uploaded files. +type FileStorage interface { + Put(context.Context, string, io.Reader) (*StoredFile, error) + Open(context.Context, string) (io.ReadCloser, error) + Delete(context.Context, string) error + Compose(context.Context, []string, string) (*StoredFile, string, error) + DeletePrefix(context.Context, string) error + List(context.Context, string, string, int) ([]*StoredFile, string, bool, error) +} diff --git a/internal/biz/system/token.go b/internal/biz/system/token.go new file mode 100644 index 0000000..2d1b427 --- /dev/null +++ b/internal/biz/system/token.go @@ -0,0 +1,48 @@ +package system + +import ( + "errors" + "time" +) + +// The token contract lives apart from the other infrastructure seams because +// the transport middleware and every service that authenticates a request reach +// for these types and errors without caring about cache or storage. + +type IssuedToken struct { + Value string + ExpiresAt time.Time + TTL time.Duration +} + +type AuthClaims struct { + UUID string + ID uint + Username string + NickName string + AuthorityID uint + UserType string + BufferTime time.Duration + MustChangePwd bool + PasswordVersion int64 + Issuer string + Audience []string + IssuedAt time.Time + NotBefore time.Time + ExpiresAt time.Time +} + +var ( + ErrTokenExpired = errors.New("token expired") + ErrTokenMalformed = errors.New("token malformed") + ErrTokenSignatureInvalid = errors.New("token signature invalid") + ErrTokenNotValidYet = errors.New("token not valid yet") + ErrTokenInvalid = errors.New("token invalid") + ErrTokenDisabled = errors.New("token disabled") +) + +type TokenIssuer interface { + IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error) + ReissueToken(*AuthClaims, uint) (*IssuedToken, error) + ParseToken(string) (*AuthClaims, error) +} diff --git a/internal/config/clone.go b/internal/config/clone.go index 513d83c..05b77bf 100644 --- a/internal/config/clone.go +++ b/internal/config/clone.go @@ -1,41 +1,12 @@ package config -// Clone returns a deep copy of a configuration snapshot. The copy helpers are -// intentionally kept in the config package so data/integration code does not -// need protobuf cloning or storage-specific serializers. -func Clone(value *Config) *Config { return cloneConfig(value) } - -func CloneData(value *Data) *Data { - if value == nil { - return nil - } - return cloneConfig(&Config{Data: value}).Data -} - -func CloneAdmin(value *Admin) *Admin { - if value == nil { - return nil - } - return cloneConfig(&Config{Admin: value}).Admin -} - -func CloneDatabase(value *Database) *Database { - if value == nil { - return nil - } - return cloneConfig(&Config{Data: &Data{Database: value}}).Data.Database -} - -func CloneStorage(value *Storage) *Storage { - if value == nil { - return nil - } - return cloneConfig(&Config{Admin: &Admin{Storage: value}}).Admin.Storage -} - -func CloneEmail(value *Email) *Email { - if value == nil { - return nil - } - return cloneConfig(&Config{Admin: &Admin{Email: value}}).Admin.Email -} +// The exported clone surface. Deep copying lives in the config package so +// data/integration code needs neither protobuf cloning nor a storage-specific +// serializer to hand out a snapshot a caller may safely mutate. Each function +// returns nil for a nil input. +func Clone(value *Config) *Config { return cloneConfig(value) } +func CloneData(value *Data) *Data { return cloneData(value) } +func CloneAdmin(value *Admin) *Admin { return cloneAdmin(value) } +func CloneDatabase(value *Database) *Database { return clonePtr(value) } +func CloneStorage(value *Storage) *Storage { return cloneStorage(value) } +func CloneEmail(value *Email) *Email { return clonePtr(value) } diff --git a/internal/config/document.go b/internal/config/document.go new file mode 100644 index 0000000..c1d764d --- /dev/null +++ b/internal/config/document.go @@ -0,0 +1,209 @@ +package config + +import ( + "fmt" + "net" + "os" + "path/filepath" + "strconv" + + "gopkg.in/yaml.v3" +) + +// Document is config.yaml as an editable YAML tree. Persisting through the tree +// rather than re-marshalling a Config keeps two things the typed model cannot +// represent: keys this program does not know about, and the user's comments. +// Loading lives next to it in this package so one place owns the file format. +type Document struct { + path string + node yaml.Node +} + +func OpenDocument(path string) (*Document, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + document := &Document{path: path} + if err = yaml.Unmarshal(raw, &document.node); err != nil { + return nil, err + } + return document, nil +} + +// Set replaces a top-level section, merging into whatever is already there. +// The value is normalized through a generic map so a typed struct and a +// hand-built map produce the same tree. +func (d *Document) Set(key string, value any) error { + normalized, err := normalizeYAML(value) + if err != nil { + return err + } + return setYAMLMapping(&d.node, key, normalized) +} + +// Delete removes the nested key path if present. +func (d *Document) Delete(keys ...string) { deleteYAMLMapping(&d.node, keys...) } + +// Has reports whether the nested key path exists. +func (d *Document) Has(keys ...string) bool { return yamlMappingValue(&d.node, keys...) != nil } + +// SetServerHTTPPort rewrites only the port of server.http.addr, keeping the +// host the user configured. A non-positive port is ignored. +func (d *Document) SetServerHTTPPort(port int) error { + if port <= 0 { + return nil + } + host := "0.0.0.0" + if addr := yamlMappingValue(&d.node, "server", "http", "addr"); addr != nil { + if currentHost, _, err := net.SplitHostPort(addr.Value); err == nil && currentHost != "" { + host = currentHost + } + } + return d.Set("server", map[string]any{"http": map[string]any{"addr": net.JoinHostPort(host, strconv.Itoa(port))}}) +} + +// Save writes the document through a temporary file so a crash mid-write can +// never leave a truncated config.yaml behind. +func (d *Document) Save() error { + output, err := yaml.Marshal(&d.node) + if err != nil { + return err + } + if err = os.MkdirAll(filepath.Dir(d.path), 0o755); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(d.path), ".kra-config-*.yaml") + if err != nil { + return err + } + tempName := temporary.Name() + defer os.Remove(tempName) + if _, err = temporary.Write(output); err != nil { + _ = temporary.Close() + return err + } + if err = temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if err = temporary.Close(); err != nil { + return err + } + return os.Rename(tempName, d.path) +} + +func normalizeYAML(input any) (map[string]any, error) { + raw, err := yaml.Marshal(input) + if err != nil { + return nil, err + } + var output map[string]any + if err = yaml.Unmarshal(raw, &output); err != nil { + return nil, err + } + return output, nil +} + +func setYAMLMapping(node *yaml.Node, key string, value any) error { + if node.Kind == yaml.DocumentNode { + node = node.Content[0] + } + if node.Kind != yaml.MappingNode { + return fmt.Errorf("configuration root is not a mapping") + } + raw, err := yaml.Marshal(value) + if err != nil { + return err + } + var replacement yaml.Node + if err = yaml.Unmarshal(raw, &replacement); err != nil { + return err + } + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Value == key { + mergeYAMLNode(node.Content[i+1], replacement.Content[0]) + return nil + } + } + node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, replacement.Content[0]) + return nil +} + +// mergeYAMLNode updates values produced from the runtime configuration while +// retaining keys that are not represented by the typed configuration and +// comments that were already present in the user's config file. +func mergeYAMLNode(dst, src *yaml.Node) { + if dst.Kind == yaml.MappingNode && src.Kind == yaml.MappingNode { + for i := 0; i+1 < len(src.Content); i += 2 { + key := src.Content[i].Value + found := false + for j := 0; j+1 < len(dst.Content); j += 2 { + if dst.Content[j].Value == key { + mergeYAMLNode(dst.Content[j+1], src.Content[i+1]) + found = true + break + } + } + if !found { + dst.Content = append(dst.Content, src.Content[i], src.Content[i+1]) + } + } + return + } + + // Keep comments attached to a scalar/sequence node when its value changes. + head, line, foot := dst.HeadComment, dst.LineComment, dst.FootComment + *dst = *src + dst.HeadComment, dst.LineComment, dst.FootComment = head, line, foot +} + +func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node { + if node == nil { + return nil + } + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + node = node.Content[0] + } + for _, key := range keys { + if node.Kind != yaml.MappingNode { + return nil + } + var next *yaml.Node + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + next = node.Content[i+1] + break + } + } + if next == nil { + return nil + } + node = next + } + return node +} + +func deleteYAMLMapping(node *yaml.Node, keys ...string) { + if len(keys) == 0 || node == nil { + return + } + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + node = node.Content[0] + } + if node.Kind != yaml.MappingNode { + return + } + key := keys[0] + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value != key { + continue + } + if len(keys) == 1 { + node.Content = append(node.Content[:i], node.Content[i+2:]...) + return + } + deleteYAMLMapping(node.Content[i+1], keys[1:]...) + return + } +} diff --git a/internal/config/document_test.go b/internal/config/document_test.go new file mode 100644 index 0000000..1ea9c12 --- /dev/null +++ b/internal/config/document_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDocumentPreservesUnknownKeysAndUpdatesHTTPPort(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + input := "# keep this comment\nserver:\n http:\n addr: 127.0.0.1:8000\ncustom:\n enabled: true\n" + if err := os.WriteFile(path, []byte(input), 0o600); err != nil { + t.Fatal(err) + } + document, err := OpenDocument(path) + if err != nil { + t.Fatal(err) + } + if err = document.SetServerHTTPPort(9000); err != nil { + t.Fatal(err) + } + if err = document.Set("admin", map[string]any{"router_prefix": "/api"}); err != nil { + t.Fatal(err) + } + if err = document.Save(); err != nil { + t.Fatal(err) + } + loaded, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(loaded) + for _, want := range []string{"127.0.0.1:9000", "custom:", "enabled: true", "admin:", "router_prefix: /api"} { + if !strings.Contains(text, want) { + t.Fatalf("saved document missing %q:\n%s", want, text) + } + } +} diff --git a/internal/config/runtime.go b/internal/config/runtime.go index a487ca7..ef3ecdf 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "sync" "sync/atomic" @@ -128,7 +129,7 @@ func (r *Store) UpdateDatabase(database *Database) { if config.Data == nil { config.Data = &Data{} } - config.Data.Database = cloneDatabase(database) + config.Data.Database = clonePtr(database) r.replace(config, false) } @@ -358,162 +359,115 @@ func decode(v *viper.Viper) (*Config, error) { } func setConfigPath(config *Config, path string) { - if config != nil && config.Admin != nil { - config.Admin.ConfigPath = path + if config == nil { + return } + if config.Admin == nil { + config.Admin = &Admin{} + } + config.Admin.ConfigPath = path +} + +// Every config type is plain data: scalars, pointers to structs, and slices of +// those. clonePtr and cloneSlice cover both shapes, so a clone function below is +// one line per pointer or slice field and nothing else. A field holding only +// scalars needs no line at all — the struct assignment already copied it. +func clonePtr[T any](value *T) *T { + if value == nil { + return nil + } + copied := *value + return &copied +} + +func cloneSlice[T any](values []*T, clone func(*T) *T) []*T { + if values == nil { + return nil + } + copied := make([]*T, len(values)) + for i, item := range values { + copied[i] = clone(item) + } + return copied } func cloneConfig(value *Config) *Config { if value == nil { return nil } - // Viper's mapstructure output contains only value fields and pointers; - // round-tripping through a temporary map is unnecessarily lossy for - // durations. Explicit copy via YAML gives us a robust deep copy while - // preserving time.Duration values through the text hook. - copy := *value - if value.Server != nil { - server := *value.Server - copy.Server = &server - if value.Server.HTTP != nil { - http := *value.Server.HTTP - copy.Server.HTTP = &http - } + copied := *value + copied.Server = clonePtr(value.Server) + if copied.Server != nil { + copied.Server.HTTP = clonePtr(value.Server.HTTP) } - if value.Data != nil { - data := *value.Data - copy.Data = &data - data.Database = cloneDatabase(value.Data.Database) - data.Redis = cloneRedis(value.Data.Redis) - data.Mongo = cloneMongo(value.Data.Mongo) - data.DatabaseList = make([]*Database, len(value.Data.DatabaseList)) - for i, item := range value.Data.DatabaseList { - data.DatabaseList[i] = cloneDatabase(item) - } - data.RedisList = make([]*Redis, len(value.Data.RedisList)) - for i, item := range value.Data.RedisList { - data.RedisList[i] = cloneRedis(item) - } - } - if value.Admin != nil { - copy.Admin = cloneAdmin(value.Admin) - } - return © + copied.Data = cloneData(value.Data) + copied.Admin = cloneAdmin(value.Admin) + return &copied } -func cloneDatabase(value *Database) *Database { +func cloneData(value *Data) *Data { if value == nil { return nil } - copy := *value - return © + copied := *value + copied.Database = clonePtr(value.Database) + copied.Redis = cloneRedis(value.Redis) + copied.Mongo = cloneMongo(value.Mongo) + copied.DatabaseList = cloneSlice(value.DatabaseList, clonePtr) + copied.RedisList = cloneSlice(value.RedisList, cloneRedis) + return &copied } + func cloneRedis(value *Redis) *Redis { - if value == nil { - return nil + copied := clonePtr(value) + if copied != nil { + copied.ClusterAddrs = slices.Clone(value.ClusterAddrs) } - copy := *value - copy.ClusterAddrs = append([]string(nil), value.ClusterAddrs...) - return © + return copied } + func cloneMongo(value *Mongo) *Mongo { - if value == nil { - return nil + copied := clonePtr(value) + if copied != nil { + copied.Hosts = cloneSlice(value.Hosts, clonePtr) } - copy := *value - copy.Hosts = make([]*MongoHost, len(value.Hosts)) - for i, item := range value.Hosts { - if item != nil { - host := *item - copy.Hosts[i] = &host - } - } - return © + return copied } func cloneAdmin(value *Admin) *Admin { if value == nil { return nil } - copy := *value - if value.JWT != nil { - item := *value.JWT - copy.JWT = &item + copied := *value + copied.JWT = clonePtr(value.JWT) + copied.Captcha = clonePtr(value.Captcha) + copied.Local = clonePtr(value.Local) + copied.Email = clonePtr(value.Email) + copied.Media = clonePtr(value.Media) + copied.System = clonePtr(value.System) + copied.App = clonePtr(value.App) + copied.DiskList = cloneSlice(value.DiskList, clonePtr) + copied.Storage = cloneStorage(value.Storage) + if copied.Zap = clonePtr(value.Zap); copied.Zap != nil { + copied.Zap.FileOnlyModules = slices.Clone(value.Zap.FileOnlyModules) } - if value.Captcha != nil { - item := *value.Captcha - copy.Captcha = &item + if copied.CORS = clonePtr(value.CORS); copied.CORS != nil { + copied.CORS.Whitelist = cloneSlice(value.CORS.Whitelist, clonePtr) } - if value.Local != nil { - item := *value.Local - copy.Local = &item - } - if value.Email != nil { - item := *value.Email - copy.Email = &item - } - if value.Media != nil { - item := *value.Media - copy.Media = &item - } - if value.System != nil { - item := *value.System - copy.System = &item - } - if value.Zap != nil { - item := *value.Zap - item.FileOnlyModules = append([]string(nil), value.Zap.FileOnlyModules...) - copy.Zap = &item - } - if value.App != nil { - item := *value.App - copy.App = &item - } - if value.CORS != nil { - item := *value.CORS - item.Whitelist = make([]*CORSRule, len(value.CORS.Whitelist)) - for i, rule := range value.CORS.Whitelist { - if rule != nil { - next := *rule - item.Whitelist[i] = &next - } - } - copy.CORS = &item - } - if value.Storage != nil { - copy.Storage = cloneStorage(value.Storage) - } - copy.DiskList = make([]*Disk, len(value.DiskList)) - for i, item := range value.DiskList { - if item != nil { - next := *item - copy.DiskList[i] = &next - } - } - return © + return &copied } func cloneStorage(value *Storage) *Storage { if value == nil { return nil } - copy := *value - if value.Qiniu != nil { - item := *value.Qiniu - copy.Qiniu = &item - } - copy.AliyunOSS = cloneObjectStore(value.AliyunOSS) - copy.HuaweiOBS = cloneObjectStore(value.HuaweiOBS) - copy.TencentCOS = cloneObjectStore(value.TencentCOS) - copy.AWSS3 = cloneObjectStore(value.AWSS3) - copy.CloudflareR2 = cloneObjectStore(value.CloudflareR2) - copy.Minio = cloneObjectStore(value.Minio) - return © -} -func cloneObjectStore(value *ObjectStore) *ObjectStore { - if value == nil { - return nil - } - copy := *value - return © + copied := *value + copied.Qiniu = clonePtr(value.Qiniu) + copied.AliyunOSS = clonePtr(value.AliyunOSS) + copied.HuaweiOBS = clonePtr(value.HuaweiOBS) + copied.TencentCOS = clonePtr(value.TencentCOS) + copied.AWSS3 = clonePtr(value.AWSS3) + copied.CloudflareR2 = clonePtr(value.CloudflareR2) + copied.Minio = clonePtr(value.Minio) + return &copied } diff --git a/internal/data/config_helpers.go b/internal/data/config_helpers.go deleted file mode 100644 index 1317767..0000000 --- a/internal/data/config_helpers.go +++ /dev/null @@ -1,12 +0,0 @@ -package data - -import ( - "kra/internal/config" -) - -func cloneAdminConfig(value *config.Admin) *config.Admin { - if value == nil { - return &config.Admin{} - } - return config.CloneAdmin(value) -} diff --git a/internal/data/config_store.go b/internal/data/config_store.go index dbe50ad..ef5f83c 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -3,144 +3,24 @@ package data import ( "context" "fmt" - "net" - "os" - "path/filepath" - "strconv" "kra/internal/config" + dataintegration "kra/internal/data/integration" "kra/internal/integration/storage" - "gopkg.in/yaml.v3" "gorm.io/gorm" ) -func configMap(input any) (map[string]any, error) { - raw, err := yaml.Marshal(input) - if err != nil { - return nil, err - } - var output map[string]any - if err = yaml.Unmarshal(raw, &output); err != nil { - return nil, err - } - return output, nil -} +// Writing config.yaml goes through config.Document, which owns the file format +// for both directions. This file only decides which sections to write. -func setYAMLMapping(node *yaml.Node, key string, value any) error { - if node.Kind == yaml.DocumentNode { - node = node.Content[0] +// cloneAdminConfig copies an admin section, normalizing nil to an empty value so +// callers can edit the result without a nil check. +func cloneAdminConfig(value *config.Admin) *config.Admin { + if value == nil { + return &config.Admin{} } - if node.Kind != yaml.MappingNode { - return fmt.Errorf("configuration root is not a mapping") - } - raw, err := yaml.Marshal(value) - if err != nil { - return err - } - var replacement yaml.Node - if err = yaml.Unmarshal(raw, &replacement); err != nil { - return err - } - for i := 0; i < len(node.Content); i += 2 { - if node.Content[i].Value == key { - mergeYAMLNode(node.Content[i+1], replacement.Content[0]) - return nil - } - } - node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, replacement.Content[0]) - return nil -} - -// mergeYAMLNode updates values produced from the runtime configuration while -// retaining keys that are not represented by the typed configuration and -// comments that were already present in the user's config file. -func mergeYAMLNode(dst, src *yaml.Node) { - if dst.Kind == yaml.MappingNode && src.Kind == yaml.MappingNode { - for i := 0; i+1 < len(src.Content); i += 2 { - key := src.Content[i].Value - found := false - for j := 0; j+1 < len(dst.Content); j += 2 { - if dst.Content[j].Value == key { - mergeYAMLNode(dst.Content[j+1], src.Content[i+1]) - found = true - break - } - } - if !found { - dst.Content = append(dst.Content, src.Content[i], src.Content[i+1]) - } - } - return - } - - // Keep comments attached to a scalar/sequence node when its value changes. - head, line, foot := dst.HeadComment, dst.LineComment, dst.FootComment - *dst = *src - dst.HeadComment, dst.LineComment, dst.FootComment = head, line, foot -} - -func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node { - if node == nil { - return nil - } - if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { - node = node.Content[0] - } - for _, key := range keys { - if node.Kind != yaml.MappingNode { - return nil - } - var next *yaml.Node - for i := 0; i+1 < len(node.Content); i += 2 { - if node.Content[i].Value == key { - next = node.Content[i+1] - break - } - } - if next == nil { - return nil - } - node = next - } - return node -} - -func deleteYAMLMapping(node *yaml.Node, keys ...string) { - if len(keys) == 0 || node == nil { - return - } - if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { - node = node.Content[0] - } - if node.Kind != yaml.MappingNode { - return - } - key := keys[0] - for i := 0; i+1 < len(node.Content); i += 2 { - if node.Content[i].Value != key { - continue - } - if len(keys) == 1 { - node.Content = append(node.Content[:i], node.Content[i+2:]...) - return - } - deleteYAMLMapping(node.Content[i+1], keys[1:]...) - return - } -} - -func setServerHTTPPort(document *yaml.Node, port int) error { - if port <= 0 { - return nil - } - host := "0.0.0.0" - if addr := yamlMappingValue(document, "server", "http", "addr"); addr != nil { - if currentHost, _, err := net.SplitHostPort(addr.Value); err == nil && currentHost != "" { - host = currentHost - } - } - return setYAMLMapping(document, "server", map[string]any{"http": map[string]any{"addr": net.JoinHostPort(host, strconv.Itoa(int(port)))}}) + return config.CloneAdmin(value) } func (d *Data) persistConfig() error { @@ -158,40 +38,28 @@ func (d *Data) persistConfigValuesLocked(dataConfig *config.Data, adminConfig *c if adminConfig == nil || adminConfig.ConfigPath == "" { return nil } - configPath := adminConfig.ConfigPath - raw, err := os.ReadFile(configPath) + document, err := config.OpenDocument(adminConfig.ConfigPath) if err != nil { return err } - var document yaml.Node - if err = yaml.Unmarshal(raw, &document); err != nil { - return err - } - dataValue, err := configMap(dataConfig) - if err != nil { + if err = document.Set("data", dataConfig); err != nil { return err } + // Storage and email live in the database; the file must not shadow them. fileAdmin := cloneAdminConfig(adminConfig) fileAdmin.Storage = nil fileAdmin.Email = nil - adminValue, err := configMap(fileAdmin) - if err != nil { + if err = document.Set("admin", fileAdmin); err != nil { return err } - if err = setYAMLMapping(&document, "data", dataValue); err != nil { - return err - } - if err = setYAMLMapping(&document, "admin", adminValue); err != nil { - return err - } - deleteYAMLMapping(&document, "admin", "storage") - deleteYAMLMapping(&document, "admin", "email") + document.Delete("admin", "storage") + document.Delete("admin", "email") if adminConfig.System != nil { - if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil { + if err = document.SetServerHTTPPort(adminConfig.System.Addr); err != nil { return err } } - return writeConfigDocument(configPath, &document) + return document.Save() } // persistDatabaseConfig writes only the database selected on the init page and @@ -207,14 +75,10 @@ func (d *Data) persistDatabaseConfig(database *config.Database, signingKey strin if configPath == "" { return nil } - raw, err := os.ReadFile(configPath) + document, err := config.OpenDocument(configPath) if err != nil { return err } - var document yaml.Node - if err = yaml.Unmarshal(raw, &document); err != nil { - return err - } source, err := databaseDSN(database, "") if err != nil { return err @@ -232,17 +96,17 @@ func (d *Data) persistDatabaseConfig(database *config.Database, signingKey strin "config": database.Config, "path": database.Path, } - if err = setYAMLMapping(&document, "data", map[string]any{"database": databaseValue}); err != nil { + if err = document.Set("data", map[string]any{"database": databaseValue}); err != nil { return err } if signingKey != "" { - if err = setYAMLMapping(&document, "admin", map[string]any{"jwt": map[string]any{"signing_key": signingKey}}); err != nil { + if err = document.Set("admin", map[string]any{"jwt": map[string]any{"signing_key": signingKey}}); err != nil { return err } } - deleteYAMLMapping(&document, "admin", "storage") - deleteYAMLMapping(&document, "admin", "email") - return writeConfigDocument(configPath, &document) + document.Delete("admin", "storage") + document.Delete("admin", "email") + return document.Save() } func (d *Data) removeIntegrationConfigFromFile() error { @@ -252,48 +116,16 @@ func (d *Data) removeIntegrationConfigFromFile() error { if configPath == "" { return nil } - raw, err := os.ReadFile(configPath) + document, err := config.OpenDocument(configPath) if err != nil { return err } - var document yaml.Node - if err = yaml.Unmarshal(raw, &document); err != nil { - return err - } - if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil { + if !document.Has("admin", "storage") && !document.Has("admin", "email") { return nil } - deleteYAMLMapping(&document, "admin", "storage") - deleteYAMLMapping(&document, "admin", "email") - return writeConfigDocument(configPath, &document) -} - -func writeConfigDocument(configPath string, document *yaml.Node) error { - output, err := yaml.Marshal(document) - if err != nil { - return err - } - if err = os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { - return err - } - temporary, err := os.CreateTemp(filepath.Dir(configPath), ".kra-config-*.yaml") - if err != nil { - return err - } - tempName := temporary.Name() - defer os.Remove(tempName) - if _, err = temporary.Write(output); err != nil { - _ = temporary.Close() - return err - } - if err = temporary.Chmod(0o600); err != nil { - _ = temporary.Close() - return err - } - if err = temporary.Close(); err != nil { - return err - } - return os.Rename(tempName, configPath) + document.Delete("admin", "storage") + document.Delete("admin", "email") + return document.Save() } func (d *Data) reloadConfig(ctx context.Context) error { @@ -311,6 +143,11 @@ func (d *Data) reloadConfig(ctx context.Context) error { return fmt.Errorf("data.database and admin configuration are required") } next.Admin.ConfigPath = configPath + // Every client below is opened before anything is published, so a failure at + // any step leaves the running process untouched. undo closes whatever this + // call opened; commit at the end hands all of it over instead. + var undo rollback + defer undo.run() databaseReady := databaseConnectionConfigured(next.Data.Database) var candidateDB *gorm.DB if databaseReady { @@ -324,14 +161,7 @@ func (d *Data) reloadConfig(ctx context.Context) error { return fmt.Errorf("reload bootstrap database: %w", err) } } - closeCandidate := true - defer func() { - if closeCandidate { - if sqlDB, closeErr := candidateDB.DB(); closeErr == nil { - _ = sqlDB.Close() - } - } - }() + undo.add(func() { closeGormDB(candidateDB) }) if databaseReady { if sqlDB, dbErr := candidateDB.DB(); dbErr != nil { return dbErr @@ -355,12 +185,12 @@ func (d *Data) reloadConfig(ctx context.Context) error { if legacyEmail == nil && currentAdmin != nil { legacyEmail = currentAdmin.Email } - storageConfig, err := resolveStorageIntegrationConfig(candidateDB.WithContext(ctx), legacyStorage) + storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), legacyStorage) if err != nil { return fmt.Errorf("reload storage configuration: %w", err) } next.Admin.Storage = storageConfig - emailConfig, err := resolveEmailIntegrationConfig(candidateDB.WithContext(ctx), legacyEmail) + emailConfig, err := dataintegration.ResolveEmailConfig(candidateDB.WithContext(ctx), legacyEmail) if err != nil { return fmt.Errorf("reload email configuration: %w", err) } @@ -371,47 +201,37 @@ func (d *Data) reloadConfig(ctx context.Context) error { } useRedis := next.Admin.System != nil && next.Admin.System.UseRedis candidateRedis := openRedis(next.Data.Redis, useRedis, d.logger()) - candidateRedisAccepted := false - defer func() { - if !candidateRedisAccepted && candidateRedis != nil { - _ = candidateRedis.Close() + undo.add(func() { + if candidateRedis != nil { + closeRedisClient(candidateRedis) } - }() + }) useRedisList := useRedis && next.Admin.System.UseMultipoint candidateRedisList := openRedisList(next.Data.RedisList, useRedisList, d.logger()) - candidateRedisListAccepted := false - defer func() { - if !candidateRedisListAccepted { - closeRedisList(candidateRedisList) - } - }() + undo.add(func() { closeRedisList(candidateRedisList) }) useMongo := next.Admin.System != nil && next.Admin.System.UseMongo + // openMongo always reports a nil client alongside its error, so an unreachable + // Mongo only warns: it must not fail an otherwise valid reload. candidateMongo, mongoErr := openMongo(next.Data.Mongo, useMongo) if mongoErr != nil { d.logger().Error("mongo unavailable during configuration reload", "mod", "mongo", "error", mongoErr) } - mongoAccepted := false - defer func() { - if !mongoAccepted && candidateMongo != nil { - _ = candidateMongo.Disconnect(context.Background()) + undo.add(func() { + if candidateMongo != nil { + closeMongoClient(candidateMongo) } - }() + }) candidateDBList, err := openDatabaseList(next.Data.DatabaseList, d.logger()) if err != nil { return err } - candidateDBListAccepted := false - defer func() { - if !candidateDBListAccepted { - closeDatabaseList(candidateDBList) - } - }() - integrationConfigs, err := readIntegrationRuntime(candidateDB.WithContext(ctx)) + undo.add(func() { closeDatabaseList(candidateDBList) }) + integrationConfigs, err := dataintegration.ReadRuntime(candidateDB.WithContext(ctx)) if err != nil { return fmt.Errorf("reload integration runtime: %w", err) } - d.gormDB.replace(candidateDB, d.enqueueDataScopeAudit) + d.replacePrimaryDB(candidateDB) d.databaseReady.Store(databaseReady) for _, item := range candidateDBList { registerDataScopeCallbacks(item, d.enqueueDataScopeAudit) @@ -431,14 +251,12 @@ func (d *Data) reloadConfig(ctx context.Context) error { } else { d.logger().Warn("keeping the previous redis list because the reloaded configuration failed to connect", "mod", "redis") } + // A nil client here means Mongo is switched off in the reloaded configuration, + // which must still retire the running client; only an outright failure keeps it. if mongoErr == nil { d.mongo.replace(candidateMongo) - mongoAccepted = true } - closeCandidate = false - candidateDBListAccepted = true - candidateRedisAccepted = true - candidateRedisListAccepted = true + undo.commit() d.runtime.Replace(next) if d.integrations != nil { d.integrations.Replace(integrationConfigs) diff --git a/internal/data/data.go b/internal/data/data.go index 7646e5f..8b67676 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -19,6 +19,7 @@ import ( datatask "kra/internal/data/task" "kra/internal/integration/runtimeconfig" "kra/internal/integration/storage" + "kra/pkg/database/migration" "kra/pkg/module" ) @@ -47,11 +48,11 @@ type Data struct { initMu sync.Mutex configMu sync.Mutex databaseReady atomic.Bool - gormDB *reloadableDB - redis *reloadableRedis + gormDB *reloadable[*gorm.DB] + redis *reloadable[redis.UniversalClient] redisListMu sync.RWMutex redisList map[string]redis.UniversalClient - mongo *reloadableMongo + mongo *reloadable[*mongo.Client] runtime *config.Store integrations *runtimeconfig.Store storage *storage.Reloadable @@ -71,7 +72,7 @@ func (d *Data) DB() *gorm.DB { if d == nil || d.gormDB == nil { return nil } - return d.gormDB.DB() + return d.gormDB.load() } // DatabaseReady reports whether the configured primary database has been @@ -103,7 +104,7 @@ func (d *Data) IntegrationRuntime() *runtimeconfig.Store { // the system export module. func (d *Data) Database(name string) (*gorm.DB, error) { if name == "" { - return d.gormDB.DB(), nil + return d.gormDB.load(), nil } d.dbListMu.RLock() db := d.dbList[name] @@ -306,11 +307,11 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor } } if !usingFallback { - storageConfig, storageErr := resolveStorageIntegrationConfig(db, admin.Storage) + storageConfig, storageErr := dataintegration.ResolveStorageConfig(db, admin.Storage) if storageErr != nil { return nil, nil, fmt.Errorf("load storage integration configuration: %w", storageErr) } - emailConfig, emailErr := resolveEmailIntegrationConfig(db, admin.Email) + emailConfig, emailErr := dataintegration.ResolveEmailConfig(db, admin.Email) if emailErr != nil { return nil, nil, fmt.Errorf("load email integration configuration: %w", emailErr) } @@ -337,7 +338,7 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor return nil, nil, fmt.Errorf("storage manager is nil") } useRedis := admin != nil && admin.System != nil && admin.System.UseRedis - d.redis = newReloadableRedis(openRedis(c.Redis, useRedis, appLogger)) + d.redis = newReloadable(openRedis(c.Redis, useRedis, appLogger), closeRedisClient) useRedisList := useRedis && admin.System.UseMultipoint d.redisList = openRedisList(c.RedisList, useRedisList, appLogger) useMongo := admin != nil && admin.System != nil && admin.System.UseMongo @@ -346,7 +347,7 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor appLogger.Error("mongo unavailable", "mod", "mongo", "error", err) mongoClient = nil } - d.mongo = newReloadableMongo(mongoClient) + d.mongo = newReloadable(mongoClient, closeMongoClient) initialized = true return d, cleanup, nil } @@ -437,8 +438,35 @@ func (d *Data) replaceRedisList(clients map[string]redis.UniversalClient) { } } +// replacePrimaryDB swaps in a hot-reloaded pool, attaching the row-level data +// scope callbacks before the handle becomes readable. +func (d *Data) replacePrimaryDB(db *gorm.DB) { + registerDataScopeCallbacks(db, d.enqueueDataScopeAudit) + d.gormDB.replace(db) +} + func (d *Data) activateDatabase(db *gorm.DB, config *config.Database) { - d.gormDB.replace(db, d.enqueueDataScopeAudit) + d.replacePrimaryDB(db) d.runtime.UpdateDatabase(config) d.databaseReady.Store(true) } + +// migrateAll is the single data-layer migration entry point. Every module +// must register its migrations through the application catalog; there is no +// hidden system/payment fallback that could silently omit a new module. +func migrateAll(db *gorm.DB, catalog module.Catalog) error { + return migration.Run(db, catalog.MigrationSteps()) +} + +// loadIntegrationRuntime republishes the database-backed integration +// configuration into the live store the long-lived adapters read. +func (d *Data) loadIntegrationRuntime(db *gorm.DB) error { + configs, err := dataintegration.ReadRuntime(db) + if err != nil { + return err + } + if d.integrations != nil { + d.integrations.Replace(configs) + } + return nil +} diff --git a/internal/data/data_scope_audit.go b/internal/data/data_scope_audit.go index c3c8bcc..b838221 100644 --- a/internal/data/data_scope_audit.go +++ b/internal/data/data_scope_audit.go @@ -81,7 +81,7 @@ func (w *dataScopeAuditWriter) run() { if len(batch) == 0 || w.data == nil || w.data.gormDB == nil { return } - db := w.data.gormDB.DB() + db := w.data.gormDB.load() if db == nil { return } diff --git a/internal/data/data_scope_audit_test.go b/internal/data/data_scope_audit_test.go index 7d3e04d..5f38803 100644 --- a/internal/data/data_scope_audit_test.go +++ b/internal/data/data_scope_audit_test.go @@ -65,7 +65,7 @@ func TestDataScopeAuditWriterUsesReloadedDatabase(t *testing.T) { t.Cleanup(w.Close) d.enqueueDataScopeAudit(dataAccessLogPO{EventType: "no_identity", TargetTable: "example"}) - d.gormDB.replace(second, nil) + d.gormDB.replace(second) waitForDataScopeAuditCount(t, second, 1) var firstCount int64 diff --git a/internal/data/initialization_backend.go b/internal/data/initialization_backend.go index 4531b25..ce24c9e 100644 --- a/internal/data/initialization_backend.go +++ b/internal/data/initialization_backend.go @@ -7,6 +7,7 @@ import ( "kra/internal/biz/system" configpkg "kra/internal/config" + dataintegration "kra/internal/data/integration" "kra/internal/integration/storage" "github.com/google/uuid" @@ -172,7 +173,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon if currentAdmin != nil { legacyStorage = currentAdmin.Storage } - storageConfig, err := resolveStorageIntegrationConfig(candidate.WithContext(ctx), legacyStorage) + storageConfig, err := dataintegration.ResolveStorageConfig(candidate.WithContext(ctx), legacyStorage) if err != nil { return fmt.Errorf("initialize storage integration configuration: %w", err) } @@ -180,7 +181,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon if currentAdmin != nil { legacyEmail = currentAdmin.Email } - emailConfig, err := resolveEmailIntegrationConfig(candidate.WithContext(ctx), legacyEmail) + emailConfig, err := dataintegration.ResolveEmailConfig(candidate.WithContext(ctx), legacyEmail) if err != nil { return fmt.Errorf("initialize email integration configuration: %w", err) } @@ -188,7 +189,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon if err := d.persistDatabaseConfig(database, signingKey); err != nil { return fmt.Errorf("persist database configuration: %w", err) } - integrationConfigs, err := readIntegrationRuntime(candidate) + integrationConfigs, err := dataintegration.ReadRuntime(candidate) if err != nil { return fmt.Errorf("initialize integration runtime: %w", err) } diff --git a/internal/data/integration/integration_config.go b/internal/data/integration/integration_config.go index ad0f7cc..b03bd51 100644 --- a/internal/data/integration/integration_config.go +++ b/internal/data/integration/integration_config.go @@ -26,6 +26,73 @@ type ConfigPO struct { func (ConfigPO) TableName() string { return "sys_integration_configs" } +// ConfigRow is one integration configuration row without its PO: the parent +// data package owns the storage and email upgrade paths, but the table shape +// stays private here. +type ConfigRow struct { + Provider string + Enabled bool + Config string +} + +// HasConfigTable reports whether the integration configuration table exists, +// which callers use to tell "not migrated yet" from "no rows". +func HasConfigTable(db *gorm.DB) bool { + return db != nil && cleanSession(db).Migrator().HasTable(&ConfigPO{}) +} + +// ListConfigs returns every row of a kind in insertion order. +func ListConfigs(db *gorm.DB, kind string) ([]ConfigRow, error) { + var rows []ConfigPO + if err := cleanSession(db).Where("kind = ?", kind).Order("id ASC").Find(&rows).Error; err != nil { + return nil, err + } + result := make([]ConfigRow, 0, len(rows)) + for _, row := range rows { + result = append(result, ConfigRow{Provider: row.Provider, Enabled: row.Enabled, Config: row.Config}) + } + return result, nil +} + +// FindConfig returns one row and whether it exists. A missing row is not an +// error because every caller treats it as "fall back to the legacy value". +func FindConfig(db *gorm.DB, kind, provider string) (ConfigRow, bool, error) { + var row ConfigPO + err := cleanSession(db).Where("kind = ? AND provider = ?", kind, provider).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return ConfigRow{}, false, nil + } + if err != nil { + return ConfigRow{}, false, err + } + return ConfigRow{Provider: row.Provider, Enabled: row.Enabled, Config: row.Config}, true, nil +} + +// UpsertConfigs writes every given row of a kind in one transaction, inserting +// the ones that do not exist yet and updating the rest in place. +func UpsertConfigs(db *gorm.DB, kind string, rows []ConfigRow) error { + return cleanSession(db).Transaction(func(tx *gorm.DB) error { + for _, item := range rows { + var current ConfigPO + err := tx.Where("kind = ? AND provider = ?", kind, item.Provider).First(¤t).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + err = tx.Create(&ConfigPO{Kind: kind, Provider: item.Provider, Enabled: item.Enabled, Config: item.Config}).Error + case err == nil: + err = tx.Model(¤t).Updates(map[string]any{"enabled": item.Enabled, "config": item.Config}).Error + } + if err != nil { + return err + } + } + return nil + }) +} + +// cleanSession drops conditions inherited from the caller's handle so a scoped +// query cannot leak a WHERE clause into these statements. +func cleanSession(db *gorm.DB) *gorm.DB { return db.Session(&gorm.Session{NewDB: true}) } + type integrationConfigRepo struct{ data Provider } type paymentConfigReader struct{ data Provider } diff --git a/internal/data/integration/migrations.go b/internal/data/integration/migrations.go index 0ddb7d2..e5fae33 100644 --- a/internal/data/integration/migrations.go +++ b/internal/data/integration/migrations.go @@ -29,7 +29,7 @@ func ensureCommunicationIntegrationConfigs(db *gorm.DB) error { for _, item := range defaults { var row ConfigPO err := db.Where("kind = ? AND provider = ?", item.kind, item.provider).First(&row).Error - if gorm.ErrRecordNotFound == err { + if errors.Is(err, gorm.ErrRecordNotFound) { values, marshalErr := json.Marshal(integrationbiz.DefaultIntegrationConfig(item.kind, item.provider)) if marshalErr != nil { return marshalErr diff --git a/internal/data/integration/storage_email.go b/internal/data/integration/storage_email.go new file mode 100644 index 0000000..c05db79 --- /dev/null +++ b/internal/data/integration/storage_email.go @@ -0,0 +1,219 @@ +package integration + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "kra/internal/config" + + "gorm.io/gorm" +) + +const ( + kindStorage = "storage" + kindEmail = "email" + providerSMTP = "smtp" +) + +var storageProviders = []string{ + "local", "qiniu", "aliyun-oss", "huawei-obs", "tencent-cos", "aws-s3", "cloudflare-r2", "minio", +} + +func normalizeStorageType(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "local" + } + return value +} + +func storageProviderValue(storage *config.Storage, provider string) any { + if storage == nil { + storage = &config.Storage{} + } + switch provider { + case "qiniu": + if storage.Qiniu == nil { + storage.Qiniu = &config.Qiniu{} + } + return storage.Qiniu + case "aliyun-oss": + return ensureObjectStore(&storage.AliyunOSS) + case "huawei-obs": + return ensureObjectStore(&storage.HuaweiOBS) + case "tencent-cos": + return ensureObjectStore(&storage.TencentCOS) + case "aws-s3": + return ensureObjectStore(&storage.AWSS3) + case "cloudflare-r2": + return ensureObjectStore(&storage.CloudflareR2) + case "minio": + return ensureObjectStore(&storage.Minio) + default: + return nil + } +} + +func ensureObjectStore(value **config.ObjectStore) *config.ObjectStore { + if *value == nil { + *value = &config.ObjectStore{} + } + return *value +} + +func marshalStorageProvider(storage *config.Storage, provider string) (string, error) { + value := storageProviderValue(storage, provider) + if value == nil { + return "{}", nil + } + raw, err := json.Marshal(value) + if err != nil { + return "", err + } + return string(raw), nil +} + +func unmarshalStorageProvider(storage *config.Storage, provider, value string) error { + if provider == "local" || strings.TrimSpace(value) == "" { + return nil + } + target := storageProviderValue(storage, provider) + if target == nil { + return nil + } + if !json.Valid([]byte(value)) { + return fmt.Errorf("invalid %s integration configuration", provider) + } + if err := json.Unmarshal([]byte(value), target); err != nil { + return fmt.Errorf("decode %s integration configuration: %w", provider, err) + } + return nil +} + +func SaveStorageConfig(db *gorm.DB, storage *config.Storage) error { + if storage == nil { + storage = &config.Storage{} + } + active := normalizeStorageType(storage.Type) + known := false + for _, provider := range storageProviders { + if provider == active { + known = true + break + } + } + if !known { + return fmt.Errorf("unsupported storage type %q", active) + } + + rows := make([]ConfigRow, 0, len(storageProviders)) + for _, provider := range storageProviders { + value, err := marshalStorageProvider(storage, provider) + if err != nil { + return fmt.Errorf("encode %s integration configuration: %w", provider, err) + } + rows = append(rows, ConfigRow{Provider: provider, Enabled: provider == active, Config: value}) + } + return UpsertConfigs(db, kindStorage, rows) +} + +func LoadStorageConfig(db *gorm.DB) (*config.Storage, bool, error) { + rows, err := ListConfigs(db, kindStorage) + if err != nil { + return nil, false, err + } + if len(rows) == 0 { + return nil, false, nil + } + storage := &config.Storage{Type: "local"} + for _, row := range rows { + if err = unmarshalStorageProvider(storage, row.Provider, row.Config); err != nil { + return nil, false, err + } + if row.Enabled { + storage.Type = row.Provider + } + } + return storage, true, nil +} + +// ResolveStorageConfig imports a legacy YAML value only when the database has +// no storage configuration yet. The database is authoritative afterwards. +func ResolveStorageConfig(db *gorm.DB, legacy *config.Storage) (*config.Storage, error) { + if !HasConfigTable(db) { + if legacy == nil { + return &config.Storage{Type: "local"}, nil + } + return config.CloneStorage(legacy), nil + } + storage, found, err := LoadStorageConfig(db) + if err != nil { + return nil, err + } + if found { + return storage, nil + } + if legacy == nil { + legacy = &config.Storage{Type: "local"} + } + if err = SaveStorageConfig(db, legacy); err != nil { + return nil, err + } + storage, _, err = LoadStorageConfig(db) + return storage, err +} + +func defaultEmailConfig() *config.Email { return &config.Email{Port: 465, IsSSL: true} } + +func SaveEmailConfig(db *gorm.DB, email *config.Email) error { + if email == nil { + email = defaultEmailConfig() + } + raw, err := json.Marshal(email) + if err != nil { + return fmt.Errorf("encode smtp integration configuration: %w", err) + } + enabled := email.Host != "" && email.From != "" && email.Secret != "" && email.Port > 0 + return UpsertConfigs(db, kindEmail, []ConfigRow{{Provider: providerSMTP, Enabled: enabled, Config: string(raw)}}) +} + +func LoadEmailConfig(db *gorm.DB) (*config.Email, bool, error) { + row, found, err := FindConfig(db, kindEmail, providerSMTP) + if err != nil || !found { + return nil, false, err + } + if !json.Valid([]byte(row.Config)) { + return nil, false, errors.New("invalid smtp integration configuration") + } + email := defaultEmailConfig() + if err = json.Unmarshal([]byte(row.Config), email); err != nil { + return nil, false, fmt.Errorf("decode smtp integration configuration: %w", err) + } + return email, true, nil +} + +func ResolveEmailConfig(db *gorm.DB, legacy *config.Email) (*config.Email, error) { + if !HasConfigTable(db) { + if legacy == nil { + return defaultEmailConfig(), nil + } + return config.CloneEmail(legacy), nil + } + email, found, err := LoadEmailConfig(db) + if err != nil { + return nil, err + } + if found { + return email, nil + } + if legacy == nil { + legacy = defaultEmailConfig() + } + if err = SaveEmailConfig(db, legacy); err != nil { + return nil, err + } + email, _, err = LoadEmailConfig(db) + return email, err +} diff --git a/internal/data/integration_config.go b/internal/data/integration_config.go index cb171ce..465abdc 100644 --- a/internal/data/integration_config.go +++ b/internal/data/integration_config.go @@ -2,292 +2,30 @@ package data import ( "context" - "encoding/json" "errors" - "fmt" - "strings" "kra/internal/config" dataintegration "kra/internal/data/integration" - - "gorm.io/gorm" ) -const ( - integrationKindStorage = "storage" - integrationKindEmail = "email" -) - -var storageProviderNames = []string{ - "local", - "qiniu", - "aliyun-oss", - "huawei-obs", - "tencent-cos", - "aws-s3", - "cloudflare-r2", - "minio", -} - -func normalizeStorageType(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - return "local" - } - return value -} - -func storageProviderValue(storage *config.Storage, provider string) any { - if storage == nil { - storage = &config.Storage{} - } - switch provider { - case "qiniu": - if storage.Qiniu == nil { - storage.Qiniu = &config.Qiniu{} - } - return storage.Qiniu - case "aliyun-oss": - return ensureObjectStore(&storage.AliyunOSS) - case "huawei-obs": - return ensureObjectStore(&storage.HuaweiOBS) - case "tencent-cos": - return ensureObjectStore(&storage.TencentCOS) - case "aws-s3": - return ensureObjectStore(&storage.AWSS3) - case "cloudflare-r2": - return ensureObjectStore(&storage.CloudflareR2) - case "minio": - return ensureObjectStore(&storage.Minio) - default: - return nil - } -} - -func ensureObjectStore(value **config.ObjectStore) *config.ObjectStore { - if *value == nil { - *value = &config.ObjectStore{} - } - return *value -} - -func marshalStorageProvider(storage *config.Storage, provider string) (string, error) { - value := storageProviderValue(storage, provider) - if value == nil { - return "{}", nil - } - raw, err := json.Marshal(value) - if err != nil { - return "", err - } - return string(raw), nil -} - -func unmarshalStorageProvider(storage *config.Storage, provider, value string) error { - if provider == "local" || strings.TrimSpace(value) == "" { - return nil - } - target := storageProviderValue(storage, provider) - if target == nil { - return nil - } - if !json.Valid([]byte(value)) { - return fmt.Errorf("invalid %s integration configuration", provider) - } - if err := json.Unmarshal([]byte(value), target); err != nil { - return fmt.Errorf("decode %s integration configuration: %w", provider, err) - } - return nil -} - -func saveStorageIntegrationConfig(db *gorm.DB, storage *config.Storage) error { - if storage == nil { - storage = &config.Storage{} - } - active := normalizeStorageType(storage.Type) - known := false - for _, provider := range storageProviderNames { - if provider == active { - known = true - break - } - } - if !known { - return fmt.Errorf("unsupported storage type %q", active) - } - - return db.Session(&gorm.Session{NewDB: true}).Transaction(func(tx *gorm.DB) error { - for _, provider := range storageProviderNames { - value, err := marshalStorageProvider(storage, provider) - if err != nil { - return fmt.Errorf("encode %s integration configuration: %w", provider, err) - } - var current dataintegration.ConfigPO - err = tx.Where("kind = ? AND provider = ?", integrationKindStorage, provider).First(¤t).Error - switch { - case errors.Is(err, gorm.ErrRecordNotFound): - current = dataintegration.ConfigPO{Kind: integrationKindStorage, Provider: provider} - current.Enabled, current.Config = provider == active, value - if err = tx.Create(¤t).Error; err != nil { - return err - } - case err != nil: - return err - default: - if err = tx.Model(¤t).Updates(map[string]any{"enabled": provider == active, "config": value}).Error; err != nil { - return err - } - } - } - return nil - }) -} - -func loadStorageIntegrationConfig(db *gorm.DB) (*config.Storage, bool, error) { - var rows []dataintegration.ConfigPO - err := db.Session(&gorm.Session{NewDB: true}). - Where("kind = ?", integrationKindStorage). - Order("id ASC"). - Find(&rows).Error - if err != nil { - return nil, false, err - } - if len(rows) == 0 { - return nil, false, nil - } - - storage := &config.Storage{Type: "local"} - for _, row := range rows { - if err = unmarshalStorageProvider(storage, row.Provider, row.Config); err != nil { - return nil, false, err - } - if row.Enabled { - storage.Type = row.Provider - } - } - return storage, true, nil -} - -// resolveStorageIntegrationConfig upgrades a legacy YAML configuration only -// when the database has no storage rows yet. From then on the database is the -// sole source of truth. -func resolveStorageIntegrationConfig(db *gorm.DB, legacy *config.Storage) (*config.Storage, error) { - clean := db.Session(&gorm.Session{NewDB: true}) - if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) { - if legacy == nil { - return &config.Storage{Type: "local"}, nil - } - return config.CloneStorage(legacy), nil - } - storage, found, err := loadStorageIntegrationConfig(clean) - if err != nil { - return nil, err - } - if found { - return storage, nil - } - if legacy == nil { - legacy = &config.Storage{Type: "local"} - } - if err = saveStorageIntegrationConfig(clean, legacy); err != nil { - return nil, err - } - storage, _, err = loadStorageIntegrationConfig(clean) - return storage, err -} - func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *config.Storage) error { if !d.databaseReady.Load() { return errors.New("database is not initialized") } - db := d.gormDB.WithContext(ctx) - if !db.Migrator().HasTable(&dataintegration.ConfigPO{}) { + db := d.gormDB.load().WithContext(ctx) + if !dataintegration.HasConfigTable(db) { return errors.New("integration configuration table does not exist") } - return saveStorageIntegrationConfig(db, storage) -} - -func defaultEmailIntegrationConfig() *config.Email { - return &config.Email{Port: 465, IsSSL: true} -} - -func saveEmailIntegrationConfig(db *gorm.DB, email *config.Email) error { - if email == nil { - email = defaultEmailIntegrationConfig() - } - raw, err := json.Marshal(email) - if err != nil { - return fmt.Errorf("encode smtp integration configuration: %w", err) - } - enabled := email.Host != "" && email.From != "" && email.Secret != "" && email.Port > 0 - clean := db.Session(&gorm.Session{NewDB: true}) - var current dataintegration.ConfigPO - err = clean.Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").First(¤t).Error - switch { - case errors.Is(err, gorm.ErrRecordNotFound): - return clean.Create(&dataintegration.ConfigPO{ - Kind: integrationKindEmail, Provider: "smtp", Enabled: enabled, Config: string(raw), - }).Error - case err != nil: - return err - default: - return clean.Model(¤t).Updates(map[string]any{"enabled": enabled, "config": string(raw)}).Error - } -} - -func loadEmailIntegrationConfig(db *gorm.DB) (*config.Email, bool, error) { - var row dataintegration.ConfigPO - err := db.Session(&gorm.Session{NewDB: true}). - Where("kind = ? AND provider = ?", integrationKindEmail, "smtp"). - First(&row).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, false, nil - } - if err != nil { - return nil, false, err - } - if !json.Valid([]byte(row.Config)) { - return nil, false, errors.New("invalid smtp integration configuration") - } - email := defaultEmailIntegrationConfig() - if err = json.Unmarshal([]byte(row.Config), email); err != nil { - return nil, false, fmt.Errorf("decode smtp integration configuration: %w", err) - } - return email, true, nil -} - -func resolveEmailIntegrationConfig(db *gorm.DB, legacy *config.Email) (*config.Email, error) { - clean := db.Session(&gorm.Session{NewDB: true}) - if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) { - if legacy == nil { - return defaultEmailIntegrationConfig(), nil - } - return config.CloneEmail(legacy), nil - } - email, found, err := loadEmailIntegrationConfig(clean) - if err != nil { - return nil, err - } - if found { - return email, nil - } - if legacy == nil { - legacy = defaultEmailIntegrationConfig() - } - if err = saveEmailIntegrationConfig(clean, legacy); err != nil { - return nil, err - } - email, _, err = loadEmailIntegrationConfig(clean) - return email, err + return dataintegration.SaveStorageConfig(db, storage) } func (d *Data) persistEmailIntegrationConfig(ctx context.Context, email *config.Email) error { if !d.databaseReady.Load() { return errors.New("database is not initialized") } - db := d.gormDB.WithContext(ctx) - if !db.Migrator().HasTable(&dataintegration.ConfigPO{}) { + db := d.gormDB.load().WithContext(ctx) + if !dataintegration.HasConfigTable(db) { return errors.New("integration configuration table does not exist") } - return saveEmailIntegrationConfig(db, email) + return dataintegration.SaveEmailConfig(db, email) } diff --git a/internal/data/integration_config_test.go b/internal/data/integration_config_test.go index 7415b11..e5da2cf 100644 --- a/internal/data/integration_config_test.go +++ b/internal/data/integration_config_test.go @@ -64,21 +64,21 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) { }, Minio: &config.ObjectStore{Endpoint: "127.0.0.1:9000", Bucket: "local", ForcePathStyle: true}, } - if err := saveStorageIntegrationConfig(db, storage); err != nil { + if err := dataintegration.SaveStorageConfig(db, storage); err != nil { t.Fatal(err) } email := &config.Email{ To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com", Secret: "smtp-secret", Nickname: "Kra", Port: 465, IsSSL: true, } - if err := saveEmailIntegrationConfig(db, email); err != nil { + if err := dataintegration.SaveEmailConfig(db, email); err != nil { t.Fatal(err) } if err := db.Create(&dataintegration.ConfigPO{Kind: integrationbiz.IntegrationKindPayment, Provider: "wechat-pay", Config: `{"merchant_id":"123"}`}).Error; err != nil { t.Fatal(err) } - loaded, found, err := loadStorageIntegrationConfig(db) + loaded, found, err := dataintegration.LoadStorageConfig(db) if err != nil { t.Fatal(err) } @@ -95,7 +95,7 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) { t.Fatalf("qiniu configuration = %#v", loaded.Qiniu) } - loadedEmail, found, err := loadEmailIntegrationConfig(db) + loadedEmail, found, err := dataintegration.LoadEmailConfig(db) if err != nil || !found { t.Fatalf("email configuration found=%v, err=%v", found, err) } @@ -104,17 +104,17 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) { } var storageCount, emailCount, paymentCount int64 - if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationKindStorage).Count(&storageCount).Error; err != nil { + if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", "storage").Count(&storageCount).Error; err != nil { t.Fatal(err) } if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationbiz.IntegrationKindPayment).Count(&paymentCount).Error; err != nil { t.Fatal(err) } - if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationKindEmail).Count(&emailCount).Error; err != nil { + if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", "email").Count(&emailCount).Error; err != nil { t.Fatal(err) } - if storageCount != int64(len(storageProviderNames)) { - t.Fatalf("storage row count = %d, want %d", storageCount, len(storageProviderNames)) + if storageCount != 8 { + t.Fatalf("storage row count = %d, want 8", storageCount) } if paymentCount != 1 { t.Fatalf("payment row count = %d, want 1", paymentCount) @@ -130,7 +130,7 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { Type: "qiniu", Qiniu: &config.Qiniu{Bucket: "legacy", SecretKey: "legacy-secret"}, } - loaded, err := resolveStorageIntegrationConfig(db, legacy) + loaded, err := dataintegration.ResolveStorageConfig(db, legacy) if err != nil { t.Fatal(err) } @@ -142,7 +142,7 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { Type: "minio", Minio: &config.ObjectStore{Bucket: "must-not-replace-database"}, } - loaded, err = resolveStorageIntegrationConfig(db, other) + loaded, err = dataintegration.ResolveStorageConfig(db, other) if err != nil { t.Fatal(err) } @@ -169,14 +169,14 @@ func TestMaskStorageSecretsLeavesUnconfiguredProvidersEmpty(t *testing.T) { func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { db := openIntegrationConfigTestDB(t) legacy := &config.Email{To: "ops@example.com", From: "old@example.com", Host: "smtp.old.example.com", Secret: "old-secret", Port: 465, IsSSL: true} - loaded, err := resolveEmailIntegrationConfig(db, legacy) + loaded, err := dataintegration.ResolveEmailConfig(db, legacy) if err != nil { t.Fatal(err) } if loaded.Host != legacy.Host || loaded.Secret != legacy.Secret { t.Fatalf("migrated email = %#v", loaded) } - loaded, err = resolveEmailIntegrationConfig(db, &config.Email{Host: "must-not-replace.example.com"}) + loaded, err = dataintegration.ResolveEmailConfig(db, &config.Email{Host: "must-not-replace.example.com"}) if err != nil { t.Fatal(err) } @@ -245,11 +245,9 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) { if err != nil { t.Fatal(err) } - reloadableDB := &reloadableDB{} - reloadableDB.current.Store(db) d := &Data{ runtime: config.NewStore(&config.Config{Data: &config.Data{}, Admin: currentAdmin}), - gormDB: reloadableDB, + gormDB: newReloadableDB(db, nil), storage: currentStorage, } d.databaseReady.Store(true) @@ -274,11 +272,11 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) { if _, err = os.Stat(filepath.Join(newRoot, "active.txt")); err != nil { t.Fatalf("active storage did not write to the new root: %v", err) } - loaded, found, err := loadStorageIntegrationConfig(db) + loaded, found, err := dataintegration.LoadStorageConfig(db) if err != nil || !found || loaded.Type != "local" { t.Fatalf("database storage config = %#v, found=%v, err=%v", loaded, found, err) } - loadedEmail, found, err := loadEmailIntegrationConfig(db) + loadedEmail, found, err := dataintegration.LoadEmailConfig(db) if err != nil || !found || loadedEmail.Secret != "runtime-secret" { t.Fatalf("database email config = %#v, found=%v, err=%v", loadedEmail, found, err) } diff --git a/internal/data/integration_runtime.go b/internal/data/integration_runtime.go deleted file mode 100644 index eec7ab5..0000000 --- a/internal/data/integration_runtime.go +++ /dev/null @@ -1,22 +0,0 @@ -package data - -import ( - "gorm.io/gorm" - dataintegration "kra/internal/data/integration" - "kra/internal/integration/runtimeconfig" -) - -func readIntegrationRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) { - return dataintegration.ReadRuntime(db) -} - -func (d *Data) loadIntegrationRuntime(db *gorm.DB) error { - configs, err := readIntegrationRuntime(db) - if err != nil { - return err - } - if d.integrations != nil { - d.integrations.Replace(configs) - } - return nil -} diff --git a/internal/data/migrations.go b/internal/data/migrations.go deleted file mode 100644 index c5220ef..0000000 --- a/internal/data/migrations.go +++ /dev/null @@ -1,15 +0,0 @@ -package data - -import ( - "kra/pkg/database/migration" - "kra/pkg/module" - - "gorm.io/gorm" -) - -// migrateAll is the single data-layer migration entry point. Every module -// must register its migrations through the application catalog; there is no -// hidden system/payment fallback that could silently omit a new module. -func migrateAll(db *gorm.DB, catalog module.Catalog) error { - return migration.Run(db, catalog.MigrationSteps()) -} diff --git a/internal/data/runtime_clients.go b/internal/data/runtime_clients.go index 0fed2b5..d1697fb 100644 --- a/internal/data/runtime_clients.go +++ b/internal/data/runtime_clients.go @@ -3,7 +3,6 @@ package data import ( "context" "sync" - "sync/atomic" "time" "github.com/redis/go-redis/v9" @@ -48,6 +47,21 @@ func (s *retiredSet[T]) take(item T) bool { return false } +// forget removes every pending retirement for item without closing it. A +// reload can legitimately publish a previously retired handle again (for +// example A -> B -> A); its old timers must no longer own that live handle. +func (s *retiredSet[T]) forget(item T) { + s.mu.Lock() + defer s.mu.Unlock() + for index := 0; index < len(s.items); { + if s.items[index] != item { + index++ + continue + } + s.items = append(s.items[:index], s.items[index+1:]...) + } +} + func (s *retiredSet[T]) drain() []T { s.mu.Lock() defer s.mu.Unlock() @@ -56,138 +70,116 @@ func (s *retiredSet[T]) drain() []T { return items } -// reloadableDB makes the pointer swap atomic. Replaced pools stay open for -// retireGrace so in-flight GORM operations remain valid. -type reloadableDB struct { - current atomic.Pointer[gorm.DB] - retired retiredSet[*gorm.DB] +// reloadable owns one hot-swappable storage client. The database, Redis and +// Mongo handles all need the same three things — a racy-free read on every +// request, an atomic swap on reload, and a close that also drains whatever the +// grace period has not reaped yet — so they share this one implementation. +type reloadable[T comparable] struct { + mu sync.RWMutex + current T + retired retiredSet[T] + closeOne func(T) } -func newReloadableDB(db *gorm.DB, enqueue dataScopeAuditEnqueue) *reloadableDB { - r := &reloadableDB{} - registerDataScopeCallbacks(db, enqueue) - r.current.Store(db) - return r +func newReloadable[T comparable](current T, closeOne func(T)) *reloadable[T] { + return &reloadable[T]{current: current, closeOne: closeOne} } -func (r *reloadableDB) WithContext(ctx context.Context) *gorm.DB { - return r.current.Load().WithContext(ctx) +// load returns the active client, or the zero value when the client was never +// configured, so optional backends need no separate presence flag. +func (r *reloadable[T]) load() T { + var zero T + if r == nil { + return zero + } + r.mu.RLock() + defer r.mu.RUnlock() + return r.current } -func (r *reloadableDB) DB() *gorm.DB { return r.current.Load() } - -func (r *reloadableDB) replace(db *gorm.DB, enqueue dataScopeAuditEnqueue) { - registerDataScopeCallbacks(db, enqueue) - old := r.current.Swap(db) - if old != nil && old != db { - r.retired.retire(old, closeGormDB) +// replace publishes next and retires the handle it displaced. +func (r *reloadable[T]) replace(next T) { + var zero T + if next != zero { + r.retired.forget(next) + } + r.mu.Lock() + old := r.current + r.current = next + r.mu.Unlock() + if old != zero && old != next { + r.retired.retire(old, func(item T) { + // Serialize the final liveness check with replace: a handle that was + // re-published while its timer fired must never be closed underneath + // the new active configuration. + r.mu.Lock() + defer r.mu.Unlock() + if r.current != item { + r.closeOne(item) + } + }) } } -func (r *reloadableDB) close() { - all := append([]*gorm.DB{r.current.Load()}, r.retired.drain()...) - seen := map[*gorm.DB]struct{}{} - for _, db := range all { - if db == nil { +func (r *reloadable[T]) close() { + var zero T + r.mu.Lock() + current := r.current + r.current = zero + r.mu.Unlock() + seen := make(map[T]struct{}) + for _, item := range append([]T{current}, r.retired.drain()...) { + if item == zero { continue } - if _, ok := seen[db]; ok { + if _, ok := seen[item]; ok { continue } - seen[db] = struct{}{} - closeGormDB(db) + seen[item] = struct{}{} + r.closeOne(item) } } +// rollback collects the cleanup for every client a multi-step reload opened +// before it knows whether the reload will succeed. Any early return closes +// exactly what was opened, in reverse order; commit hands every handle over to +// the process and cancels all of it. One flag replaces one accepted-boolean plus +// one deferred closure per client. +type rollback struct { + cleanups []func() + committed bool +} + +func (r *rollback) add(cleanup func()) { r.cleanups = append(r.cleanups, cleanup) } + +func (r *rollback) commit() { r.committed = true } + +func (r *rollback) run() { + if r.committed { + return + } + for index := len(r.cleanups) - 1; index >= 0; index-- { + r.cleanups[index]() + } +} + +// newReloadableDB attaches the row-level data scope callbacks the pool needs +// before it becomes readable. Data.replacePrimaryDB is the reload counterpart. +func newReloadableDB(db *gorm.DB, enqueue dataScopeAuditEnqueue) *reloadable[*gorm.DB] { + registerDataScopeCallbacks(db, enqueue) + return newReloadable(db, closeGormDB) +} + func closeGormDB(db *gorm.DB) { if sqlDB, err := db.DB(); err == nil { _ = sqlDB.Close() } } -type reloadableMongo struct { - mu sync.RWMutex - current *mongo.Client - retired retiredSet[*mongo.Client] -} - -func newReloadableMongo(client *mongo.Client) *reloadableMongo { - return &reloadableMongo{current: client} -} - -func (r *reloadableMongo) replace(client *mongo.Client) { - r.mu.Lock() - old := r.current - r.current = client - r.mu.Unlock() - if old != nil && old != client { - r.retired.retire(old, closeMongoClient) - } -} - -func (r *reloadableMongo) load() *mongo.Client { - if r == nil { - return nil - } - r.mu.RLock() - defer r.mu.RUnlock() - return r.current -} - -func (r *reloadableMongo) close() { - r.mu.Lock() - current := r.current - r.current = nil - r.mu.Unlock() - for _, client := range append([]*mongo.Client{current}, r.retired.drain()...) { - if client != nil { - closeMongoClient(client) - } - } -} - func closeMongoClient(client *mongo.Client) { _ = client.Disconnect(context.Background()) } -type reloadableRedis struct { - mu sync.RWMutex - current redis.UniversalClient - retired retiredSet[redis.UniversalClient] -} - -func newReloadableRedis(client redis.UniversalClient) *reloadableRedis { - return &reloadableRedis{current: client} -} - -func (r *reloadableRedis) load() redis.UniversalClient { - r.mu.RLock() - defer r.mu.RUnlock() - return r.current -} - -func (r *reloadableRedis) replace(client redis.UniversalClient) { - r.mu.Lock() - old := r.current - r.current = client - r.mu.Unlock() - if old != nil && old != client { - r.retired.retire(old, closeRedisClient) - } -} - -func (r *reloadableRedis) close() { - r.mu.Lock() - current := r.current - r.current = nil - r.mu.Unlock() - for _, client := range append([]redis.UniversalClient{current}, r.retired.drain()...) { - if client != nil { - closeRedisClient(client) - } - } -} - func closeRedisClient(client redis.UniversalClient) { _ = client.Close() } diff --git a/internal/data/runtime_clients_test.go b/internal/data/runtime_clients_test.go new file mode 100644 index 0000000..19e95d1 --- /dev/null +++ b/internal/data/runtime_clients_test.go @@ -0,0 +1,38 @@ +package data + +import "testing" + +type reloadableTestClient struct{ id int } + +func TestReloadableReclaimsRepublishedClient(t *testing.T) { + first := &reloadableTestClient{id: 1} + second := &reloadableTestClient{id: 2} + closed := map[*reloadableTestClient]int{} + clients := newReloadable(first, func(client *reloadableTestClient) { closed[client]++ }) + + clients.replace(second) + clients.replace(first) + + if clients.retired.take(first) { + t.Fatal("republished client remained scheduled for retirement") + } + clients.close() + if closed[first] != 1 { + t.Fatalf("active client closed %d times, want 1", closed[first]) + } + if closed[second] != 1 { + t.Fatalf("retired client closed %d times, want 1", closed[second]) + } +} + +func TestReloadableCloseDeduplicatesHandles(t *testing.T) { + client := &reloadableTestClient{id: 1} + closed := 0 + clients := newReloadable(client, func(*reloadableTestClient) { closed++ }) + clients.retired.items = append(clients.retired.items, client, client) + + clients.close() + if closed != 1 { + t.Fatalf("client closed %d times, want 1", closed) + } +} diff --git a/internal/initialize/backend.go b/internal/initialize/backend.go deleted file mode 100644 index cc951e8..0000000 --- a/internal/initialize/backend.go +++ /dev/null @@ -1,20 +0,0 @@ -package initialize - -import ( - "context" - "kra/internal/biz/system" - "kra/internal/config" - - "gorm.io/gorm" -) - -// Backend is the infrastructure boundary required by application -// initialization and runtime configuration management. -type Backend interface { - IsInitialized(context.Context) (bool, error) - InitializeDatabase(context.Context, *system.DatabaseConfig, func(context.Context, *gorm.DB) error) error - PersistConfig(context.Context) error - PersistRuntimeConfig(context.Context, *config.Config) error - ReloadConfig(context.Context) error - Config() *config.Config -} diff --git a/internal/initialize/configuration.go b/internal/initialize/configuration.go index a1d09f9..fd88045 100644 --- a/internal/initialize/configuration.go +++ b/internal/initialize/configuration.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" + "unicode" "kra/internal/config" ) @@ -16,7 +18,7 @@ type configurationEnvelope struct { } func (r *Repo) ConfigurationJSON() (json.RawMessage, error) { - current := r.backend.Config() + current := r.Config() if current == nil { current = &config.Config{} } @@ -26,7 +28,7 @@ func (r *Repo) ConfigurationJSON() (json.RawMessage, error) { } func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error { - current := r.backend.Config() + current := r.Config() if current == nil { current = &config.Config{} } @@ -56,14 +58,13 @@ func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) e if next.Admin != nil && current.Admin != nil { next.Admin.ConfigPath = current.Admin.ConfigPath } - if err := refreshDatabaseSources(next.Data); err != nil { - return err - } + // The data backend rebuilds every DSN from the structured fields while + // preserving a standalone Source, so no pre-clearing is needed here. return r.PersistRuntimeConfig(ctx, next) } func (r *Repo) DiskMountPoints() []string { - current := r.backend.Config() + current := r.Config() if current == nil || current.Admin == nil { return nil } @@ -85,74 +86,45 @@ func mergeJSON(raw json.RawMessage, target any) error { func mergeDataJSON(raw json.RawMessage, target **config.Data) error { return mergeJSONMap(raw, target, func(values map[string]any) error { - if redis := jsonObject(values["redis"]); redis != nil { - if err := normalizeDuration(redis, "read_timeout", "readTimeout"); err != nil { - return err - } - if err := normalizeDuration(redis, "write_timeout", "writeTimeout"); err != nil { - return err - } + if err := normalizeRedisDurations(jsonObject(values["redis"])); err != nil { + return err } - if items, ok := values["redis_list"].([]any); ok { - for _, item := range items { - redis := jsonObject(item) - if err := normalizeDuration(redis, "read_timeout", "readTimeout"); err != nil { - return err - } - if err := normalizeDuration(redis, "write_timeout", "writeTimeout"); err != nil { - return err - } + items, _ := values["redis_list"].([]any) + for _, item := range items { + if err := normalizeRedisDurations(jsonObject(item)); err != nil { + return err } } return nil }) } +func normalizeRedisDurations(values map[string]any) error { + if err := normalizeDuration(values, "read_timeout"); err != nil { + return err + } + return normalizeDuration(values, "write_timeout") +} + +// The admin page posts the camelCase shape managementConfig produced, so only +// the duration strings still need converting once mergeJSONMap has restored the +// snake_case key names the configuration structs declare. func mergeAdminJSON(raw json.RawMessage, target **config.Admin) error { return mergeJSONMap(raw, target, func(values map[string]any) error { - moveJSONKey(values, "routerPrefix", "router_prefix") - if jwt := jsonObject(values["jwt"]); jwt != nil { - moveJSONKey(jwt, "signingKey", "signing_key") - moveJSONKey(jwt, "expiresTime", "expires_time") - moveJSONKey(jwt, "bufferTime", "buffer_time") - if err := normalizeDuration(jwt, "expires_time", "expiresTime"); err != nil { - return err - } - if err := normalizeDuration(jwt, "buffer_time", "bufferTime"); err != nil { - return err - } + jwt := jsonObject(values["jwt"]) + if err := normalizeDuration(jwt, "expires_time"); err != nil { + return err } - if captcha := jsonObject(values["captcha"]); captcha != nil { - moveJSONKey(captcha, "keyLong", "key_long") - moveJSONKey(captcha, "imgWidth", "img_width") - moveJSONKey(captcha, "imgHeight", "img_height") - moveJSONKey(captcha, "storeExpiration", "store_expiration") - return normalizeDuration(captcha, "store_expiration", "storeExpiration") + if err := normalizeDuration(jwt, "buffer_time"); err != nil { + return err } - if local := jsonObject(values["local"]); local != nil { - moveJSONKey(local, "storePath", "store_path") - moveJSONKey(local, "pathPrefix", "path_prefix") - } - if media := jsonObject(values["media"]); media != nil { - moveJSONKey(media, "sessionTtl", "session_ttl") - moveJSONKey(media, "maxFileSize", "max_file_size") - moveJSONKey(media, "chunkDir", "chunk_dir") - } - if system := jsonObject(values["system"]); system != nil { - moveJSONKey(system, "useRedis", "use_redis") - moveJSONKey(system, "useMultipoint", "use_multipoint") - moveJSONKey(system, "useStrictAuth", "use_strict_auth") - moveJSONKey(system, "disableAutoMigrate", "disable_auto_migrate") - moveJSONKey(system, "useMongo", "use_mongo") - moveJSONKey(system, "iplimitCount", "iplimit_count") - moveJSONKey(system, "iplimitTime", "iplimit_time") - } - return nil + return normalizeDuration(jsonObject(values["captcha"]), "store_expiration") }) } func mergeEmailJSON(raw json.RawMessage, target **config.Email) error { return mergeJSONMap(raw, target, func(values map[string]any) error { + // Two legacy dashed keys the generic snake_case pass cannot derive. moveJSONKey(values, "is-ssl", "is_ssl") moveJSONKey(values, "is-loginauth", "is_login_auth") return nil @@ -167,6 +139,7 @@ func mergeJSONMap(raw json.RawMessage, target any, transform func(map[string]any if err := json.Unmarshal(raw, &values); err != nil { return err } + snakeCaseKeys(values) if transform != nil { if err := transform(values); err != nil { return err @@ -179,31 +152,62 @@ func mergeJSONMap(raw json.RawMessage, target any, transform func(map[string]any return mergeJSON(normalized, target) } +// snakeCaseKeys rewrites every camelCase key in the decoded payload to the +// snake_case name the configuration structs declare. One recursive pass replaces +// a hand-written rename list that had to grow with every new setting — and that +// silently dropped whole sections when one of them was missed. +func snakeCaseKeys(value any) { + switch typed := value.(type) { + case map[string]any: + renamed := map[string]any{} + for key, item := range typed { + snakeCaseKeys(item) + if snake := snakeCase(key); snake != key { + renamed[snake] = item + delete(typed, key) + } + } + for key, item := range renamed { + typed[key] = item + } + case []any: + for _, item := range typed { + snakeCaseKeys(item) + } + } +} + +func snakeCase(key string) string { + var builder strings.Builder + for index, symbol := range key { + if !unicode.IsUpper(symbol) { + builder.WriteRune(symbol) + continue + } + if index > 0 { + builder.WriteByte('_') + } + builder.WriteRune(unicode.ToLower(symbol)) + } + return builder.String() +} + func jsonObject(value any) map[string]any { result, _ := value.(map[string]any) return result } -func normalizeDuration(values map[string]any, keys ...string) error { - if values == nil { +// A nil map reads as absent, so callers can pass jsonObject(...) straight in. +func normalizeDuration(values map[string]any, key string) error { + text, ok := values[key].(string) + if !ok { return nil } - for _, key := range keys { - raw, ok := values[key] - if !ok { - continue - } - text, ok := raw.(string) - if !ok { - return nil - } - value, err := time.ParseDuration(text) - if err != nil { - return fmt.Errorf("invalid duration %q: %w", text, err) - } - values[key] = int64(value) - return nil + value, err := time.ParseDuration(text) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", text, err) } + values[key] = int64(value) return nil } @@ -458,34 +462,3 @@ func preserveStorageSecrets(next, current *config.Storage) { } } } - -func refreshDatabaseSources(value *config.Data) error { - if value == nil { - return nil - } - if err := refreshDatabaseSource(value.Database); err != nil { - return err - } - for _, database := range value.DatabaseList { - if database == nil || database.Disable { - continue - } - if err := refreshDatabaseSource(database); err != nil { - return err - } - } - return nil -} -func refreshDatabaseSource(database *config.Database) error { - if database == nil { - return nil - } - hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" || database.Password != "" || database.Name != "" || database.Config != "" || database.Path != "" - if hasStructuredConfig { - // Driver-specific DSN construction remains in data. Clearing Source - // makes the data backend rebuild it from structured values, while a - // standalone DSN is preserved when no structured fields are supplied. - database.Source = "" - } - return nil -} diff --git a/internal/initialize/configuration_test.go b/internal/initialize/configuration_test.go index 0ab06e8..7b5b7fd 100644 --- a/internal/initialize/configuration_test.go +++ b/internal/initialize/configuration_test.go @@ -35,7 +35,7 @@ func TestConfigurationJSONMasksSecretsWithoutMutatingConfig(t *testing.T) { Data: &config.Data{Database: &config.Database{Password: "database-secret", Source: "dsn"}, Redis: &config.Redis{Password: "redis-secret"}}, Admin: &config.Admin{JWT: &config.JWT{SigningKey: "jwt-secret", ExpiresTime: 24 * time.Hour}, Email: &config.Email{Secret: "email-secret"}, Storage: &config.Storage{Qiniu: &config.Qiniu{SecretKey: "storage-secret"}}}, }} - raw, err := (&Repo{backend: backend}).ConfigurationJSON() + raw, err := (&Repo{Backend: backend}).ConfigurationJSON() if err != nil { t.Fatal(err) } @@ -58,7 +58,7 @@ func TestSaveConfigurationJSONMergesPartialValuesAndPreservesSecrets(t *testing. Admin: &config.Admin{ConfigPath: "config.yaml", JWT: &config.JWT{SigningKey: "jwt-secret", Issuer: "old", ExpiresTime: time.Hour}, Captcha: &config.Captcha{StoreExpiration: time.Minute}, Email: &config.Email{Host: "old.smtp", Secret: "email-secret", IsSSL: true}}, }} raw := json.RawMessage(`{"data":{"database":{"host":"new","password":"******"},"redis":{"read_timeout":"250ms"}},"admin":{"jwt":{"issuer":"new","signingKey":"******","expiresTime":"48h"},"captcha":{"storeExpiration":"5m"}},"email":{"host":"new.smtp","secret":"******","is-ssl":false}}`) - if err := (&Repo{backend: backend}).SaveConfigurationJSON(context.Background(), raw); err != nil { + if err := (&Repo{Backend: backend}).SaveConfigurationJSON(context.Background(), raw); err != nil { t.Fatal(err) } got := backend.persisted @@ -81,7 +81,7 @@ func TestSaveConfigurationJSONPreservesStandaloneDSN(t *testing.T) { Data: &config.Data{Database: &config.Database{Driver: "mysql", Source: "user:secret@tcp(database.example:3306)/kra"}}, Admin: &config.Admin{}, }} - if err := (&Repo{backend: backend}).SaveConfigurationJSON(context.Background(), json.RawMessage(`{"admin":{"routerPrefix":"/api"}}`)); err != nil { + if err := (&Repo{Backend: backend}).SaveConfigurationJSON(context.Background(), json.RawMessage(`{"admin":{"routerPrefix":"/api"}}`)); err != nil { t.Fatal(err) } if got := backend.persisted.Data.Database.Source; got != "user:secret@tcp(database.example:3306)/kra" { diff --git a/internal/initialize/initialize.go b/internal/initialize/initialize.go index 2ee5f39..9ada5c4 100644 --- a/internal/initialize/initialize.go +++ b/internal/initialize/initialize.go @@ -12,36 +12,36 @@ import ( "gorm.io/gorm" ) +// Backend is the infrastructure boundary required by application +// initialization and runtime configuration management. +type Backend interface { + IsInitialized(context.Context) (bool, error) + InitializeDatabase(context.Context, *system.DatabaseConfig, func(context.Context, *gorm.DB) error) error + PersistConfig(context.Context) error + PersistRuntimeConfig(context.Context, *config.Config) error + ReloadConfig(context.Context) error + Config() *config.Config +} + +// Repo adapts Backend to system.InitializationRepo. Backend is embedded so the +// four identically shaped methods are promoted instead of hand-forwarded; this +// file adds only what the two boundaries genuinely disagree on. type Repo struct { - backend Backend + Backend catalog platformmodule.Catalog } func NewRepo(backend Backend, catalog platformmodule.Catalog) system.InitializationRepo { - return &Repo{backend: backend, catalog: catalog} -} - -func (r *Repo) IsInitialized(ctx context.Context) (bool, error) { - return r.backend.IsInitialized(ctx) + return &Repo{Backend: backend, catalog: catalog} } +// Initialize layers system and task seeding on top of the backend's database +// lifecycle, which is the one place the two interfaces differ in shape. func (r *Repo) Initialize(ctx context.Context, input *system.DatabaseConfig) error { - return r.backend.InitializeDatabase(ctx, input, func(ctx context.Context, db *gorm.DB) error { + return r.InitializeDatabase(ctx, input, func(ctx context.Context, db *gorm.DB) error { if err := datasystem.SeedSystemWithCatalog(ctx, db, input, r.catalog); err != nil { return err } return datatask.SeedDefaults(ctx, db, r.catalog.DefaultTimedTasks()) }) } - -func (r *Repo) PersistConfig(ctx context.Context) error { - return r.backend.PersistConfig(ctx) -} - -func (r *Repo) PersistRuntimeConfig(ctx context.Context, value *config.Config) error { - return r.backend.PersistRuntimeConfig(ctx, value) -} - -func (r *Repo) ReloadConfig(ctx context.Context) error { - return r.backend.ReloadConfig(ctx) -} diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 9d89b67..8dfd29a 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -28,8 +28,13 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) { if got := catalog.MigrationSteps(); len(got) != 8 { t.Fatalf("module migrations = %d, want 8", len(got)) } - if surface := catalog.Surface(); len(surface.Menus) != 3 || len(surface.APIs) != 15 { - t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", len(surface.Menus), len(surface.APIs)) + var menus, apis int + for _, definition := range catalog.Definitions { + menus += len(definition.Surface.Menus) + apis += len(definition.Surface.APIs) + } + if menus != 3 || apis != 15 { + t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", menus, apis) } if got := catalog.DefaultTimedTasks(); len(got) != 2 { t.Fatalf("default timed tasks = %d, want 2", len(got)) diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index c39cab1..1f8bd76 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -235,6 +235,62 @@ var routes = map[string]routeValue{ "PUT /user/setUserInfo": {group: "系统用户", description: "设置用户信息", audit: true}, } +// compiled is one catalog entry with its path pre-split into segments so that +// per-request matching neither parses the map key nor allocates. wildcard marks +// a Gin catch-all tail ("/*any"); that segment is dropped from parts because it +// absorbs every remaining segment instead of matching one. +type compiled struct { + parts []string + wildcard bool + descriptor Descriptor +} + +// exact answers a canonical "METHOD /path" in a single map hit. byMethod holds +// the same entries pre-split and bucketed by method, so the fallback needed for +// path parameters and router prefixes scans one method's routes rather than the +// whole catalog, and never re-splits a pattern. +var ( + exact = make(map[string]Descriptor, len(routes)) + byMethod = make(map[string][]compiled) +) + +func init() { + for key, value := range routes { + descriptor := descriptorFrom(key, value) + exact[descriptorKey(descriptor.Method, descriptor.Path)] = descriptor + entry := compiled{parts: splitPath(descriptor.Path), descriptor: descriptor} + if last := len(entry.parts) - 1; last >= 0 && strings.HasPrefix(entry.parts[last], "*") { + entry.parts, entry.wildcard = entry.parts[:last], true + } + byMethod[descriptor.Method] = append(byMethod[descriptor.Method], entry) + } + // Suffix matching lets two patterns answer the same URL. Ordering the + // buckets most-specific-first makes the winner deterministic instead of + // dependent on map iteration order. + for method := range byMethod { + entries := byMethod[method] + sort.SliceStable(entries, func(i, j int) bool { + if len(entries[i].parts) != len(entries[j].parts) { + return len(entries[i].parts) > len(entries[j].parts) + } + if entries[i].wildcard != entries[j].wildcard { + return entries[j].wildcard + } + return literalCount(entries[i].parts) > literalCount(entries[j].parts) + }) + } +} + +func literalCount(parts []string) int { + count := 0 + for _, part := range parts { + if !strings.HasPrefix(part, ":") { + count++ + } + } + return count +} + func splitKey(key string) (string, string) { parts := strings.SplitN(key, " ", 2) if len(parts) != 2 { @@ -267,20 +323,52 @@ func descriptorFrom(key string, value routeValue) Descriptor { } } -func pathMatches(pattern, path string) bool { - patternParts := strings.Split(strings.Trim(normalizePath(pattern), "/"), "/") - pathParts := strings.Split(strings.Trim(normalizePath(path), "/"), "/") - if len(patternParts) == 1 && patternParts[0] == "" { - return len(pathParts) == 1 && pathParts[0] == "" +func splitPath(path string) []string { + return strings.Split(strings.Trim(normalizePath(path), "/"), "/") +} + +// Lookup accepts either a canonical Gin route template or an actual URL. A +// configured router prefix is tolerated by matching canonical paths by suffix. +// Every middleware resolves the descriptor on each request, so the pattern set +// is pre-split at init: a miss on the exact key costs one path split and a scan +// of the entries declared for that method, not a full catalog walk. +func Lookup(method, path string) (Descriptor, bool) { + if descriptor, ok := exact[descriptorKey(method, path)]; ok { + return descriptor, true } - if len(pathParts) < len(patternParts) { + parts := splitPath(path) + for _, entry := range byMethod[strings.ToUpper(strings.TrimSpace(method))] { + if entry.matches(parts) { + return entry.descriptor, true + } + } + return Descriptor{}, false +} + +// matches compares the pre-split pattern against a pre-split request path. +// Canonical patterns match by suffix so a router prefix is tolerated; a +// wildcard tail absorbs any number of trailing segments, so its fixed head is +// searched at every offset that leaves the wildcard something to consume. +func (c compiled) matches(pathParts []string) bool { + if len(c.parts) == 0 { + return !c.wildcard && len(pathParts) == 1 && pathParts[0] == "" + } + if len(pathParts) < len(c.parts) { return false } - offset := len(pathParts) - len(patternParts) - for index, patternPart := range patternParts { - if strings.HasPrefix(patternPart, "*") { - return true + if c.wildcard { + for offset := 0; offset+len(c.parts) < len(pathParts); offset++ { + if matchAt(c.parts, pathParts, offset) { + return true + } } + return false + } + return matchAt(c.parts, pathParts, len(pathParts)-len(c.parts)) +} + +func matchAt(patternParts, pathParts []string, offset int) bool { + for index, patternPart := range patternParts { actual := pathParts[offset+index] if strings.HasPrefix(patternPart, ":") { if actual == "" { @@ -295,22 +383,6 @@ func pathMatches(pattern, path string) bool { return true } -// Lookup accepts either a canonical Gin route template or an actual URL. A -// configured router prefix is tolerated by matching canonical paths by suffix. -func Lookup(method, path string) (Descriptor, bool) { - key := descriptorKey(method, path) - if value, ok := routes[key]; ok { - return descriptorFrom(key, value), true - } - for key, value := range routes { - descriptor := descriptorFrom(key, value) - if strings.EqualFold(descriptor.Method, method) && pathMatches(descriptor.Path, path) { - return descriptor, true - } - } - return Descriptor{}, false -} - func Describe(method, path string) Descriptor { if descriptor, ok := Lookup(method, path); ok { return descriptor diff --git a/internal/routecatalog/catalog_test.go b/internal/routecatalog/catalog_test.go index f651a3b..b11f24d 100644 --- a/internal/routecatalog/catalog_test.go +++ b/internal/routecatalog/catalog_test.go @@ -28,6 +28,15 @@ func TestRoutePoliciesShareOneDescriptor(t *testing.T) { } } +func TestLookupMatchesWildcardTail(t *testing.T) { + if descriptor, ok := Lookup("GET", "/api/swagger/index.html"); !ok || !descriptor.Public || descriptor.Path != "/swagger/*any" { + t.Fatalf("wildcard descriptor = %#v, ok=%v", descriptor, ok) + } + if _, ok := Lookup("GET", "/api/swagger"); ok { + t.Fatal("wildcard route matched without a tail") + } +} + func TestEveryRouteHasAPIMetadata(t *testing.T) { for _, descriptor := range Descriptors() { if descriptor.Group == "" || descriptor.Description == "" { diff --git a/internal/server/gin.go b/internal/server/gin.go index 8291d95..4d9c02a 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -21,10 +21,10 @@ import ( ) func NewGinEngine(runtime *config.Store, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine { - return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, platformmodule.NewRuntime(router.NewRoutes(handlers)), nil) + return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, router.NewRoutes(handlers), nil) } -func NewGinEngineWithRuntime(runtime *config.Store, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine { +func NewGinEngineWithRuntime(runtime *config.Store, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes platformmodule.RouteRegistrar, ws *websocket.Server) *gin.Engine { if runtime == nil { runtime = config.NewStore(nil) } diff --git a/internal/server/handler/http.go b/internal/server/handler/http.go index 2efa88c..b20e72c 100644 --- a/internal/server/handler/http.go +++ b/internal/server/handler/http.go @@ -1,9 +1,12 @@ +// This file is the handler package's single vocabulary for HTTP replies: the +// envelope helpers come from pkg/httpx and Claims from the auth middleware, so +// handlers name one package instead of two. package handler import ( "kra/internal/biz/system" "kra/internal/server/middleware" - httpx "kra/pkg/httpx" + "kra/pkg/httpx" "github.com/gin-gonic/gin" ) diff --git a/internal/worker/task_registry.go b/internal/worker/task_registry.go index f43c6e9..01a60fc 100644 --- a/internal/worker/task_registry.go +++ b/internal/worker/task_registry.go @@ -11,8 +11,7 @@ import ( platformtask "kra/pkg/task" ) -// TaskMethods is the system module's dependency-bearing task contribution. -// Other modules provide their own contributor instead of editing this file. +// TaskMethods owns the dependency-bearing built-in task handlers. type TaskMethods struct { tasks *taskbiz.TaskUsecase maintenance *system.MaintenanceUsecase @@ -24,8 +23,6 @@ func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceU return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime} } -var _ platformtask.Contributor = (*TaskMethods)(nil) - func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) { if methods == nil || registry == nil { return diff --git a/pkg/module/module.go b/pkg/module/module.go index 2daaede..d7d4842 100644 --- a/pkg/module/module.go +++ b/pkg/module/module.go @@ -6,7 +6,6 @@ package module import ( "github.com/gin-gonic/gin" "kra/pkg/database/migration" - "kra/pkg/task" ) type Menu struct { @@ -33,7 +32,6 @@ type Definition struct { Migrations []migration.Step Surface Surface TimedTasks []TimedTask - Tasks []task.Method } type Catalog struct { @@ -48,15 +46,6 @@ func (c Catalog) MigrationSteps() []migration.Step { return steps } -func (c Catalog) Surface() Surface { - var surface Surface - for _, item := range c.Definitions { - surface.Menus = append(surface.Menus, item.Surface.Menus...) - surface.APIs = append(surface.APIs, item.Surface.APIs...) - } - return surface -} - func (c Catalog) DefaultTimedTasks() []TimedTask { var tasks []TimedTask for _, item := range c.Definitions { @@ -65,41 +54,6 @@ func (c Catalog) DefaultTimedTasks() []TimedTask { return tasks } -func (c Catalog) TaskMethods() []task.Method { - var methods []task.Method - for _, item := range c.Definitions { - methods = append(methods, item.Tasks...) - } - return methods -} - type RouteRegistrar interface { RegisterRoutes(public, private *gin.RouterGroup, engine *gin.Engine) } - -type Runtime struct { - routes []RouteRegistrar -} - -func NewRuntime(routes ...RouteRegistrar) *Runtime { - return &Runtime{routes: append([]RouteRegistrar(nil), routes...)} -} - -func (r *Runtime) Add(routes ...RouteRegistrar) { - if r == nil { - return - } - r.routes = append(r.routes, routes...) -} - -func (r *Runtime) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.Engine) { - if r == nil { - return - } - for _, registrar := range r.routes { - if registrar == nil { - continue - } - registrar.RegisterRoutes(public, private, engine) - } -} diff --git a/pkg/module/module_test.go b/pkg/module/module_test.go index 5f25371..d0e0594 100644 --- a/pkg/module/module_test.go +++ b/pkg/module/module_test.go @@ -1,59 +1,34 @@ package module import ( - "context" - "encoding/json" "testing" - "github.com/gin-gonic/gin" "kra/pkg/database/migration" - "kra/pkg/task" ) func TestCatalogCollectsContributions(t *testing.T) { - run := func(context.Context, json.RawMessage) error { return nil } catalog := Catalog{Definitions: []Definition{ { Name: "system", Migrations: []migration.Step{{ID: "system_schema"}}, Surface: Surface{Menus: []Menu{{Name: "users"}}, APIs: []API{{Path: "/users"}}}, TimedTasks: []TimedTask{{Name: "system.cleanup"}}, - Tasks: []task.Method{{Name: "system.cleanup", Run: run}}, }, { Name: "orders", Migrations: []migration.Step{{ID: "orders_schema"}}, Surface: Surface{Menus: []Menu{{Name: "orders"}}, APIs: []API{{Path: "/orders"}}}, TimedTasks: []TimedTask{{Name: "orders.expire"}}, - Tasks: []task.Method{{Name: "orders.expire", Run: run}}, }, }} if got := catalog.MigrationSteps(); len(got) != 2 || got[0].ID != "system_schema" || got[1].ID != "orders_schema" { t.Fatalf("unexpected migrations: %#v", got) } - if got := catalog.Surface(); len(got.Menus) != 2 || len(got.APIs) != 2 || got.Menus[1].Name != "orders" { + if got := catalog.Definitions[1].Surface; len(got.Menus) != 1 || len(got.APIs) != 1 || got.Menus[0].Name != "orders" { t.Fatalf("unexpected surface: %#v", got) } if got := catalog.DefaultTimedTasks(); len(got) != 2 || got[1].Name != "orders.expire" { t.Fatalf("unexpected default tasks: %#v", got) } - if got := catalog.TaskMethods(); len(got) != 2 || got[1].Name != "orders.expire" { - t.Fatalf("unexpected task methods: %#v", got) - } -} - -type routeRegistrarStub struct{ called bool } - -func (stub *routeRegistrarStub) RegisterRoutes(*gin.RouterGroup, *gin.RouterGroup, *gin.Engine) { - stub.called = true -} - -func TestRuntimeSkipsNilRouteRegistrars(t *testing.T) { - stub := &routeRegistrarStub{} - runtime := NewRuntime(nil, stub) - runtime.RegisterRoutes(nil, nil, nil) - if !stub.called { - t.Fatal("non-nil route registrar was not called") - } } diff --git a/pkg/protoutil/json.go b/pkg/protoutil/json.go deleted file mode 100644 index dd0725d..0000000 --- a/pkg/protoutil/json.go +++ /dev/null @@ -1,80 +0,0 @@ -// Package protoutil contains protobuf helpers that are independent of any -// application module. -package protoutil - -import ( - "encoding/json" - "strings" - - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" -) - -// MergeProtoJSON applies a JSON object as a partial protobuf update while -// retaining fields omitted by the caller. -func MergeProtoJSON(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error { - currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target) - if err != nil { - return err - } - var current map[string]any - if err = json.Unmarshal(currentRaw, ¤t); err != nil { - return err - } - var incoming map[string]any - if err = json.Unmarshal(patch, &incoming); err != nil { - return err - } - incoming = normalizeJSONKeys(incoming).(map[string]any) - mergeJSONObjects(current, incoming) - merged, err := json.Marshal(current) - if err != nil { - return err - } - return options.Unmarshal(merged, target) -} - -func normalizeJSONKeys(value any) any { - switch item := value.(type) { - case map[string]any: - result := make(map[string]any, len(item)) - for key, nested := range item { - result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested) - } - return result - case []any: - result := make([]any, len(item)) - for index, nested := range item { - result[index] = normalizeJSONKeys(nested) - } - return result - default: - return value - } -} - -func snakeToLowerCamel(value string) string { - if !strings.Contains(value, "_") { - return value - } - parts := strings.Split(value, "_") - result := parts[0] - for _, part := range parts[1:] { - if part != "" { - result += strings.ToUpper(part[:1]) + part[1:] - } - } - return result -} - -func mergeJSONObjects(target, patch map[string]any) { - for key, value := range patch { - if incoming, ok := value.(map[string]any); ok { - if existing, exists := target[key].(map[string]any); exists { - mergeJSONObjects(existing, incoming) - continue - } - } - target[key] = value - } -} diff --git a/pkg/task/registry.go b/pkg/task/registry.go index cfafad2..5fedeb7 100644 --- a/pkg/task/registry.go +++ b/pkg/task/registry.go @@ -17,13 +17,6 @@ type Method struct { Run MethodFunc } -// Contributor registers task methods whose handlers need constructed runtime -// dependencies. Modules expose a contributor and the application composition -// root activates it; the task runtime never imports business modules. -type Contributor interface { - RegisterTasks(*Registry) -} - type Registry struct { mu sync.RWMutex methods map[string]Method @@ -31,17 +24,6 @@ type Registry struct { func NewRegistry() *Registry { return &Registry{methods: make(map[string]Method)} } -func Apply(registry *Registry, contributors ...Contributor) { - if registry == nil { - return - } - for _, contributor := range contributors { - if contributor != nil { - contributor.RegisterTasks(registry) - } - } -} - func (r *Registry) Register(method Method) { if r == nil || method.Name == "" || method.Run == nil { return diff --git a/pkg/task/registry_test.go b/pkg/task/registry_test.go index b5bdb6c..b2824ee 100644 --- a/pkg/task/registry_test.go +++ b/pkg/task/registry_test.go @@ -15,18 +15,3 @@ func TestRegistryIsComposableAndIdempotent(t *testing.T) { t.Fatalf("unexpected methods: %#v", methods) } } - -type testContributor struct{ name string } - -func (contributor testContributor) RegisterTasks(registry *Registry) { - registry.Register(Method{Name: contributor.name, Run: func(context.Context, json.RawMessage) error { return nil }}) -} - -func TestApplyCollectsModuleContributors(t *testing.T) { - registry := NewRegistry() - Apply(registry, testContributor{name: "system.cleanup"}, testContributor{name: "orders.expire"}) - methods := registry.List() - if len(methods) != 2 || methods[0].Name != "orders.expire" || methods[1].Name != "system.cleanup" { - t.Fatalf("unexpected contributed methods: %#v", methods) - } -}