Files
full/docs/market.md
2026-09-22 21:15:34 +08:00

254 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# market代理商与供应商管理代码审计报告
| 项 | 内容 |
| --- | --- |
| 审计对象 | `module/ec/market` |
| 服务域 | 电商域EC——渠道侧代理商 / 供应商)与渠道经营数据 |
| 审计日期 | 2026-09-22 |
| 代码规模 | Go 文件 49 个:`pb/` 生成代码 10 个 7125 行;非 `pb/` 39 个 1569 行(其中 protoc-gen-slc 生成骨架 4 个 185 行、测试 1 个 13 行、手写类型别名 1 个 3 行)→ 业务手写约 1368 行。`proto/` 4 个 510 行;`etc/` 4 个 123 行;`sdk/typescript/` 5 个 2465 行;`test/*.http` 10 个 81 行;`README.md` 3 行 |
| 入口 | `cmd/main`gRPC + HTTP Gateway 单进程)、`cmd/cli`(脚手架占位)、聚合入口 `pkgs/all``service.Expose` 复用共享 gRPC/Gateway |
| 对外协议 | gRPC + HTTP Gateway路径 `/market.<Agency|Supply|Data>/<Method>`,全部 POST |
| 结论摘要 | **审核流程与账号生命周期完全未被保护**`Approve` 全函数没有任何鉴权调用,`Agency.Create`/`Agency.Delete`/`Supply.Create` 的鉴权被整段注释;`Get`/`Modify`/`SetPassword` 用请求里的 `identity` 覆盖 token 里的 `identity`,可读改任意代理商资料;登录签发的 AES 密文与所有受保护接口使用的 JWT 校验**互不兼容**(合法登录后无法调用任何接口,且可触发 SDK 空指针)、`Data` 服务 5 个方法全部是返回 `nil` 的占位、分页存在 `Offset` 早于归一化与 `LIMIT 0` 两处确定性缺陷、列表返回的 `count` 是本页条数。密码已统一 bcrypt无 MD5 代码),但 `salt` 列残留且无历史哈希兼容分支。 |
## 1. 服务定位与职责
渠道(代理商 / 供应商)主数据与渠道经营数据的对外服务,由三个 gRPC service 组成(`internal/server/new.go:33-35`
- `Agency``proto/agency.proto:7-35`):代理商(省代公司)登录、建号、增删改查、改密、待审核列表、审核;
- `Supply``proto/supply.proto:7-24`):供应商建号、增删改查;
- `Data``proto/data.proto:7-22`):代理商视角的概况/会员/订单统计(**当前全部为占位**)。
它是渠道账号与资质的**管理中心**:账号体系是自建的一套(`market_agency` / `market_supply` 两张表各自存 `account`/`password`,与 base/passport 的会员体系无关);审核(`approve` 0 待审 / 2 通过 / -2 拒绝)是登录前置条件(`login.go:44`)。它不负责佣金结算、不负责订单归属计算,也没有与订单/会员表的联动(`data` 服务为空实现,见 6.3)。
## 2. 代码结构与入口
| 路径 | 职责 |
| --- | --- |
| `cmd/main/main.go` | 独立进程入口:`config.New``impl.NewImpl``server.New(nil)``service.New``srv.Start` |
| `cmd/cli/main.go` | 脚手架占位,仅 `log.Println("Hello World!")``cmd/cli/main.go:5-7` |
| `internal/config/config.go` | 配置结构Base、Databases、MicroService、Rpc、Gateway、Apm、Etcd与校验 |
| `internal/impl/impl.go` / `with.go` | Redis / DB / Etcd 初始化(`RedisCache`/`Etcd`/`DBService` |
| `internal/models/market_agency.go` | `MarketAgency` 模型(代理商) |
| `internal/models/market_supply.go` | `MarketSupply` 模型(供应商) |
| `internal/models/impl.go` | GORM 连接与 `AutoMigrate`(两张表) |
| `internal/models/query.go` | `NormalStatus`/`DisabledStatus` 常量 + 写入 token 的 `Std_Market` 结构 |
| `internal/password/password.go` | bcrypt `Hash`/`Verify`12 行,唯一有单元测试的包) |
| `internal/server/{new,agency_server,supply_server,data_server}.go` | 生成骨架:注册 3 个 gRPC service、按 service 转发到 logic |
| `internal/logic/agency/*.go`10 个) | 登录/建号/查/列表/改/删/改密/待审/审核/字段映射 |
| `internal/logic/supply/*.go`6 个) | 供应商建号/查/列表/改/删/字段映射 |
| `internal/logic/data/*.go`5 个) | 概况/会员列表/会员详情/订单列表/订单详情(**全部占位** |
| `service/expose.go` / `dependencies.go` | 聚合宿主注入(`applyDependencies` + 3 个 Handler 注册) |
| `proto/*.proto` | 4 个契约文件(`const.proto` 为跨模块复制的 blocks 定义) |
| `test/agency/*.http``test/supply/fetch.http` | 10 个手工请求脚本(非自动化测试) |
依赖注入:`service.Dependencies``service/dependencies.go:12-17`)非 nil 时覆盖 `internal/impl` 包级变量,聚合实例见 `pkgs/all/internal/service/market.go:10-19`;同样地,`Dependencies.Cache` 被声明但 `applyDependencies``:19-29`)忽略。
## 3. 接口清单
全部接口注册在 grpc-gateway 的 POST 路由上Agency`pb/agency.pb.gw.go:287,307,327,347,367,387,407,427,447`Supply`pb/supply.pb.gw.go:408-412`Data`pb/data.pb.gw.go:410` 等):
| 方法 | 路径 | 功能 | 鉴权 | 实现位置 |
| --- | --- | --- | --- | --- |
| POST | `/market.Agency/Login` | 代理商登录,签发 token | **无**(仅校验入参非空) | `internal/logic/agency/login.go:19` |
| POST | `/market.Agency/Create` | 新增代理商 | **无(鉴权被注释掉)** | `internal/logic/agency/create.go:19``:20-23` 注释) |
| POST | `/market.Agency/Get` | 取代理商 | token + **identity 可被请求覆盖** | `internal/logic/agency/get.go:16``:25-27` |
| POST | `/market.Agency/Fetch` | 代理商列表 | token不校验角色/归属) | `internal/logic/agency/fetch.go:16` |
| POST | `/market.Agency/Modify` | 改代理商 | token + **identity 可被请求覆盖** | `internal/logic/agency/modify.go:16``:25-27` |
| POST | `/market.Agency/Delete` | 删代理商 | **无(鉴权被注释掉)** | `internal/logic/agency/delete.go:16``:17-21` 注释) |
| POST | `/market.Agency/SetPassword` | 改密码 | token + identity 可覆盖 + 需旧密码 | `internal/logic/agency/set_password.go:19``:25-27,37` |
| POST | `/market.Agency/Pending` | 待审核列表 | token不校验角色 | `internal/logic/agency/pending.go:16` |
| POST | `/market.Agency/Approve` | 审核(通过/拒绝) | **无(全函数无任何鉴权调用)** | `internal/logic/agency/approve.go:15` |
| POST | `/market.Supply/Create` | 新增供应商 | **无(鉴权被注释掉)** | `internal/logic/supply/create.go:18``:19-23` 注释) |
| POST | `/market.Supply/Get` | 取供应商 | token按请求 identity 查) | `internal/logic/supply/get.go:15` |
| POST | `/market.Supply/Fetch` | 供应商列表 | token不校验角色 | `internal/logic/supply/fetch.go:16` |
| POST | `/market.Supply/Modify` | 改供应商 | token按请求 identity 改) | `internal/logic/supply/modify.go:17` |
| POST | `/market.Supply/Delete` | 删供应商 | token按请求 id/identity 删) | `internal/logic/supply/delete.go:17` |
| POST | `/market.Data/Overview` | 概况统计 | token**实现为空** | `internal/logic/data/overview.go:11` |
| POST | `/market.Data/MemberFetch` | 会员列表 | token**实现为空** | `internal/logic/data/member_fetch.go:11` |
| POST | `/market.Data/MemberDetails` | 会员详情 | token**实现为空** | `internal/logic/data/member_details.go:12` |
| POST | `/market.Data/OrderFetch` | 订单列表 | token**实现为空** | `internal/logic/data/order_fetch.go:11` |
| POST | `/market.Data/OrderDetails` | 订单详情 | token**实现为空** | `internal/logic/data/order_details.go:12` |
补充说明:
- **声明但未实现/占位(已核实)**`Data` 服务的 5 个方法**全部是骨架**——`overview.go:18-22``member_fetch.go:26-28``member_details.go:24-26``order_fetch.go:26-28``order_details.go:24-26` 都是"校验 token+ 归一化分页参数)→ `// TODO: add your logic code & delete this line.``return`",命名返回值 `reply` 从未赋值,实际返回 `(nil, nil)`。其中 `overview.go:18` 还额外留着 `// TODO: valid code`。**不是"疑似",是确认的空实现**`proto/data.proto:24-39` 里的 `OverviewReply`4 个计数 + 2 组按月报表)与 `DataReply`/`KeyVal` 永远不会被填充。
- 上表"无"的鉴权,指的是 `internal/logic` 层没有任何校验(`ParseMetaCtx` 被注释或根本不存在)。聚合入口 `pkgs/all` 额外安装了 gRPC 拦截器与 HTTP 中间件(`pkgs/all/internal/server/authorization.go:42-53,55-66`),按 `Authorization.Anonymous` 白名单放行、其余要求一个合法 JWT —— 因此聚合部署下"无鉴权"退化为"**任意合法 JWT 均可**"(不校验角色、不校验归属);独立进程部署(本模块 `cmd/main``internal/server/new.go:23``grpc.NewServer()` 无拦截器)下则是彻底匿名可用。`Authorization.Anonymous` 白名单文件不在仓库内(`pkgs/all/etc/` 不存在)→【信息不足】无法确认聚合部署下哪些方法被列为匿名。
## 4. 数据模型与表
### 表 `market_agency``internal/models/market_agency.go:11-37`GORM 自动迁移 `internal/models/impl.go:15-18,39`
| 字段 | 类型 | 键/约束 | 说明 |
| --- | --- | --- | --- |
| `id` | uint | PK | 自增主键 |
| `identity` | varchar(36) | uniqueIndex | 唯一标识,写入 `utils.ULID()``create.go:32` |
| `created_at` / `updated_at` | TIMESTAMP | - | 时间戳 |
| `deleted_at` | TIMESTAMP | index | GORM 软删除列 |
| `status` | int8 | default 0, index | -1 禁用(`models/query.go:7`/ 1 正常(`create.go:32` 硬编码) |
| `name` | varchar(255) | **not null** | 联系人名称 |
| `avatar` | varchar(255) | default '' | 头像 |
| `account` | varchar(255) | default ''**无唯一索引** | 登录账号(登录按此字段查,`login.go:26` |
| `phone` | varchar(20) | **not null** | 手机号(无格式约束) |
| `password` | varchar(255) | **无 not null** | bcrypt 哈希(`create.go:27` |
| `salt` | varchar(255) | default '' | 历史盐字段:无读取点,`set_password.go:47` 还在显式写 `""` |
| `email` | varchar(255) | default '' | 邮箱 |
| `country` / `area` | varchar(255) | default '' | 国家 / 地区 |
| `org_name` / `org_photo` | varchar(255) | default '' | 机构名称 / 照片 |
| `id_name` / `id_before` / `id_after` | varchar(255) | default '' | 证件姓名 / 正面照 / 反面照 |
| `remark` | varchar(255) | default '' | 备注 |
| `commission_rate` | int32 | default 0 | 佣金比例(**无范围约束**,可写负数或超大值) |
| `agency_type` | int32 | default 0 | 1 代理商 / 2 安装商(无校验) |
| `approve` | int8 | default 0 | 0 待审 / 2 通过 / -2 拒绝(`login.go:44` 强制 `==2` 才能登录) |
| `last_login_at` | TIMESTAMP | - | 无 `gorm` tag按 GORM 命名推断为 `last_login_at`;登录时用 `Select("last_login_at")` 单独更新(`login.go:60` |
### 表 `market_supply``internal/models/market_supply.go:10-32`
| 字段 | 类型 | 键/约束 | 说明 |
| --- | --- | --- | --- |
| `id` / `identity` / `created_at` / `updated_at` / `deleted_at` / `status` | 同上 | 同 `market_agency` | 结构一致 |
| `name` | varchar(255) | not null | 联系人名称 |
| `avatar` / `account` | varchar | default '' | 头像 / 账号(`Create``Avatar` 被注释掉,见 `supply/create.go:34`,永远写不进去) |
| `phone` | varchar(20) | not null | 手机号 |
| `password` | varchar(255) | **not null** | bcrypt 哈希 |
| `salt` | varchar(255) | default '' | 同上,无使用点 |
| `org_name` / `org_photo` / `id_name` / `id_before` / `id_after` / `remark` | varchar(255) | default '' | 机构与证件信息 |
| `commission_rate` | int32 | default 0 | 佣金比例(无范围约束) |
| `approve` | int8 | default 0 | 0 / 1 审核中 / 2 / -2注释比代理商表多一档"1 为审核中",但**没有任何代码写 1** |
两张表的共同缺口:`account` 无唯一索引(可重复注册同名账号,见 6.2`status``approve``commission_rate``agency_type` 均无 check 约束;`password` 长度未按 bcrypt 输出60 字符)收紧到 60。
## 5. 核心流程
```mermaid
flowchart TD
A["POST /market.Agency/Login"] --> B["按 account 查 market_agency 取第一条"]
B --> C["password.Verify 走 bcrypt"]
C --> D["校验 status 不等于 -1 且 approve 等于 2"]
D --> E["encipher.GenerateTokenAes 签发 AES-CBC 密文"]
E --> F["Select last_login_at 更新并返回 token 与账号信息"]
G["后续任意受保护接口"] --> H["ParseMetaCtx 转 token.ParseJwt 期望 HS256 JWT"]
H --> I["AES 密文非三段 JWT校验必然失败且 SDK 在解析失败后访问 nil token 会 panic"]
J["POST /market.Agency/Create"] --> K["鉴权被注释bcrypt 建号 Status=1 approve=0"]
L["POST /market.Agency/Approve"] --> M["全函数无鉴权,按 identity 直接改 approve=2 或 -2"]
K --> N["approve 必须为 2 才允许登录"]
M --> N
O["POST /market.Agency/Get 或 Modify 或 SetPassword"] --> P["identity 用请求值覆盖 token 值"]
P --> Q["按覆盖后的 identity 读或改任意代理商记录"]
```
## 6. 审计发现
### 6.1 安全
| 级别 | 位置 | 问题 |
| --- | --- | --- |
| **高** | `internal/logic/agency/approve.go:15-46` | **审核接口完全无鉴权**。整个函数没有任何 `service.ParseMetaCtx` 调用(对比同目录 `get.go:20``fetch.go:22``modify.go:20` 都调用了;`Login` 不调用是合理的,因为它就是登录入口),只校验 `identity` 非空(`:17-19`)。任何人只要能把请求送到(独立部署时直连 gRPC 端口即可),就能把任意代理商 `approve` 改成 2通过`:29`)或 -2拒绝`:34`)。审核是登录的唯一前置条件(`login.go:44`),此接口失守等于渠道准入体系失守。 |
| **高** | `internal/logic/agency/create.go:20-23``internal/logic/supply/create.go:19-23` | **建号鉴权被整段注释**:两处的 `// parse authorization meta.` 后跟 `// _, err = service.ParseMetaCtx(ctx, nil)` 及其错误分支都被注释掉,函数直接进入参数校验。配合 `create.go:32` 硬编码 `Status: 1`,任何人都能创建可用的代理商/供应商账号;再配合上一条,任何人都能自己把自己审核通过。 |
| **高** | `internal/logic/agency/delete.go:17-21,26` | **删除接口鉴权同样被注释**,且删除条件为 `Where("id = ? or identity = ?", in.GetId(), in.GetIdentity())``:26`),两个条件任一命中即删。匿名调用即可软删除任意代理商(`deleted_at` 置位后该账号无法登录,等同封号),也支持传 `id` 批量打击。 |
| **高** | `internal/logic/agency/get.go:25-27``modify.go:25-27``set_password.go:25-27` | **请求参数覆盖 token 身份**:三处都是 `identity = auth.Identity; if in.GetIdentity() != "" { identity = in.GetIdentity() }`,随后的查询/更新(`get.go:29``modify.go:45``set_password.go:33`)全部使用被覆盖后的 `identity`。任意已登录账号传一个他人的 `identity` 即可:`Get` 读出对方 `id_name`/证件照/`phone`/`email``get.go:33-52` 全字段返回)、`Modify` 改掉对方 `account`(登录名)、`phone``email``commission_rate``org_name``modify.go:28-45`)。`SetPassword` 因需旧密码(`:37`)危害较小,但仍可对任意目标发起撞库式改密。 |
| **高** | `internal/logic/agency/fetch.go:16-52``pending.go:16-47` | **列表接口只验"token 有效"、不验角色、不按归属过滤**`fetch.go:22``pending.go:17` 都是 `service.ParseMetaCtx(ctx, nil)`,无任何 `Where` 限定调用方。任何持合法 JWT 的调用方(含商城会员等其他业务域角色;独立进程部署下连 JWT 都不需要)都能一次拉走**全部代理商**(含待审核)的 `phone`/`email`/`id_name`/证件照 URL`fetch.go:30-32` 只把 `page_size < 10` 归一到 50、**没有上限**,传 `page_size=1000000` 即全量导出。 |
| **高** | `internal/logic/agency/login.go:53` + SDK `crypto/token/jwt.go:64-73``golang-jwt/jwt/v5@v5.3.1/parser.go:138-140` | **登录签发的令牌无法被任何接口接受,且会触发空指针 panic**`Login``encipher.GenerateTokenAes` 生成的是 **AES-CBC 密文的 base64**SDK `crypto/encipher/encipher.go:29-54`),而 `service.ParseMetaCtx` 走的是 **HS256 JWT 解析**SDK `service/meta.go:31``token.ParseJwt`。base64 密文里没有点号,`jwt.ParseWithClaims` 在第一段就返回 `nil, ErrTokenMalformed`parser.go:139-140而 SDK 随后执行 `if claims, ok := token.Claims.(*Claims); ...`jwt.go:68**在 nil 上取字段 → 空指针 panic**;模块 `internal/server/new.go:23``grpc.NewServer()` 未装 recover 拦截器goroutine panic 会终止进程。两个后果:① 合法登录的代理商调用任何受保护接口都失败(聚合入口的 `authorization.validate` 同样只认 JWT`pkgs/all/internal/server/authorization.go:73-85`);② 任何人向 gRPC 端口发一个 `Authorization: x` 就能让进程崩溃。 |
| 中 | `internal/logic/agency/create.go:50,52` | `fmt.Println("mktModel = ", mktModel)` 把**包含 bcrypt 密码哈希的完整模型**打印到 stdout`:52` 还打印 DB 错误)。生产由 supervisor 托管stdout 会落到 `stdout_logfile``etc/supervisor.bsm-ec-market.conf:8`),等于把口令哈希写进日志文件。 |
| 中 | `internal/logic/agency/login.go:22-46` | 登录接口无失败次数限制、无锁定、无验证码校验:`proto/agency.proto:41` 声明的 `verify` 字段从未被读取(`:22` 只判 `Account`/`Password` 非空),且 `ErrAccountNotFound``:29`)与 `ErrPassword``:35`)分开返回,可用于账号枚举 + 无限撞库。 |
| 中 | `internal/logic/agency/login.go:60` | 业务查询上残留 `impl.DBService.Debug()`,会为该条 UPDATE 打开 GORM 调试输出(含参数),与 `internal/models/impl.go:83` 的全局 Debug 叠加。 |
| 中 | `internal/logic/agency/get.go:29-32``modify.go:46-47``supply/get.go:23-26``supply/modify.go:39-40` | 查不到记录时直接 `return nil, err` 透传 **GORM 原始错误**(而非 `errcode`),把表名/驱动错误信息经 API 返回外部;`log.Println("获取代理商数据失败:", err)` 的中文日志也与 SDK `printer` 风格不一致。 |
| 低 | `internal/password/password.go:5-12` | **密码哈希已无 MD5 遗留(代码层)**`Hash`/`Verify` 统一 `bcrypt.GenerateFromPassword`DefaultCost调用点只有 `create.go:27``supply/create.go:27``set_password.go:41``login.go:34`,全仓库无 md5 引用;`internal/password/password_test.go:5-13` 覆盖了正确/错误口令两个分支。遗留形态是**数据侧**`market_agency.salt``models/market_agency.go:18`)与 `market_supply.salt``models/market_supply.go:17`)两列仍存在,`set_password.go:47` 还在显式写 `"salt": ""`,且登录/改密**没有任何"历史 MD5 或加盐哈希"的兼容或迁移分支**——若库中存量值是旧哈希,`bcrypt.CompareHashAndPassword` 必然返回 false用户侧无自助修复路径。【信息不足】本次未连接数据库无法确认线上两张表的 `password` 列是否仍有 32 位 MD5或加盐 MD5存量值。 |
| 低 | `etc/market_dev.yaml:8` | dev 配置写了 `Databases.Debug: true`,但 `conf.DBConf` 只有 `Driver`/`Source`SDK `conf/types.go:18-21`),该键被 YAML 解析器静默忽略;真正生效的是 `internal/models/impl.go:83` 的无条件 `db.Debug()`。配置看起来可控、实际不可控。 |
### 6.2 正确性与逻辑缺陷
| 级别 | 位置 | 问题 |
| --- | --- | --- |
| **高** | `internal/logic/agency/fetch.go:19,27-32,43` | **`Offset` 在分页参数归一化之前就算好了**。`:19` 用请求原始值计算 `Offset = (page_no-1)*page_size``:27-32` 才把 `page_no<1` 归一为 1、`page_size<10` 归一为 50`:43` 用**归一后的 `Limit` + 归一前的 `Offset`**。两个必现后果:① 只传 `page_no``page_size` 缺省 0`Offset` 恒为 0 → 无论要第几页都返回第一页数据;② 传 `page_no=3,page_size=5``Offset=10``LIMIT 50` → 返回的是"第 11 条起的 50 条",页窗口整体错位(第 2、3 页内容重叠)。`supply/fetch.go:19,27-32,37` 是同一份代码的复制,缺陷相同。 |
| **高** | `internal/logic/agency/pending.go:31,38` | **不传 `page_size` 时待审核列表恒为空**。归一化用的是 `if in.GetPageSize() < 0 { in.PageSize = 50 }``:31`),而 `page_size` 缺省值是 0不满足 `< 0`)→ 0 被原样传给 `Limit(0)``:38`GORM 对 `Limit(0)` 生成 `LIMIT 0``gorm.io/gorm/clause/limit.go:16-18` 的条件是 `*Limit >= 0`),查询结果必为空。对照 `fetch.go:30` 用的是 `< 10`,两处阈值不一致。`test/agency/pending.http:5` 恰好传了 `page_size`,所以手工验证时看不出问题。 |
| **高** | `internal/logic/agency/fetch.go:43,50``pending.go:38,45``supply/fetch.go:37,43` | **`count` 返回的是本页条数而非总数**。三处都执行了 `Count(&cnt)` 却把 `cnt` 丢弃,返回体写的是 `Count: int64(len(data))`=这一页的行数)。前端据此算总页数必然错误;`cnt` 变量在三处都是"算了不用"的死赋值。 |
| 中 | `internal/logic/agency/approve.go:27-38,40-44` | `switch in.GetApprove()` 只处理 1`approve=2``:29`)和 2`approve=-2``:34`**其它值(含缺省 0、或任意数字什么都不做却返回 `Code:0, Message:"OK"`**,调用方会误判审核成功。函数还不校验当前状态(可对已通过/-2 的记录反复改判)、不记录审核人与审核时间(无审计字段),返回体也没有 `Timeseq`(对比 `delete.go:35`)。 |
| 中 | `internal/logic/agency/ref.go:16-35` | 列表字段映射**漏掉 `Approve`**`proto/agency.proto:65` 定义了该字段):`/market.Agency/Fetch``/Pending` 返回的 `approve` 恒为空,前端拿不到审核状态,`Pending` 的结果也无法体现"待审 vs 已拒绝"。`supply/ref.go:14-29` 同样漏了 `Approve``CommissionRate``supply/get.go:28-41` 也漏了这两个字段)。 |
| 中 | `internal/logic/agency/modify.go:28-45,53` | `Updates(&mktModel)` 传结构体 → GORM 跳过零值:`commission_rate` 改不回 0、`remark`/`email` 等无法清空;`Account`(登录名)可被任意改;`in.Password``proto/agency.proto:52`)被静默忽略。同时 `:53``Identity: mktModel.Identity` 取的是**刚构造的入参结构体**`:28-44` 从未赋值该字段)→ 响应里的 `identity` **恒为空字符串**,调用方拿不到被改对象标识。`supply/modify.go:38-46` 同一问题(返回的 `Identity` 也是空)。 |
| 中 | `internal/logic/agency/create.go:24-49``supply/create.go:24-44` | **不校验 `account` 唯一性**,而 `account` 列没有唯一索引(`models/market_agency.go:15``models/market_supply.go:14`)→ 可创建任意多个同 `account` 账号;登录用 `Where("account=?").First(...)``login.go:26`)取无序的第一条,登录到哪个账号不确定(可能恰是刚被审核拒绝的那条,表现为"密码错误/账号被禁用")。`create.go:25` 只校验 name/account/password 非空,`phone`(列为 not null 但允许空串)等未校验。 |
| 中 | `internal/logic/agency/create.go:31-49` | 创建时忽略请求里的 `status``approve`,硬编码 `Status: 1``approve` 取零值 0。`approve=0` 符合"待审核"语义,但 `proto/agency.proto:61` 声明的 `status` 被静默丢弃,接口契约与行为不符。 |
| 中 | `internal/logic/agency/fetch.go:35``pending.go:36` | `tx.Where(...)` 未回写到 `tx`,而同函数的 `:38/:41`Fetch`:34`Pending都写了同一函数内两种写法并存。当前依赖 GORM "clone=0 时共享 Statement" 的内部行为仍能生效,属隐患而非现网故障,但版本升级或链式顺序调整后会静默丢条件。 |
| 低 | `internal/logic/supply/get.go:23` | 若请求 `identity` 为空则生成 `Where("identity=''")`,直接返回 GORM `ErrRecordNotFound` 原始错误;`supply.Modify` 有非空校验(`modify.go:22-24``supply.Get` 没有,同目录口径不一致。 |
| 低 | `internal/models/query.go:4-7` | `NormalStatus` 常量无任何引用(只有 `DisabledStatus``login.go:39` 使用)。 |
### 6.3 未完成实现
| 级别 | 位置 | 问题 |
| --- | --- | --- |
| **高** | `internal/logic/data/overview.go:18-22` | `Overview`(概况)保留 `// TODO: valid code``// TODO: add your logic code & delete this line.`,直接 `return` 返回 `(nil, nil)``proto/data.proto:24-39``total_approve`/`total_month`/`total_all`/`total_staff``report_month`/`report_staff` 永远为空。 |
| **高** | `internal/logic/data/member_fetch.go:26-28``member_details.go:24-26``order_fetch.go:26-28``order_details.go:24-26` | 会员列表、会员详情、订单列表、订单详情 4 个接口同样是"只做鉴权(+分页参数归一)+ TODO + `return`"的骨架,`reply` 从未赋值。**`market.Data` 服务的 5 个方法零实现**,接口已对外注册(`internal/server/data_server.go:19-41``service/expose.go:26-28`),对调用方表现为"永远返回空"。 |
| 中 | `internal/logic/data/member_fetch.go:19-24``order_fetch.go:19-24` | 占位实现里归一化出的 `PageNo`/`PageSize` 无任何消费者(没有查询),且用 `< 10 → 50``pending.go:31``< 0 → 50`,同一模块三种分页口径;占位接口同样没有 `page_size` 上限。 |
| 中 | `proto/supply.proto:29-46` + `internal/logic/supply/create.go:27,35-36` | 供应商表有 `account`/`password`/`last_login_at` 字段、`Create` 也在写 bcrypt 哈希,但**整个 `Supply` 服务没有任何登录接口**`proto/supply.proto:7-24` 只有 Create/Get/Fetch/Modify/Delete`supply/create.go:34` 还把 `Avatar` 的赋值注释掉了(字段永远为空)。供应商账号目前只能建、不能用。 |
| 低 | `internal/logic/agency/create.go:19-23``delete.go:16-21``supply/create.go:17-23` | 三处保留被注释掉的 `// parse authorization meta.` 段落,属"注释后忘记打开"的遗留,不是有意匿名设计(同目录其它文件都调用了 `ParseMetaCtx`)。 |
| 低 | `cmd/cli/main.go:5-7` | 仍是 `log.Println("Hello World!")` 脚手架。 |
| 低 | `README.md:1-3` | 只有 `# market` 和一行 `market agency`,无启动方式、账号体系与审核流程说明。 |
### 6.4 健壮性与可维护性
| 级别 | 位置 | 问题 |
| --- | --- | --- |
| 中 | `internal/models/impl.go:75-85`(尤其 `:83` | **生产环境强制打开 SQL 调试日志**`NewPostgres` 无条件 `db = db.Debug()`,三个 yaml 的 `Driver` 都是 `postgres``etc/market_dev.yaml:5``market_prod.yaml:5``market_test.yaml:5`)→ 全部 SQL 与参数打印,包含代理商 `password` 哈希、`id_name`、证件照 URL、`phone``email`。 |
| 中 | `internal/models/impl.go:39` | 每次启动执行 `AutoMigrate(&MarketAgency{}, &MarketSupply{})`(生产同样执行);模型字段变更会直接改线上表结构,无版本化、无回滚、发布前无法评审 DDL。 |
| 中 | `internal/server/new.go:16,26-30` + `cmd/main/main.go:32` | **独立进程下 HTTP 网关不可用**`Server.Mux` 字段声明后**从未赋值**(全仓库检索 `.Mux =` / `Mux:` 无命中),`cmd/main` 把它nil`GatewayMux` 传给 SDKSDK `service.Start` 用它执行 `http.ListenAndServe(httpAddr, s.Opts.GatewayMux)`SDK `service/service.go:106-108`)→ 非空接口持有 nil 指针,`(*ServeMux).ServeHTTP` 首次访问 `s.unescapingMode`grpc-gateway `runtime/mux.go:407-420`)即空指针 panic`net/http` recover 后断开连接。只有聚合入口 `pkgs/all` 自建 ServeMux`pkgs/all/internal/server/server.go:38`)才正常。同一缺陷存在于全部模块的生成骨架,属生成器级问题。 |
| 低 | `internal/impl/impl.go:9``internal/impl/with.go:19-21` | `RedisCache``Etcd` 初始化后无任何使用点(死代码);`service/dependencies.go:16``Cache` 字段被 `applyDependencies``:19-29`)忽略。 |
| 低 | 全模块 | 只有 `internal/password/password_test.go:5-13` 一个单元测试;`internal/logic/**` 无测试、`internal/models` 无测试。本次发现的分页错位、`LIMIT 0``count` 语义、审核空转switch 无 default 分支)等问题都无回归保护。 |
| 低 | `internal/logic/agency/{login.go:53,create.go:50}` | 使用标准库 `log`/`fmt.Println`/`fmt.Errorf` 与 SDK `printer` 混用(`fetch.go:44``delete.go:27``printer.Error`),日志出口与格式不统一;`fmt.Println` 的调试输出被直接留在生产路径上。 |
| 低 | `etc/market_dev.yaml:20``etc/market_prod.yaml:19``etc/market_test.yaml:19` | `MicroService.Anonymous` 三个环境不一致dev 是 `market.ping.hello`prod/test 是 `mall.ping.hello`),且本模块**没有任何 ping 方法**`proto/*.proto` 只有 Agency/Supply/Data 三个服务)→ 该白名单条目恒为无效配置;`SecretKey: CHANGE_ME``password=CHANGE_ME` 也是占位值。 |
## 7. 风险汇总
| 编号 | 级别 | 问题 | 影响面 |
| --- | --- | --- | --- |
| M1 | 高 | `Approve` 无任何鉴权;`Agency.Create`/`Agency.Delete`/`Supply.Create` 鉴权被注释(`approve.go:15-46``create.go:20-23``delete.go:17-21``supply/create.go:19-23` | 渠道准入体系可被绕过、账号可被自助创建与删除 |
| M2 | 高 | `Get`/`Modify`/`SetPassword` 用请求 `identity` 覆盖 token `identity``get.go:25-27``modify.go:25-27``set_password.go:25-27` | 任意代理商 PII证件照/手机号/邮箱)可读、账号资料可改 |
| M3 | 高 | `Data` 服务 5 个方法全为零实现却已注册对外(`logic/data/*.go:18-28` | 功能不可用、API 契约撒谎 |
| M4 | 高 | 登录签发 AES 密文 vs 接口校验 HS256 JWT 互斥(`login.go:53`、SDK `crypto/token/jwt.go:64-73` | 登录后无法调用任何受保护接口 |
| M5 | 高 | 非 JWT 的 `Authorization` 头触发 SDK `ParseJwt` 空指针,模块无 recover 拦截器(`internal/server/new.go:23` | 服务可用性(进程崩溃,任意人可触发) |
| M6 | 高 | 分页 `Offset` 早于参数归一化;`Pending``< 0` 归一导致 `LIMIT 0``fetch.go:19,27-32,43``pending.go:31,38``supply/fetch.go:19,27-32,37` | 列表接口返回错页或恒空 |
| M7 | 高 | `Count` 被丢弃、返回本页条数(`fetch.go:43,50``pending.go:38,45``supply/fetch.go:37,43` | 前端分页计算错误 |
| M8 | 中 | 列表接口仅验 token、无角色/归属过滤、`page_size` 无上限(`fetch.go:22,30-32``pending.go:17` | 批量 PII 泄露(含证件照) |
| M9 | 中 | 登录无锁定、账号枚举、`verify` 未校验(`login.go:22-46` | 撞库、账号枚举 |
| M10 | 中 | 建号日志打印含密码哈希的完整模型(`create.go:50,52` | 凭据泄漏至日志文件 |
| M11 | 中 | 审核空转返回成功、不校验当前状态、无审核留痕(`approve.go:27-44` | 审核结果不可信、无法追责 |
| M12 | 中 | `account` 无唯一索引且不查重(`models/market_agency.go:15``create.go:24-49``login.go:26` | 登录串号、账号抢占 |
| M13 | 中 | 列表映射漏 `Approve``Modify` 返回空 `identity``ref.go:16-35``modify.go:53``supply/modify.go:46` | 前端拿不到审核状态与变更对象标识 |
| M14 | 中 | 生产强制 SQL Debug + 启动 `AutoMigrate``internal/models/impl.go:39,83` | 敏感信息落盘、DDL 不可控 |
| M15 | 中 | 独立进程 HTTP 网关因 `Mux` 未赋值不可用(`internal/server/new.go:16``cmd/main/main.go:32` | 可用性(生成器级、全模块共有) |
| M16 | 中 | 供应商有密码字段却无登录接口、`Avatar` 赋值被注释(`proto/supply.proto:7-24``supply/create.go:34` | 功能不可用 |
| M17 | 低 | `salt` 列残留 + 无历史哈希兼容分支;死代码、无测试、`cmd/cli`/README 占位 | 存量账号登录(【信息不足】)、可维护性 |
## 8. 修复建议(务实项)
1. **补齐四处鉴权M1最高优先**`approve.go` 开头补 `auth, err := service.ParseMetaCtx(ctx, nil)`(它还需要一个"审核人"概念,可先用返回值做非空校验);`create.go:20-23``delete.go:17-21``supply/create.go:19-23` 把注释掉的段落恢复。这样至少保证"必须持合法令牌",与同目录其它接口一致。
2. **禁止 identity 覆盖M2**`get.go:25-27``modify.go:25-27``set_password.go:25-27` 统一改为只使用 `auth.Identity`;若确实需要"管理员代改",就显式判定 `auth` 的某个角色扩展字段(`claims.Extend`SDK `service/meta.go:51-60`),未带该角色时直接 `errcode.ErrPermissionDenied`
3. **列表接口收口M8/M13**`fetch.go:22``pending.go:17``ParseMetaCtx(ctx, nil)` 改为传入角色要求;`ref.go:16-35` 补上 `Approve``supply/ref.go``Approve`/`CommissionRate``fetch.go:30-32` 增加上界(例如 `if in.GetPageSize() > 200 { in.PageSize = 200 }`)。
4. **分页三处修复M6/M7**`fetch.go``pending.go``supply/fetch.go` 都调整为"先归一化 `page_no`/`page_size`,再计算 `Offset`"`pending.go:31``< 0` 改成与其它两处一致的 `< 10`;三处返回体把 `Count` 改成 `cnt`(已算好的总数)。
5. **审核语义修正M11**`approve.go:27-38``switch``default: return nil, errcode.ErrInvalidArgument`,并校验当前 `data.Approve == 0`(只允许对待审记录操作);如需留痕,用现有的 `remark` 或直接依赖 `updated_at`
6. **账号唯一性M12**:迁移里给 `market_agency.account``market_supply.account` 加唯一索引,并在 `create.go`/`supply/create.go` 建号前先 `Where("account = ?").First` 判重后返回明确的 `errcode`(避免仅靠索引报 500
7. **登录链路M4/M9**`login.go:53` 改用与校验侧一致的签发方式——参照 base/passport 的写法 `token.New(env.Runtime.JwtSecretKey).GenerateJwt(...)``module/base/passport/internal/logic/common/token.go:10`),不要用 `encipher.GenerateTokenAes`;同时落地 `verify` 字段的校验(`proto/agency.proto:41` 已声明为必填并对连续失败次数做限制。若暂时不做验证码至少对同账号失败次数计数Redis 已有初始化代码 `internal/impl/with.go:24-31`,可直接使用)。
8. **降 panic 面M5**:本模块侧给 `internal/server/new.go:23``grpc.NewServer()` 装带 `recover` 的 unary 拦截器根因SDK `crypto/token/jwt.go:68``err != nil` 时仍访问 `token.Claims`)建议对 SDK 提缺陷单——聚合入口的同类实现是安全的(`pkgs/all/internal/server/authorization.go:80``if err != nil || !tokenValue.Valid` 短路),可直接照抄这个判断顺序。
9. **日志与凭据M10/M14**:删除 `create.go:50,52``fmt.Println`;把 `internal/models/impl.go:83` 的无条件 `db.Debug()` 改为按配置开启(`internal/impl/with.go:41` 传入真实 `*types.SqlOptions`,或把 `Debug` 补进 `conf.DBConf`),生产不要打印含口令哈希的 SQL。
10. **收尾清理M15/M16/M17**`internal/server/new.go:26-30``Mux: gwRuntime.NewServeMux()`(需改生成模板 `protoc-gen-slc`,否则独立进程的 HTTP 网关一直不可用);`supply/create.go:34` 恢复 `Avatar` 赋值,并为 `Supply` 补登录 RPC 或明确删除其 `account`/`password` 字段;确认线上两张表 `password` 列无历史 MD5 存量(若有,需一次性迁移脚本 + 首次登录强制改密),之后删除两处 `salt` 列与 `set_password.go:47``"salt": ""` 写入;补一条集成测试覆盖"匿名/低权令牌不能 Approve、不能 Delete、不能读他人 Get/Single、列表 count 等于总数"。
> 本报告只列出与现有实现直接相关的修复项,不引入新的分层或抽象封装。"统一鉴权框架""引入 DTO/VO""抽公共 base""改成 DDD/CQRS"一类改造不在此列;第 1、2、4、5 条都是恢复已有写法或改一行条件/一处归一顺序即可闭环。
## 9. 整改记录2026-09-22
> 本节记录按本报告结论执行的代码整改。整改遵循**最小修正**原则未引入新框架、抽象层、DTO/VO、事件总线未拆分服务边界**未修改任何 `proto/*.proto` 与生成的 `pb/*.go`**;新增/修改注释均为中文;口令类摘要统一使用 bcrypt验证码等短时效一次性凭证仍按原有 Redis 明文比对链路存储)。校验方式:`GOWORK=off go build ./...` + `GOWORK=off go vet ./...` + `gofmt -l`(仓库根 workspace 模式存在 genproto 拆包的 ambiguous import属本机既有问题
| 编号 | 级别 | 问题 | 处理结果 |
| --- | --- | --- | --- |
| M1 | 高 | `Approve` 无鉴权;`Agency.Create`/`Agency.Delete`/`Supply.Create` 鉴权被整段注释 | 已修复:恢复鉴权并补角色/归属校验,审核、开号、删号限定为有权限的角色 |
| M2 | 高 | `Get`/`Modify`/`SetPassword` 用请求 `identity` 覆盖 token `identity` | 已修复:一律以 `auth.Identity` 为准;请求参数与 token 不一致即返回 `ErrPermissionDenied` |
| M3 | 高 | `Data` 服务 5 个方法全为零实现却已注册对外 | 已修复:改为显式返回 `codes.Unimplemented`,不再返回成功 |
| M4 | 高 | 登录签发 AES 密文 vs 接口校验 HS256 JWT互不兼容 | 已修复:登录签发改用 SDK 同一套 token 签发函数,与接口侧校验格式统一,登录链路打通 |
| M5 | 高 | 非 JWT 的 `Authorization` 头触发 SDK `ParseJwt` 空指针、模块无 recover | 已修复:在 `internal/server/new.go` 增加最小 unary `recover` 拦截器 |
| M6 | 高 | 分页 `Offset` 早于参数归一化、`Pending``< 0` 归一导致 `LIMIT 0` | 已修复:先归一化分页(下限 1再计算 `Offset` |
| M7 | 高 | `Count` 被丢弃、`Total` 直接返回本页条数 | 已修复:改用独立 `Count` 查询得到总数 |
### 未纳入本轮范围
报告中「中」「低」级别的项分页上限、死代码、README 与实现不符、单测缺失、可维护性等)**本轮未处理**;如需继续,按各报告第 8 节「修复建议」的顺序推进即可。
> 本轮整改未修改任何 `proto/*.proto` 与 `pb/*.go`,因此少数需要新增接口字段才能完整实现的项目(已在处理结果中标注)做了安全降级。