38 KiB
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 |
| 结论摘要 | 审核流程与账号生命周期完全未被保护: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. 核心流程
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. 修复建议(务实项)
- 补齐四处鉴权(M1,最高优先):
approve.go开头补auth, err := service.ParseMetaCtx(ctx, nil)(它还需要一个"审核人"概念,可先用返回值做非空校验);create.go:20-23、delete.go:17-21、supply/create.go:19-23把注释掉的段落恢复。这样至少保证"必须持合法令牌",与同目录其它接口一致。 - 禁止 identity 覆盖(M2):
get.go:25-27、modify.go:25-27、set_password.go:25-27统一改为只使用auth.Identity;若确实需要"管理员代改",就显式判定auth的某个角色扩展字段(claims.Extend,SDKservice/meta.go:51-60),未带该角色时直接errcode.ErrPermissionDenied。 - 列表接口收口(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 })。 - 分页三处修复(M6/M7):
fetch.go、pending.go、supply/fetch.go都调整为"先归一化page_no/page_size,再计算Offset";pending.go:31的< 0改成与其它两处一致的< 10;三处返回体把Count改成cnt(已算好的总数)。 - 审核语义修正(M11):
approve.go:27-38的switch加default: return nil, errcode.ErrInvalidArgument,并校验当前data.Approve == 0(只允许对待审记录操作);如需留痕,用现有的remark或直接依赖updated_at。 - 账号唯一性(M12):迁移里给
market_agency.account、market_supply.account加唯一索引,并在create.go/supply/create.go建号前先Where("account = ?").First判重后返回明确的errcode(避免仅靠索引报 500)。 - 登录链路(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,可直接使用)。 - 降 panic 面(M5):本模块侧给
internal/server/new.go:23的grpc.NewServer()装带recover的 unary 拦截器;根因(SDKcrypto/token/jwt.go:68在err != nil时仍访问token.Claims)建议对 SDK 提缺陷单——聚合入口的同类实现是安全的(pkgs/all/internal/server/authorization.go:80用if err != nil || !tokenValue.Valid短路),可直接照抄这个判断顺序。 - 日志与凭据(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。 - 收尾清理(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,因此少数需要新增接口字段才能完整实现的项目(已在处理结果中标注)做了安全降级。