diff --git a/AGENTS.md b/AGENTS.md index 0683da2..1441e25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,19 +6,22 @@ agents must follow when changing the template. ## Project structure ``` -api/// Proto sources and generated stubs. Public contract. -cmd// Entrypoint, Wire injector, main.go. +cmd/ Entrypoint, Wire injector, main.go. configs/ Runtime config (config.yaml). No secrets. internal/config/ Viper config models, loading, snapshots, and reloads. -internal/global/ Process-wide shared resource registry. -internal/server/ HTTP/gRPC server wiring. -internal/service/ Transport adapters; one file per resource. +internal/server/ Gin handlers, middleware, routers, static files, HTTP wiring. +internal/service/dto/ Hand-written request and response DTOs. +internal/service/ DTO/DO transport adapters; organized by domain. internal/biz/ Domain models, usecases, repo interfaces, errors. internal/data/ Repo implementations, database clients, migrations. internal/initialize/ First-install and configuration orchestration. internal/integration/ External I/O adapters: cache, email, payment, storage. -internal/logging/ Application logging infrastructure. +internal/modules/ Built-in module schema, seed, menu, API, and task contributions. +internal/routecatalog/ HTTP route metadata and runtime policies. +internal/worker/ Timed-task runtime and scheduler. internal/utils/ Stateless, internal-only helper packages. +pkg/ Reusable infrastructure packages. +web/ Vue administration frontend. ``` ## Layering & dependency rules @@ -32,7 +35,7 @@ owns the PO; `service` is a pass-through that converts at its boundary. │ declares │ implements └─── repo IF ────┘ - DTO Data Transfer Object — proto request / response. + DTO Data Transfer Object — hand-written HTTP request / response. DO Domain Object — pure biz model, no proto, no storage tags. PO Persistent Object — storage shape, owned by `data`. ``` @@ -43,9 +46,9 @@ owns the PO; `service` is a pass-through that converts at its boundary. | biz | DO | DO | DTO, PO, storage client | | data | PO | DO ↔ PO | DTO | -- `service` imports `api/...` (DTO) and `biz` (DO). Never `data`. -- `biz` imports `api/...` only for error reason enums. Never `service`, - never `data`. The repo interface declared here is the inversion seam. +- `service` imports `internal/service/dto` (DTO) and `biz` (DO). Never `data`. +- `biz` never imports `service` or `data`. The repo interface declared here + is the inversion seam. - `data` imports `biz` to implement the repo interface. Never `service`, never DTOs. - `integration` implements external I/O boundaries and may import `biz` and @@ -61,15 +64,10 @@ design rather than add the import. **service (DTO ↔ DO)** -- `convert` parses an incoming proto into a DO. The reverse - direction is built inline at the return site; the reply type is - whatever the proto declares — usually the resource itself - (`return &v1.{...}, nil`), sometimes a list wrapper - (`*v1.Set`), or `&emptypb.Empty{}` for deletes. Inlining - keeps each handler self-contained. -- Embed `UnimplementedServiceServer`. -- Parse AIP list requests via `filtering` / `ordering` / `pagination`; - apply `fieldmask.Update` for partial updates. +- Convert hand-written HTTP DTOs into DOs at the service boundary and build + response DTOs from returned DOs. +- Keep handlers focused on binding, authentication context, response envelopes, + and transport-specific limits. - Validate request inputs at the service boundary before delegating to the usecase. - Return `biz` errors. No business rules, no storage access, no PO. @@ -78,8 +76,8 @@ design rather than add the import. - Owns the DO (`type struct` — no proto, no storage tags), the usecase, and the repo interface (`type Repo interface`). -- Owns typed errors built with `errors.NotFound` / `errors.BadRequest` - plus the API error reason enum. +- Owns typed errors built with `errors.NotFound` / `errors.BadRequest` and + stable reason strings. - Owns `ListOption` helpers — `ListFilter`, `ListOrderBy`, `ListOffset`, `ListLimit` — so callers compose queries without leaking storage primitives. @@ -103,23 +101,23 @@ design rather than add the import. **server** -- Construct HTTP/gRPC servers, apply middleware, register services. No - translation, no business logic. +- Construct the Gin engine and Kratos HTTP server, apply middleware, register + handlers and routes. No persistence access or business rules. ### Add-a-resource checklist -1. **DTO**: define `Create` / `Get` / - `List` / `Update` / `Delete` in - `api///`, then `make api`. +1. **DTO**: define request and response types in `internal/service/dto/`. 2. **DO + repo interface**: declare both in `biz`; build the usecase on top of the interface. 3. **Repo impl**: implement in `data` returning `biz.Repo`; add a PO and the matching conversion helpers when storage shape diverges from DO. -4. **Wiring**: register the repo constructor in `data.ProviderSet`, the - usecase in `biz.ProviderSet`, the service in `service.ProviderSet`; - register HTTP/gRPC services in `internal/server`. -5. **Regenerate**: `make all` to refresh Wire and `go.mod`. +4. **Transport**: add the service adapter, handler, router registration, and + routecatalog metadata. +5. **Wiring**: register the repo constructor in `data.ProviderSet`, the + usecase in `biz.ProviderSet`, and the service/handler providers in their sets. +6. **Regenerate**: run `make generate` or `make all` to refresh Wire and + `go.mod`. ### Testing seam @@ -129,8 +127,7 @@ tests exercise repo implementations at the storage boundary. ## Generation & generated files -Regenerate via `make api` or `make all`; never hand-edit -`*.pb.go`, `*_grpc.pb.go`, `*_http.pb.go`, or `wire_gen.go`. +Regenerate via `make generate` or `make all`; never hand-edit `wire_gen.go`. ## Naming & error reasons @@ -140,8 +137,8 @@ Regenerate via `make api` or `make all`; never hand-edit `Service`. PO types live inside `internal/data/`; pick a name that fits the storage driver and convert with `new(do)` / `toBiz(po)` free functions. -- Error reasons: declared in `api///error_reason.proto`, - surfaced as `Err` in `biz`. +- Error reasons use stable strings and are surfaced as + `Err` in `biz`. ## Commits & security diff --git a/configs/config.yaml b/configs/config.yaml index 067a761..5698f26 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -138,7 +138,7 @@ admin: # Example: # whitelist: # - allow_origin: https://admin.example.com - # allow_headers: Content-Type,Authorization,X-Token,X-User-Id + # allow_headers: Content-Type,Authorization,X-Token # allow_methods: POST,GET,PUT,DELETE,OPTIONS # expose_headers: Content-Length,Content-Type # allow_credentials: true diff --git a/docs/ADMIN_BACKEND.md b/docs/ADMIN_BACKEND.md new file mode 100644 index 0000000..4f18ae4 --- /dev/null +++ b/docs/ADMIN_BACKEND.md @@ -0,0 +1,52 @@ +# 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 new file mode 100644 index 0000000..772c114 --- /dev/null +++ b/docs/OA_MIGRATION_PLAN.md @@ -0,0 +1,1355 @@ +# OA 到 KRA 完整迁移执行计划 + +> 计划基线日期:2026-08-21 +> 源系统:`oa` +> 目标系统:`kra-new` +> 当前状态:仅完成代码盘点与迁移规划,尚未开始业务迁移 + +## 1. 目标与结论 + +目标是在 **不重构现有移动端、PC 端和旧后台调用协议** 的前提下,将 OA 的完整系统能力迁移到 KRA,并最终由 KRA 统一承载: + +- 管理后台接口与页面; +- 会长/渠道移动端接口(`/tg`); +- 玩家及客服端接口(`/imserver`); +- 对外 SDK 接口(`/sdk`); +- 游戏、渠道、玩家、订单、扶持、福利、结算、IM、分发、报表等业务; +- 中间件、定时任务、多数据库、Redis、消息队列、对象存储和第三方服务; +- 数据初始化、历史数据迁移、部署和回滚能力。 + +盘点后的结论是:**KRA 可以承载 OA,但不能直接复制 OA 目录或模型。** 两边虽然都保留了 GVA 风格接口,但后端分层、系统表、鉴权方式和任务机制已经不同。迁移必须以 KRA 的分层、模块目录、系统表和运行时为主,通过 OA 兼容 DTO、兼容中间件和显式数据迁移保持旧客户端协议不变。 + +## 2. 当前基线 + +以下数字用于评估工作量,不代表最终有效接口数;正式迁移前仍需从运行时路由和实际客户端请求中去重、排除未注册代码。 + +| 项目 | OA | KRA 当前状态 | +|---|---:|---:| +| 路由注册语句 | 约 756 | 约 191 | +| System 模型文件 | 32 | 已有完整基础后台模型 | +| Game 模型文件 | 52 | 未迁移 | +| User 玩家模型文件 | 12 | 未迁移 | +| IM 模型文件 | 16 | 仅有通用 WebSocket 基础设施 | +| Issue 模型文件 | 4 | 未迁移 | +| Report 模型文件 | 2 | 未迁移 | +| SW 模型文件 | 4 | 未迁移 | +| OA 管理端 API 文件 | 57 | 仅 KRA 自身后台 API | + +OA 路由注册语句按原目录的粗略分布: + +| 原路由域 | 注册语句数 | 主要用途 | +|---|---:|---| +| `system` | 198 | 用户、角色、部门、平台、配置、日志、黑名单等 | +| `game` | 303 | 游戏、渠道、订单、扶持、福利、结算、打包等 | +| `user` | 60 | 玩家、角色、补单、发放、协议、短信等 | +| `tg` | 49 | 会长/渠道移动端 | +| `im` | 32 | 管理后台客服 IM | +| `imserver` | 22 | 玩家端 IM 与回调 | +| `sdk` | 18 | 对外签名接口 | +| `issue` | 28 | 分发系统 | +| `report` | 2 | 报表 | +| `sw` | 12 | 商务用户与平台绑定 | +| `common` / `example` | 32 | 公共下载、上传及遗留工具/示例 | + +## 3. 不可变更的迁移规则 + +1. **KRA 是唯一目标代码库。** 不长期保留 OA 与 KRA 两套业务实现。 +2. **KRA 系统表优先。** KRA 已有语义等价表时,不再创建 OA 同义系统表;OA 多出的字段增量加入 KRA 表。 +3. **OA 独有业务表保留业务语义。** 没有 KRA 对应物的业务表可迁入模块,但必须进入 KRA 的迁移体系和数据层。 +4. **旧客户端不改协议。** HTTP 方法、路径、参数来源、字段名、字段类型、默认值、空值形态、响应码、消息、Header 和下载行为均以旧接口契约为准。 +5. **后台能力同步迁移。** OA 有而 KRA 没有的管理功能,必须同时迁移后端接口、KRA 后台页面、菜单、按钮权限和 API 权限数据。 +6. **接口域不能混用鉴权。** 管理后台、会长端、玩家端、SDK 和第三方回调必须使用各自的 token、签名和上下文。 +7. **逐文件核对。** 每一个 OA 源文件必须被标记为“已迁移、被 KRA 替代、确认废弃”之一,不允许无记录遗漏。 +8. **逐接口差异测试。** 不以“页面能打开”作为完成标准,必须比较响应字段和业务副作用。 +9. **只做显式数据库迁移。** 禁止用无审查的 `AutoMigrate` 修改生产旧表。 +10. **迁移定义永久保留。** 已执行的 migration ID 不得从 KRA Catalog 删除或改名。 + +## 4. 目标模块结构 + +业务模块建议使用独立目录,并在模块内部继续遵守 KRA 的 `service -> biz -> data` 分层: + +```text +internal/modules// + biz/ # 领域对象、状态机、用例、仓储接口 + data/ # PO、GORM 查询、事务、数据转换、迁移 + service/ # OA 请求/响应 DTO 与领域对象转换 + server/handler/ # Gin handler + server/router/ # 路由与接口域注册 + worker/ # 本模块定时任务注册 + definition.go # migrations / menus / APIs / default tasks + providers.go # Wire ProviderSet + +web/src/modules// + api/ + view/ + components/ +``` + +修改 KRA 既有系统表和系统行为的内容仍放在现有 `internal/biz/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 new file mode 100644 index 0000000..fbd1582 --- /dev/null +++ b/docs/PAYMENT.md @@ -0,0 +1,410 @@ +# 支付接入设计 + +## 代码分层 + +支付渠道适配器统一放在 `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/code-review-issues.md b/docs/code-review-issues.md new file mode 100644 index 0000000..97638a9 --- /dev/null +++ b/docs/code-review-issues.md @@ -0,0 +1,261 @@ +# 代码审查问题清单(internal + pkg) + +- 审查日期:2026-08-27(三轮)。第三轮(2026-08-27):核查前两轮修复落地情况 + 以"高内聚低耦合 / 简单实现复杂化 / 过分拆分 / 包归属(可抽 pkg/utils)"为重点重审全库 +- 审查方式:第一轮 codegraph + 5 路并行深读;第二轮 6 路逐文件深读;第三轮 5 路并行(修复核查 + 重审)。所有死代码结论均经全仓库 Grep 反查调用方验证;第三轮附带 `go build ./...` 编译验证通过 +- 第三轮修复核查结论:**S0×17、P0×6、P1-1/2/3/4/5/6/7/10、P4-4、S4 两条全部真实落地**(已从本文档删除,留痕见文末"已修复确认清单");**未修复**:S1 死代码大部分、P2/P3/P4 结构问题大部分、1-8/1-8b/1-9/2-10/3-8/3-9 +- 当前待办集中在:T0 修复回归缺陷 → S1/T3 死代码 → T1 过分拆分合并 → T2 归属移动 → S2 重复消除 → P2/P4 结构 + +优先级定义:P0 = 零风险可直接删;P1 = 低风险去重;P2 = 中风险删转发层;P3 = 涉及 import 路径批量修改的移位;P4 = 大动作结构合并/文档修正。 + +--- + +## P1 重复实现 / 双份维护(遗留) + +| # | 问题 | 位置 | 建议 | +|---|------|------|------| +| 1-8 | DSN 一致性双写:`persistDatabaseConfig`、`refreshDatabaseSource`、`InitializeDatabase` 三处维护"结构化字段→Source"不变式,refresh 用"先清空再恢复"绕开 databaseDSN 短路 | internal/data/config_store.go:78-94;internal/data/initialization_backend.go:42-59,135-140 | 固化进 databaseDSN 唯一入口 | +| 1-8b | "storage/email 缺省则沿用现值"策略散布 4 处(文件/DB 配置合并规则;原 5 处已收敛为 4 处,但无统一 MergeRuntimeConfig) | internal/config/runtime.go:275-286;internal/data/config_store.go:173-183;internal/data/initialization_backend.go:77-85,171-186 | config 包提供唯一 `MergeRuntimeConfig` | +| 1-9 | config.Store 与 runtimeconfig.Store 各写一套同构的 listener 注册/通知/克隆机制(语义确有差异:文件全量 vs DB 集成窄通道,可辩护为有意分离,需明确决策) | internal/config/runtime.go:29,150-196;internal/integration/runtimeconfig/store.go:62-167;桥接靠 data 层三处 Replace(data.go:417、config_store.go:258、initialization_backend.go:214) | 抽泛型 notifier,或写决策注释固化现状 | +| 1-11 | mq.Registry 接口面积翻倍且恶化:legacy API(Subscribe/Unsubscribe/Publish)内部转译为声明式 Register,双面并存;且整个 mq 体系业务消费者为零 | pkg/mq/mq.go;internal/integration/mq/emqx.go:539-589 | 见 T3:整体裁撤或收缩 | + +## P2 转发 shim / 门面层(过度分层) + +| # | 问题 | 位置 | +|---|------|------| +| 2-1 | payment 回调 ack 穿透仍四层(第三轮部分收敛:定义已单源化到 paymentkit、biz 用别名零拷贝;但 biz/service/handler 三层转发仍在,且 dto/payment.go:58-62 重复声明同构 3 字段结构体,加字段需同步三处) | pkg/paymentkit/callback.go:11-83 → biz/payment/payment.go:206-215 → service/payment/payment.go:131-148 → handler/payment.go:173-184 | +| 2-2 | integration/payment/result.go 自称 "Compatibility shims":11 个纯转发函数全部仍在(normalizePaymentStatus/parseIntegerAmount/jsonObject/nestedString 等) | internal/integration/payment/result.go:68-110 | +| 2-3 | service 根包 25 个 `type X = systemservice.X` 别名门面 + 函数转发(迁移脚手架,注释自辩 "one import instead of four";使 handler 无法感知真实包结构) | internal/service/service.go:18-51 | +| 2-4 | handler/http.go 纯转发别名层:3 常量+2 类型+6 函数一对一转发到 pkg/httpx 与 middleware.Claims,仅为省一个 import(每个 handler 文件仍要同时认识两个包) | internal/server/handler/http.go:14-29 | +| 2-5 | service/task 双面 API:DO 签名版与 DTO 版并存;DO 版 Create/Update/Tasks 仅被同文件 DTO 版内部调用,handler 只用 `*Request` 版 | internal/service/task/task.go:35-46 | +| 2-6 | TaskUsecase 嵌入透传且加重:仍嵌入 TaskRepo 透传 9 方法给 worker;新增 TaskApplicationUsecase 再包一层,其 6 方法纯转发——双 usecase 并存(worker 持前者、service 持后者,wire_gen.go:115-119) | internal/biz/task/task.go:76-79,159-237 | +| 2-7 | `openWithDriver` 单调用点便捷转发(第三轮部分修复:已拆出 openWithDriverConfig;wrapper 仍有 1 个生产调用 + 约 20 处测试调用) | internal/data/database.go:134-136(生产调用 :248) | +| 2-8 | Backend 接口三层缝合:biz `InitializationRepo` → initialize.Repo(内嵌 6 方法 Backend + seed 回调单传入点)→ data.Data;initialize 包只为"转手 6 个同名方法 + 注入 catalog"存在 | internal/initialize/initialize.go:17-46;internal/data/initialization_backend.go:17-217;cmd/wire.go:45 | +| 2-9 | data-scope 审计回调 `dataScopeAuditEnqueue` 穿透 6 层签名,生产实现唯一 | internal/data/data.go:238,245,392 → runtime_clients.go:168 → data_scope.go:19-136 | +| 2-10 | `*Data` 方法约 10 处模板式 `if d == nil` 防御(构造归 Wire 管理,不可能为 nil);`NewIntegrationRuntime` 的 nil→空 Store 回退掩盖错误状态 | internal/data/data.go:41,72,82,88,97,119,126,373-375;initialization_backend.go:18 | + +## P3 包归属问题(pkg 应为可对外复用、无业务语义) + +| # | 包 | 问题 | 建议 | +|---|----|------|------| +| 3-1 | pkg/paymentkit | 目录名≠包名(实际 `package paymentutil`);17 个支付渠道常量是本项目商户目录(与 biz/integration 中文渠道定义一一对应);回调 ack 硬编码微信/支付宝协议、中文业务文案;调用方 100% 在 internal | 整体并入 internal/integration/payment,顺带消灭 17 常量双份导出(biz/payment/payment.go:20-43 逐个重命名再导出一遍)、三层金额/回调转发、text/firstAny 双份 | +| 3-2 | pkg/logging | source.go:54-61 硬编码 internal 目录布局(效果等同反向依赖);zap.go:346-383 中文上报文案、:342 特判遗留 gva 项目文件名;AGENTS.md 声称的 `internal/logging/` 目录实际不存在 | 内移为 internal/logging,同步修 AGENTS.md | +| 3-3 | pkg/httpx | response.go:17 业务状态码 10001(PasswordChangeRequired);:60-69 硬编码 `x-token` cookie 契约;调用方全部在 internal/server | 移入 internal/server | +| 3-4 | internal/initialize/configuration.go | `management*` 家族(:221-318)手工构造 camelCase JSON,属 service 层 DTO 塑形职责;根因是 biz `InitializationRepo` 以 `json.RawMessage` 为出入参,表现形状泄漏进 repo 层 | JSON 形状定义移至 internal/service/dto;snakeCase/normalizeDuration 等纯函数与掩码逻辑分文件(当前 464 行混杂三种职责) | +| 3-5 | pkg/database/pagination、gormkit | 调用方 100% 在 internal/data(分页纯数学无业务语义,轻度) | 可下沉 internal/data(低优先级) | +| 3-6 | pkg/mq | rabbitmq.go:127 硬编码项目名前缀 `kra-`;subscription.go 的 Contributor 面向本项目 module 概念 | 去项目化(低优先级) | +| 3-7 | pkg/module、pkg/task、pkg/database/migration | 只被本仓库消费,但互为依赖构成同层契约组(module 依赖 migration/task,单独搬会破坏依赖方向一致性) | 维持现状 | +| 3-8 | internal/data/runtime_clients.go | `newReloadableDB` 在通用热切换构造器里调用领域函数 `registerDataScopeCallbacks`,越界 | 该调用移到 data.go 与 replacePrimaryDB 同址 | +| 3-9 | internal/data/data_scope_record.go | 整文件只有一行类型别名 `dataAccessLogPO = datasystem.DataAccessLogPO` | 删除文件并入 data_scope.go | + +## P4 结构性设计(双份数据 / 文档漂移,大动作需决策) + +| # | 问题 | 位置 | 建议 | +|---|------|------|------| +| 4-1 | router 与 routecatalog 双声明:21 个 router 文件纯声明式注册 method+path;routecatalog 又用一张 map 声明同一批路由的元数据(public/audit/group),靠契约测试强制对齐——改一条路径要同时改两处 | internal/server/router/*(routes.go:22-43 手工 21 连调);internal/routecatalog/catalog.go:41- | 表驱动合并为单一声明源(method+path+handlerFunc+元数据),可同时消掉对齐测试 | +| 4-2 | 新增一个资源实际要触碰 7 处:dto、service/system、handler、router/资源文件、router/routes.go、routecatalog、Set/provider | — | 同 4-1 | +| 4-3 | 同一份集成配置三种形状两条通道:`config.Storage` 强类型 ↔ `sys_integration_configs` JSON 行 ↔ 运行时客户端;storage/email 走 config.Store(email 即时读快照、storage 手动 Replace),mq/websocket 走 runtimeconfig 订阅——同一"集成"概念两套配置源两种重载模式 | internal/data/integration_config.go:43-104,157-179;internal/data/integration/runtime.go:13-29;storage/reloadable.go:18-32;email/email.go:25-34 | 评估统一为一条解析-分发通道 | +| 4-5 | swagger 运行时文档:约 160 行 map 手拼 Swagger 2.0 JSON + 正则加工,全局单例仍在 server 根包 | internal/server/swagger.go:21-159 | 可辩护(README 有意为之),但按 server/README 自己的规则更宜独立子包 | +| 4-6 | 错误日志热路径做磁盘 IO + go/parser AST 解析(为错误上报附上出错方法源码) | pkg/logging/source.go:71-104;pkg/logging/zap.go:373-379 | 展示性需求不该在日志关键路径,缓存或降级 | +| 4-7 | dto 包文件组织混乱:dto/system.go 混装 Login/User/ServerInfo 三域;dto/settings.go 横跨 Dictionary/SystemParameter/APIToken/SecurityConfig 四域(名不副实) | internal/service/dto/system.go:5-105;settings.go:5-172 | 按域拆分重命名 | +| 4-8 | 校验双轨制:handler 手工 if 校验与 dto binding 标签并存;menu dto 无 binding 标签全靠 handler 手补 | internal/server/handler/user.go:27-64;internal/service/dto/settings.go:46-48 | 统一为 binding 标签 | +| 4-9 | 单方法 handler 各占结构体+构造器+Set 字段:Session/Navigation 可并入相邻资源 handler(Set 已膨胀到 23 个字段、provider 23 个构造器) | internal/server/handler/session.go:10-12;navigation.go:9-11;set.go:3-26 | 合并 | +| 4-10 | local 存储两套入口逻辑:staticfiles 直读 config 本地盘语义 vs integration/storage 的 Reloadable 体系 | internal/server/staticfiles/staticfiles.go:49-92;internal/integration/storage/local.go | 边界收敛(低优先级) | + +## 审查后认为合理、不建议改动的部分 + +- **modules 与 routecatalog 分离**:前者是启动期模块装配(wire/种子/迁移),后者是请求期热路径策略查询(有 byMethod 桶等性能优化),消费方零重叠,仅存在 modules→routecatalog 的合理单向依赖。 +- **Provider 接口缝模式**(子包不反向 import 根 data 避免成环 + 各子包测试假 Data):模式本身正当,仅 1-1 所述两处重复需合并。 +- **config.Store(YAML 文件)与 runtimeconfig.Store(DB 集成表)职责分离**:不重叠,不建议合并包;仅需评估统一 pub-sub 实现(1-9)。 +- **utils/routepath 与 routecatalog**:互补非重复(前者剥离配置前缀供落库/Casbin 用,后者纯策略匹配)。 +- **TaskScheduler 多锁**:每处均有并发正确性注释论证,是真实并发需求的代价。 +- **worker→biz 正向依赖 + biz 经 TaskRuntime/TaskReloader 接口反向倒置**:整个任务链路最规范的一段。 +- **三个"registry"实为三种角色**(pkg/task=实现、biz/task=接口缝、worker=贡献者):不是重复机制,但命名误导(建议重命名 worker/task_registry.go)。 +- **data 根 11 个文件同包共享 Data 状态与重载锁**:符合 data/README 既定约定,不建议拆包。 + +--- + +## 第二轮深审发现(S 系列编号) + +### S1 死代码补充(第一轮 P0 之外;第三轮核查:约 15 组仍存在,仅 PersistConfig 属误报已更正) + +**biz 层死接口方法/死注入面**: +- PermissionRepo 3/4 方法死(Buttons/SetAuthorityButtons/AuthorityButtonIDs)— internal/biz/system/permission.go:6-8 +- APITokenRepo.DisableAPIToken — api_token.go:26;UserRepo.CreateUser/UpdateUserWithAuthorities — user.go:45,49 +- MediaMetadataRepo.FindMediaByHash — media_metadata.go:44;MenuRepo.AuthorityMenuIDs — menu.go:61 +- SecurityUsecase.ActiveTokenMatches — security.go:243-249;EmailUsecase.Alert — email.go:31-48 +- PaymentUsecase 注册面全死:SetHooks/SetOrderSourceRegistry/SetFulfillmentRegistry/RegisterBusinessModule(含两阶段注册+回滚补偿)— biz/payment/payment.go:296-326;PayInternal/RefundInternal/AuthorizeRefund 无生产实现,service 却作为正式 API 暴露(Fulfill) + +**data 层死实现**(与上述接口配套):data/system/export.go:430-440、user.go:317-319,474-491、api_token.go:108-114、media.go:67-73、permission.go:113-117、menu.go:283-287 + +**service 层死方法**:MenuService.List(menu.go:14-20,handler 实际用 Tree,连带 biz MenuUsecase.List 也死)、RecordLogin/RecordDataAccess(audit.go:56-58,91-93,后者整链含 biz 接口+data 实现全死)、IsTokenDisabled(api_token.go:60-62)、security_session.go 14 方法中 8 个死(:17-67)、AuditRecorder.CreateErrorRequest+recordedErrorDomain(audit_error.go:17-31) + +**integration 层零消费者基础设施**: +- mq legacy 发布/订阅 API 全链零生产调用(Publish/Subscribe/Unsubscribe 及 To 变体、Register/Unregister、订阅簿记/dispatcher/reconcile 约 350 行空转)— integration/mq/emqx.go:358-630;连带 cmd/main.go:69 `_ mq.Client` 幻影参数与 integration/provider.go:28,29 两条死 wire 绑定(外加 :31 的 platformws.Hub 死绑定) +- websocket:Hub 接口零消费者(pkg/websocket/melody.go:28-40);integration 层 On* 四注册方法零调用(server.go:326-373),双层 handler 登记机制两层都永远为空(server.go:28-31,169-180 vs melody.go:65-118) +- s3 多 provider endpoint 五个死分支(唯一调用点只传 "minio")— storage/s3_storage.go:29-41 +- namedClient/Client() 整型死代码 — emqx.go:632-650 +- 零散:wechat_v2.go:584 `stringOr`、paypal.go:651 `normalizePayPalOrderState`、paypal.go:388-391 `paypalOrderTradeNo`、result.go:98,148-155 `nestedString`/`amountFromDecimalField`(shim 连调用者都没有)、middleware/capture.go:72-74 `isBootstrapPath` + +**仅测试调用(生产死代码)**:decryptWechatV3(wechat_v3.go:600-605)、alipaySignContent(alipay.go:572-585)、validatePaymentConfig(data/payment/payment.go:551-553) + +**dto 死类型/死字段**:LoginLogRequest(dto/system.go:68-75)、DataAccessRecordRequest(dto/audit.go:21-30)、GetAuthorityButtonsRequest.Selected、MenuResponse.Authorities 恒 null(biz 无此字段)、DynamicMenuResponse.MenuButtons 恒 nil、SysBaseMenuID 输入被静默丢弃(dto/menu.go:21,28)、version 导出结构体大量零值噪声字段(ID:0/CreatedAt 零时间/authoritys:null,version.go:23-94) + +### S2 重复模式补充(大块可消除,估算合计 1000+ 行) + +- **data/system List 样板 13 处同构**(构建 db→逐字段翻译 filter→Count→分页→Find→循环 toBiz),可抽泛型 helper 消 200+ 行 — user.go:281-315、api.go:119-181、data_access_log.go:32-55、operation_log.go:39-68、error_record.go:83-109 等 13 处 +- **payment 渠道适配器家族重复**(合计约 500 行):下单方式归一化骨架 9 份(Replacer 归一化行逐字出现 9 次);退款身份校验 4 份同构;状态归一化 switch 10 份(可收敛为 `normalizeState(state, successWords, failedWords)` + 词表);金额拆分守恒 5 份 + biz 层再校验第 6 份;SDK 客户端构造 10 份同构;firstNonEmpty 三胞胎(alipay.go:475/qq.go:146/paypal.go:707);mustMarshalAlipayPayload ≡ mustJSON +- **storage provider 家族重复**(约 200 行):key/unkey/file 三件套 5 份逐行相同(aliyun:35-48/aws:63-76/huawei:31-44/s3:61-74/tencent:45-58);DeletePrefix 分页循环 5 份(可提 `deletePrefixViaList`);Compose 一行委托 7 份;limit 守卫 7 份;构造尾部样板 5 份 +- **handler 四段式样板约 70 处**(ShouldBindJSON→Fail→service→Write),抽 `bindJSON`+`respond` 两个 helper 可消一半 +- **biz 接口嵌入透传 12 处 usecase**(API/APIRepo/Token/LogViewer/Audit/AuditRecorder/Authority/Dictionary/Export/Media/Parameter/Permission/Position/Version),与同包 9 个私有字段风格并存 +- **middleware**:请求体读取三处(access_log.go:56-74 / audit.go:49-67 fallback / handler/media.go:17-21,同一请求体三层 MaxBytesReader);脱敏/截断三处且分散(capture.go:41-68 / audit.go:118-136 / redact 词表在 redact.go 但函数散在 access_log.go);双重脱敏双重截断(audit.go:47-48,93 对已 mask 已截断的正文再处理一遍) +- mq/websocket 两包各写一套 map 解码 helper 且逐字符相同(emqx.go:226-260 vs server.go:189-240);TestConfig 探测骨架三处同构(emqx.go:92-133/server.go:65-126/connectivity.go) +- authority 树构建算法两份(biz authority.go:48-78 vs menu.go:80-99);CreateAuthority/CopyAuthority 前 8 行校验逐字重复(data authority.go:133-151,211-245) +- service:authorityResponse 与 convertAuthority 逐字重复(authority.go:30-36 vs user_conversion.go:8-14);"单条 DTO helper + for-append"样板 9 处;"Request 包装 + Filter 包装 + 裸方法"三重入口家族(audit/parameter/export/dictionary/media);user 空对象兜底三连 +- data/payment:callbackFields/first 整函数复制(payment.go:507-540 vs result.go:14-56);values() 与 testRow() 近重复;渠道"免 notify_url 方法"同义词表在 data 层重抄一份(payment.go:390-426,与各适配器 createMethod 表双份维护) +- gorm.DeletedAt→*time.Time 转换 4 处逐字重复(user.go:99-104、menu.go:17-21,241-245,250-255) +- version 阶段→消息映射两份(handler version.go:107-121 vs 158-167);engine.Routes()→dto 转换两份(api.go:161-167 vs public.go:135-139) +- defaults 合并逻辑三层三份(service/integration:86-98 / biz/integration:215-224 / data/integration/migrations.go:69-75) +- media 分片根目录逻辑两份(biz media.go:126-132 vs media_upload.go:61-69) +- media_upload 三处复制会话校验样板(biz media_upload.go:120-129,176-184,244-250,顺带吞掉 ErrUploadSessionNotFound 可判定性) + +### S3 分层/归属违规补充 + +- **data 直依赖 integration**:data/payment/payment.go:17 import `kra/internal/integration/payment`;TestProvider 是 145 行业务编排(本地建单、500ms×3 重试轮询、退款闭环)长在 data 层;还自建第二 paymentOrderRepo 实例绕过 wire 单一构造点 +- **service 层混入业务/存储细节**:system_init.go:13-26 DSN/驱动连接串与回退规则;system_info.go:14-41 直连 gopsutil 采集(每请求阻塞 200ms);export_excel.go:63-71 SQL 别名/前缀归一;api_token.go:28-42 发币编排与到期规则;user.go:36-37,85-96 密码策略编排;api.go:60-82 Groups 分组推导;version.go:184-221 导入导出全编排 +- **biz DO 带 json 标签**:PaymentResult/PaymentTestResult(死标签,service 逐字段转 dto);PaymentRequest(把指纹编码格式锚死在 DO);biz/integration 的 Definition/Field/Option 家族直接充当前端契约(dto/integration_config.go:21 内嵌 biz 类型,biz 事实上兼任 DTO 提供方) +- **DO 兼过滤器**:API.OrderKey/Desc/StrictAll、SystemParameter/ExportTemplate.StartCreatedAt/EndCreatedAt 混入实体(LoginLog 已改为独立 Filter) +- **middleware 硬编码业务语义**:error_audit.go:70-77 靠中文消息黑名单判断是否审计(改文案即改审计行为);error_audit.go:25,55-67 硬编码业务路径;access_log.go:82-203 支付回调专用逻辑内嵌通用中间件;rate_limit.go:33-51 限流策略参数内联;A8 限流挂在全局链却只匹配两个 public 路由 +- **biz 契约泄漏存储/表现原语**:QueryExport 返回 `[]map[string]any`;LogViewer 的文件读取器细节(NextCursor/LimitedByBytes);export DO 字面携带 SQL/Join/Table 片段(Export 域整体是查询引擎不是领域逻辑,应下沉 data);UserOptions `{Label,Value}` UI 形状进 biz;AuthenticationResult 携带含密码哈希的完整 User +- **data 层纪律**:转换函数命名四种风格违反 new/toBiz 契约(FromPO×16/ToPO×4/ToBiz×2/new×2);PO 分布无规则(models.go 集中 6 个 + 散落 30+,models.go:98-100 还混 repo 声明);Table("字符串") 绕过已有 PO 7 处(user.go:58,70,88,231,259、announcement.go:100、security.go:92);saveRelations 回写入参 DO 约 10 处;audit.go 名不副实(只有构造器,实现在 5 个文件);runtime.go 拼盘(settings + tokenIssuer 无关联) +- **编排类文件过重**:seedSystem 单函数 126 行 10 类职责(seed.go:30-156);authority.go 834 行四类职责(CRUD/严格权限引擎被 4 个文件 15+ 处借用/DataScope 域解析/用户-角色关联,权限引擎应独立 accessGuard);BuildVersionBundle 105 行五职责(version.go:76-181);migrations.go 两个通信 surface 迁移互为重复子集(:36-130 vs :132-173) +- **dto 契约问题**:ID 类型三处分叉(int/uint/string 混用,迫使 handler 做转换);AuthorityResponse.DeletedAt 泄漏且破坏全库 `json:"-"` 约定(dto/authority.go:34);ErrorRecordMutationRequest 一半指针一半值不自洽;DTO 反向依赖 biz 类型(dto/integration_config.go:21) +- **错误体系**:错误定义双体系(errors.go 仅 3 个 kratos 类型错误,其余 stdlib errors.New 散落 13+ 文件,无 reason 码);错误包装 Error+Unwrap 与 Error+Is 两机制混用;PasswordPolicyError 类型定义在 service(security.go:11-19) +- **List 契约三种风格并存**(过滤结构体内含分页 / 位置参数+指针 DO / 裸标量 5-6 参) +- **data 分页三风格**(pagination.ApplyRequired/Apply/手写 Limit-Offset),手写版 page=0 产生负 offset(position.go:89、export.go:208-210 无防护,media.go:112-115 有) + +### S4 简单实现复杂化补充(遗留) + +- payment Create 过度防御:先拷 9+1 字段再逐一回比 + Extra 双次 JSON 序列化,同一不可变保证指纹层(RequestFingerprint + 落库后二次指纹比对)已做两道 — biz/payment/payment.go:385-402,898-907,588-590 +- loadUser/loadUsers 双实现(单实体 6 次串行查询 vs 批量实现,可复用)— data/system/user.go:53-97,115-218 +- JWT 签名双重检查(validateSigningOptions 后 signToken 再查一遍)— data/system/token.go:34-51 +- 中间件 nil 防御四处(gin.go:28-30 已兜底,access.go:33/access_log.go:36/audit.go:27/cors.go:22-25 仍各自检查) +- public.go captchaConfig 恒真分支与无效首调用(public.go:29-43) +- 媒体上传三重大小防御(limitMultipartBody + rejectMediaTooLarge + header.Size 检查)— handler/media.go:38-50,226-240 +- emqx.go 恒真 ctx 判断(:561-569,592-600) +- PaymentLogger 单实现接口(仅为包装 *slog.Logger)— biz/payment/payment_log.go:10-32 +- dictionary.go Tree 解析结果被丢弃(handler dictionary.go:211-213,byType 分支不用 id/parseErr) +- DictionaryRepo/MediaRepo 组合式子接口无独立消费方(biz dictionary.go:56-59、media.go:19-22) + +### 第二轮结构性发现(遗留) + +- **mq 与 websocket 是"零消费者基础设施"**:大量生命周期/重放/簿记机制空转,要么接入首个真实业务消费者,要么裁掉 legacy 半区保留最小面(TestConfig 探测 + Enabled/Path/HandleRequest) +- **payment 域 data/integration 边界倒置**:paymentRepo 实为"配置读取+适配器编排+ack 组装"的编排层,仅 4 个纯转发方法符合 repo 形态;渠道知识(同义词表)在 data 与 integration 双份维护必然漂移 +- **gopay.go/gopay_helpers.go 名实相反**:gopay.go 是窄基座(4 个函数);gopay_helpers.go 是杂物间(7 类职责混装,渠道专属谓词/状态机应下沉各渠道文件,公共函数 mergeMap 反而散在 alipay.go) +- **vendor.go**:vendorSupperPay 是死枚举(26,38 定义注册,switch 无 case);金额拆分 14 键配置 DSL 疑似投机通用性(无内置默认使用);通用渠道定义 18 字段全 Required=true +- **gva/ 目录是遗留参考库**(独立 module 不参与 kra 编译),pkg/logging 为其保留文件名特判(zap.go:342) + +--- + +## 第三轮审查发现(T 系列编号,2026-08-27) + +### T0 修复回归缺陷与残留(S0 修复核查时发现,优先处理) + +| # | 问题 | 位置 | +|---|------|------| +| T0-1 | **vendor 退款静默受理残留(资金安全,高危)**:S0-1 修复覆盖了"FAIL 状态字段"场景,但 create/refund 响应 HTTP 200 且 body 非 JSON、或 JSON 无可识别状态字段时仍默认 `created`;biz `validatePaymentRefundResult` 接受 created → 真实被渠道拒绝但响应无状态字段的退款会被永久记为已受理。建议:refund 端点响应无状态字段时报错或至少 pending | internal/integration/payment/vendor.go:135-163;biz/payment/payment.go:1020-1021,837-855 | +| T0-2 | Excel `[]byte` 全转文本副作用:MySQL 数值列也会变文本单元格,大面积"数字以文本存储"警告(原值保真达成,展示体验回退);可结合模板列类型区分处理 | internal/service/system/export_excel.go:85-86 | +| T0-3 | ErrAuthoritiesRequired 错误透传缺失:biz 返回具体错误后 handler 只回"修改失败",用户看不到"至少一个角色";data 层 setUserAuthorities 还残留一处重复中文防御错误(双轨) | internal/server/handler/user.go:208-212;internal/data/system/user.go:563-565 | +| T0-4 | 版本导入非原子残留:落库成功但留痕 CreateVersion 失败时返回错误,用户误判"导入失败"(幂等查重使重试可控) | internal/service/system/version.go:256-270 | +| T0-5 | 任务元数据双源漂移已发生:种子描述("**定时**清理数据库过期日志…")与注册方法描述("清理数据库过期日志…")不一致——两处维护必然继续漂移;建议 worker 注册时复用 catalog TimedTask 元数据 | internal/modules/task/definition.go:14-15 vs internal/worker/task_registry.go:31,45 | +| T0-6 | export SQL/ImportSQL 摆设字段:dto 保留但 ValidateExportTemplate 拒绝非空,只能提交空值且响应回显空值 | internal/service/dto/export.go:26-27;internal/biz/system/export.go:72-74 | +| T0-7 | payment 回调读体失败分支未入 Gin 错误链(S0-12 修复只覆盖 service 调用错误) | internal/server/handler/payment.go:149-153 | +| T0-8 | LoginLogFilter 收敛为仅 Username/Status 两字段:若前端需按 IP/时间筛选登录日志则能力缺失(设计取舍需确认) | internal/biz/system/audit.go:42-45 | +| T0-9 | 任务种子与注册方法的"必经链路"提示:PaymentUsecase 注册面(SetHooks 等 4 个方法)零调用时,生产装配下支付主链路 Create/Refund/Fulfill 必然在 preparePaymentRequest 报"支付业务订单来源未注册"——模板未完成态,建议 Wire/cmd 层提供默认注册或 fail-fast 提示 | internal/biz/payment/payment.go:296-326,407-410 | + +### T1 过分拆分清单(用户重点维度,量化) + +**根因模式三条**:①零逻辑 usecase 壳(wire 强制每域一个构造器放大);②"每资源 N 文件"机械切分(dto+biz+service+handler+router 各一个);③为 import 美观引入的中间缝合包/门面。 + +**A. biz/system:38 文件 3430 行,其中 17 个非测试文件 <60 行(合计约 575 行,保守可归并 8-10 个文件)**: + +| 文件 | 行数 | 内容 | 合并目标 | +|---|---|---|---| +| errors.go | 14 | 4 个错误变量(token.go:35-42 另有 6 个,同类分散) | 并入 user.go 或统一 errors | +| cache.go | 15 | Cache 接口 4 方法 | 并入 security.go(主消费者) | +| maintenance.go | 19 | Repo 接口+纯透传壳 | 并入 user.go 或保留(worker 消费) | +| actor.go | 19 | ctx 携带 helper | 并入 authority.go(唯一消费者) | +| access_control.go | 20 | 透传壳 2 方法 | 并入 authority.go/api.go | +| storage.go | 26 | FileStorage 接口 | 并入 media.go(主消费者) | +| data_scope.go | 27 | DataScope 类型+ctx helper | 并入 authority.go | +| upload_session.go | 38 | DO+Repo 13 方法无 usecase | 并入 media_upload.go(同域) | +| parameter.go | 33 | DO+Repo+零方法壳 | 壳删后 28 行 | +| settings.go | 43 | 3 设置类型+接口 | 可保留(wire 5 处消费) | +| department.go | 46 | DO+Repo+改名壳 | 壳删后 32 行 | +| position.go | 48 | DO+Repo | 与 department.go 合并为 organization.go | +| token.go | 48 | AuthClaims+TokenIssuer | 并入 authentication.go(唯一 biz 消费) | +| media_metadata.go | 53 | DO+Repo | 并入 media.go(media 域 4 文件最典型同域碎片) | +| email.go / api_token.go / version.go | 48/55/57 | — | 保留(有逻辑)或去壳 | + +**B. 纯透传壳 usecase 12 个**(整个 struct 无自有逻辑/纯改名转发,service 可直依赖 biz repo 接口——repo 接口仍在 biz,分层契约不破;wire 链物证 wire_gen.go:88-91 permission 全程零逻辑): +ParameterUsecase(parameter.go:29)、PermissionUsecase(permission.go:14)、VersionUsecase(version.go:55)、AnnouncementUsecase(announcement.go:39,7 方法全一行透传)、MaintenanceUsecase(maintenance.go:11)、AccessControlUsecase(access_control.go:5)、DepartmentUsecase(department.go:34,2 改名)、AuthorityUsecase(authority.go:42,仅 Tree 有逻辑)、TaskUsecase(task.go:76)、MenuUsecase(menu.go:72,10 处透传)、UserUsecase(user.go:59,13 处透传)、SystemConfigUsecase(system_init.go:50,7 处透传)。 +对照组(有真实逻辑应保留):Authentication/Security/Media/Email/IntegrationConfig/Payment/TaskApplication。 + +**C. service/system 碎片**: +- SystemConfigService 一型拆四文件:system.go(19)+system_config.go(26)+system_init.go(38)+system_info.go(42)=125 行 4 文件 +- security_session.go:17-68:SecurityService 14 方法全部一行透传(零 DTO 工作) +- audit.go 与 audit_error.go 同属 AuditService/AuditRecorder 可合并(audit_log_file.go 是独立 LogViewerService,保留) +- email.go(16)/permission.go(24)/access_control.go(28,仅 5 行逻辑) 近纯透传小文件 + +**D. router 碎片**:22 文件 464 行,平均 21 行/文件(最小 email.go 12 行);routes.go:18-43 手工 21 连调——纯注册碎片无内聚(与 4-1 表驱动合并一并解决)。 + +**E. 单符号包/微文件**: +- internal/modules/surface:整包只有一个 10 行函数(surface.go:10-19),2 个调用方 +- internal/data/provider:整包只有一个 7 行 2 方法接口(provider.go:5-8)——中性缝可辩护,建议与 provider.go 别名使用方注释互指 +- provider.go+providers.go 双小文件模式 ×4 子包(integration 12+5 行、payment 8+5、task 7+5、system 17+29)→ 8 文件并 4 +- data/system/bootstrap.go 27 行仅 seed.go 使用可并入;worker/worker.go 7 行、initialize/provider.go 5 行、pkg/task/provider.go 3 行、biz/payment/provider.go 8 行均为纯 wire-set 微文件 +- data_scope_record.go 整文件 1 行别名(同 3-9) + +**F. 巨微并存两极**: +- biz/payment/payment.go 单文件 1150 行(usecase+常量再导出+5 validator+指纹工具)vs 同域 payment_log.go 32 行/provider.go 8 行微文件 +- dto:authentication.go 8 行/email.go 7 行超小文件 vs settings.go 170 行横跨四域(同 4-7) + +### T2 包归属移动建议(从 → 到) + +| # | 从 | 到 | 动作 | 影响 | +|---|---|---|---|---| +| 1 | pkg/httpx | internal/server/httpx | 整包内移(消费者 100% 在 internal/server 7 文件;中文文案+x-token 是本项目契约;logging/source.go:59 已预留该路径 marker) | 7 文件 | +| 2 | pkg/logging | internal/logging | 整包内移(source.go:47-69 硬编码本仓库 internal 路径;zap.go:366-382 中文文案;模块路由硬编码服务日志查看器语义) | 约 5 文件 | +| 3 | pkg/module | internal/modules | 契约内移(Menu/API/Surface/TimedTask 是本项目模块系统契约;import gin;15 消费者全在本仓库) | 15 文件 | +| 4 | pkg/paymentkit 渠道常量(provider.go) | internal/biz/payment 本体 | 常量归位并删 biz/payment/payment.go:20-43 的 17+2 个别名转发层;目录更名 paymentutil 对齐包名;通用工具(signing/json/amount/xml)可留 pkg 或并入 internal/utils | 约 8 文件 | +| 5 | pkg/mq + pkg/websocket(Hub) | 收缩或合并进 internal/integration/mq | 全链零业务消费者(详见 T3);保留 TestConfig 探测+基础驱动,删 Registry/Client()/namedClient/legacy API/Hub/ApplySubscriptions | 约 7 文件 | +| 6 | integration/mq emqx.go:226-260 与 integration/websocket server.go:189-240 的重复 JSON map helper | internal/utils/jsonvalue | 上收去重(configText≡text、configBool≡boolValue 逐字符相同) | 2-3 文件 | +| 7 | storage:aws-sdk-v2 栈与 minio-go 栈双 S3 实现并存 | 统一 S3 兼容单栈(可选) | qiniu/aliyun/huawei/tencent 原生 SDK 均有 S3 兼容端点,可收敛删 4 实现+s3 死分支 | 5-6 文件 | +| 8 | handler/query.go:13-61(Gin 工具)、middleware/request.go:67-102(纯函数)、handler/announcement.go parseTime(被 export.go 跨域借用) | pkg 或 internal/utils 候选 | 通用无状态逻辑上收 | 3 文件 | + +依赖方向合规确认(第三轮验证):pkg 无 import internal(仅 zap_test.go:131 字符串字面量);integration 无 import data/service;service→biz→data 无反向。 + +### T3 死代码回归与新增(S1 现状核查) + +- **S1 清单约 15 组死方法仍存在**(biz 接口+data 实现+service 包装三层残留);已更正误报:service 层 PersistConfig 实为活代码 +- mq 零消费者机制未清理且**规模扩大**:emqx.go 约 710 行 + mq.go 106 行 + subscription.go 55 行(声明式订阅全套无注册者);legacy API 转译层又加了一层(1-11 恶化) +- websocket Hub 死绑定、s3 五个不可达 endpoint 分支(local.go:39-48 只让 minio 进 newS3Storage)、namedClient/Client()、`_ mq.Client` 幻影参数(main.go:65)、isBootstrapPath 死函数——全部未清理 +- 新增死代码:wechat_v2.go:47 私有 wechatV2Sign 变为仅测试调用(可移测试文件);pkg/task RegisterAll 仅测试调用;**NewGinEngine 生产死代码**(gin.go:23-25,wire 用 NewGinEngineWithRuntime);TaskScheduler.Trigger(task) 可未导出(仅 TriggerID 内部用) +- P1-3 修复残留:result.go:58-64 与 payment_helpers.go:5-11 两包仍保留同名本地包装转发 paymentutil(micro-shim 未拆) + +### 已修复确认清单(第三轮验证后从本文档删除,留痕) + +- P0 全部 6 条:protoutil 整包删除;WechatV2Sign 死实现删除;Data 三死方法删除;Bootstrap 别名删除;静态任务注册链删除(重建为活的 TimedTasks 单源链路);Schedule(task) 删除 +- P1-1/2/4/5/6/7/10:provider.Database 中性接口(task/security 两处仅剩一行别名);种子单源化(含防回归测试);validateMenuRequest;surface.APIsForPrefix;persistConfig Locked 变体删除;NewData/reloadConfig 均用 rollback 收集器;websocket snapshotHandlers 泛型收敛 +- P1-3:paymentkit.Text/FirstText 落地(残留 micro-shim 见 T3) +- P4-4:AGENTS.md 已更新为真实栈 +- S0 全部 17 条(含回归验证):登录日志三态过滤、claims 注入、时间戳服务端生成、x-user-id 删除+CORS 收紧(含防回归测试)、export 安全查询构建器(双保险)、ListAuthorities 委托统一入口、版本错误返回、失败关闭、SecurityConfig nil+error、限流白名单闭环、回调错误入链、Excel 文本保真、Apple JWS 固定 Root CA G3 真实指纹(经官方根证书清单核实)+完整链校验+签名时刻验证、ErrAuthoritiesRequired、OriginSetting 错误传播、cache Lua 原子补 TTL(语义正确) +- S4 两条:QueryExport 单次严格解析;saveRelations 单 replace 参数 + +## 处置建议总览(三轮合并,按优先级) + +1. **先修 T0 修复回归缺陷**(T0-1 资金安全高危优先) +2. **删 S1+T3 死代码**(纯减法零风险:biz/data/service 三层死方法约 15 组 + mq/websocket 零消费者机制 + 微死码,估算 800+ 行) +3. **T1 过分拆分合并**(透传壳 usecase 12 个、biz/system 小文件归并、SystemConfigService 四合一、provider/providers 双文件 ×4——零行为变更的文件级减法) +4. **T2 归属移动**(httpx/logging/module/paymentkit 内移 + jsonvalue 上收,按表逐项决策) +5. **S2 重复消除三大块**(data/system List 泛型、payment 渠道骨架、storage provider 基座,估算 900+ 行) +6. **P2/P4 结构收敛**(service 根门面、Backend 三层缝合、router+routecatalog 表驱动合并) diff --git a/docs/system-pkg-audit.md b/docs/system-pkg-audit.md new file mode 100644 index 0000000..b550906 --- /dev/null +++ b/docs/system-pkg-audit.md @@ -0,0 +1,87 @@ +# `` 到 `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 new file mode 100644 index 0000000..9832817 --- /dev/null +++ b/docs/system-structure-audit.md @@ -0,0 +1,63 @@ +# `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/biz/integration/integration_config_definition.go b/internal/biz/integration/integration_config_definition.go index 61640cb..0258ace 100644 --- a/internal/biz/integration/integration_config_definition.go +++ b/internal/biz/integration/integration_config_definition.go @@ -62,6 +62,10 @@ var genericPaymentFields = []IntegrationConfigField{ integrationField("create_url", "下单接口", true, false, "url"), integrationField("query_url", "查单接口", true, false, "url"), integrationField("refund_url", "退款接口", true, false, "url"), + integrationField("refund_status_field", "退款状态字段", false, false, "text"), + integrationField("refund_success_values", "退款受理状态值", false, false, "text"), + integrationField("refund_failure_values", "退款拒绝状态值", false, false, "text"), + integrationField("refund_status_field", "退款状态字段", false, false, "text"), integrationField("app_key", "签名密钥", true, true, "password"), integrationField("query_status_field", "查单状态字段", true, false, "text"), integrationField("query_success_values", "查单成功值", true, false, "text"), diff --git a/internal/biz/system/audit.go b/internal/biz/system/audit.go index 7e0d865..882f2bf 100644 --- a/internal/biz/system/audit.go +++ b/internal/biz/system/audit.go @@ -37,7 +37,11 @@ type LoginLog struct { ErrorMessage, Agent string UserID uint User *User - FilterByStatus bool +} + +type LoginLogFilter struct { + Username string + Status *bool } type DataAccessLog struct { @@ -101,7 +105,7 @@ type AuditQueryRepo interface { ListOperations(context.Context, int, int, *OperationRecord) ([]*OperationRecord, int64, error) FindOperation(context.Context, uint) (*OperationRecord, error) DeleteOperations(context.Context, []int) error - ListLogins(context.Context, int, int, *LoginLog) ([]*LoginLog, int64, error) + ListLogins(context.Context, int, int, *LoginLogFilter) ([]*LoginLog, int64, error) FindLogin(context.Context, uint) (*LoginLog, error) DeleteLogins(context.Context, []int) error ListDataAccess(context.Context, int, int, *DataAccessLog) ([]*DataAccessLog, int64, error) diff --git a/internal/biz/system/errors.go b/internal/biz/system/errors.go index 2bbb461..bfb7ce2 100644 --- a/internal/biz/system/errors.go +++ b/internal/biz/system/errors.go @@ -8,6 +8,7 @@ var ( ErrUserNotFound = errors.NotFound("USER_NOT_FOUND", "user not found") // ErrInvalidCredentials is returned for any failed login, regardless of // whether the user exists, to avoid leaking which accounts are registered. - ErrInvalidCredentials = errors.Unauthorized("INVALID_CREDENTIALS", "invalid credentials") - ErrUserDisabled = errors.Forbidden("USER_DISABLED", "user disabled") + ErrInvalidCredentials = errors.Unauthorized("INVALID_CREDENTIALS", "invalid credentials") + ErrAuthoritiesRequired = errors.BadRequest("AUTHORITIES_REQUIRED", "at least one authority is required") + ErrUserDisabled = errors.Forbidden("USER_DISABLED", "user disabled") ) diff --git a/internal/biz/system/export.go b/internal/biz/system/export.go index 6d478b1..454ee14 100644 --- a/internal/biz/system/export.go +++ b/internal/biz/system/export.go @@ -2,9 +2,21 @@ package system import ( "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" "time" ) +var ( + exportIdentifierPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$`) + exportParameterPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + exportColumnPattern = regexp.MustCompile(`(?i)^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?(\s+AS\s+[A-Za-z_][A-Za-z0-9_]*)?$`) + exportJoinOnPattern = regexp.MustCompile(`(?i)^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\s*=\s*[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*(\s+AND\s+[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\s*=\s*[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*)*$`) +) + type ExportCondition struct { ID uint CreatedAt, UpdatedAt time.Time @@ -43,3 +55,81 @@ type ExportRepo interface { type ExportUsecase struct{ ExportRepo } func NewExportUsecase(repo ExportRepo) *ExportUsecase { return &ExportUsecase{ExportRepo: repo} } + +func ValidateExportTemplate(value *ExportTemplate) error { + if value == nil { + return errors.New("导出模板为空") + } + if strings.TrimSpace(value.Name) == "" || strings.TrimSpace(value.TemplateID) == "" { + return errors.New("导出模板名称和模板 ID 不能为空") + } + if !exportIdentifierPattern.MatchString(strings.TrimSpace(value.TableName)) { + return errors.New("导出模板表名不合法") + } + if strings.TrimSpace(value.DBName) != "" && !exportIdentifierPattern.MatchString(strings.TrimSpace(value.DBName)) { + return errors.New("导出模板数据库别名不合法") + } + if strings.TrimSpace(value.SQL) != "" || strings.TrimSpace(value.ImportSQL) != "" { + return errors.New("导出模板不允许自定义 SQL") + } + var columns map[string]string + if err := json.Unmarshal([]byte(value.TemplateInfo), &columns); err != nil { + return fmt.Errorf("导出模板列定义无效: %w", err) + } + if len(columns) == 0 { + return errors.New("导出模板列为空") + } + for column := range columns { + if !exportColumnPattern.MatchString(strings.TrimSpace(column)) { + return fmt.Errorf("导出模板列不合法: %s", column) + } + } + for _, join := range value.Joins { + kind := strings.ToUpper(strings.Join(strings.Fields(join.Join), " ")) + switch kind { + case "JOIN", "INNER JOIN", "LEFT JOIN", "RIGHT JOIN": + default: + return fmt.Errorf("导出模板 Join 类型不合法: %s", join.Join) + } + if !exportIdentifierPattern.MatchString(strings.TrimSpace(join.Table)) || !exportJoinOnPattern.MatchString(strings.TrimSpace(join.On)) { + return errors.New("导出模板 Join 表或条件不合法") + } + } + for _, condition := range value.Conditions { + if !exportParameterPattern.MatchString(strings.TrimSpace(condition.From)) || !exportIdentifierPattern.MatchString(strings.TrimSpace(condition.Column)) { + return errors.New("导出模板筛选字段不合法") + } + switch strings.ToUpper(strings.Join(strings.Fields(condition.Operator), " ")) { + case "=", "!=", "<>", ">", ">=", "<", "<=", "LIKE", "IN", "NOT IN", "BETWEEN": + default: + return fmt.Errorf("导出模板筛选运算符不合法: %s", condition.Operator) + } + } + if value.Limit != nil && (*value.Limit < 1 || *value.Limit > 100000) { + return errors.New("导出模板行数限制必须在 1 到 100000 之间") + } + if order := strings.TrimSpace(value.Order); order != "" { + parts := strings.Fields(order) + if len(parts) > 2 || !exportIdentifierPattern.MatchString(parts[0]) { + return errors.New("导出模板排序不合法") + } + if len(parts) == 2 && !strings.EqualFold(parts[1], "asc") && !strings.EqualFold(parts[1], "desc") { + return errors.New("导出模板排序方向不合法") + } + } + return nil +} + +func (uc *ExportUsecase) CreateExportTemplate(ctx context.Context, value *ExportTemplate) error { + if err := ValidateExportTemplate(value); err != nil { + return err + } + return uc.ExportRepo.CreateExportTemplate(ctx, value) +} + +func (uc *ExportUsecase) UpdateExportTemplate(ctx context.Context, value *ExportTemplate) error { + if err := ValidateExportTemplate(value); err != nil { + return err + } + return uc.ExportRepo.UpdateExportTemplate(ctx, value) +} diff --git a/internal/biz/system/export_validation_test.go b/internal/biz/system/export_validation_test.go new file mode 100644 index 0000000..ff100f7 --- /dev/null +++ b/internal/biz/system/export_validation_test.go @@ -0,0 +1,26 @@ +package system + +import ( + "strings" + "testing" +) + +func validExportTemplateForTest() *ExportTemplate { + return &ExportTemplate{Name: "users", TemplateID: "users", TableName: "sys_users", TemplateInfo: `{"id":"ID","username":"用户名"}`} +} + +func TestValidateExportTemplateRejectsCustomSQL(t *testing.T) { + value := validExportTemplateForTest() + value.SQL = "SELECT * FROM sys_users" + if err := ValidateExportTemplate(value); err == nil || !strings.Contains(err.Error(), "不允许自定义 SQL") { + t.Fatalf("ValidateExportTemplate() error = %v", err) + } +} + +func TestValidateExportTemplateRejectsUnsafeJoinAndCondition(t *testing.T) { + value := validExportTemplateForTest() + value.Joins = []ExportJoin{{Join: "LEFT JOIN", Table: "sys_roles", On: "1=1 OR 1=1"}} + if err := ValidateExportTemplate(value); err == nil { + t.Fatal("ValidateExportTemplate accepted unsafe join") + } +} diff --git a/internal/biz/system/security.go b/internal/biz/system/security.go index 09edbf5..0d918ea 100644 --- a/internal/biz/system/security.go +++ b/internal/biz/system/security.go @@ -99,13 +99,6 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error) uc.mu.RUnlock() value, err := uc.repo.SecurityConfig(ctx) if err != nil { - // Return the default value together with the database-not-ready - // error. Callers such as login/runtime policy consumers intentionally - // ignore the error and continue with that default, while the HTTP - // settings endpoint still reports the failure. - if value != nil { - return value, err - } return nil, err } uc.mu.Lock() diff --git a/internal/biz/system/user.go b/internal/biz/system/user.go index b848231..9b9851c 100644 --- a/internal/biz/system/user.go +++ b/internal/biz/system/user.go @@ -155,7 +155,7 @@ func (uc *UserUsecase) Authorities(ctx context.Context) ([]*Authority, error) { } func (uc *UserUsecase) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error { if len(authorityIDs) == 0 { - return ErrInvalidCredentials + return ErrAuthoritiesRequired } return uc.repo.SetUserAuthorities(ctx, id, authorityIDs) } diff --git a/internal/biz/system/user_test.go b/internal/biz/system/user_test.go index 0b1e390..9d384a5 100644 --- a/internal/biz/system/user_test.go +++ b/internal/biz/system/user_test.go @@ -52,3 +52,10 @@ func TestLoginRejectsMissingUserWithoutPanic(t *testing.T) { t.Fatalf("Login() = %#v, %v; want nil, ErrInvalidCredentials", user, err) } } + +func TestSetUserAuthoritiesRejectsEmptyListAsBadRequest(t *testing.T) { + err := NewUserUsecase(&loginUserRepo{}).SetUserAuthorities(context.Background(), 1, nil) + if !errors.Is(err, ErrAuthoritiesRequired) || errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("SetUserAuthorities() error = %v, want ErrAuthoritiesRequired", err) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index f698a8c..3969442 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -11,8 +11,6 @@ type Config struct { Admin *Admin `mapstructure:"admin" yaml:"admin" json:"admin"` } -type Bootstrap = Config - type Server struct { HTTP *ServerHTTP `mapstructure:"http" yaml:"http" json:"http"` } diff --git a/internal/data/config_store.go b/internal/data/config_store.go index ef5f83c..bd590dc 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -31,10 +31,6 @@ func (d *Data) persistConfig() error { func (d *Data) persistConfigValues(dataConfig *config.Data, adminConfig *config.Admin) error { d.configMu.Lock() defer d.configMu.Unlock() - return d.persistConfigValuesLocked(dataConfig, adminConfig) -} - -func (d *Data) persistConfigValuesLocked(dataConfig *config.Data, adminConfig *config.Admin) error { if adminConfig == nil || adminConfig.ConfigPath == "" { return nil } diff --git a/internal/data/data.go b/internal/data/data.go index 8b67676..8f41d63 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -122,55 +122,6 @@ func (d *Data) RedisClient() redis.UniversalClient { return d.redis.load() } -// MongoClient returns the currently active Mongo client. The client remains -// owned by Data and is only exposed here for framework-level resource wiring. -func (d *Data) MongoClient() *mongo.Client { - if d == nil || d.mongo == nil { - return nil - } - return d.mongo.load() -} - -// NamedDatabases returns a copy of configured secondary database handles for -// the process-wide resource registry. Data retains lifecycle ownership. -func (d *Data) NamedDatabases() map[string]*gorm.DB { - if d == nil { - return nil - } - d.dbListMu.RLock() - defer d.dbListMu.RUnlock() - if len(d.dbList) == 0 { - return nil - } - result := make(map[string]*gorm.DB, len(d.dbList)) - for name, db := range d.dbList { - if name != "" && db != nil { - result[name] = db - } - } - return result -} - -// NamedRedisClients returns a copy of configured secondary Redis handles for -// the process-wide resource registry. Data retains lifecycle ownership. -func (d *Data) NamedRedisClients() map[string]redis.UniversalClient { - if d == nil { - return nil - } - d.redisListMu.RLock() - defer d.redisListMu.RUnlock() - if len(d.redisList) == 0 { - return nil - } - result := make(map[string]redis.UniversalClient, len(d.redisList)) - for name, client := range d.redisList { - if name != "" && client != nil { - result[name] = client - } - } - return result -} - func (d *Data) logger() *slog.Logger { if d != nil && d.appLogger != nil { return d.appLogger @@ -262,12 +213,9 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor } }) } - initialized := false - defer func() { - if !initialized { - cleanup() - } - }() + var undo rollback + undo.add(cleanup) + defer undo.run() usingFallback := !databaseConnectionConfigured(c.Database) var db *gorm.DB var err error @@ -348,7 +296,7 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor mongoClient = nil } d.mongo = newReloadable(mongoClient, closeMongoClient) - initialized = true + undo.commit() return d, cleanup, nil } diff --git a/internal/data/payment/payment_helpers.go b/internal/data/payment/payment_helpers.go index 2d6f03c..b9321cd 100644 --- a/internal/data/payment/payment_helpers.go +++ b/internal/data/payment/payment_helpers.go @@ -3,17 +3,11 @@ package payment import "kra/pkg/paymentkit" func text(values map[string]any, key string) string { - value, _ := values[key].(string) - return value + return paymentutil.Text(values, key) } func firstAny(values map[string]any, keys ...string) string { - for _, key := range keys { - if value, ok := values[key].(string); ok && value != "" { - return value - } - } - return "" + return paymentutil.FirstText(values, keys...) } // configuredInt64 keeps repository-side validation independent from the diff --git a/internal/data/provider/provider.go b/internal/data/provider/provider.go new file mode 100644 index 0000000..6ec0537 --- /dev/null +++ b/internal/data/provider/provider.go @@ -0,0 +1,8 @@ +package provider + +import "gorm.io/gorm" + +type Database interface { + DB() *gorm.DB + DatabaseReady() bool +} diff --git a/internal/data/system/api_token.go b/internal/data/system/api_token.go index 7af9294..c17e9ca 100644 --- a/internal/data/system/api_token.go +++ b/internal/data/system/api_token.go @@ -96,7 +96,11 @@ func (r *apiTokenRepo) ListAPITokens(ctx context.Context, page, size int, userID } users := make(map[uint]*system.User, len(userPOs)) for i := range userPOs { - users[userPOs[i].ID] = baseBizUser(&userPOs[i]) + user, err := baseBizUser(&userPOs[i]) + if err != nil { + return nil, 0, err + } + users[userPOs[i].ID] = user } out := make([]*system.APIToken, 0, len(pos)) for _, po := range pos { diff --git a/internal/data/system/department.go b/internal/data/system/department.go index 64cf292..8d3297b 100644 --- a/internal/data/system/department.go +++ b/internal/data/system/department.go @@ -66,7 +66,11 @@ func (r *departmentRepo) attachDepartmentLeaders(ctx context.Context, department } leaderByID := make(map[uint]*system.User, len(leaders)) for i := range leaders { - leaderByID[leaders[i].ID] = baseBizUser(&leaders[i]) + user, err := baseBizUser(&leaders[i]) + if err != nil { + return err + } + leaderByID[leaders[i].ID] = user } for _, department := range departments { department.Leader = leaderByID[department.LeaderID] diff --git a/internal/data/system/error_record.go b/internal/data/system/error_record.go index cbeffff..942108e 100644 --- a/internal/data/system/error_record.go +++ b/internal/data/system/error_record.go @@ -37,16 +37,15 @@ func (r *auditRecorderRepo) CreateError(ctx context.Context, v *system.ErrorReco if v.Status == "" { v.Status = "未处理" } - return r.data.DB().WithContext(ctx).Create(&errorRecordPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status}).Error + po := errorRecordPO{Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status} + if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { + return err + } + v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt + return nil } func (r *auditQueryRepo) UpdateError(ctx context.Context, v *system.ErrorRecord) error { updates := make(map[string]any, 9) - if v.ID != 0 { - updates["id"] = v.ID - } - if !v.CreatedAt.IsZero() { - updates["created_at"] = v.CreatedAt - } if v.Form != nil { updates["form"] = v.Form } diff --git a/internal/data/system/error_record_test.go b/internal/data/system/error_record_test.go index 9ffe63b..1b00977 100644 --- a/internal/data/system/error_record_test.go +++ b/internal/data/system/error_record_test.go @@ -45,7 +45,27 @@ func TestCreateErrorBeforeDatabaseInitializationIsNoop(t *testing.T) { } } -func TestCreateErrorPreservesNullableFieldsAndDefaultStatus(t *testing.T) { +func TestCreateErrorOwnsServerIdentityAndTimestamps(t *testing.T) { + recorder, query := newErrorRecordRepos(t) + createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC) + form := "前端" + value := &system.ErrorRecord{ID: 41, CreatedAt: createdAt, Form: &form, Level: "error"} + if err := recorder.CreateError(context.Background(), value); err != nil { + t.Fatal(err) + } + if value.ID == 41 || value.CreatedAt.IsZero() || value.CreatedAt.Equal(createdAt) { + t.Fatalf("created metadata = %+v", value) + } + stored, err := query.FindError(context.Background(), value.ID) + if err != nil { + t.Fatal(err) + } + if stored.ID != value.ID || stored.CreatedAt.IsZero() || stored.CreatedAt.Equal(createdAt) { + t.Fatalf("stored metadata = %+v", stored) + } +} + +func TestCreateErrorGeneratesMetadataAndPreservesNullableFields(t *testing.T) { recorder, query := newErrorRecordRepos(t) createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC) form := "前端" @@ -58,7 +78,7 @@ func TestCreateErrorPreservesNullableFieldsAndDefaultStatus(t *testing.T) { t.Fatal(err) } - got, err := query.FindError(context.Background(), 41) + got, err := query.FindError(context.Background(), 1) if err != nil { t.Fatal(err) } @@ -71,8 +91,8 @@ func TestCreateErrorPreservesNullableFieldsAndDefaultStatus(t *testing.T) { if got.Status != "未处理" { t.Fatalf("status = %q, want 未处理", got.Status) } - if !got.CreatedAt.Equal(createdAt) { - t.Fatalf("created_at = %v, want %v", got.CreatedAt, createdAt) + if got.ID == 41 || got.CreatedAt.IsZero() || got.CreatedAt.Equal(createdAt) { + t.Fatalf("server metadata = ID %d, created_at %v", got.ID, got.CreatedAt) } } @@ -108,6 +128,7 @@ func TestUpdateErrorDistinguishesOmittedAndExplicitEmptyFields(t *testing.T) { t.Fatalf("omitted fields changed: info=%#v solution=%#v", got.Info, got.Solution) } + originalCreatedAt := got.CreatedAt changedCreatedAt := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) if err = query.UpdateError(context.Background(), &system.ErrorRecord{ ID: id, @@ -133,7 +154,7 @@ func TestUpdateErrorDistinguishesOmittedAndExplicitEmptyFields(t *testing.T) { if got.RequestID != "request-new" || got.TraceID != "trace-new" { t.Fatalf("trace fields = (%q, %q), want updated values", got.RequestID, got.TraceID) } - if !got.CreatedAt.Equal(changedCreatedAt) { - t.Fatalf("created_at = %v, want %v", got.CreatedAt, changedCreatedAt) + if !got.CreatedAt.Equal(originalCreatedAt) || got.CreatedAt.Equal(changedCreatedAt) { + t.Fatalf("created_at = %v, want unchanged %v", got.CreatedAt, originalCreatedAt) } } diff --git a/internal/data/system/export.go b/internal/data/system/export.go index 18a5978..c18d103 100644 --- a/internal/data/system/export.go +++ b/internal/data/system/export.go @@ -11,7 +11,6 @@ import ( "time" "gorm.io/gorm" - "gorm.io/gorm/clause" ) type exportTemplatePO struct { @@ -78,9 +77,9 @@ func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []e return v } func exportToPO(v *system.ExportTemplate) exportTemplatePO { - return exportTemplatePO{ID: v.ID, DBName: v.DBName, Name: v.Name, DBTableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order} + return exportTemplatePO{DBName: v.DBName, Name: v.Name, DBTableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order} } -func (r *exportRepo) saveRelations(tx *gorm.DB, v *system.ExportTemplate, resetIDs, replace, forceTemplateID bool) error { +func (r *exportRepo) saveRelations(tx *gorm.DB, v *system.ExportTemplate, replace bool) error { if replace { if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil { return err @@ -91,50 +90,19 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *system.ExportTemplate, resetI } conditions := make([]exportConditionPO, 0, len(v.Conditions)) for _, x := range v.Conditions { - id := x.ID - if resetIDs { - id = 0 - } - templateID := x.TemplateID - // GORM's has-many association on Create always writes the parent's - // TemplateID into the child foreign key (references:TemplateID). - // Preserve that observable behavior for creates; updates intentionally - // keep the request's value after the compatible update deletes and - // recreates relations. - if forceTemplateID || templateID == "" { - templateID = v.TemplateID - } - conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, From: x.From, Column: x.Column, Operator: x.Operator}) + conditions = append(conditions, exportConditionPO{TemplateID: v.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator}) } if len(conditions) > 0 { - create := tx - if !resetIDs { - // GORM saves Create associations with ON CONFLICT DO NOTHING. - // Preserve that behavior for copied templates carrying relation IDs. - create = create.Clauses(clause.OnConflict{DoNothing: true}) - } - if err := create.Create(&conditions).Error; err != nil { + if err := tx.Create(&conditions).Error; err != nil { return err } } joins := make([]exportJoinPO, 0, len(v.Joins)) for _, x := range v.Joins { - id := x.ID - if resetIDs { - id = 0 - } - templateID := x.TemplateID - if forceTemplateID || templateID == "" { - templateID = v.TemplateID - } - joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, Join: x.Join, Table: x.Table, On: x.On}) + joins = append(joins, exportJoinPO{TemplateID: v.TemplateID, Join: x.Join, Table: x.Table, On: x.On}) } if len(joins) > 0 { - create := tx - if !resetIDs { - create = create.Clauses(clause.OnConflict{DoNothing: true}) - } - return create.Create(&joins).Error + return tx.Create(&joins).Error } return nil } @@ -145,7 +113,7 @@ func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *system.ExportT return err } v.ID = po.ID - return r.saveRelations(tx, v, false, false, true) + return r.saveRelations(tx, v, false) }) } func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *system.ExportTemplate) error { @@ -154,7 +122,7 @@ func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *system.ExportT if err := tx.Model(&exportTemplatePO{}).Where("id = ?", v.ID).Updates(&po).Error; err != nil { return err } - return r.saveRelations(tx, v, true, true, false) + return r.saveRelations(tx, v, true) }) } func (r *exportRepo) DeleteExportTemplates(ctx context.Context, ids []uint) error { @@ -219,49 +187,70 @@ func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q } func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate, params map[string]string) ([]map[string]any, string, error) { - selected, err := r.data.Database(t.DBName) + query, err := r.buildExportQuery(ctx, t, params) if err != nil { return nil, "", err } - db := selected.WithContext(ctx) var rows []map[string]any - if t.SQL != "" { - args := map[string]any{} - for k, v := range params { - args[k] = v - } - err := db.Raw(t.SQL, args).Scan(&rows).Error - return rows, t.SQL, err - } + preview := query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) }) + err = query.Find(&rows).Error + return rows, preview, err +} + +func exportColumns(raw string) ([]string, error) { columns := make([]string, 0) - if err := json.Unmarshal([]byte(t.TemplateInfo), &map[string]string{}); err != nil { - return nil, "", err + decoder := json.NewDecoder(strings.NewReader(raw)) + if token, err := decoder.Token(); err != nil || token != json.Delim('{') { + return nil, errors.New("导出模板列定义必须是 JSON 对象") } - decoder := json.NewDecoder(strings.NewReader(t.TemplateInfo)) - _, _ = decoder.Token() for decoder.More() { - key, _ := decoder.Token() - columns = append(columns, key.(string)) + key, err := decoder.Token() + if err != nil { + return nil, err + } + column, ok := key.(string) + if !ok { + return nil, errors.New("导出模板列名无效") + } + columns = append(columns, column) var ignored any - _ = decoder.Decode(&ignored) + if err = decoder.Decode(&ignored); err != nil { + return nil, err + } } if len(columns) == 0 { - return nil, "", errors.New("模板列为空") + return nil, errors.New("模板列为空") + } + return columns, nil +} + +func (r *exportRepo) buildExportQuery(ctx context.Context, t *system.ExportTemplate, params map[string]string) (*gorm.DB, error) { + if err := system.ValidateExportTemplate(t); err != nil { + return nil, err + } + selected, err := r.data.Database(t.DBName) + if err != nil { + return nil, err + } + db := selected.WithContext(ctx) + columns, err := exportColumns(t.TemplateInfo) + if err != nil { + return nil, err } query := db.Table(t.TableName).Select(strings.Join(columns, ",")) for _, j := range t.Joins { - query = query.Joins(j.Join + " " + j.Table + " ON " + j.On) + query = query.Joins(strings.ToUpper(strings.Join(strings.Fields(j.Join), " ")) + " " + j.Table + " ON " + j.On) } if params["filterDeleted"] == "true" { - query = query.Where(fmt.Sprintf("%s.deleted_at IS NULL", t.TableName)) + query = query.Where(t.TableName + ".deleted_at IS NULL") for _, join := range t.Joins { if db.Migrator().HasColumn(join.Table, "deleted_at") { - query = query.Where(fmt.Sprintf("%s.deleted_at IS NULL", join.Table)) + query = query.Where(join.Table + ".deleted_at IS NULL") } } } for _, condition := range t.Conditions { - operator := condition.Operator + operator := strings.ToUpper(strings.Join(strings.Fields(condition.Operator), " ")) value := params[condition.From] switch operator { case "LIKE": @@ -270,7 +259,11 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate, } case "IN", "NOT IN": if value != "" { - query = query.Where(condition.Column+" "+operator+" (?)", value) + values := strings.Split(value, ",") + for index := range values { + values[index] = strings.TrimSpace(values[index]) + } + query = query.Where(condition.Column+" "+operator+" ?", values) } case "BETWEEN": start, end := params["start"+condition.From], params["end"+condition.From] @@ -285,7 +278,10 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate, } limit := 0 if raw := params["limit"]; raw != "" { - limit, _ = strconv.Atoi(raw) + limit, err = strconv.Atoi(raw) + if err != nil || limit < 0 || limit > 100000 { + return nil, errors.New("导出行数限制不合法") + } } else if t.Limit != nil { limit = *t.Limit } @@ -293,139 +289,54 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate, query = query.Limit(limit) } if raw := params["offset"]; raw != "" { - if offset, parseErr := strconv.Atoi(raw); parseErr == nil { - query = query.Offset(offset) + offset, parseErr := strconv.Atoi(raw) + if parseErr != nil || offset < 0 { + return nil, errors.New("导出偏移量不合法") } + query = query.Offset(offset) } order := params["order"] if order == "" { order = t.Order } if order != "" { - parts := strings.Split(order, " ") + parts := strings.Fields(order) + if len(parts) > 2 { + return nil, errors.New("导出排序不合法") + } fields := map[string]bool{} columnTypes, columnErr := db.Migrator().ColumnTypes(t.TableName) if columnErr != nil { - return nil, "", columnErr + return nil, columnErr } for _, column := range columnTypes { fields[column.Name()] = true } - if !fields[parts[0]] { - return nil, "", fmt.Errorf("order by %s is not in the fields", order) + columnName := parts[0] + if dot := strings.LastIndex(columnName, "."); dot >= 0 { + columnName = columnName[dot+1:] + } + if !fields[columnName] { + return nil, fmt.Errorf("order by %s is not in the fields", order) } orderSQL := parts[0] if len(parts) > 1 { - if parts[1] != "asc" && parts[1] != "desc" { - return nil, "", fmt.Errorf("order by %s is not secure", order) + if !strings.EqualFold(parts[1], "asc") && !strings.EqualFold(parts[1], "desc") { + return nil, fmt.Errorf("order by %s is not secure", order) } - orderSQL += " " + parts[1] + orderSQL += " " + strings.ToUpper(parts[1]) } query = query.Order(orderSQL) } - preview := query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) }) - err = query.Find(&rows).Error - return rows, preview, err + return query, nil } func (r *exportRepo) PreviewExport(ctx context.Context, t *system.ExportTemplate, params map[string]string) (string, error) { - columns := make([]string, 0) - decoder := json.NewDecoder(strings.NewReader(t.TemplateInfo)) - if token, err := decoder.Token(); err != nil || token != json.Delim('{') { + query, err := r.buildExportQuery(ctx, t, params) + if err != nil { return "", err } - for decoder.More() { - key, err := decoder.Token() - if err != nil { - return "", err - } - columns = append(columns, key.(string)) - var ignored any - if err = decoder.Decode(&ignored); err != nil { - return "", err - } - } - var builder strings.Builder - builder.WriteString("SELECT ") - builder.WriteString(strings.Join(columns, ", ")) - builder.WriteString(" FROM ") - builder.WriteString(t.TableName) - for _, join := range t.Joins { - builder.WriteString(" " + join.Join + " " + join.Table + " ON " + join.On) - } - wheres := make([]string, 0) - if params["filterDeleted"] == "true" { - wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", t.TableName)) - selected, err := r.data.Database(t.DBName) - if err != nil { - return "", err - } - db := selected.WithContext(ctx) - for _, join := range t.Joins { - if db.Migrator().HasColumn(join.Table, "deleted_at") { - wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", join.Table)) - } - } - } - for _, condition := range t.Conditions { - op, column, value := strings.ToUpper(strings.TrimSpace(condition.Operator)), strings.TrimSpace(condition.Column), params[condition.From] - switch op { - case "BETWEEN": - start, end := params["start"+condition.From], params["end"+condition.From] - if start != "" && end != "" { - wheres = append(wheres, fmt.Sprintf("%s BETWEEN '%s' AND '%s'", column, start, end)) - } else { - wheres = append(wheres, fmt.Sprintf("%s BETWEEN {start%s} AND {end%s}", column, condition.From, condition.From)) - } - case "IN", "NOT IN": - if value != "" { - parts := strings.Split(value, ",") - for index := range parts { - parts[index] = strings.TrimSpace(parts[index]) - } - wheres = append(wheres, fmt.Sprintf("%s %s ('%s')", column, op, strings.Join(parts, "','"))) - } else { - wheres = append(wheres, fmt.Sprintf("%s %s ({%s})", column, op, condition.From)) - } - case "LIKE": - if value != "" { - wheres = append(wheres, fmt.Sprintf("%s LIKE '%%%s%%'", column, value)) - } else { - wheres = append(wheres, fmt.Sprintf("%s LIKE {%%%s%%}", column, condition.From)) - } - default: - if value != "" { - wheres = append(wheres, fmt.Sprintf("%s %s '%s'", column, op, value)) - } else { - wheres = append(wheres, fmt.Sprintf("%s %s {%s}", column, op, condition.From)) - } - } - } - if len(wheres) > 0 { - builder.WriteString(" WHERE " + strings.Join(wheres, " AND ")) - } - order := params["order"] - if order == "" { - order = t.Order - } - if order != "" { - builder.WriteString(" ORDER BY " + order) - } - limitRaw, offsetRaw := params["limit"], params["offset"] - if limitRaw == "" && t.Limit != nil && *t.Limit != 0 { - limitRaw = strconv.Itoa(*t.Limit) - } - limit, _ := strconv.Atoi(limitRaw) - offset, _ := strconv.Atoi(offsetRaw) - if limit > 0 { - builder.WriteString(" LIMIT " + strconv.Itoa(limit)) - if offset > 0 { - builder.WriteString(" OFFSET " + strconv.Itoa(offset)) - } - } else if offset > 0 { - builder.WriteString(" OFFSET " + strconv.Itoa(offset)) - } - return builder.String(), nil + return query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) }), nil } func parseTemplateColumns(raw string) map[string]string { out := map[string]string{} @@ -439,23 +350,14 @@ func parseTemplateColumns(raw string) map[string]string { return out } func (r *exportRepo) ImportExportRows(ctx context.Context, t *system.ExportTemplate, rows []map[string]any) error { - // ImportSQL is checked verbatim. In particular, a whitespace-only value - // is still treated as custom SQL and is allowed to return the driver's - // native error instead of silently falling back to GORM insertion. - sql := t.ImportSQL + if err := system.ValidateExportTemplate(t); err != nil { + return err + } selected, err := r.data.Database(t.DBName) if err != nil { return err } return selected.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if sql != "" { - for _, row := range rows { - if err := tx.Exec(sql, row).Error; err != nil { - return err - } - } - return nil - } needCreated := tx.Migrator().HasColumn(t.TableName, "created_at") needUpdated := tx.Migrator().HasColumn(t.TableName, "updated_at") for _, row := range rows { diff --git a/internal/data/system/login_log.go b/internal/data/system/login_log.go index 0310074..6febb93 100644 --- a/internal/data/system/login_log.go +++ b/internal/data/system/login_log.go @@ -34,14 +34,14 @@ func (r *auditRecorderRepo) RecordLogin(ctx context.Context, v *system.LoginLog) func loginFromPO(v loginLogPO) *system.LoginLog { return &system.LoginLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID} } -func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *system.LoginLog) ([]*system.LoginLog, int64, error) { +func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *system.LoginLogFilter) ([]*system.LoginLog, int64, error) { db := r.data.DB().WithContext(ctx).Model(&loginLogPO{}) if q != nil { if q.Username != "" { db = db.Where("username LIKE ?", "%"+q.Username+"%") } - if q.FilterByStatus { - db = db.Where("status = ?", q.Status) + if q.Status != nil { + db = db.Where("status = ?", *q.Status) } } var total int64 diff --git a/internal/data/system/login_log_filter_test.go b/internal/data/system/login_log_filter_test.go new file mode 100644 index 0000000..80e1074 --- /dev/null +++ b/internal/data/system/login_log_filter_test.go @@ -0,0 +1,27 @@ +package system + +import ( + "context" + "testing" + + bizsystem "kra/internal/biz/system" +) + +func TestListLoginsCanFilterFailedStatus(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + if err := data.gormDB.WithContext(ctx).Create(&[]loginLogPO{ + {Username: "ok", Status: true}, + {Username: "failed", Status: false}, + }).Error; err != nil { + t.Fatal(err) + } + status := false + items, total, err := (&auditQueryRepo{data: data}).ListLogins(ctx, 1, 20, &bizsystem.LoginLogFilter{Status: &status}) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(items) != 1 || items[0].Status { + t.Fatalf("failed login filter = total %d items %#v", total, items) + } +} diff --git a/internal/data/system/menu.go b/internal/data/system/menu.go index 0a5c08b..bf6662a 100644 --- a/internal/data/system/menu.go +++ b/internal/data/system/menu.go @@ -23,7 +23,7 @@ func menuFromPO(po menuPO) *system.Menu { } func menuToPO(v *system.Menu) menuPO { - return menuPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType} + return menuPO{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType} } func replaceMenuRelations(tx *gorm.DB, menu *system.Menu) error { @@ -39,7 +39,7 @@ func replaceMenuRelations(tx *gorm.DB, menu *system.Menu) error { func createMenuRelations(tx *gorm.DB, menu *system.Menu) error { parameters := make([]menuParameterPO, 0, len(menu.Parameters)) for _, parameter := range menu.Parameters { - parameters = append(parameters, menuParameterPO{ID: parameter.ID, CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + parameters = append(parameters, menuParameterPO{ID: parameter.ID, MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) } if len(parameters) > 0 { if err := tx.Create(¶meters).Error; err != nil { @@ -48,7 +48,7 @@ func createMenuRelations(tx *gorm.DB, menu *system.Menu) error { } buttons := make([]menuButtonPO, 0, len(menu.Buttons)) for _, button := range menu.Buttons { - buttons = append(buttons, menuButtonPO{ID: button.ID, CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, Name: button.Name, Description: button.Description, MenuID: menu.ID}) + buttons = append(buttons, menuButtonPO{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: menu.ID}) } if len(buttons) > 0 { return tx.Create(&buttons).Error @@ -89,6 +89,7 @@ func (r *menuRepo) Create(ctx context.Context, v *system.Menu) error { } } po := menuToPO(v) + po.ID = 0 if err := tx.Create(&po).Error; err != nil { return err } diff --git a/internal/data/system/menu_test.go b/internal/data/system/menu_test.go index 584c213..14d55dd 100644 --- a/internal/data/system/menu_test.go +++ b/internal/data/system/menu_test.go @@ -26,7 +26,7 @@ func newMenuTestData(t *testing.T) *Data { return data } -func TestMenuCreatePreservesSubmittedTimestamps(t *testing.T) { +func TestMenuCreateGeneratesServerTimestamps(t *testing.T) { data := newMenuTestData(t) ctx := context.Background() createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC) @@ -40,8 +40,8 @@ func TestMenuCreatePreservesSubmittedTimestamps(t *testing.T) { if err := data.gormDB.WithContext(ctx).First(&stored, menu.ID).Error; err != nil { t.Fatal(err) } - if !stored.CreatedAt.Equal(createdAt) || !stored.UpdatedAt.Equal(updatedAt) { - t.Fatalf("stored timestamps = (%v, %v), want (%v, %v)", stored.CreatedAt, stored.UpdatedAt, createdAt, updatedAt) + if stored.CreatedAt.IsZero() || stored.UpdatedAt.IsZero() || stored.CreatedAt.Equal(createdAt) || stored.UpdatedAt.Equal(updatedAt) { + t.Fatalf("stored timestamps = (%v, %v), want server-generated values", stored.CreatedAt, stored.UpdatedAt) } } @@ -114,8 +114,8 @@ func TestMenuUpdateRebuildsRelationsWithoutChangingRequestedIdentity(t *testing. if parameter.MenuID != 10 || parameter.Type != "params" || parameter.Key != "new" || parameter.Value != "value" { t.Fatalf("parameter = %+v", parameter) } - if !parameter.CreatedAt.Equal(createdAt) || !parameter.UpdatedAt.Equal(updatedAt) { - t.Fatalf("parameter timestamps = (%v, %v), want (%v, %v)", parameter.CreatedAt, parameter.UpdatedAt, createdAt, updatedAt) + if parameter.CreatedAt.Equal(createdAt) || parameter.UpdatedAt.Equal(updatedAt) { + t.Fatalf("parameter timestamps = (%v, %v), want server-generated values", parameter.CreatedAt, parameter.UpdatedAt) } var button menuButtonPO @@ -125,8 +125,8 @@ func TestMenuUpdateRebuildsRelationsWithoutChangingRequestedIdentity(t *testing. if button.MenuID != 10 || button.Name != "edit" || button.Description != "new" { t.Fatalf("button = %+v", button) } - if !button.CreatedAt.Equal(createdAt) || !button.UpdatedAt.Equal(updatedAt) { - t.Fatalf("button timestamps = (%v, %v), want (%v, %v)", button.CreatedAt, button.UpdatedAt, createdAt, updatedAt) + if button.CreatedAt.Equal(createdAt) || button.UpdatedAt.Equal(updatedAt) { + t.Fatalf("button timestamps = (%v, %v), want server-generated values", button.CreatedAt, button.UpdatedAt) } var links int64 diff --git a/internal/data/system/parameter.go b/internal/data/system/parameter.go index 6f84f3f..ec6766e 100644 --- a/internal/data/system/parameter.go +++ b/internal/data/system/parameter.go @@ -29,7 +29,7 @@ type parameterPO struct { func (parameterPO) TableName() string { return "sys_params" } func (r *parameterRepo) CreateParameter(ctx context.Context, v *system.SystemParameter) error { - po := parameterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc} + po := parameterPO{Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc} if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { return err } @@ -37,9 +37,7 @@ func (r *parameterRepo) CreateParameter(ctx context.Context, v *system.SystemPar return nil } func (r *parameterRepo) UpdateParameter(ctx context.Context, v *system.SystemParameter) error { - // The compatible update uses a struct, so zero-value optional fields (notably desc) - // are intentionally ignored rather than clearing an existing value. - return r.data.DB().WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(¶meterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}).Error + return r.data.DB().WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "key": v.Key, "value": v.Value, "desc": v.Desc}).Error } func (r *parameterRepo) DeleteParameters(ctx context.Context, ids []string) error { if len(ids) == 1 { diff --git a/internal/data/system/parameter_test.go b/internal/data/system/parameter_test.go index 734ccb4..e0bc997 100644 --- a/internal/data/system/parameter_test.go +++ b/internal/data/system/parameter_test.go @@ -7,7 +7,7 @@ import ( "time" ) -func TestParameterRepositoryPreservesModelMetadata(t *testing.T) { +func TestParameterRepositoryOwnsModelMetadata(t *testing.T) { data := newTransactionTestData(t) repo := ¶meterRepo{data: data} createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.Local) @@ -16,10 +16,11 @@ func TestParameterRepositoryPreservesModelMetadata(t *testing.T) { if err := repo.CreateParameter(context.Background(), value); err != nil { t.Fatal(err) } - if value.ID != 41 || !value.CreatedAt.Equal(createdAt) { + if value.ID == 0 || value.ID == 41 || value.CreatedAt.IsZero() || value.CreatedAt.Equal(createdAt) { t.Fatalf("created parameter metadata = ID %d, CreatedAt %v", value.ID, value.CreatedAt) } + originalCreatedAt := value.CreatedAt nextCreatedAt := createdAt.Add(time.Hour) value.CreatedAt = nextCreatedAt value.Name = "updated" @@ -27,10 +28,10 @@ func TestParameterRepositoryPreservesModelMetadata(t *testing.T) { t.Fatal(err) } var persisted parameterPO - if err := data.gormDB.WithContext(context.Background()).First(&persisted, 41).Error; err != nil { + if err := data.gormDB.WithContext(context.Background()).First(&persisted, value.ID).Error; err != nil { t.Fatal(err) } - if persisted.Name != "updated" || !persisted.CreatedAt.Equal(nextCreatedAt) { + if persisted.Name != "updated" || !persisted.CreatedAt.Equal(originalCreatedAt) || persisted.CreatedAt.Equal(nextCreatedAt) { t.Fatalf("updated parameter = %+v", persisted) } } diff --git a/internal/data/system/security.go b/internal/data/system/security.go index 817423c..655defd 100644 --- a/internal/data/system/security.go +++ b/internal/data/system/security.go @@ -4,6 +4,7 @@ import ( "context" "errors" "kra/internal/biz/system" + dataprovider "kra/internal/data/provider" "time" "gorm.io/gorm" @@ -11,10 +12,7 @@ import ( // DatabaseProvider is the smaller seam used by security settings, which also // need to work before the primary database is initialized. -type DatabaseProvider interface { - DB() *gorm.DB - DatabaseReady() bool -} +type DatabaseProvider = dataprovider.Database type SecurityConfigPO struct { ID uint `gorm:"primaryKey"` @@ -62,9 +60,7 @@ func securityToPO(v *system.SecurityConfig) SecurityConfigPO { func (r *securityRepo) SecurityConfig(ctx context.Context) (*system.SecurityConfig, error) { if !r.data.DatabaseReady() { - po := DefaultSecurityConfig() - po.ID = 0 - return securityFromPO(po), errors.New("数据库未初始化") + return nil, errors.New("数据库未初始化") } db := r.data.DB().WithContext(ctx) var po SecurityConfigPO diff --git a/internal/data/system/security_unready_test.go b/internal/data/system/security_unready_test.go new file mode 100644 index 0000000..23855c0 --- /dev/null +++ b/internal/data/system/security_unready_test.go @@ -0,0 +1,15 @@ +package system + +import ( + "context" + "testing" +) + +func TestSecurityConfigReturnsNoValueWhenDatabaseIsUnready(t *testing.T) { + data := newTransactionTestData(t) + data.databaseReady.Store(false) + value, err := (&securityRepo{data: data}).SecurityConfig(context.Background()) + if err == nil || value != nil { + t.Fatalf("SecurityConfig() = %#v, %v; want nil and error", value, err) + } +} diff --git a/internal/data/system/user.go b/internal/data/system/user.go index 89a71e2..a3ef401 100644 --- a/internal/data/system/user.go +++ b/internal/data/system/user.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "kra/internal/biz/system" "strconv" "strings" @@ -62,9 +63,9 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro for _, item := range authorityPOs { authorities = append(authorities, toBizAuthority(item)) } - setting := map[string]any(nil) - if po.OriginSetting != "" { - _ = json.Unmarshal([]byte(po.OriginSetting), &setting) + setting, err := parseOriginSetting(po.OriginSetting) + if err != nil { + return nil, err } var departmentPOs []departmentPO if err := r.data.DB().WithContext(ctx).Table("sys_departments").Joins("JOIN sys_user_departments ON sys_user_departments.sys_department_id = sys_departments.id").Where("sys_user_departments.sys_user_id = ? AND sys_departments.deleted_at IS NULL", po.ID).Find(&departmentPOs).Error; err != nil { @@ -104,12 +105,23 @@ func toBizAuthority(po authorityPO) system.Authority { return system.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAt, AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter} } -func baseBizUser(po *userPO) *system.User { +func parseOriginSetting(raw string) (map[string]any, error) { setting := map[string]any(nil) - if po.OriginSetting != "" { - _ = json.Unmarshal([]byte(po.OriginSetting), &setting) + if raw == "" { + return setting, nil } - return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, DeptID: po.DeptID, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt} + if err := json.Unmarshal([]byte(raw), &setting); err != nil { + return nil, fmt.Errorf("解析用户设置: %w", err) + } + return setting, nil +} + +func baseBizUser(po *userPO) (*system.User, error) { + setting, err := parseOriginSetting(po.OriginSetting) + if err != nil { + return nil, err + } + return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, DeptID: po.DeptID, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt}, nil } func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*system.User, error) { @@ -189,7 +201,10 @@ func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*system.User, users := make([]*system.User, 0, len(pos)) for i := range pos { po := &pos[i] - user := baseBizUser(po) + user, err := baseBizUser(po) + if err != nil { + return nil, err + } user.Authority = toBizAuthority(authorityByID[po.AuthorityID]) user.Authorities = make([]system.Authority, 0, len(authorityByUser[po.ID])) for _, id := range authorityByUser[po.ID] { @@ -376,7 +391,10 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *system.U // query. The reference handler returns the request's authority IDs and // leaves related authority/department records at their zero values; keep // that response contract while retaining the persisted timestamps/UUID. - created := baseBizUser(&po) + created, err := baseBizUser(&po) + if err != nil { + return nil, err + } created.Authorities = createdAuthorities return created, nil } @@ -521,16 +539,7 @@ func (r *userRepo) UpdatePassword(ctx context.Context, id uint, password string, return r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Updates(updates).Error } func (r *userRepo) ListAuthorities(ctx context.Context) ([]*system.Authority, error) { - var pos []authorityPO - if err := r.data.DB().WithContext(ctx).Order("authority_id").Find(&pos).Error; err != nil { - return nil, err - } - result := make([]*system.Authority, 0, len(pos)) - for _, po := range pos { - a := toBizAuthority(po) - result = append(result, &a) - } - return result, nil + return (&authorityAccessRepo{data: r.data}).ListAuthorities(ctx) } func (r *userRepo) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error { diff --git a/internal/data/system/user_origin_setting_test.go b/internal/data/system/user_origin_setting_test.go new file mode 100644 index 0000000..45c1342 --- /dev/null +++ b/internal/data/system/user_origin_setting_test.go @@ -0,0 +1,16 @@ +package system + +import ( + "context" + "testing" +) + +func TestFindUserRejectsCorruptOriginSetting(t *testing.T) { + data := newTransactionTestData(t) + if err := data.gormDB.WithContext(context.Background()).Create(&userPO{Username: "broken-setting", Password: "hash", AuthorityID: 888, OriginSetting: "{"}).Error; err != nil { + t.Fatal(err) + } + if _, err := (&userRepo{data: data}).FindUserByID(context.Background(), 1); err == nil { + t.Fatal("FindUserByID accepted corrupt OriginSetting") + } +} diff --git a/internal/data/task/migrations.go b/internal/data/task/migrations.go index 993614e..c9f408c 100644 --- a/internal/data/task/migrations.go +++ b/internal/data/task/migrations.go @@ -25,12 +25,6 @@ func SeedDefaults(ctx context.Context, db *gorm.DB, defaults []platformmodule.Ti if db == nil { return nil } - if len(defaults) == 0 { - defaults = []platformmodule.TimedTask{ - {Name: "ClearDB", Description: "定时清理数据库过期日志", Spec: "@daily", MethodName: "ClearDB", Enabled: true}, - {Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true}, - } - } return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, item := range defaults { if strings.TrimSpace(item.Name) == "" { diff --git a/internal/data/task/provider.go b/internal/data/task/provider.go index c44e68c..e967012 100644 --- a/internal/data/task/provider.go +++ b/internal/data/task/provider.go @@ -1,10 +1,7 @@ package task -import "gorm.io/gorm" +import dataprovider "kra/internal/data/provider" // Provider is the minimal database seam required by the task tables. // Task persistence must not depend on the full data runtime or system repos. -type Provider interface { - DB() *gorm.DB - DatabaseReady() bool -} +type Provider = dataprovider.Database diff --git a/internal/integration/cache/cache.go b/internal/integration/cache/cache.go index ef0116b..8c6b428 100644 --- a/internal/integration/cache/cache.go +++ b/internal/integration/cache/cache.go @@ -89,15 +89,23 @@ func (s *Store) Delete(ctx context.Context, key string) error { func (s *Store) Increment(ctx context.Context, key string, expiration time.Duration) (int64, error) { if client := s.client(); client != nil { + if expiration > 0 { + expirationMS := expiration.Milliseconds() + if expirationMS < 1 { + expirationMS = 1 + } + const incrementWithTTL = ` +local value = redis.call('INCR', KEYS[1]) +if redis.call('PTTL', KEYS[1]) < 0 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end +return value` + return client.Eval(ctx, incrementWithTTL, []string{key}, expirationMS).Int64() + } value, err := client.Incr(ctx, key).Result() if err != nil { return 0, err } - if value == 1 && expiration > 0 { - if err := client.Expire(ctx, key, expiration).Err(); err != nil { - return 0, err - } - } return value, nil } s.mu.Lock() @@ -120,6 +128,8 @@ func (s *Store) Increment(ctx context.Context, key string, expiration time.Durat } else { entry.expiresAt = time.Time{} } + } else if entry.expiresAt.IsZero() && expiration > 0 { + entry.expiresAt = time.Now().Add(expiration) } s.memory[key] = entry return value, nil diff --git a/internal/integration/cache/cache_test.go b/internal/integration/cache/cache_test.go index 5e56537..d4d7204 100644 --- a/internal/integration/cache/cache_test.go +++ b/internal/integration/cache/cache_test.go @@ -40,3 +40,18 @@ func TestStoreIncrementRecreatesExpiredKeyWithoutStaleExpiry(t *testing.T) { t.Fatalf("Get() after recreation = %q, %v, %v", value, ok, err) } } + +func TestStoreIncrementAddsTTLToExistingPersistentCounter(t *testing.T) { + store := New(nil) + ctx := context.Background() + if err := store.Set(ctx, "counter", "5", 0); err != nil { + t.Fatal(err) + } + if value, err := store.Increment(ctx, "counter", time.Millisecond); err != nil || value != 6 { + t.Fatalf("Increment() = %d, %v", value, err) + } + time.Sleep(5 * time.Millisecond) + if _, ok, err := store.Get(ctx, "counter"); err != nil || ok { + t.Fatalf("persistent counter did not expire: ok=%v err=%v", ok, err) + } +} diff --git a/internal/integration/payment/apple_jws.go b/internal/integration/payment/apple_jws.go index d58f642..06acbb6 100644 --- a/internal/integration/payment/apple_jws.go +++ b/internal/integration/payment/apple_jws.go @@ -1,12 +1,17 @@ package payment import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" "crypto/x509" "encoding/asn1" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" + "math/big" "strings" "time" ) @@ -20,12 +25,16 @@ const ( var ( appleJWSLeafExtensionOID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 11, 1} appleJWSIntermediateExtensionOID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 2, 1} + appleTrustedRootFingerprints = map[string]struct{}{ + "63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179": {}, + } ) -// validateAppleJWSChain binds the leaf certificate used by GoPay for JWS -// signature verification to the intermediate and root certificates that -// GoPay validates against its embedded Apple trust anchor. func validateAppleJWSChain(signedPayload string) error { + return validateAppleJWSChainWithRoots(signedPayload, appleTrustedRootFingerprints) +} + +func validateAppleJWSChainWithRoots(signedPayload string, trustedRoots map[string]struct{}) error { headerSegment, payloadSegment, signatureSegment, err := splitAppleJWS(signedPayload) if err != nil { return err @@ -73,6 +82,10 @@ func validateAppleJWSChain(signedPayload string) error { } certificates = append(certificates, certificate) } + rootFingerprint := sha256.Sum256(certificates[2].Raw) + if _, trusted := trustedRoots[hex.EncodeToString(rootFingerprint[:])]; !trusted { + return errors.New("Apple JWS 根证书不受信任") + } if err = certificates[0].CheckSignatureFrom(certificates[1]); err != nil { return fmt.Errorf("Apple JWS 叶子证书不属于声明的证书链: %w", err) } @@ -101,19 +114,20 @@ func validateAppleJWSChain(signedPayload string) error { return errors.New("Apple JWS 中间证书缺少 Apple 签名扩展") } - if hasVerificationTime { - roots := x509.NewCertPool() - roots.AddCert(certificates[2]) - intermediates := x509.NewCertPool() - intermediates.AddCert(certificates[1]) - if _, err = certificates[0].Verify(x509.VerifyOptions{ - Roots: roots, - Intermediates: intermediates, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, - CurrentTime: verificationTime, - }); err != nil { - return fmt.Errorf("验证 Apple JWS 证书链: %w", err) - } + if !hasVerificationTime { + verificationTime = time.Now() + } + roots := x509.NewCertPool() + roots.AddCert(certificates[2]) + intermediates := x509.NewCertPool() + intermediates.AddCert(certificates[1]) + if _, err = certificates[0].Verify(x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + CurrentTime: verificationTime, + }); err != nil { + return fmt.Errorf("验证 Apple JWS 证书链: %w", err) } if certificates[0].IsCA { return errors.New("Apple JWS 叶子证书不能是 CA") @@ -121,6 +135,18 @@ func validateAppleJWSChain(signedPayload string) error { if certificates[0].KeyUsage != 0 && certificates[0].KeyUsage&x509.KeyUsageDigitalSignature == 0 { return errors.New("Apple JWS 叶子证书不允许数字签名") } + publicKey, ok := certificates[0].PublicKey.(*ecdsa.PublicKey) + if !ok || publicKey.Curve != elliptic.P256() { + return errors.New("Apple JWS 叶子证书不是 P-256 ECDSA 公钥") + } + signature, err := base64.RawURLEncoding.DecodeString(signatureSegment) + if err != nil || len(signature) != 64 { + return errors.New("Apple JWS ES256 签名格式无效") + } + digest := sha256.Sum256([]byte(headerSegment + "." + payloadSegment)) + if !ecdsa.Verify(publicKey, digest[:], new(big.Int).SetBytes(signature[:32]), new(big.Int).SetBytes(signature[32:])) { + return errors.New("Apple JWS 签名校验失败") + } return nil } diff --git a/internal/integration/payment/apple_jws_test.go b/internal/integration/payment/apple_jws_test.go index bac0aa6..1f131c5 100644 --- a/internal/integration/payment/apple_jws_test.go +++ b/internal/integration/payment/apple_jws_test.go @@ -4,10 +4,12 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/sha256" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" "encoding/base64" + "encoding/hex" "encoding/json" "math/big" "testing" @@ -16,7 +18,8 @@ import ( func TestValidateAppleJWSChain(t *testing.T) { chain := testAppleCertificateChain(t) - if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err != nil { + jws := testAppleJWS(t, "ES256", chain, time.Now()) + if err := validateAppleJWSChainWithRoots(jws, testAppleTrustedRoots(t, chain)); err != nil { t.Fatalf("validateAppleJWSChain() error = %v", err) } } @@ -25,17 +28,30 @@ func TestValidateAppleJWSChainRejectsUnrelatedLeaf(t *testing.T) { chain := testAppleCertificateChain(t) attackerChain := testAppleCertificateChain(t) chain[0] = attackerChain[0] - if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil { + if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, time.Now()), testAppleTrustedRoots(t, chain)); err == nil { t.Fatal("validateAppleJWSChain() accepted a leaf outside the declared chain") } } +func TestValidateAppleJWSChainRejectsInvalidSignature(t *testing.T) { + chain := testAppleCertificateChain(t) + jws := testAppleJWS(t, "ES256", chain, time.Now()) + header, payload, _, err := splitAppleJWS(jws) + if err != nil { + t.Fatal(err) + } + tampered := header + "." + payload + "." + base64.RawURLEncoding.EncodeToString(make([]byte, 64)) + if err = validateAppleJWSChainWithRoots(tampered, testAppleTrustedRoots(t, chain)); err == nil { + t.Fatal("validateAppleJWSChain accepted an invalid ES256 signature") + } +} + func TestValidateAppleJWSChainRejectsUnexpectedAlgorithmAndShape(t *testing.T) { chain := testAppleCertificateChain(t) - if err := validateAppleJWSChain(testAppleJWS(t, "ES384", chain, time.Now())); err == nil { + if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES384", chain, time.Now()), testAppleTrustedRoots(t, chain)); err == nil { t.Fatal("validateAppleJWSChain() accepted a non-ES256 algorithm") } - if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain[:2], time.Now())); err == nil { + if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain[:2], time.Now()), nil); err == nil { t.Fatal("validateAppleJWSChain() accepted an incomplete x5c chain") } } @@ -43,14 +59,14 @@ func TestValidateAppleJWSChainRejectsUnexpectedAlgorithmAndShape(t *testing.T) { func TestValidateAppleJWSChainRejectsAdditionalCertificates(t *testing.T) { chain := testAppleCertificateChain(t) chain = append(chain, chain[2]) - if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil { + if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, time.Now()), testAppleTrustedRoots(t, chain[:3])); err == nil { t.Fatal("validateAppleJWSChain() accepted additional x5c certificates") } } func TestValidateAppleJWSChainRejectsNonAppleSigningCertificates(t *testing.T) { chain := testAppleCertificateChainWithoutAppleExtensions(t) - if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil { + if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, time.Now()), testAppleTrustedRoots(t, chain)); err == nil { t.Fatal("validateAppleJWSChain() accepted certificates without Apple signing extensions") } } @@ -58,7 +74,7 @@ func TestValidateAppleJWSChainRejectsNonAppleSigningCertificates(t *testing.T) { func TestValidateAppleJWSChainUsesSignedDateForHistoricalPayload(t *testing.T) { signedAt := time.Now().AddDate(-2, 0, 0) chain := testAppleCertificateChainAt(t, signedAt, true) - if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, signedAt)); err != nil { + if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, signedAt), testAppleTrustedRoots(t, chain)); err != nil { t.Fatalf("validateAppleJWSChain() rejected historical payload: %v", err) } } @@ -78,17 +94,17 @@ func TestValidateAppleJWSChainUsesNestedSignedDateForNotification(t *testing.T) if err != nil { t.Fatal(err) } - outer := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" - if err := validateAppleJWSChain(outer); err != nil { + outer := testAppleSignedJWS(t, header, payload, chain) + if err := validateAppleJWSChainWithRoots(outer, testAppleTrustedRoots(t, chain)); err != nil { t.Fatalf("validateAppleJWSChain() rejected historical notification: %v", err) } } -func TestValidateAppleJWSChainAcceptsExpiredChainWithoutSignedDate(t *testing.T) { +func TestValidateAppleJWSChainRejectsExpiredChainWithoutSignedDate(t *testing.T) { signedAt := time.Now().AddDate(-2, 0, 0) chain := testAppleCertificateChainAt(t, signedAt, true) - if err := validateAppleJWSChain(testAppleJWSWithoutDate(t, chain)); err != nil { - t.Fatalf("validateAppleJWSChain() rejected an expired chain without signedDate: %v", err) + if err := validateAppleJWSChainWithRoots(testAppleJWSWithoutDate(t, chain), testAppleTrustedRoots(t, chain)); err == nil { + t.Fatal("validateAppleJWSChain() accepted an expired chain without signedDate") } } @@ -96,7 +112,7 @@ func TestValidateAppleJWSChainRejectsBrokenChainWithoutSignedDate(t *testing.T) chain := testAppleCertificateChain(t) attackerChain := testAppleCertificateChain(t) chain[0] = attackerChain[0] - if err := validateAppleJWSChain(testAppleJWSWithoutDate(t, chain)); err == nil { + if err := validateAppleJWSChainWithRoots(testAppleJWSWithoutDate(t, chain), testAppleTrustedRoots(t, chain)); err == nil { t.Fatal("validateAppleJWSChain() accepted a broken chain without signedDate") } } @@ -169,11 +185,13 @@ func testAppleCertificateChainAt(t *testing.T, validAt time.Time, includeAppleEx }} } leafDER := testAppleCertificate(t, leafTemplate, intermediate, &leafKey.PublicKey, intermediateKey) - return []string{ + chain := []string{ base64.StdEncoding.EncodeToString(leafDER), base64.StdEncoding.EncodeToString(intermediateDER), base64.StdEncoding.EncodeToString(rootDER), } + testAppleLeafKeys[chain[0]] = leafKey + return chain } func testAppleECDSAKey(t *testing.T) *ecdsa.PrivateKey { @@ -204,7 +222,7 @@ func testAppleJWS(t *testing.T, algorithm string, chain []string, signedAt time. if err != nil { t.Fatal(err) } - return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" + return testAppleSignedJWS(t, header, payload, chain) } func testAppleJWSWithoutDate(t *testing.T, chain []string) string { @@ -217,5 +235,36 @@ func testAppleJWSWithoutDate(t *testing.T, chain []string) string { if err != nil { t.Fatal(err) } - return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" + return testAppleSignedJWS(t, header, payload, chain) +} + +var testAppleLeafKeys = map[string]*ecdsa.PrivateKey{} + +func testAppleSignedJWS(t *testing.T, header, payload []byte, chain []string) string { + t.Helper() + headerSegment := base64.RawURLEncoding.EncodeToString(header) + payloadSegment := base64.RawURLEncoding.EncodeToString(payload) + key := testAppleLeafKeys[chain[0]] + if key == nil { + t.Fatal("missing test Apple leaf key") + } + digest := sha256.Sum256([]byte(headerSegment + "." + payloadSegment)) + r, s, err := ecdsa.Sign(rand.Reader, key, digest[:]) + if err != nil { + t.Fatal(err) + } + signature := make([]byte, 64) + r.FillBytes(signature[:32]) + s.FillBytes(signature[32:]) + return headerSegment + "." + payloadSegment + "." + base64.RawURLEncoding.EncodeToString(signature) +} + +func testAppleTrustedRoots(t *testing.T, chain []string) map[string]struct{} { + t.Helper() + rootDER, err := base64.StdEncoding.DecodeString(chain[2]) + if err != nil { + t.Fatal(err) + } + fingerprint := sha256.Sum256(rootDER) + return map[string]struct{}{hex.EncodeToString(fingerprint[:]): {}} } diff --git a/internal/integration/payment/result.go b/internal/integration/payment/result.go index 4d3c9bb..f82cfff 100644 --- a/internal/integration/payment/result.go +++ b/internal/integration/payment/result.go @@ -56,17 +56,11 @@ func first(values map[string]string, keys ...string) string { } func text(values map[string]any, key string) string { - value, _ := values[key].(string) - return value + return paymentutil.Text(values, key) } func firstAny(values map[string]any, keys ...string) string { - for _, key := range keys { - if value, ok := values[key].(string); ok && value != "" { - return value - } - } - return "" + return paymentutil.FirstText(values, keys...) } // Compatibility shims keep channel adapters focused on protocol and diff --git a/internal/integration/payment/vendor.go b/internal/integration/payment/vendor.go index 1de36d8..bf4dccc 100644 --- a/internal/integration/payment/vendor.go +++ b/internal/integration/payment/vendor.go @@ -133,6 +133,35 @@ func (a *vendorPaymentAdapter) call(ctx context.Context, endpointKey string, pay } result := &bizpayment.PaymentResult{Provider: a.provider, Status: "created", TradeNo: tradeNo, Payload: ensureJSON(body)} if endpointKey != "query_url" { + if object := jsonObject(body); object != nil { + operation := strings.TrimSuffix(endpointKey, "_url") + statusField := text(c, operation+"_status_field") + if statusField == "" { + statusField = text(c, "query_status_field") + } + state := "" + if statusField != "" { + state = stringAtPath(object, statusField) + } + if state == "" { + for _, key := range []string{"status", "trade_status", "order_status", "pay_status", "refund_status"} { + if value := stringAtPath(object, key); value != "" { + state = value + break + } + } + } + if state != "" { + switch { + case containsFold(configuredValues(c, operation+"_failure_values"), state): + result.Status = "failed" + case containsFold(configuredValues(c, operation+"_success_values"), state): + result.Status = "created" + default: + result.Status = normalizePaymentStatus(state, "created") + } + } + } return result, nil } object := jsonObject(body) diff --git a/internal/integration/payment/vendor_test.go b/internal/integration/payment/vendor_test.go index 29e74c5..5e40ff0 100644 --- a/internal/integration/payment/vendor_test.go +++ b/internal/integration/payment/vendor_test.go @@ -1,10 +1,15 @@ package payment import ( + "context" "crypto/sha256" "encoding/hex" + "net/http" + "net/http/httptest" "strings" "testing" + + bizpayment "kra/internal/biz/payment" ) func TestChinaumsCallbackAuthorizationVerification(t *testing.T) { @@ -31,3 +36,23 @@ func TestChinaumsCallbackAuthorizationVerification(t *testing.T) { t.Fatalf("mismatched Chinaums AppId error = %v", err) } } + +func TestVendorRefundMapsBusinessFailureStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"FAIL","message":"rejected"}`)) + })) + defer server.Close() + + adapter := &vendorPaymentAdapter{provider: "vendor", profile: vendorSFT} + result, err := adapter.Refund(context.Background(), &bizpayment.PaymentRefundRequest{TradeNo: "trade-1", RefundNo: "refund-1", Amount: 100, TotalAmount: 100, Currency: "CNY"}, map[string]any{ + "refund_url": server.URL, + "app_key": "secret", + }) + if err != nil { + t.Fatalf("Refund() error = %v", err) + } + if result == nil || result.Status != "failed" { + t.Fatalf("Refund() result = %#v, want failed status", result) + } +} diff --git a/internal/modules/integration/definition.go b/internal/modules/integration/definition.go index 755c6f6..887d316 100644 --- a/internal/modules/integration/definition.go +++ b/internal/modules/integration/definition.go @@ -3,10 +3,8 @@ package integration import ( - "strings" - dataintegration "kra/internal/data/integration" - "kra/internal/routecatalog" + "kra/internal/modules/surface" "kra/pkg/module" ) @@ -16,18 +14,7 @@ func Definition() module.Definition { Migrations: dataintegration.Migrations(), Surface: module.Surface{ Menus: []module.Menu{{Name: "integrationConfig", Path: "integrationConfig", ParentName: "extensions", Component: "view/systemTools/integration/config.vue", Title: "通信集成", Icon: "connection", Sort: 8}}, - APIs: integrationAPIs(), + APIs: surface.APIsForPrefix("/integration/configs", 5), }, } } - -func integrationAPIs() []module.API { - items := make([]module.API, 0, 5) - for _, descriptor := range routecatalog.Descriptors() { - if descriptor.Public || !strings.HasPrefix(descriptor.Path, "/integration/configs") || descriptor.Description == "" { - continue - } - items = append(items, module.API{Path: descriptor.Path, Method: descriptor.Method, Group: descriptor.Group, Description: descriptor.Description}) - } - return items -} diff --git a/internal/modules/payment/definition.go b/internal/modules/payment/definition.go index 825579f..cd04175 100644 --- a/internal/modules/payment/definition.go +++ b/internal/modules/payment/definition.go @@ -3,10 +3,8 @@ package payment import ( - "strings" - datapayment "kra/internal/data/payment" - "kra/internal/routecatalog" + "kra/internal/modules/surface" "kra/pkg/module" ) @@ -20,18 +18,7 @@ func Definition() module.Definition { {Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6}, {Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7}, }, - APIs: paymentAPIs(), + APIs: surface.APIsForPrefix("/payment", 10), }, } } - -func paymentAPIs() []module.API { - items := make([]module.API, 0, 10) - for _, descriptor := range routecatalog.Descriptors() { - if descriptor.Public || !strings.HasPrefix(descriptor.Path, "/payment") || descriptor.Description == "" { - continue - } - items = append(items, module.API{Path: descriptor.Path, Method: descriptor.Method, Group: descriptor.Group, Description: descriptor.Description}) - } - return items -} diff --git a/internal/modules/surface/surface.go b/internal/modules/surface/surface.go new file mode 100644 index 0000000..26329da --- /dev/null +++ b/internal/modules/surface/surface.go @@ -0,0 +1,19 @@ +package surface + +import ( + "strings" + + "kra/internal/routecatalog" + "kra/pkg/module" +) + +func APIsForPrefix(prefix string, capacity int) []module.API { + items := make([]module.API, 0, capacity) + for _, descriptor := range routecatalog.Descriptors() { + if descriptor.Public || !strings.HasPrefix(descriptor.Path, prefix) || descriptor.Description == "" { + continue + } + items = append(items, module.API{Path: descriptor.Path, Method: descriptor.Method, Group: descriptor.Group, Description: descriptor.Description}) + } + return items +} diff --git a/internal/server/handler/announcement.go b/internal/server/handler/announcement.go index 0eb0b24..cec19f0 100644 --- a/internal/server/handler/announcement.go +++ b/internal/server/handler/announcement.go @@ -15,12 +15,17 @@ func NewAnnouncement(service *service.AnnouncementService) *Announcement { } func (h *Announcement) Create(c *gin.Context) { + claims := Claims(c) + if claims == nil || claims.ID == 0 { + NoAuth(c, "未登录或非法访问") + return + } var req dto.AnnouncementRequest if err := c.ShouldBindJSON(&req); err != nil { Fail(c, err.Error()) return } - if err := h.service.Create(c.Request.Context(), &req); err != nil { + if err := h.service.Create(c.Request.Context(), claims.ID, &req); err != nil { Fail(c, "创建失败") return } @@ -44,12 +49,17 @@ func (h *Announcement) DeleteByIDs(c *gin.Context) { } func (h *Announcement) Update(c *gin.Context) { + claims := Claims(c) + if claims == nil || claims.ID == 0 { + NoAuth(c, "未登录或非法访问") + return + } var req dto.AnnouncementRequest if err := c.ShouldBindJSON(&req); err != nil { Fail(c, err.Error()) return } - if err := h.service.Update(c.Request.Context(), &req); err != nil { + if err := h.service.Update(c.Request.Context(), claims.ID, &req); err != nil { Fail(c, "更新失败") return } diff --git a/internal/server/handler/media.go b/internal/server/handler/media.go index 51af87d..6e3add9 100644 --- a/internal/server/handler/media.go +++ b/internal/server/handler/media.go @@ -139,12 +139,17 @@ func (h *Media) Rename(c *gin.Context) { Write(c, CodeSuccess, gin.H{}, "编辑成功") } func (h *Media) ImportURLs(c *gin.Context) { + claims := Claims(c) + if claims == nil || claims.ID == 0 { + NoAuth(c, "未登录或非法访问") + return + } var req []dto.ImportMediaRequest if err := c.ShouldBindJSON(&req); err != nil { Fail(c, err.Error()) return } - if err := h.service.ImportURLRequests(c.Request.Context(), req); err != nil { + if err := h.service.ImportURLRequests(c.Request.Context(), claims.ID, req); err != nil { Fail(c, "导入URL失败") return } diff --git a/internal/server/handler/menu.go b/internal/server/handler/menu.go index e0014b2..0f68fcc 100644 --- a/internal/server/handler/menu.go +++ b/internal/server/handler/menu.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "kra/internal/service" "kra/internal/service/dto" @@ -11,6 +12,27 @@ type Menu struct{ service *service.MenuService } func NewMenu(service *service.MenuService) *Menu { return &Menu{service: service} } +func validateMenuRequest(req *dto.MenuRequest) error { + if req == nil { + return errors.New("菜单参数不能为空") + } + checks := []struct { + value string + field string + }{ + {req.Path, "Path"}, {req.Name, "Name"}, {req.Component, "Component"}, {req.Meta.Title, "Title"}, + } + for _, check := range checks { + if check.value == "" { + return errors.New(check.field + "值不能为空") + } + } + if req.Sort < 0 { + return errors.New("Sort长度或值不在合法范围,ge=0") + } + return nil +} + func (h *Menu) List(c *gin.Context) { items, err := h.service.Tree(c.Request.Context()) if err != nil { @@ -35,24 +57,8 @@ func (h *Menu) Create(c *gin.Context) { Fail(c, err.Error()) return } - if req.Path == "" { - Fail(c, "Path值不能为空") - return - } - if req.Name == "" { - Fail(c, "Name值不能为空") - return - } - if req.Component == "" { - Fail(c, "Component值不能为空") - return - } - if req.Sort < 0 { - Fail(c, "Sort长度或值不在合法范围,ge=0") - return - } - if req.Meta.Title == "" { - Fail(c, "Title值不能为空") + if err := validateMenuRequest(&req); err != nil { + Fail(c, err.Error()) return } if err := h.service.Create(c.Request.Context(), &req); err != nil { @@ -68,24 +74,8 @@ func (h *Menu) Update(c *gin.Context) { Fail(c, err.Error()) return } - if req.Path == "" { - Fail(c, "Path值不能为空") - return - } - if req.Name == "" { - Fail(c, "Name值不能为空") - return - } - if req.Component == "" { - Fail(c, "Component值不能为空") - return - } - if req.Sort < 0 { - Fail(c, "Sort长度或值不在合法范围,ge=0") - return - } - if req.Meta.Title == "" { - Fail(c, "Title值不能为空") + if err := validateMenuRequest(&req); err != nil { + Fail(c, err.Error()) return } if err := h.service.Update(c.Request.Context(), &req); err != nil { diff --git a/internal/server/handler/payment.go b/internal/server/handler/payment.go index 9dade84..82f4888 100644 --- a/internal/server/handler/payment.go +++ b/internal/server/handler/payment.go @@ -165,7 +165,9 @@ func (h *Payment) Callback(c *gin.Context) { } ack, err := h.service.Callback(c.Request.Context(), &dto.PaymentCallbackRequest{Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query}) writePaymentCallbackAck(c, ack) - _ = err + if err != nil { + _ = c.Error(err) + } } func writePaymentCallbackAck(c *gin.Context, ack dto.PaymentCallbackAck) { diff --git a/internal/server/middleware/audit.go b/internal/server/middleware/audit.go index 275df8d..502632b 100644 --- a/internal/server/middleware/audit.go +++ b/internal/server/middleware/audit.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "net/url" - "strconv" "strings" "time" @@ -75,8 +74,6 @@ func OperationAudit(runtime *config.Store, recorder *service.AuditRecorder) gin. userID := uint(0) if claims := Claims(c); claims != nil { userID = claims.ID - } else if value, err := strconv.ParseUint(c.GetHeader("x-user-id"), 10, 64); err == nil { - userID = uint(value) } requestID, _ := c.Get("request_id") status := c.Writer.Status() diff --git a/internal/server/middleware/cors.go b/internal/server/middleware/cors.go index d360d7f..64aa740 100644 --- a/internal/server/middleware/cors.go +++ b/internal/server/middleware/cors.go @@ -10,7 +10,7 @@ import ( ) const ( - defaultCORSHeaders = "Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token,X-User-Id" + defaultCORSHeaders = "Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token" defaultCORSMethods = "POST, GET, OPTIONS,DELETE,PUT" defaultCORSExpose = "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type, New-Token, New-Expires-At" ) diff --git a/internal/server/middleware/cors_identity_test.go b/internal/server/middleware/cors_identity_test.go new file mode 100644 index 0000000..8081d23 --- /dev/null +++ b/internal/server/middleware/cors_identity_test.go @@ -0,0 +1,12 @@ +package middleware + +import ( + "strings" + "testing" +) + +func TestDefaultCORSHeadersDoNotAllowUserIdentityHeader(t *testing.T) { + if strings.Contains(strings.ToLower(defaultCORSHeaders), "x-user-id") { + t.Fatal("default CORS headers still allow the forgeable x-user-id header") + } +} diff --git a/internal/server/middleware/error_audit.go b/internal/server/middleware/error_audit.go index 2ff43ee..75b64e5 100644 --- a/internal/server/middleware/error_audit.go +++ b/internal/server/middleware/error_audit.go @@ -68,7 +68,7 @@ func failureLogModule(path string) string { } func expectedClientFailure(message string) bool { - for _, value := range []string{"参数错误", "请输入用户名和密码", "验证码错误", "用户名不存在或者密码错误", "用户被禁止登录", "账号已锁定", "权限不足", "密码已过期", "未登录", "token", "令牌失效"} { + for _, value := range []string{"参数错误", "请输入用户名和密码", "验证码错误", "用户名不存在或者密码错误", "用户被禁止登录", "账号已锁定", "请求太过频繁", "权限不足", "密码已过期", "未登录", "token", "令牌失效"} { if strings.Contains(message, value) { return true } diff --git a/internal/service/dto/announcement.go b/internal/service/dto/announcement.go index 9172479..b963a64 100644 --- a/internal/service/dto/announcement.go +++ b/internal/service/dto/announcement.go @@ -9,7 +9,6 @@ type AnnouncementRequest struct { ID uint `json:"ID"` Title string `json:"title"` Content string `json:"content"` - UserID *int `json:"userID"` Attachments json.RawMessage `json:"attachments"` } diff --git a/internal/service/dto/audit.go b/internal/service/dto/audit.go index 224c4fb..7af159f 100644 --- a/internal/service/dto/audit.go +++ b/internal/service/dto/audit.go @@ -48,7 +48,7 @@ type LoginLogSearchRequest struct { Page int `form:"page"` PageSize int `form:"pageSize"` Username string `form:"username"` - Status bool `form:"status"` + Status *bool `form:"status"` } type AuditIDQuery struct { @@ -73,16 +73,14 @@ type ErrorRecordRequest struct { // fields preserve the distinction between an omitted value and an explicitly // supplied empty string, which is required by the update contract. type ErrorRecordMutationRequest struct { - ID uint `json:"ID"` - CreatedAt time.Time `json:"CreatedAt"` - UpdatedAt time.Time `json:"UpdatedAt"` - Form *string `json:"form" binding:"required"` - Info *string `json:"info"` - Level string `json:"level"` - RequestID string `json:"request_id"` - TraceID string `json:"trace_id"` - Solution *string `json:"solution"` - Status string `json:"status"` + ID uint `json:"ID"` + Form *string `json:"form" binding:"required"` + Info *string `json:"info"` + Level string `json:"level"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` + Solution *string `json:"solution"` + Status string `json:"status"` } type OperationRecordResponse struct { diff --git a/internal/service/dto/export.go b/internal/service/dto/export.go index 787773c..db9fc72 100644 --- a/internal/service/dto/export.go +++ b/internal/service/dto/export.go @@ -3,22 +3,18 @@ package dto import "time" type ExportConditionRequest struct { - ID uint `json:"ID"` - CreatedAt time.Time `json:"CreatedAt"` - UpdatedAt time.Time `json:"UpdatedAt"` - TemplateID string `json:"templateID"` - From string `json:"from"` - Column string `json:"column"` - Operator string `json:"operator"` + ID uint `json:"ID"` + TemplateID string `json:"templateID"` + From string `json:"from"` + Column string `json:"column"` + Operator string `json:"operator"` } type ExportJoinRequest struct { - ID uint `json:"ID"` - CreatedAt time.Time `json:"CreatedAt"` - UpdatedAt time.Time `json:"UpdatedAt"` - TemplateID string `json:"templateID"` - Join string `json:"joins"` - Table string `json:"table"` - On string `json:"on"` + ID uint `json:"ID"` + TemplateID string `json:"templateID"` + Join string `json:"joins"` + Table string `json:"table"` + On string `json:"on"` } type ExportTemplateRequest struct { ID uint `json:"ID"` diff --git a/internal/service/dto/media.go b/internal/service/dto/media.go index d061b32..a8fe840 100644 --- a/internal/service/dto/media.go +++ b/internal/service/dto/media.go @@ -77,7 +77,6 @@ type ImportMediaRequest struct { Size int64 `json:"size"` Mime string `json:"mime"` MD5 string `json:"md5"` - UserID uint `json:"userId"` } type StorageListRequest struct { Prefix string `json:"prefix"` diff --git a/internal/service/dto/menu.go b/internal/service/dto/menu.go index d9d7c31..b02aa64 100644 --- a/internal/service/dto/menu.go +++ b/internal/service/dto/menu.go @@ -13,27 +13,21 @@ type MenuMetaRequest struct { } type MenuButtonRequest struct { - CreatedAt time.Time `json:"CreatedAt"` - UpdatedAt time.Time `json:"UpdatedAt"` - ID uint `json:"ID"` - Name string `json:"name"` - Description string `json:"desc"` - SysBaseMenuID uint `json:"sysBaseMenuID"` + ID uint `json:"ID"` + Name string `json:"name"` + Description string `json:"desc"` + SysBaseMenuID uint `json:"sysBaseMenuID"` } type MenuParameterRequest struct { - CreatedAt time.Time `json:"CreatedAt"` - UpdatedAt time.Time `json:"UpdatedAt"` - ID uint `json:"ID"` - SysBaseMenuID uint `json:"SysBaseMenuID"` - Type string `json:"type"` - Key string `json:"key"` - Value string `json:"value"` + ID uint `json:"ID"` + SysBaseMenuID uint `json:"SysBaseMenuID"` + Type string `json:"type"` + Key string `json:"key"` + Value string `json:"value"` } type MenuRequest struct { - CreatedAt time.Time `json:"CreatedAt"` - UpdatedAt time.Time `json:"UpdatedAt"` ID uint `json:"ID"` ParentID uint `json:"parentId"` Path string `json:"path"` diff --git a/internal/service/dto/settings.go b/internal/service/dto/settings.go index 6ac96b4..90c65ff 100644 --- a/internal/service/dto/settings.go +++ b/internal/service/dto/settings.go @@ -40,13 +40,11 @@ type DictionaryDetailListRequest struct { Level *int } type SystemParameterRequest struct { - ID uint `json:"ID" form:"ID"` - CreatedAt time.Time `json:"CreatedAt" form:"CreatedAt"` - UpdatedAt time.Time `json:"UpdatedAt" form:"UpdatedAt"` - Name string `json:"name" form:"name" binding:"required"` - Key string `json:"key" form:"key" binding:"required"` - Value string `json:"value" form:"value" binding:"required"` - Description string `json:"desc" form:"desc"` + ID uint `json:"ID" form:"ID"` + Name string `json:"name" form:"name" binding:"required"` + Key string `json:"key" form:"key" binding:"required"` + Value string `json:"value" form:"value" binding:"required"` + Description string `json:"desc" form:"desc"` } type SystemParameterSearchRequest struct { diff --git a/internal/service/system/announcement.go b/internal/service/system/announcement.go index 7fc3a79..9b0b53c 100644 --- a/internal/service/system/announcement.go +++ b/internal/service/system/announcement.go @@ -19,16 +19,17 @@ func announcementDTO(item *system.Announcement) *dto.AnnouncementResponse { return &dto.AnnouncementResponse{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, DeletedAt: nil, Title: item.Title, Content: item.Content, UserID: item.UserID, Attachments: json.RawMessage(item.Attachments)} } -func announcementDO(in *dto.AnnouncementRequest) *system.Announcement { - return &system.Announcement{ID: in.ID, Title: in.Title, Content: in.Content, UserID: in.UserID, Attachments: in.Attachments} +func announcementDO(in *dto.AnnouncementRequest, userID uint) *system.Announcement { + authorID := int(userID) + return &system.Announcement{ID: in.ID, Title: in.Title, Content: in.Content, UserID: &authorID, Attachments: in.Attachments} } -func (s *AnnouncementService) Create(ctx context.Context, in *dto.AnnouncementRequest) error { - return s.uc.Create(ctx, announcementDO(in)) +func (s *AnnouncementService) Create(ctx context.Context, userID uint, in *dto.AnnouncementRequest) error { + return s.uc.Create(ctx, announcementDO(in, userID)) } -func (s *AnnouncementService) Update(ctx context.Context, in *dto.AnnouncementRequest) error { - return s.uc.Update(ctx, announcementDO(in)) +func (s *AnnouncementService) Update(ctx context.Context, userID uint, in *dto.AnnouncementRequest) error { + return s.uc.Update(ctx, announcementDO(in, userID)) } func (s *AnnouncementService) Delete(ctx context.Context, id string) error { diff --git a/internal/service/system/announcement_identity_test.go b/internal/service/system/announcement_identity_test.go new file mode 100644 index 0000000..6c28605 --- /dev/null +++ b/internal/service/system/announcement_identity_test.go @@ -0,0 +1,14 @@ +package system + +import ( + "testing" + + "kra/internal/service/dto" +) + +func TestAnnouncementDomainUsesAuthenticatedUser(t *testing.T) { + value := announcementDO(&dto.AnnouncementRequest{Title: "notice"}, 42) + if value.UserID == nil || *value.UserID != 42 { + t.Fatalf("announcement user = %#v, want 42", value.UserID) + } +} diff --git a/internal/service/system/audit.go b/internal/service/system/audit.go index c2f77fb..943faff 100644 --- a/internal/service/system/audit.go +++ b/internal/service/system/audit.go @@ -56,8 +56,8 @@ func (s *AuditService) DeleteOperations(ctx context.Context, ids []int) error { func (s *AuditRecorder) RecordLogin(ctx context.Context, v *system.LoginLog) error { return s.uc.RecordLogin(ctx, v) } -func (s *AuditService) LoginsFilter(ctx context.Context, page, size int, username string, status bool) ([]*dto.LoginLogResponse, int64, error) { - return s.Logins(ctx, page, size, &system.LoginLog{Username: username, Status: status, FilterByStatus: status}) +func (s *AuditService) LoginsFilter(ctx context.Context, page, size int, username string, status *bool) ([]*dto.LoginLogResponse, int64, error) { + return s.Logins(ctx, page, size, &system.LoginLogFilter{Username: username, Status: status}) } func loginDTO(v *system.LoginLog) *dto.LoginLogResponse { var user any = map[string]any{} @@ -66,7 +66,7 @@ func loginDTO(v *system.LoginLog) *dto.LoginLogResponse { } return &dto.LoginLogResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID, User: user} } -func (s *AuditService) Logins(ctx context.Context, page, size int, q *system.LoginLog) ([]*dto.LoginLogResponse, int64, error) { +func (s *AuditService) Logins(ctx context.Context, page, size int, q *system.LoginLogFilter) ([]*dto.LoginLogResponse, int64, error) { items, total, err := s.uc.ListLogins(ctx, page, size, q) if err != nil { return nil, 0, err diff --git a/internal/service/system/audit_error.go b/internal/service/system/audit_error.go index 409ab5b..361bc33 100644 --- a/internal/service/system/audit_error.go +++ b/internal/service/system/audit_error.go @@ -24,13 +24,15 @@ func recordedErrorDomain(value *dto.ErrorRecordRequest) *system.ErrorRecord { return result } func mutatedErrorDomain(value *dto.ErrorRecordMutationRequest) *system.ErrorRecord { - return &system.ErrorRecord{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, Form: value.Form, Info: value.Info, Level: value.Level, RequestID: value.RequestID, TraceID: value.TraceID, Solution: value.Solution, Status: value.Status} + return &system.ErrorRecord{ID: value.ID, Form: value.Form, Info: value.Info, Level: value.Level, RequestID: value.RequestID, TraceID: value.TraceID, Solution: value.Solution, Status: value.Status} } func (s *AuditRecorder) CreateErrorRequest(ctx context.Context, req *dto.ErrorRecordRequest) error { return s.CreateError(ctx, recordedErrorDomain(req)) } func (s *AuditRecorder) CreateErrorMutationRequest(ctx context.Context, req *dto.ErrorRecordMutationRequest) error { - return s.CreateError(ctx, mutatedErrorDomain(req)) + value := mutatedErrorDomain(req) + value.ID = 0 + return s.CreateError(ctx, value) } func (s *AuditService) UpdateErrorRequest(ctx context.Context, req *dto.ErrorRecordMutationRequest) error { return s.UpdateError(ctx, mutatedErrorDomain(req)) diff --git a/internal/service/system/export.go b/internal/service/system/export.go index 30dfeb2..1ac54c9 100644 --- a/internal/service/system/export.go +++ b/internal/service/system/export.go @@ -70,10 +70,10 @@ func (s *ExportService) ConsumeToken(ctx context.Context, token string, expectBl func exportTemplateDomain(value *dto.ExportTemplateRequest) *system.ExportTemplate { out := &system.ExportTemplate{ID: value.ID, DBName: value.DBName, Name: value.Name, TableName: value.TableName, TemplateID: value.TemplateID, TemplateInfo: value.TemplateInfo, SQL: value.SQL, ImportSQL: value.ImportSQL, Limit: value.Limit, Order: value.Order} for _, item := range value.Conditions { - out.Conditions = append(out.Conditions, system.ExportCondition{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, TemplateID: item.TemplateID, From: item.From, Column: item.Column, Operator: item.Operator}) + out.Conditions = append(out.Conditions, system.ExportCondition{ID: item.ID, TemplateID: item.TemplateID, From: item.From, Column: item.Column, Operator: item.Operator}) } for _, item := range value.Joins { - out.Joins = append(out.Joins, system.ExportJoin{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, TemplateID: item.TemplateID, Join: item.Join, Table: item.Table, On: item.On}) + out.Joins = append(out.Joins, system.ExportJoin{ID: item.ID, TemplateID: item.TemplateID, Join: item.Join, Table: item.Table, On: item.On}) } return out } diff --git a/internal/service/system/export_excel.go b/internal/service/system/export_excel.go index c8a70fa..88ca4da 100644 --- a/internal/service/system/export_excel.go +++ b/internal/service/system/export_excel.go @@ -4,9 +4,7 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" - "strconv" "strings" "time" @@ -69,15 +67,7 @@ func (s *ExportService) Export(ctx context.Context, tid string, params map[strin lookup = parts[1] } } - value := fmt.Sprintf("%v", row[lookup]) - if timestamp, ok := row[lookup].(time.Time); ok { - value = timestamp.Format("2006-01-02 15:04:05") - } - if number, parseErr := strconv.ParseFloat(value, 64); parseErr == nil { - _ = file.SetCellValue(sheet, cell, number) - } else { - _ = file.SetCellValue(sheet, cell, value) - } + _ = file.SetCellValue(sheet, cell, exportCellValue(row[lookup])) } } buffer, err := file.WriteToBuffer() @@ -87,6 +77,19 @@ func (s *ExportService) Export(ctx context.Context, tid string, params map[strin } return buffer.Bytes(), template.Name + ".xlsx", nil } + +func exportCellValue(value any) any { + switch typed := value.(type) { + case time.Time: + return typed.Format("2006-01-02 15:04:05") + case []byte: + return string(typed) + case string: + return typed + default: + return value + } +} func (s *ExportService) ExportBlankTemplate(ctx context.Context, tid string) ([]byte, string, error) { template, err := s.uc.FindExportTemplate(ctx, 0, tid) if err != nil { diff --git a/internal/service/system/export_excel_value_test.go b/internal/service/system/export_excel_value_test.go new file mode 100644 index 0000000..2b5d6dc --- /dev/null +++ b/internal/service/system/export_excel_value_test.go @@ -0,0 +1,10 @@ +package system + +import "testing" + +func TestExportCellValuePreservesNumericText(t *testing.T) { + value, ok := exportCellValue("007").(string) + if !ok || value != "007" { + t.Fatalf("exportCellValue() = %#v, want string 007", exportCellValue("007")) + } +} diff --git a/internal/service/system/media.go b/internal/service/system/media.go index c507a0e..ceccbfe 100644 --- a/internal/service/system/media.go +++ b/internal/service/system/media.go @@ -72,10 +72,10 @@ func (s *MediaService) Rename(ctx context.Context, id uint, name string) error { func (s *MediaService) ImportURLs(ctx context.Context, items []*system.MediaFile) error { return s.uc.CreateMediaBatch(ctx, items) } -func (s *MediaService) ImportURLRequests(ctx context.Context, values []dto.ImportMediaRequest) error { +func (s *MediaService) ImportURLRequests(ctx context.Context, userID uint, values []dto.ImportMediaRequest) error { items := make([]*system.MediaFile, 0, len(values)) for _, value := range values { - items = append(items, &system.MediaFile{Name: value.Name, CategoryID: value.ClassID, URL: value.URL, Tag: value.Tag, Key: value.Key, Size: value.Size, Mime: value.Mime, MD5: value.MD5, UserID: value.UserID}) + items = append(items, &system.MediaFile{Name: value.Name, CategoryID: value.ClassID, URL: value.URL, Tag: value.Tag, Key: value.Key, Size: value.Size, Mime: value.Mime, MD5: value.MD5, UserID: userID}) } return s.ImportURLs(ctx, items) } diff --git a/internal/service/system/media_import_test.go b/internal/service/system/media_import_test.go new file mode 100644 index 0000000..be85b03 --- /dev/null +++ b/internal/service/system/media_import_test.go @@ -0,0 +1,30 @@ +package system + +import ( + "context" + "testing" + + bizsystem "kra/internal/biz/system" + "kra/internal/service/dto" +) + +type mediaImportRepo struct { + bizsystem.MediaRepo + items []*bizsystem.MediaFile +} + +func (r *mediaImportRepo) CreateMediaBatch(_ context.Context, items []*bizsystem.MediaFile) error { + r.items = items + return nil +} + +func TestImportURLRequestsUsesAuthenticatedUser(t *testing.T) { + repo := &mediaImportRepo{} + service := NewMediaService(bizsystem.NewMediaUsecase(repo, nil, nil), nil) + if err := service.ImportURLRequests(context.Background(), 42, []dto.ImportMediaRequest{{Name: "image", URL: "https://example.test/image.png"}}); err != nil { + t.Fatal(err) + } + if len(repo.items) != 1 || repo.items[0].UserID != 42 { + t.Fatalf("imported media = %#v, want authenticated user 42", repo.items) + } +} diff --git a/internal/service/system/menu.go b/internal/service/system/menu.go index 3e3106d..5ca353c 100644 --- a/internal/service/system/menu.go +++ b/internal/service/system/menu.go @@ -75,14 +75,14 @@ func (s *MenuService) SetRoles(ctx context.Context, req *dto.SetMenuRolesRequest } func menuDomain(v *dto.MenuRequest) *system.Menu { - menu := &system.Menu{CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType} + menu := &system.Menu{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType} menu.Buttons = make([]*system.MenuButton, 0, len(v.MenuButtons)) for _, button := range v.MenuButtons { - menu.Buttons = append(menu.Buttons, &system.MenuButton{CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID}) + menu.Buttons = append(menu.Buttons, &system.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID}) } menu.Parameters = make([]*system.MenuParameter, 0, len(v.Parameters)) for _, parameter := range v.Parameters { - menu.Parameters = append(menu.Parameters, &system.MenuParameter{CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + menu.Parameters = append(menu.Parameters, &system.MenuParameter{ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) } return menu } diff --git a/internal/service/system/menu_test.go b/internal/service/system/menu_test.go index db72e45..d88e361 100644 --- a/internal/service/system/menu_test.go +++ b/internal/service/system/menu_test.go @@ -4,32 +4,27 @@ import ( "encoding/json" "kra/internal/biz/system" "testing" - "time" "kra/internal/service/dto" ) func TestMenuDomainPreservesRelationIdentity(t *testing.T) { - createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC) - updatedAt := time.Date(2025, time.April, 5, 6, 7, 8, 0, time.UTC) menu := menuDomain(&dto.MenuRequest{ - CreatedAt: createdAt, - UpdatedAt: updatedAt, - ID: 10, + ID: 10, MenuButtons: []dto.MenuButtonRequest{{ - CreatedAt: createdAt, UpdatedAt: updatedAt, ID: 31, Name: "edit", Description: "编辑", + ID: 31, Name: "edit", Description: "编辑", }}, Parameters: []dto.MenuParameterRequest{{ - CreatedAt: createdAt, UpdatedAt: updatedAt, ID: 21, Type: "query", Key: "id", Value: "1", + ID: 21, Type: "query", Key: "id", Value: "1", }}, }) - if !menu.CreatedAt.Equal(createdAt) || !menu.UpdatedAt.Equal(updatedAt) { - t.Fatalf("menu timestamps = (%v, %v), want (%v, %v)", menu.CreatedAt, menu.UpdatedAt, createdAt, updatedAt) + if !menu.CreatedAt.IsZero() || !menu.UpdatedAt.IsZero() { + t.Fatalf("menu timestamps = (%v, %v), want zero before persistence", menu.CreatedAt, menu.UpdatedAt) } - if len(menu.Buttons) != 1 || menu.Buttons[0].ID != 31 || menu.Buttons[0].MenuID != 10 || !menu.Buttons[0].CreatedAt.Equal(createdAt) || !menu.Buttons[0].UpdatedAt.Equal(updatedAt) { + if len(menu.Buttons) != 1 || menu.Buttons[0].ID != 31 || menu.Buttons[0].MenuID != 10 || !menu.Buttons[0].CreatedAt.IsZero() || !menu.Buttons[0].UpdatedAt.IsZero() { t.Fatalf("button conversion = %+v", menu.Buttons) } - if len(menu.Parameters) != 1 || menu.Parameters[0].ID != 21 || menu.Parameters[0].MenuID != 10 || !menu.Parameters[0].CreatedAt.Equal(createdAt) || !menu.Parameters[0].UpdatedAt.Equal(updatedAt) { + if len(menu.Parameters) != 1 || menu.Parameters[0].ID != 21 || menu.Parameters[0].MenuID != 10 || !menu.Parameters[0].CreatedAt.IsZero() || !menu.Parameters[0].UpdatedAt.IsZero() { t.Fatalf("parameter conversion = %+v", menu.Parameters) } } diff --git a/internal/service/system/parameter.go b/internal/service/system/parameter.go index b1f9233..5ccd659 100644 --- a/internal/service/system/parameter.go +++ b/internal/service/system/parameter.go @@ -15,7 +15,7 @@ func NewParameterService(uc *system.ParameterUsecase) *ParameterService { } func parameterDomain(value *dto.SystemParameterRequest) *system.SystemParameter { - return &system.SystemParameter{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, Name: value.Name, Key: value.Key, Value: value.Value, Desc: value.Description} + return &system.SystemParameter{ID: value.ID, Name: value.Name, Key: value.Key, Value: value.Value, Desc: value.Description} } func (s *ParameterService) CreateParameterRequest(ctx context.Context, req *dto.SystemParameterRequest) error { return s.CreateParameter(ctx, parameterDomain(req)) diff --git a/internal/service/system/security.go b/internal/service/system/security.go index 861ffa6..ee25176 100644 --- a/internal/service/system/security.go +++ b/internal/service/system/security.go @@ -55,6 +55,13 @@ func (s *SecurityService) ValidatePassword(ctx context.Context, password string) if err != nil { return err } + return s.ValidatePasswordWithConfig(cfg, password) +} + +func (s *SecurityService) ValidatePasswordWithConfig(cfg *system.SecurityConfig, password string) error { + if cfg == nil { + return errors.New("安全配置不可用") + } if err := s.uc.ValidatePassword(cfg, password); err != nil { return &PasswordPolicyError{Err: err} } diff --git a/internal/service/system/user.go b/internal/service/system/user.go index e8f007a..b2b45a2 100644 --- a/internal/service/system/user.go +++ b/internal/service/system/user.go @@ -29,10 +29,13 @@ func (s *UserService) ListUsersRequest(ctx context.Context, value *dto.UserListR } func (s *UserService) CreateUserRequest(ctx context.Context, value *dto.UserRequest) (*dto.UserResponse, error) { - if err := s.settings.ValidatePassword(ctx, value.Password); err != nil { + security, err := s.settings.CurrentSecurity(ctx) + if err != nil { + return nil, err + } + if err := s.settings.ValidatePasswordWithConfig(security, value.Password); err != nil { return nil, err } - security, _ := s.settings.CurrentSecurity(ctx) mustChange := security != nil && security.ForceNewUserChangePassword user, err := s.uc.CreateUser(ctx, &system.User{Username: value.Username, Password: value.Password, NickName: value.NickName, HeaderImg: value.HeaderImg, AuthorityID: value.AuthorityID, Phone: value.Phone, Email: value.Email, Enable: value.Enable, MustChangePassword: mustChange}, value.AuthorityIDs) if err != nil { diff --git a/internal/service/system/version.go b/internal/service/system/version.go index a8bb461..da09d8c 100644 --- a/internal/service/system/version.go +++ b/internal/service/system/version.go @@ -256,12 +256,17 @@ func (s *VersionService) ImportRequest(ctx context.Context, req *dto.ImportVersi if err := s.Import(ctx, bundle); err != nil { return err } - raw, _ := json.Marshal(req) - _ = s.uc.CreateVersion(ctx, &system.Version{ + raw, err := json.Marshal(req) + if err != nil { + return &system.VersionStageError{Stage: system.VersionStageJSON, Err: err} + } + if err = s.uc.CreateVersion(ctx, &system.Version{ Name: versionPointer(req.Version.Name), Code: versionPointer(req.Version.Code + "_imported_" + time.Now().Format("20060102150405")), Description: versionPointer("导入版本: " + req.Version.Description), Data: versionPointer(string(raw)), - }) + }); err != nil { + return &system.VersionStageError{Stage: system.VersionStageSave, Err: err} + } return nil } diff --git a/internal/service/system/version_import_test.go b/internal/service/system/version_import_test.go new file mode 100644 index 0000000..01b1b90 --- /dev/null +++ b/internal/service/system/version_import_test.go @@ -0,0 +1,36 @@ +package system + +import ( + "context" + "errors" + "testing" + + bizsystem "kra/internal/biz/system" + "kra/internal/service/dto" +) + +type versionImportRepo struct { + bizsystem.VersionRepo + saveErr error +} + +func (r *versionImportRepo) ImportVersionBundle(context.Context, *bizsystem.VersionBundle) error { + return nil +} + +func (r *versionImportRepo) CreateVersion(context.Context, *bizsystem.Version) error { + return r.saveErr +} + +func TestImportRequestReturnsVersionAuditSaveFailure(t *testing.T) { + repo := &versionImportRepo{saveErr: errors.New("database unavailable")} + service := NewVersionService(bizsystem.NewVersionUsecase(repo)) + request := &dto.ImportVersionRequest{} + request.Version.Name = "release" + request.Version.Code = "v1" + err := service.ImportRequest(context.Background(), request) + var stageErr *bizsystem.VersionStageError + if !errors.As(err, &stageErr) || stageErr.Stage != bizsystem.VersionStageSave { + t.Fatalf("ImportRequest() error = %v, want save stage error", err) + } +} diff --git a/internal/worker/task_runtime_test.go b/internal/worker/task_runtime_test.go index c152c7e..fff3191 100644 --- a/internal/worker/task_runtime_test.go +++ b/internal/worker/task_runtime_test.go @@ -75,7 +75,8 @@ func TestReloadPreservesExistingScheduleOnPreparationFailure(t *testing.T) { repo := &workerTaskRepo{} scheduler := newTestTaskScheduler(repo) old := &taskbiz.TimedTask{ID: 1, Name: "old", Spec: "0 0 * * *", Enabled: true} - if err := scheduler.Schedule(old); err != nil { + repo.items = []*taskbiz.TimedTask{old} + if err := scheduler.ScheduleID(context.Background(), old.ID); err != nil { t.Fatalf("schedule old task: %v", err) } @@ -116,16 +117,17 @@ func assertOnlyScheduledTask(t *testing.T, scheduler *TaskScheduler, id uint) { } } -func TestConcurrentScheduleKeepsSingleEntry(t *testing.T) { - scheduler := newTestTaskScheduler(&workerTaskRepo{}) +func TestConcurrentScheduleIDKeepsSingleEntry(t *testing.T) { + repo := &workerTaskRepo{items: []*taskbiz.TimedTask{{ID: 7, Name: "same", Spec: "0 0 * * *", Enabled: true}}} + scheduler := newTestTaskScheduler(repo) const workers = 32 var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() - if err := scheduler.Schedule(&taskbiz.TimedTask{ID: 7, Name: "same", Spec: "0 0 * * *", Enabled: true}); err != nil { - t.Errorf("Schedule() error = %v", err) + if err := scheduler.ScheduleID(context.Background(), 7); err != nil { + t.Errorf("ScheduleID() error = %v", err) } }() } diff --git a/internal/worker/task_scheduler.go b/internal/worker/task_scheduler.go index 92d17af..31d467b 100644 --- a/internal/worker/task_scheduler.go +++ b/internal/worker/task_scheduler.go @@ -325,34 +325,6 @@ func (s *TaskScheduler) run(ctx context.Context, task *taskbiz.TimedTask, trigge } } -func (s *TaskScheduler) Schedule(task *taskbiz.TimedTask) error { - if task == nil { - return errors.New("定时任务不能为空") - } - var schedule cron.Schedule - var err error - if task.Enabled { - schedule, err = parseTaskSchedule(task) - if err != nil { - return err - } - } - s.mu.Lock() - defer s.mu.Unlock() - s.runMu.Lock() - stopping := s.stopping - s.runMu.Unlock() - if stopping { - return errors.New("任务调度器正在停止") - } - s.removeLocked(task.ID) - if !task.Enabled { - return nil - } - s.scheduleLocked(cloneTimedTask(task), schedule) - return nil -} - func parseTaskSchedule(task *taskbiz.TimedTask) (cron.Schedule, error) { if task.WithSeconds { return cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor).Parse(task.Spec) diff --git a/pkg/paymentkit/signing.go b/pkg/paymentkit/signing.go index 7ba4b15..6c1d012 100644 --- a/pkg/paymentkit/signing.go +++ b/pkg/paymentkit/signing.go @@ -11,26 +11,6 @@ import ( "strings" ) -func WechatV2Sign(values map[string]string, key, signType string) string { - keys := make([]string, 0, len(values)) - for k, v := range values { - if k != "sign" && v != "" { - keys = append(keys, k) - } - } - sort.Strings(keys) - parts := make([]string, 0, len(keys)) - for _, k := range keys { - parts = append(parts, k+"="+values[k]) - } - raw := strings.Join(parts, "&") + "&key=" + key - if strings.EqualFold(signType, "HMAC-SHA256") { - return HMACSHA256Hex([]byte(raw), key, true) - } - sum := md5.Sum([]byte(raw)) - return strings.ToUpper(hex.EncodeToString(sum[:])) -} - func MD5Canonical(values map[string]any, secret string) string { keys := make([]string, 0, len(values)) for key, value := range values { diff --git a/pkg/paymentkit/signing_test.go b/pkg/paymentkit/signing_test.go deleted file mode 100644 index a85e011..0000000 --- a/pkg/paymentkit/signing_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package paymentutil - -import "testing" - -func TestWechatV2SignIsStableAndExcludesSign(t *testing.T) { - values := map[string]string{"b": "2", "a": "1", "sign": "old"} - first := WechatV2Sign(values, "secret", "MD5") - values["sign"] = "different" - if second := WechatV2Sign(values, "secret", "MD5"); first != second { - t.Fatalf("signature changed when sign field changed: %q != %q", first, second) - } -} diff --git a/pkg/paymentkit/status.go b/pkg/paymentkit/status.go index 12b6030..1f77fea 100644 --- a/pkg/paymentkit/status.go +++ b/pkg/paymentkit/status.go @@ -7,11 +7,11 @@ import ( func NormalizeStatus(value, fallback string) string { switch strings.ToUpper(strings.TrimSpace(value)) { - case "SUCCESS", "PAID", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS": + case "SUCCESS", "OK", "PAID", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS", "REFUND_SUCCESS", "REFUNDED": return "success" - case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "ACCEPT", "PAYING": + case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "ACCEPT", "ACCEPTED", "PAYING": return "pending" - case "CLOSED", "TRADE_CLOSED", "CANCELLED", "CANCELED", "REVOKED", "REFUND", "REFUNDED", "FAILED", "FAIL", "PAYERROR": + case "CLOSED", "TRADE_CLOSED", "CANCELLED", "CANCELED", "REVOKED", "REFUND", "FAILED", "FAIL", "REJECTED", "DENIED", "DECLINED", "ERROR", "PAYERROR": return "failed" default: return fallback @@ -51,6 +51,20 @@ func ContainsFold(values []string, value string) bool { return false } +func Text(values map[string]any, key string) string { + value, _ := values[key].(string) + return value +} + +func FirstText(values map[string]any, keys ...string) string { + for _, key := range keys { + if value := Text(values, key); value != "" { + return value + } + } + return "" +} + func toString(value any) string { if value == nil { return "" diff --git a/pkg/websocket/melody.go b/pkg/websocket/melody.go index aafef0c..651ff70 100644 --- a/pkg/websocket/melody.go +++ b/pkg/websocket/melody.go @@ -176,36 +176,26 @@ func (s *Server) OnDisconnect(handler func(*melody.Session)) { s.mu.Unlock() } +func snapshotHandlers[T any](mu *sync.RWMutex, handlers []T) []T { + mu.RLock() + defer mu.RUnlock() + return append([]T(nil), handlers...) +} + func (s *Server) messageHandlerSnapshot() []func(*melody.Session, []byte) { - s.mu.RLock() - defer s.mu.RUnlock() - handlers := make([]func(*melody.Session, []byte), len(s.messageHandlers)) - copy(handlers, s.messageHandlers) - return handlers + return snapshotHandlers(&s.mu, s.messageHandlers) } func (s *Server) binaryHandlerSnapshot() []func(*melody.Session, []byte) { - s.mu.RLock() - defer s.mu.RUnlock() - handlers := make([]func(*melody.Session, []byte), len(s.binaryHandlers)) - copy(handlers, s.binaryHandlers) - return handlers + return snapshotHandlers(&s.mu, s.binaryHandlers) } func (s *Server) connectHandlerSnapshot() []func(*melody.Session) { - s.mu.RLock() - defer s.mu.RUnlock() - handlers := make([]func(*melody.Session), len(s.connectHandlers)) - copy(handlers, s.connectHandlers) - return handlers + return snapshotHandlers(&s.mu, s.connectHandlers) } func (s *Server) disconnectHandlerSnapshot() []func(*melody.Session) { - s.mu.RLock() - defer s.mu.RUnlock() - handlers := make([]func(*melody.Session), len(s.disconnectHandlers)) - copy(handlers, s.disconnectHandlers) - return handlers + return snapshotHandlers(&s.mu, s.disconnectHandlers) } func (s *Server) Send(session *melody.Session, message []byte) error { if session == nil { diff --git a/web/src/modules/announcement/form/info.vue b/web/src/modules/announcement/form/info.vue index e2a5c57..3d4b46b 100644 --- a/web/src/modules/announcement/form/info.vue +++ b/web/src/modules/announcement/form/info.vue @@ -18,21 +18,6 @@ - - - - - @@ -47,7 +32,6 @@