# 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./`,全部 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` 传给 SDK,SDK `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 条都是恢复已有写法或改一行条件/一处归一顺序即可闭环。