Add static security/quality audit reports for all 18 Go service modules under module/, plus a consolidated index (docs/audit/README.md) with per-module statistics, top risks, cross-module systemic defects and a phased TODO list (T1-T19). No production code is modified.
72 KiB
审计报告:module/social/group
1. 模块概览
- 路径:
D:\work\bsm-infra\full\module\social\group(Go modulebsm/full/module/social/group,go.work成员,见go.work中./module/social/group)。 - 形态:gRPC + gRPC-gateway 微服务,独立进程入口
cmd/main/main.go,proto 定义Basic(6 个 RPC)与Member(7 个 RPC)两个 service,共 13 个 RPC(proto/group.proto:7-25、proto/member.proto:7-30)。 - 规模:27 个非 pb Go 文件 / 805 行;另有 3 个 proto(
group/member/const)+ 7 个生成文件(const.pb.go、group.pb.go、group.pb.gw.go、group_grpc.pb.go、member.pb.go、member.pb.gw.go、member_grpc.pb.go)+ 4 个 etc 配置(dev/test/prod/supervisor)。 - 数据模型:
GroupBasic→group_basic(internal/models/group_basic.go:28-30)、GroupMember→relation_group_member(group_member.go:18-20)、GroupApply→relation_group_apply(group_apply.go:19-21)、GroupRes→relation_group_res(group_res.go:19-21,全模块零引用)。三者均内嵌types.Std_IICUDS(软删除DeletedAt)与types.Std_Passport(passport_id/passport_identity),没有任何creator_id字段(D:\work\bsm-sdk\core\types\db.go:28,52)。 - 路由/鉴权模型:每个 handler 都调用
service.ParseMetaCtx(ctx, nil)校验authorization元数据中的 JWT(D:\work\bsm-sdk\core\service\meta.go:19-49),opts==nil因此不做角色限制;鉴权只区分"是否登录",群内角色(群主/管理员/成员)由各 logic 自行判断,这正是本模块问题最集中之处。 - 接入状态:
MicroService.Enable: false(不注册 etcd 路由),Gateway.Enable: true,service/目录存在但为空,未按README.md:313约定提供service.Expose;顶层README.md:45,231明确"社交服务不接入all/ecmall"。 - 一句话结论:13 个 RPC 中 12 个存在确定性缺陷(SQL 表名/列名错误、结果被丢弃、权限查询恒假、模板 TODO 空实现),唯一"能落库"的
Create仍缺群号、事务与校验;同时"成员上限""免验证入群""搜索开关"等开关全部未生效。
2. 审计范围与方法
已读完(100%)
- 全部 27 个非 pb Go 文件、805 行:
cmd/main、cmd/cli、internal/{config,impl,server,models,logic/basic,logic/member}。 proto/group.proto、proto/member.proto、proto/const.proto;pb/blocks_compat.go;生成代码按需阅读(group.pb.gw.go、*_grpc.pb.go、group.pb.go)。etc/group_{dev,test,prod}.yaml、etc/supervisor.bsm-social-group.conf、go.mod、README.md(模块)、顶层README.md、wiki/api/14-group.md、docs/audit/README.md。
外部交叉验证(用于判定"是模块缺陷还是平台约定")
- SDK:
D:\work\bsm-sdk\core\{service,conf,with,database,errcode,types,crypto/token,utils,env}。 - 平台网关:
D:\work\bsm-infra\gateway\internal\rpc\rpc.go:130-207(路由与 JWT 转发)、internal/http/http.go:154-176。 - SQL 生成语义:GORM
v1.31.2源码(clause/expression.go、clause/set.go、statement.go:175-269、scan.go:126-357、finisher_api.go、schema/schema.go、callbacks/update.go);grpc-gateway/v2@v2.30.0/runtime/mux.go。 - 同构模块对照:
module/finance/wallet/service/expose.go(有网关注册)、module/base/passport|social/relation|social/feed/internal/server/new.go(同为单模板产物)。
静态检查(在 module/social/group 下执行)
| 命令 | 结果 |
|---|---|
gofmt -l . |
无输出,exit=0(格式干净) |
go vet ./... |
无输出,exit=0 |
go test ./... |
9 个包全部 [no test files],exit=0;_test.go 数量 = 0 |
方法:以"证据优先"逐方法核对 13 个 RPC 的鉴权、SQL、事务、返回值与错误处理;对无法运行验证的结论(SQL 渲染、驱动报错、DDL 索引)在正文中显式标注「推测」。本次审计未修改/新建任何代码文件,唯一新增文件即本报告。
返回值证据口径(避免泛化):本模块 27 个非 pb 文件中只有 2 处裸 return——internal/models/query.go:21 与 :36,且两者上一行都显式赋值了命名返回值(err = impl.DBService.Raw(...).Find(&groups).Error),不存在"命名返回值未赋值 → 恒返回 nil 且 err 为 nil"的路径。报告中所有"某字段恒为空/结果被丢弃"的判断都来自显式结构体字面量返回并已附原文摘录,例如 return &pb.GroupsReply{Total: int32(len(groups))}, nil(internal/logic/basic/fetch.go:27-29,未设置的 Groups 为零值 nil)、return &pb.GroupItem{}, nil(internal/logic/basic/get.go:25)、return &pb.GroupMemberReply{Total: int32(len(members))}, nil(internal/logic/member/fetch.go:31-33)。
无法覆盖/无法验证
- 真实表结构:仓库内没有任何 DDL/SQL 迁移文件,
IsAutoMigrate默认false(D:\work\bsm-sdk\core\database\sql\postgresql.go:17,46)且本模块未调用AutoMigrate(对照module/ec/order/internal/models/impl.go:41),因此group_basic是否真有creator_id、number是否有唯一索引、relation_group_member是否有唯一约束均无法从仓库确认——相关结论已写成"分支 A/分支 B"。 - 运行时行为:未连接真实 PostgreSQL 执行 SQL,也未启动服务;所有"SQL 报错/panic"结论是基于所用库源码的推演(已标注)。
- 生产配置注入方式:
conf.New只做os.ExpandEnv(不支持$VAR之外的覆盖,etc/*.yaml中也没有变量),但发布系统是否外部覆盖etc/无法验证。 - 前端渲染方式(决定群名/公告是否构成存储型 XSS)不在本仓库。
3. 问题清单
P0
无。
判定说明(逐条排除,避免误报):本次未发现"当前可被利用的 P0 级漏洞"。最接近的两个候选都被其他缺陷挡住,因而降级为 P1:
Basic.Modify的破坏性写入(改写群identity、清零passport_id/member_limit/member_total)本身足以构成数据损坏,但其WHERE引用了模型中不存在的creator_id(internal/logic/basic/modify.go:35),使该UPDATE在当前代码下要么直接报ErrDB、要么 0 行受影响后静默返回 OK —— 破坏性 SET 属于潜在而非必然触发(见 P1-1)。- 越权面最大的
Member.JoinDoHandle(完全无权限校验)需要攻击者拿到入群申请的identity,而该值从不出现在任何响应中(DoJoin返回的是另一个随机 UUID,见 P1-12),当前不可达,故列为 P1(见 P1-8)。
P1
1. Basic.Modify 用 UpdateColumns 提交整个结构体:会连带写入零值字段并把群 identity 改成随机 UUID,且所有权条件引用不存在的列
- 位置:
module/social/group/internal/logic/basic/modify.go:24-35、internal/models/group_basic.go:9-11、D:\work\bsm-sdk\core\types\db.go:52 - 证据:
24 record := &models.GroupBasic{
25 Name: in.GetName(),
30 EnableSearchByNumber: in.GetEnableSearchByNumber(),
32 }
33 record.Identity = utils.UUID() // ← 重新生成群 identity
35 err = impl.DBService.Where("identity=? and creator_id=?", in.Identity, auth.ID).UpdateColumns(record).Error
GroupBasic 只有 types.Std_Passport(passport_id/passport_identity),全仓库(含 proto、其它模块模型)检索不到 creator_id 列的定义(grep -r creator_id module/ 仅命中本模块 3 处逻辑代码 + 1 处 proto)。
- 影响:
UpdateColumns(struct)的语义是"更新全部字段(含零值)"——依据本模块实际使用的 GORMv1.31.2:finisher_api.go中UpdateColumns仅设置SkipHooks=true,而callbacks/update.go的ConvertToAssignments在!restricted(未调用Select)时对每个非主键字段都执行赋值(callbacks/update.go:278-279),因此Number/MemberLimit/MemberTotal/PassportID/PassportIdentity/Status/Province/City/Area/UpdatedAt都会以 record 的零值写库;Identity更是被替换成新的 UUIDv7。成员(relation_group_member.group_identity)、申请(relation_group_apply.group_identity)都以group_identity关联,identity 一旦变化,该群的全部成员与申请关系立即断裂,且不可恢复。- 所有权条件
creator_id在模型/迁移中不存在:分支 A(库里无此列)→ PostgreSQLcolumn "creator_id" does not exist,接口恒定返回errcode.ErrDB;分支 B(库里有此列但由本模块Create从不写入,create.go:33写的是PassportID)→ 条件恒不成立,RowsAffected=0而.Error==nil,接口返回Data: vars.OK的假成功,什么也没改。
- 建议:改为
Model(&models.GroupBasic{}).Where("identity=? AND passport_id=?", in.Identity, auth.ID).Updates(map[string]any{...白名单字段...});删除record.Identity = utils.UUID();用RowsAffected==0显式返回ErrRecordNotFound/ErrPermissionDenied;统一所有权字段为passport_id(或补CreatorID字段并让Create写入)。
2. Basic.Fetch(群列表)原始 SQL 表名/关键字/列歧义三重错误,且查询结果被丢弃
- 位置:
module/social/group/internal/models/query.go:14-22、internal/logic/basic/fetch.go:20-29 - 证据:
16 sql := `Select group.*,gm.nickname,gm.remark_name,gm.role
17 From group_member as gm
18 Left join group on group.id=gm.group_id
19 Where passport_id=? and deleted_at is null`
20 err = impl.DBService.Raw(sql, passport_id).Find(&groups).Error
27 return &pb.GroupsReply{
28 Total: int32(len(groups)), // groups 未被赋值给 Groups
29 }, nil
- 影响:
group_member不是模型声明的表名(模型是relation_group_member,group_member.go:19),group也不是(模型是group_basic),且group是 PostgreSQL 保留字,FROM group/Select group.*无法解析;passport_id/deleted_at在 join 后同时存在于成员表与群表,属于歧义列(42702)。推测:该接口对所有用户恒返回ErrDB(1501)(表不存在/语法错误),未连库无法给出确切 SQLSTATE。- 即便 SQL 修好,返回值也丢掉了数据:
Total用len(groups)表示,Groups字段始终为 nil —— 客户端"获取群组列表"永远拿到空列表(groups变量在函数内被完全浪费)。
- 建议:用 GORM 模型 +
Joins写查询(让表名/软删条件由 schema 决定),或至少把表名修正为relation_group_member/group_basic并给列加表别名前缀;返回时补Groups: groups,Total改为独立Count。
3. Basic.Get(群详情)把 pb 结构体当查询模型:表名推导为 group_item,且最终丢弃结果返回空对象
- 位置:
module/social/group/internal/logic/basic/get.go:20-25 - 证据:
20 err = impl.DBService.Where("identity=?", in.Identity).First(&reply) // reply 是 *pb.GroupItem,无 TableName
21 if err != nil {
23 return nil, errcode.ErrDB
24 }
25 return &pb.GroupItem{}, nil // 查询结果被丢弃
- 影响:
First(&reply)以pb.GroupItem作为 Dest/Model,SDK 的 NamingStrategy 是SingularTable: true(D:\work\bsm-sdk\core\database\sql\postgresql.go:39-41),因此表名被推导为group_item而真实表是group_basic→ 推测该接口对任意入参恒返回ErrDB;若表名恰好存在,代码也会丢弃已扫描到的reply并返回全零GroupItem(id=0、identity=""、name=""),任何调用者都拿不到群资料。另外in.Id被完全忽略。 - 建议:
var g models.GroupBasic; ...First(&g)后显式映射到 pb;Id/Identity二者至少校验其一或都支持。
4. Member.Fetch(成员列表)表名错误 + 结果被丢弃,且无成员校验、无分页
- 位置:
module/social/group/internal/models/query.go:30-37、internal/logic/member/fetch.go:20-33 - 证据:
31 sql := `Select pe.identity,pe.nickname,pe.avatar,pe.sex,gm.remark_name,gm.role
32 From group_member as gm
33 Left join passport_extend as pe on pe.passport_id=gm.passport_id
34 Where group_identity=? and deleted_at is null`
31 return &pb.GroupMemberReply{
32 Total: int32(len(members)), // Members 未赋值
33 }, nil
- 影响:表名
group_member不存在 → 推测恒ErrDB;即便修好,Members仍恒为空。同时该接口只校验"已登录",不校验调用者是否属于该群,一旦 SQL 修正即成为"知道群 identity 就能拉全群成员档案(昵称/头像/性别/群内备注)"的横向越权(群 identity 为 UUIDv7,不可枚举,故计为 P1 而非 P0);且查询无LIMIT/分页,大群会把全部成员一次加载进内存。 - 建议:先查调用者的成员行(
group_identity=? AND passport_id=?,未命中返回ErrPermissionDenied),加分页参数(proto 目前只有blocks.IdentRequest,需扩参),修正表名并补deleted_at表别名前缀,返回Members。
5. Member.JoinFetch(入群申请列表)SQL 缺少占位符、循环内 N+1 查询、复用同一指针导致所有申请卡片被最后一条覆盖
- 位置:
module/social/group/internal/logic/member/join_fetch.go:22-37、internal/models/query.go:24-28 - 证据:
24 err = impl.DBService.Model(&models.GroupApply{}).Where("group_id in ", group_ids).Find(&fetch).Error
...
29 var card = &pb.PassportInfoDetailCard{} // ← 循环外,指针被复用
30 for idx, item := range fetch {
31 err = impl.DBService.Table("passport_extend").Select(filed).Where("passport_identity = ?", item.From.Identity).First(card).Error
...
36 fetch[idx].From = card
37 }
- 影响:
Where("group_id in ", group_ids)的 SQL 串不含?,GORM 会把group_ids作为"多余变量"追加(clause/expression.go的Expr.Build尾部 →statement.go:182-183对sql.NamedArg只入参不写占位符)→ 渲染成... WHERE group_id in AND "relation_group_apply"."deleted_at" IS NULL。推测:只要用户管理着 ≥1 个群,该接口必报ErrDB(语法错误/参数个数不符)。正确写法是Where("group_id IN ?", group_ids)。- 每条申请一次
passport_extend查询 = N+1。 var card定义在循环外,每次First(card)都写同一块内存,fetch[idx].From = card让所有申请项的from指向同一个对象,其内容恒为最后一条申请人的资料 → 申请列表里除最后一项外的申请人资料全部错乱(展示的是他人资料),既是正确性问题也是隐私问题。
- 建议:
Where("group_id IN ?", ids).Find(&fetch);申请人资料用一次passport_identity IN ?批量取回并建 map;每项分配独立对象。
6. Member.DoKick(踢人)权限查询列名与参数类型双重错误 → 恒定拒绝;目标标识语义与对外数据不一致
- 位置:
module/social/group/internal/logic/member/do_kick.go:24-39、internal/models/query.go:31(对外成员身份的来源) - 证据:
24 if err = impl.DBService.Model(&models.GroupMember{}).Where("identity=? and role !=0 and passport_id=?", in.GroupIdentity, auth.Identity).Count(&tc).Error; err != nil {
...
28 if tc == 0 {
29 return nil, errcode.ErrPermissionDenied
30 }
...
32 err = impl.DBService.Model(&models.GroupMember{}).Delete("group_identity=? and identity=?", in.GroupIdentity, in.Identity).Error
auth.Identity 是 string(D:\work\bsm-sdk\core\token 的 Claims{ID uint; Identity string}),而 passport_id 是整型列;GroupMember 的 identity 列是成员行自身的 UUID,群标识在 group_identity 列。
- 影响:该权限查询把群 identity 比到了成员行的
identity列上,同时把字符串auth.Identity比到passport_id上(PostgreSQL 无bigint = text运算符)→ 推测恒定报错并返回ErrDB,群主与管理员都无法踢人(fail-closed,因此暂不构成越权)。此外Delete用identity=in.Identity(成员行 UUID)作为目标,而对外唯一暴露成员身份的地方返回的是 passport identity(query.go:31pe.identity AS identity)——即便权限查询修好,调用方也拿不到可用于踢人的identity,API 契约断裂。删除影响 0 行时仍继续扣减群成员数(见 P1-11)。 - 建议:权限校验改为
Where("group_identity=? AND passport_id=? AND role<>0", in.GroupIdentity, auth.ID);踢人目标改为passport_identity(或 proto 明确该字段语义并统一);Delete(...).RowsAffected==0时返回ErrRecordNotFound且不更新计数。
7. Member.DoSetManager:目标成员未按群限定(跨群越权),UpdateColumn 错误被吞,非法 direction 静默成功
- 位置:
module/social/group/internal/logic/member/do_set_manager.go:24-45 - 证据:
30 if auth.ID != creator_id {
31 return nil, errcode.ErrPermissionDenied
32 }
34 switch strings.ToUpper(in.Direction) {
35 case "SET":
37 impl.DBService.Model(&models.GroupMember{}).Where("identity=?", in.Identity).UpdateColumn("role", ROLE_MASTER)
38 case "CANCEL":
40 impl.DBService.Model(&models.GroupMember{}).Where("identity=?", in.Identity).UpdateColumn("role", ROLE_MEMBER)
41 }
42 return &pb.DataStatusReply{Data: vars.OK, ...
- 影响:
Where("identity=?", in.Identity)完全没有group_identity限定:只要creator_id分支可用,群创建者就能把任意群的成员行改成管理员(经典 IDOR)。实际可达性受两点限制(成员行 identity 无对外出口、creator_id列缺失),故计 P1 而非 P0。- 两次
UpdateColumn的.Error从未检查,改角色失败仍返回 OK;配合上一行的creator_id缺失问题,可能出现"校验报错/静默无效"两种极端。 Direction非 SET/CANCEL 时直接返回 OK(switch无 default),非法状态流转被静默接受。
- 建议:
Where("group_identity=? AND identity=?", in.GroupIdentity, in.Identity);检查.Error与RowsAffected;default:返回ErrInvalidArgument;把 direction 从字符串改成 proto enum(见 P3-26)。
8. Member.JoinDoHandle(审核入群申请)完全缺少群权限校验,且非幂等、非申请状态机
- 位置:
module/social/group/internal/logic/member/join_do_handle.go:18-56 - 证据:
19 _, err = service.ParseMetaCtx(ctx, nil) // 只校验"已登录",auth 被丢弃
...
27 if err = impl.DBService.Where("identity=?", in.Identity).First(&apply).Error; err != nil {
...
43 if err = impl.DBService.Create(&member).Error; err != nil {
...
49 impl.DBService.Model(&models.GroupApply{}).Where("identity=?", in.Identity).UpdateColumn("status", 1)
52 models.UpsetGroupMemberTotal(apply.GroupIdentity, "+")
- 影响:
- 没有
in.GroupIdentity相关的任何角色校验(对比DoSetManager/DoQuit至少查了creator_id):任何已登录用户只要掌握某条申请的identity,就能替任意群通过/拒绝该申请。当前可达性受"申请 identity 从不出现在任何响应中"限制(DoJoin返回的是另一个随机 UUID,见 P1-12),因此判定为 P1 越权缺口而非 P0;但一旦有任何接口(含未来的管理端/日志/通知)泄露申请 identity,即直接升级为 P0(普通用户自批入群)。 - 非幂等:对同一条申请重复 PASS 会重复
Create成员行并重复member_total+1;申请的status是否已处理完全不检查(模型也没有"pending/approved"状态机约束)。 apply.GroupID/apply.GroupIdentity直接取自申请行,而申请行是客户端可控的(DoJoin用请求里的id与identity,见 P1-12),审核动作因此可能给"另一个群"建成员、给"另一个群"加计数。direction非法时静默"成功":switch strings.ToUpper(in.Direction)(第 24 行)只有PASS/REJECT两个 case、没有 default,非法值会跳过整个 switch 落到第 58 行的if err != nil(此时err仍是第 19 行ParseMetaCtx的 nil),最终返回Data: identity(空字符串)的OK。
- 没有
- 建议:入参以
group_identity为准,校验调用者在该群role<>0;查到申请后校验其status==0(未处理)再处理;整段(建成员 + 改申请 status + 计数)放进同一事务并加ON CONFLICT DO NOTHING/唯一约束保证幂等;switch加default返回ErrInvalidArgument。
9. Member.DoQuit(退群):依赖不存在的 creator_id,群主校验失效且没有转让机制,删除 0 行仍扣减计数
- 位置:
module/social/group/internal/logic/member/do_quit.go:23-41 - 证据:
23 var creator_id uint
24 if err = impl.DBService.Model(&models.GroupBasic{}).Select("creator_id").Where("identity=?", in.GroupIdentity).Scan(&creator_id).Error; err != nil {
26 return nil, errcode.ErrDB
27 }
29 if auth.ID == creator_id {
30 return nil, errcode.ErrPermissionDenied
31 }
33 err = impl.DBService.Model(&models.GroupMember{}).Delete("group_identity=? and passport_id=?", in.GroupIdentity, auth.ID).Error
39 models.UpsetGroupMemberTotal(in.GroupIdentity, "-")
- 影响:分支 A(无
creator_id列)→ 推测恒ErrDB,用户永远无法退出群组;分支 B(列存在但从未被写入、值为 0/NULL)→Scan不报错且creator_id=0,auth.ID==0恒假,群主可以直接退群,而模块内没有任何转让群主/继任者机制(proto 与 logic 均无),群将永久失去创建者:后续DoSetManager、Modify、Disband(若有实现)全部无人有权执行。另外Delete影响 0 行(非成员调用)仍会扣减member_total。 - 建议:统一为
passport_id;群主退群必须走"先转让"(新增TransferRPC 或在退群入参中要求继承人并校验其为成员);用RowsAffected决定是否更新计数。
10. Basic.Disband(解散群组)是模板 TODO 占位:校验后直接返回 OK,不解散任何数据
- 位置:
module/social/group/internal/logic/basic/disband.go:16-31 - 证据:
16 _, err = service.ParseMetaCtx(ctx, nil)
22 if in.Id == 0 && in.Identity == "" {
23 return nil, errcode.ErrInvalidArgument
24 }
26 // TODO: add your logic code & delete this line.
28 return &pb.DataStatusReply{
29 Data: vars.OK,
- 影响:这是本模块唯一的模板 TODO(顶层
README.md:209也确认"Basic.Disband解散群组仍为占位")。行为上:调用者(任意登录用户,该方法没有任何所有权校验)收到OK,客户端有充分理由把群从 UI 移除,而服务端群、成员、申请记录全部保留——"假成功"造成的状态分歧比直接报错更危险;同时该方法也没有软删除/级联清理成员与申请的语义。一旦有人"顺手补实现"而漏掉群主校验(当前文件里就没有这个校验骨架),即成为 P0 越权解散。 - 建议:实现为事务:校验
passport_id == auth.ID(群主)→ 软删group_basic+ 级联软删relation_group_member/relation_group_apply→ 清理缓存;未实现前应返回ErrUnimplemented而不是OK。
11. 群成员计数 member_total 链路整体失效:SQL 非法、错误被吞、直接入群不计数、0 行删除仍扣减
- 位置:
module/social/group/internal/models/query.go:45-46;调用点internal/logic/member/do_kick.go:32-39、do_quit.go:33-41、join_do_handle.go:52、do_join.go:28-37;写入点internal/logic/basic/create.go:30 - 证据:
45 func UpsetGroupMemberTotal(group_identity string, op string) {
46 impl.DBService.Model(&GroupBasic{}).Where("identity=?", group_identity).UpdateColumn("member_total", gorm.Expr("member_total ? ?", op, 1))
47 }
op 是被当作绑定参数传入的运算符:GORM 会把 Expr 中每个 ? 渲染成占位符(clause/expression.go + statement.go:263-265),实际 SQL 形如 SET "member_total"=member_total $1 $2(参数 "-", 1),运算符位置是绑定变量 → 推测 PostgreSQL 直接报语法/运算符错误;而该函数没有返回值、也不检查 .Error,调用方(do_kick.go:38、do_quit.go:39)也无从得知失败。
- 影响:
- 计数从未被正确更新(错误 100% 被吞),并且
do_join.go:28-37的"免验证直接入群"分支根本不调用计数更新 → 计数与实际成员数永久不一致;Create写死的MemberTotal: 1(create.go:30)是唯一可靠值。 - 即使 SQL 修好,协议也是错的:
Delete影响 0 行(踢不存在的成员、非成员退群)时仍会-1,可把计数刷成负数(无member_total > 0守卫)。 - 计数器是反规范化字段,却没有与成员增删放在同一事务内(见问题 17),并发下必然丢失更新。
- 计数从未被正确更新(错误 100% 被吞),并且
- 建议:
gorm.Expr("member_total + ?", 1)/-1并把ok暴露给调用方;用RowsAffected门控;或直接删除该冗余计数,改为COUNT(*)(MemberFetchPage场景加索引即可)。
12. Member.DoJoin:返回值不是落库 identity;无重复申请/入群去重;不校验群存在、(id,identity) 匹配与成员上限;免验证开关恒失效
- 位置:
module/social/group/internal/logic/member/do_join.go:22-60、internal/models/query.go:39-43 - 证据:
23 identity := utils.UUID() // ← 返回给客户端
26 if models.GetGroupEnableAnyJoin(auth.ID, in.Identity) { // 参数用「申请人」当群主
28 apply := &models.GroupMember{GroupID: uint(in.Id), GroupIdentity: in.Identity, ...}
33 apply.Identity = utils.UUID() // ← 落库 identity 与返回值不同
...
40 apply := &models.GroupApply{FromID: uint(auth.ID), GroupID: uint(in.Id), GroupIdentity: in.Identity, Message: in.Message}
47 apply.Identity = utils.UUID() // ← 落库 identity 与返回值不同
调查询开关的实现:
41 impl.DBService.Model(&GroupBasic{}).Select("enable_any_join").Where("createor_id=? and identity=?", passport_id, group_identity).Scan(&is)
- 影响:
- 契约断裂:接口返回的
Data是一个从未入库的 UUIDv7,客户端无法用它做后续JoinDoHandle(会ErrRecordNotFound);data字段实际是"无效凭据"。 - 无去重:同一用户可对同一群无限次
DoJoin(申请表中无数一约束、代码无存在性检查),形成申请刷量/骚扰;若走直接入群分支则产生重复成员行,而DoQuit/DoKick的软删除Delete会一次删掉所有匹配行,计数却只减 1。 - 无群存在性校验、无 (id, identity) 一致性校验:
GroupID与GroupIdentity都直接来自请求,二者可任意组合,构造出的申请会在审核通过后生成"group_id属 A、group_identity属 B"的错配成员行,并把计数加到 B 群头上(join_do_handle.go:35-36,52)。属于 IDOR 型数据污染。 - 成员上限完全未实现:
member_limit只在create.go:28被写成固定 500,任何路径都没有member_total < member_limit判断,proto 注释"0代表不限制"(group.proto:35)与模型默认 100(group_basic.go:15)也不一致。 enable_any_join开关恒失效:GetGroupEnableAnyJoin的列名拼错为createor_id(正确是creator_id,且模型根本没有该列),Scan错误被忽略,且语义错位(把申请人当作群主去匹配)——该函数必然返回false,do_join.go:28-37的免验证直接入群分支是死代码,"免验证入群"这一产品能力从未生效(basic/const.go:10的DefaultEnableAnyJoin = true也从未被读取)。
- 契约断裂:接口返回的
- 建议:
identity与落库值统一(apply.Identity = identity,或返回落库对象);入参改为只接受group_identity并据此查群取id;加(group_identity, passport_id)唯一约束 +FirstOrCreate;加member_total < member_limit校验与入群频率限制;把开关查询改为Where("identity=?", group_identity)并检查错误。
13. Basic.Create:无事务、群号从不生成、忽略请求中的 member_limit、零输入校验
- 位置:
module/social/group/internal/logic/basic/create.go:23-55、internal/models/group_basic.go:11,15 - 证据:
23 record := &models.GroupBasic{
24 Name: in.GetName(),
28 MemberLimit: DefaultMemberLimit, // 请求里的 member_limit 被忽略
29 EnableAnyJoin: DefaultEnableAnyJoin,
30 MemberTotal: 1, // 第 23-31 行整体未给 Number(群号)赋值
31 }
32 record.Identity = utils.UUID()
33 record.PassportID = auth.ID
36 err = impl.DBService.Create(&record).Error
...
42 member := &models.GroupMember{GroupID: record.ID, GroupIdentity: record.Identity, Role: ROLE_CREATEOR}
51 err = impl.DBService.Create(&member).Error
- 影响:
- 无事务:群记录成功、创建者成员行失败时留下"无创建者成员的孤儿群"(群主自己都不在成员表里),而接口返回错误;反之成员行成功而群失败亦不可回滚。
- 群号恒为 0:
Number声明为column:number;not null(group_basic.go:11)却从不赋值,GORM 会把零值写库 → 所有群的number=0。若库上对该列有唯一索引,第一个群之后所有创建请求都会失败;若无唯一索引,则"群号"完全失去标识意义(enable_search_by_number也因此无意义)。分支取决于库外 DDL(无法验证)。 member_limit固定 500 与模型默认 100 不一致,客户端无法设置;DefaultEnableAnyJoin=true也只写进了库(写入后无人读取,见 P1-12)。- 零校验:
Name允许为空串(not null不拦空串)或超 255 字符(DB 报错 →ErrDB);Introduce/Notice(text)与Avatar/Background(varchar 255)同样无长度/内容校验;Master/Tags/Number等请求字段被静默丢弃(GroupItem.cretor_identity拼写错误,见 P3-28)。 - 无频率限制:任意登录用户可无限建群。
- 建议:
DBService.Transaction(...)包住两步写入(或用Association/同事务Create);生成唯一群号(序列或随机不重复号 + 唯一索引 + 冲突重试);白名单读取member_limit(限制上界)并统一默认值;校验name非空与长度、限制公告/简介长度并做内容过滤。
P2
14. 配置声明开启 HTTP gateway,但 Mux 从未初始化、也没有任何 gateway handler 注册 → 请求必定 panic
- 位置:
module/social/group/internal/server/new.go:13-33、cmd/main/main.go:24-35、etc/group_{dev,test,prod}.yaml:19-21、D:\work\bsm-sdk\core\service\service.go:106-111,120-129 - 证据:
13 type Server struct {
16 Mux *gwRuntime.ServeMux // 从未赋值
20 func New(addr string) *Server {
21 srv := &Server{Ctx: context.Background(), Grpc: grpc.NewServer(), grpcConns: make(...)} // 结构体无 Mux 赋值
28 pb.RegisterBasicServer(srv.Grpc, NewBasicServer())
Gateway:
Enable: true
Port: 12425
108 go s.Gateway(s.Opts.Addr, addr)
126 if err := http.ListenAndServe(httpAddr, s.Opts.GatewayMux); err != nil {
- 跨模块系统缺陷(本报告不展开论证,也不作为本模块独立 P0):内部
internal/server/new.go只注册 gRPC(Mux恒 nil、从不调用gwRuntime.NewServeMux());service/expose.go(本模块缺失,对照module/finance/wallet/service/expose.go:30-41)把options.Gateway直接交给生成的pb.Register*HandlerServer,而生成代码首行即mux.Handle(...)(pb/group.pb.gw.go:206、pb/member.pb.gw.go:233)无 nil 防御;独立部署入口cmd/main又从不调用Expose。因此"独立部署时 HTTP 网关不可用"是全仓模板缺陷,本模块只需决策"是否启用 Gateway",修复应在模板/聚合层统一进行。 - 影响:
Mux恒为 nil,且全模块(含生成的pb/group.pb.gw.go:205、pb/member.pb.gw.go:232)从未调用RegisterBasicHandlerServer/RegisterMemberHandlerServer(对照module/finance/wallet/service/expose.go:30-41的标准接入方式)。因此http.ListenAndServe("0.0.0.0:12425", (*gwRuntime.ServeMux)(nil))收到任何请求都会在ServeMux.ServeHTTP解引用 nil(推测:grpc-gateway/v2@v2.30.0/runtime/mux.go中s.unescapingMode字段读取 → nil pointer panic);net/http会按连接 recover 并打印 64KB 栈(http: panic serving ...),即每个请求都会产生一次 panic 日志放大,客户端拿到空响应。wiki/api/14-group.md:11承诺的POST /group.Basic/*路由实际不存在。 - 建议:按
module/finance/wallet/service/expose.go补service/expose.go(创建runtime.NewServeMux()并注册RegisterBasicHandlerServer/RegisterMemberHandlerServer),或在未接入前把Gateway.Enable置 false;同时确认Gateway.Port的唯一性(见 P2-15)。
15. 三份环境配置完全相同:prod 指向本地 bsm_dev 与 CHANGE_ME,端口与其它模块冲突,且未注册 etcd
- 位置:
etc/group_dev.yaml、etc/group_test.yaml、etc/group_prod.yaml(三文件 MD5 均为1D0D05F67F310F43608020899215E982)、etc/supervisor.bsm-social-group.conf:1-8 - 证据:
Databases:
Driver: postgres
Source:
- host=127.0.0.1 user=postgres password=CHANGE_ME dbname=bsm_dev port=5432 sslmode=disable TimeZone=Asia/Shanghai
Cache: redis://null:CHANGE_ME@127.0.0.1:6379/
MicroService:
Enable: false
Gateway:
Enable: true
Port: 12425
- 影响:
group_prod.yaml与 dev 完全一致:生产指向127.0.0.1/bsm_dev、凭据为CHANGE_ME。顶层README.md:286-288明确"YAML 仅为结构示例,部署时必须注入真实值",但本模块连环境区分都没做(conf.New只支持os.ExpandEnv,而文件里没有$VAR),一旦发布流程未覆盖etc/,生产会连到开发库。Gateway.Port: 12425与module/base/passport/etc/passport_prod.yaml、module/social/relation/etc/relation_prod.yaml相同(同一主机启动必然有一个绑定失败),而绑定失败在 SDK 里只打一条printer.Error不退出(service.go:126-128)→ 网关静默缺失。MicroService.Enable: false(service.go:66-90)→ 不向 etcd 注册路由表,平台网关(gateway/internal/rpc/rpc.go:202的RpcPool.RandomConn)无法发现该服务;配合 P2-14,本模块实际只有"直连 gRPC 端口"一种可达方式。supervisor以user=root运行、只redirect_stderr、无日志轮转(stdout_logfile=/data/app/logs/social-group.log);Etcd段在 YAML 中缺失(config.Spec.Etcd == nil),Token/Kyc/WeChatConf/Rpc/APM段或缺失或从不被使用(见 P3-27)。
- 建议:为 prod/test 提供真实(或至少明确的占位+注入说明)配置并区分端口;开启
MicroService.Enable或明确文档化"仅直连 gRPC";supervisor 降权、加stdout_logfile_maxbytes/轮转。
16. 数据库连接常开 Debug 日志:生产环境全量打印 SQL 与参数
- 位置:
module/social/group/internal/impl/impl.go:19-25、D:\work\bsm-sdk\core\with\databases.go:12-26、D:\work\bsm-sdk\core\database\sql\postgresql.go:11-24,46-48 - 证据:
23 DBService = with.Databases(config.Spec.Databases, nil) // opts = nil
13-21 options = &types.SqlOptions{ ..., IsAutoMigrate: false, ..., Debug: true } // SetOptions(nil) 默认值,Debug 在第 19 行
46 if options.Debug {
47 gormDb = gormDb.Debug()
48 }
- 影响:
Debug: true使 GORM 的 default logger 以 Info 级别把每条 SQL 及其参数输出到标准输出,再由 supervisor 落盘;生产环境日志量成倍放大,且慢查询/全表扫描会被记录为普通日志(无指标、无阈值)。参数里包含passport_id/群 identity 等可关联到个人的标识(无密码类字段,故未按"敏感信息泄露"计 P1)。 - 建议:改用
with.Databases(cfg, &types.SqlOptions{Debug: env.Runtime.Mode != "prod", ...}),或显式传入配好Logger与LogLevel(SlowThreshold)的 options。
17. 多写路径全部没有事务,任一步失败即产生不一致数据
- 位置:
internal/logic/basic/create.go:36,51、internal/logic/member/join_do_handle.go:43,49,52、internal/logic/member/do_kick.go:32,38、internal/logic/member/do_quit.go:33,39(全模块grep Transaction无匹配) - 证据:
36 err = impl.DBService.Create(&record).Error // 群
51 err = impl.DBService.Create(&member).Error // + 创建者成员(两步之间无事务)
- 影响:群 + 成员 + 申请状态 + 计数分属 2~4 条独立语句:
Create可留下无群主的群;JoinDoHandle可留下"成员已建但申请仍为 pending / 计数未加"的状态;踢人/退群可出现"成员已删但计数未减"。GORM 的隐式事务只覆盖单条语句,跨语句必须显式开启,而全模块没有任何Transaction/Begin调用(grep -rn "Transaction\|Begin(" internal/无匹配);SDK 的 MySQL 分支还显式设置了SkipDefaultTransaction: true(D:\work\bsm-sdk\core\database\new.go的NewMysql),说明该团队默认依赖调用方自行控制事务边界。 - 建议:所有涉及 ≥2 表写入的用例用
impl.DBService.Transaction(func(tx *gorm.DB) error {...}),并把计数更新纳入事务。
18. 错误被静默吞掉(4 处),失败与成功对外不可区分
- 位置:
internal/models/query.go:26(Pluck的 error 被丢弃)、internal/models/query.go:41(Scan的 error 被丢弃)、internal/models/query.go:46(UpdateColumn无返回值/未检查)、internal/logic/member/do_set_manager.go:37,40(UpdateColumn未检查) - 证据:
26 impl.DBService.Model(GroupMember{}).Select("group_id").Where("passport_id=? and role!=0", passport_id).Pluck("group_id", &ids) // 无 .Error 检查,失败返回空切片
41 impl.DBService.Model(&GroupBasic{}).Select("enable_any_join").Where("createor_id=? and identity=?", ...).Scan(&is) // 同上
- 影响:数据库故障/列名错误被伪装成业务结果——
GetMasterGroup失败会让JoinFetch返回"空申请列表",GetGroupEnableAnyJoin失败会静默改变入群分支,DoSetManager失败仍返回Data: vars.OK。与 P1-11 的错误吞没叠加后,运维无法通过日志区分"没有数据"和"查询挂了"。 - 建议:把这些函数签名改为返回
error,调用方统一映射为ErrDB;对UpdateColumn一律检查.Error与RowsAffected。
19. 无 context 传递、无超时/重试,客户端取消无法中断数据库查询
- 位置:全模块(
grep WithContext module/social/group无匹配);internal/impl/impl.go:23 - 证据:
20 groups, err := models.GetMyGroup(auth.ID) // fetch.go:20,未传 ctx
35 err = impl.DBService.Where("identity=? and creator_id=?", in.Identity, auth.ID).UpdateColumns(record).Error // modify.go:35
- 影响:所有查询/更新都跑在
*gorm.DB的默认上下文上,gRPC 请求被取消或超时后 SQL 仍会执行到底(连接被占用),无context.WithTimeout、无重试策略、无熔断;在慢查询(例如无索引的成员列表)下会放大连接池压力。service.ParseMetaCtx解析出的 claims 也未放入 ctx 传递(后续日志/审计无法关联请求)。 - 建议:
impl.DBService.WithContext(ctx)统一注入;对外部依赖设置超时;在 logic 入口加context.WithTimeout。
20. 无分页、无索引声明、无迁移:列表接口是全量扫描,索引只能依赖库外 DDL
- 位置:
internal/models/query.go:14-22,30-37、internal/logic/basic/fetch.go、internal/logic/member/fetch.go、internal/logic/member/join_fetch.go(均无 LIMIT/OFFSET);internal/models/group_member.go:11、group_apply.go:11-14 - 证据:
11 GroupID uint `gorm:"group_id;not null;" json:"group_id"` // 未用 column: 前缀,也没有 index/uniqueIndex 标签
- 影响:
proto的Fetch只接受blocks.Empty/blocks.IdentRequest,没有page_no/page_size(对照const.proto:10-14已有的FetchRequest未被使用)→ 成员列表/申请列表/群列表都是"一次性全量",大群会把全部行读进内存([]*pb.PassportInfoSimpleCard)再整体序列化。同时模型层没有声明任何索引,group_id/passport_id/group_identity是否建索引完全取决于库外 DDL(IsAutoMigrate: false,本模块无AutoMigrate调用)。按条件列推断(WHERE group_identity=?、WHERE passport_id=? AND role<>0、WHERE group_id IN ?)缺少(group_identity, passport_id)组合索引即会全表扫描——实际索引存在性无法验证(推测)。 - 建议:列表 RPC 增加分页参数并强制上界;模型补
index/uniqueIndex标签并补一份 DDL 迁移脚本纳入仓库;至少为relation_group_member(group_identity, passport_id)、relation_group_member(passport_id, role)、relation_group_apply(group_id, status)建索引。
21. 权限/可见性校验缺口汇总(除 P1-6/7/8 外)
- 位置:
internal/logic/member/fetch.go:14-33(只校验登录,不校验调用者是群成员)、internal/logic/basic/get.go:16-25(无可见性/成员校验,也不读enable_search_by_name/number)、internal/logic/basic/search.go:17-21 - 证据:
15 _, err = service.ParseMetaCtx(ctx, nil) // Member.Fetch:claims 被丢弃
20 if in.Identity == "" { return nil, errcode.ErrInvalidArgument }
24 members, err := models.GetGroupMember(in.Identity)
- 影响:群成员列表与群资料对所有"已登录用户"开放,只要知道群 identity 即可读取(identity 为 UUIDv7,不可枚举,故计 P2);
enable_search_by_name/enable_search_by_number两个开关(group_basic.go:22-23、group.proto:41-42)在整个模块中没有任何读取点,Basic.Search又只做空返回,所以"按名称/群号搜索"这一产品能力实际不存在,开关形同虚设。 - 建议:为读接口补成员校验(或明确"群资料公开"并在 proto/文档写清);实现
Search时把两个开关作为查询条件,并加分页。
22. 无输入长度/内容校验,群名与公告存在内容注入面
- 位置:
internal/logic/basic/create.go:24-27、internal/logic/member/do_join.go:40-46、internal/models/group_basic.go:13-20、internal/models/group_apply.go:15 - 证据:
24 Name: in.GetName(), // varchar(255) not null,无长度/非空校验
26 Notice: in.GetNotice(), // text,无任何过滤
45 Message: in.Message, // varchar(255),无长度校验
- 影响:超长入参会直接触发 PostgreSQL
value too long并被统一包装成ErrDB(1501)(错误语义掩盖真实原因);Name/Notice/Introduce/RemarkName原样入库、无转义无长度上限,若客户端以 HTML 渲染即为存储型 XSS / 内容注入面(本仓库无前端,标记为推测)。Message也没有敏感词或频率限制(见 P2-23)。 - 建议:在 logic 入口做
utf8.RuneCount长度校验与 trim/非空校验,返回ErrInvalidArgument;对Notice/Name/Introduce做 HTML 转义或按纯文本协议在前端渲染;统一超长错误的语义。
23. 无任何限流/防刷与幂等键,已初始化的 Redis/内存缓存完全未使用
- 位置:
internal/impl/impl.go:12-25(RedisService/MemorySerice只被赋值,全模块无引用)、internal/logic/member/do_join.go、internal/logic/basic/create.go - 证据:
13 RedisService *redis.RedisClient
16 MemorySerice *cache.Cache
22 RedisService = with.RedisCache(config.Spec.Cache) // 之后无任何读取
- 影响:
Create(建群)、DoJoin(申请加群)都没有频次限制、验证码或幂等键,恶意用户可无限建群/刷申请(写放大 + 存储膨胀);同时模块没有任何缓存,Basic.Get(群资料)这类热点读也无法缓存,纯靠数据库。 - 建议:用已注入的
RedisService做SETNX幂等键与滑动窗口限流(按passport_id+ 接口维度);群资料/成员数等热点数据加短 TTL 缓存并明确失效点(成员增删、Modify 时删除缓存)。
24. 软删除与唯一约束的冲突风险:踢人/退群是软删除,重新加入会新增一行
- 位置:
internal/models/group_member.go:9,19(内嵌Std_IICUDS,含gorm.DeletedAt)、internal/logic/member/do_kick.go:32、do_quit.go:33、do_join.go:37 - 证据:
32 err = impl.DBService.Model(&models.GroupMember{}).Delete("group_identity=? and identity=?", in.GroupIdentity, in.Identity).Error // 软删除(UPDATE deleted_at)
- 影响:成员行不会被物理删除,
(group_id, passport_id)/(group_identity, identity)上的唯一索引(若 DDL 建了)会与重新入群的新行冲突 → 退群/被踢后无法再次加入(返回ErrDB);若没有唯一索引,则同一用户会在表中留下多行历史记录,成员列表(若修好)会出现重复项。此外query.go:34的deleted_at is null在多表 join 下是歧义列(见 P1-4)。DDL 不可见,冲突必然性标注为推测。 - 建议:明确"退出"的语义——要么物理删除(
Unscoped()+ 唯一约束),要么改为状态字段(status=-1)并在重新入群时"复活"原行(OnConflict更新deleted_at=NULL);join 查询统一使用带表别名的gm.deleted_at IS NULL。
25. 可观测性与生命周期缺口:日志无上下文、无健康检查、cmd/cli 空入口、优雅退出不完整
- 位置:
cmd/cli/main.go:1-4(空main)、cmd/main/main.go:37-44、internal/server/new.go:22-31(reflection.Register)、全部 logic 的printer.Error(err.Error()) - 证据:
3 func main() {
4 }
31 reflection.Register(srv.Grpc)
- 影响:
cmd/cli是空实现(顶层 README 也把它当作"命令行辅助入口");日志只有裸err.Error(),没有 request_id/操作者passport_id/群标识等结构化字段,而这些问题恰恰发生在权限判定路径上,线上无法复盘"谁对哪个群做了什么";无Health/Ready探针(无法区分进程存活与 DB/Redis 可用);gRPCreflection在生产开放(顶层README.md:288建议评估关闭),配合无 TLS 的明文 gRPC(grpc.NewServer()无凭据)会暴露完整接口面并让 JWT 走明文(BindIP取自本机地址,conf.CheckIP)。service.Stop()只做GracefulStop,没有先从 etcd 摘除注册(service.go:142-144)。 - 建议:日志改用带 request_id/操作者的结构化输出(SDK
printer支持格式串);增加Health/ReadyRPC;按环境关闭 reflection 并启用 TLS/mTLS;Stop()先Revoke租约再GracefulStop;删除空cmd/cli或补齐子命令。
P3
26. 角色/状态枚举与常量重复定义、魔法数字散落,direction 用字符串承载枚举
- 位置:
internal/logic/basic/const.go:3-14与internal/logic/member/const.go:3-14(两文件 15 行逐行重复)、internal/models/group_member.go:14、proto/member.proto:87 - 证据:
4 ROLE_MEMBER = 0 // 普通会员
5 ROLE_MASTER = 1 // 管理员
7 ROLE_CREATEOR = 10 // 创建者
87 string direction=3; //操作方式:Pass通过,Reject拒绝,Set设置,cancel取消
- 影响:同一套角色常量在两个包各定义一份(
basic.DefaultMemberLimit与member.DefaultMemberLimit也重复),跨包使用同一含义的魔法数字0(join_do_handle.go:37直接写Role: 0,而do_set_manager.go:40用常量),常量名ROLE_CREATEOR拼写错误;角色语义(0/1/2/10,其中ROLE_SUPER_MASTER=2无任何使用点)没有单一事实来源。direction用大小写不敏感的字符串承载 4 种枚举,非法值被switch静默吞掉(P1-7、P1-8),且与Basic服务的IdentRequest风格不统一。 - 建议:把角色/状态抽到一个共享包(或
internal/models)并定义Role类型常量+Valid();direction改为 proto enum(OP_PASS/OP_REJECT/OP_SET/OP_CANCEL/OP_UNSPECIFIED),OP_UNSPECIFIED一律返回ErrInvalidArgument。
27. 死代码与被静默丢弃的字段/配置
- 位置:
internal/models/group_res.go:1-21(relation_group_res模型全模块零引用)、internal/logic/basic/const.go:14(filed仅在 member 包使用)、internal/server/new.go:17,24(grpcConns连接池只创建不使用)、internal/impl/impl.go:21-22(MemorySerice/RedisService零引用,且MemorySerice拼写错误)、internal/config/config.go:15-25(WeChat/Kyc/Rpc/Apm/Token配置段无任何读取点)、proto/group.proto:36-39(master/background/tags字段无落库逻辑) - 证据:
14 filed string = "passport_identity as identity,nickname,avatar,sex,province,city,area,sign"
- 影响:这些残留物让"能力"看起来比实现多(群文件/群主列表/背景/标签、第三方配置、连接池),会持续误导接入方与后续审计(顶层
README.md:276把service描述为聚合接入层,而本模块该目录为空,也属此类)。 - 建议:删除或接线:
GroupRes与relation_group_res要么补 RPC 要么删;filed移入 member 包或删除 basic 包中的重复定义;未使用的配置段从SrvConfig移除或在 etc 中注释说明。
28. 命名/文档与实现不一致(proto、README、wiki)
- 位置:
proto/group.proto:34(cretor_identity拼写错误)、:33(creator_id在模型中实为passport_id)、:35("0代表不限制" vs 代码固定 500 / 模型默认 100)、wiki/api/14-group.md:11(承诺 grpc-gateway 路由POST /group.Basic/*,实际无 handler,见 P2-14)、README.md:207-209(把搜索/列表/修改列为"主要功能",实际 Search 空实现、Fetch/Get/Modify 不可用)、顶层README.md:313(要求service.Expose) - 证据:
12 func Search(...) (reply *pb.GroupsReply, err error) {
21 return &pb.GroupsReply{}, nil // 空实现
- 影响:接入方按 wiki/README 编写客户端会得到空列表/错误码;
cretor_identity一旦被前端依赖就形成永久兼容负担。 - 建议:统一为
creator_identity(proto 改字段名需保留json_name兼容);把各接口的真实实现状态写进模块 README(现在只有一行# group);service/补空目录说明或补expose.go。
29. 错误码语义不当与"总数"语义错误
- 位置:
internal/logic/basic/get.go:23、internal/logic/member/join_do_handle.go:29(记录不存在返回ErrDB)、internal/logic/member/do_quit.go:30("群主不能退群"用ErrPermissionDenied)、internal/logic/basic/fetch.go:28、internal/logic/member/fetch.go:32、internal/logic/member/join_fetch.go:41(Total= 本页/本次返回条数) - 证据:
28 Total: int32(len(groups)), // 语义上应为全量总数
- 影响:调用方无法区分"参数不存在"(应为
ErrRecordNotFound/ErrInvalidArgument)与"数据库故障"(ErrDB);分页引入后Total若继续用len()会直接算错总页数。 - 建议:
gorm.ErrRecordNotFound映射为ErrRecordNotFound;Total用独立Count(*)。
30. 群 identity 兼作加入凭证,且缺少 id/identity 一致性校验;UUIDv7 时间有序
- 位置:
internal/logic/member/do_join.go:28-46(请求同时携带id与identity,二者不校验)、D:\work\bsm-sdk\core\utils\identity.go:8-10(utils.UUID()= UUIDv7) - 证据:
8 func UUID() string {
9 return uuid.Must(uuid.NewV7()).String()
10 }
- 影响:模块没有独立的邀请码/邀请链接机制,
group.identity既是公开标识又是加入凭证(持有即可申请入群),一旦泄露无法吊销(无过期、无重置接口);UUIDv7 前 48 位是毫秒时间戳,可推断群/成员的创建时间与相对顺序(信息泄露面很小,但不符合"不可预测凭证"的最佳实践)。请求中的id与identity从不互相校验(P1-12 的错配来源)。 - 建议:入参只保留
identity(或强校验二者匹配);需要邀请制时引入独立、可吊销、可设过期的邀请码;对外标识与加入凭证分离。
31. 生成代码与脚手架残留的一致性提示
- 位置:
internal/server/{basic_server.go,member_server.go,new.go}:1(// Code generated by protoc-gen-slc. DO NOT EDIT.)、internal/server/new.go:20(New(addr string)的addr参数未使用)、internal/server/basic_server.go:11、member_server.go:11(内嵌Unimplemented*Server,但所有 13 个方法都被覆写为"假实现",因此codes.Unimplemented永远不会返回) - 影响:
pb.Unimplemented*Server的语义是"未实现即报Unimplemented",而本模块 13 个方法全部被覆写成"返回成功/返回空对象",库层面的"未实现保护"被绕过——这正是Disband/Search等空实现能对外返回 OK 的机制原因。addr形参未使用、Mux未初始化(P2-14)说明脚手架版本与控制器模板不一致。 - 建议:未实现的方法不要覆写(保留
Unimplemented*Server默认行为直接返回codes.Unimplemented),或显式返回ErrUnimplemented;同步更新protoc-gen-slc模板(生成Mux初始化 + gateway 注册),三处一起改,避免只修一个模块。
4. 推荐优化方案
按"先止血、再补能力、后清理"三步推进;由于本模块目前几乎不可用(13 个 RPC 中 12 个有确定性缺陷),工作量集中在第 1 步。
第 1 步:让已有接口"要么正确、要么明确报错"(P0/P1 级,1~2 人日)
- 统一数据访问层:删掉全部 raw SQL(
internal/models/query.go:14-22,30-37),改用 GORM 模型 +Joins/Where("... IN ?"),让表名(group_basic/relation_group_member/relation_group_apply)与软删条件由 schema 产生;所有查询WithContext(ctx)、所有写操作检查.Error与RowsAffected。 - 统一"所有权/角色"判定:新增一个内部函数
membership(tx, groupIdentity, passportID) (role, err),把creator_id全部替换为passport_id(或补CreatorID字段并让Create写入),并按"角色矩阵"落地:解散/改资料=群主;设置管理员/踢人=群主或管理员(管理员不可操作群主与同级);退群=本人(群主须先转让);审核申请=群主或管理员。 - 修返回值与契约:
Fetch/Get/Member.Fetch补回数据;DoJoin返回落库 identity;Disband/Search未实现前返回ErrUnimplemented;非法direction返回ErrInvalidArgument。 - 修计数:
gorm.Expr("member_total + ?", 1)+RowsAffected门控,并把成员变更与计数放进同一事务;do_join.go的直接入群分支补计数与上限校验。 Modify白名单更新:Updates(map)+ 删除record.Identity = utils.UUID()。
第 2 步:补齐产品能力与安全护栏(P1/P2 级,2~3 人日)
- 入群链路完整化:
DoJoin只接受group_identity(服务端查id),(group_identity, passport_id)加唯一约束 +FirstOrCreate;申请去重与限流;JoinDoHandle加权限校验 + 申请状态机(仅status=0可处理)+ 事务;实现enable_any_join(修正createor_id拼写与语义)与member_limit上限校验。 - 群生命周期:
Create生成唯一群号(唯一索引 + 冲突重试)、事务写入群与创建者成员、校验名称非空/长度;新增群主转让 RPC 后再允许群主退群;实现Disband(事务级联软删)。 - 列表与检索:
Fetch/Member.Fetch/JoinFetch/Search全部加分页(复用const.proto:10-14的FetchRequest或新增分页消息)并实现Search(按enable_search_by_name/number过滤)。 - 接入与配置:补
service/expose.go(对齐module/finance/wallet/service/expose.go),或明确"仅直连 gRPC"并把Gateway.Enable关掉;区分 dev/test/prod 配置与端口;Debug日志按环境关闭;按README.md:288打开 TLS、关闭 reflection。 - 测试:为权限矩阵(群主/管理员/成员 × 13 个 RPC)、重复入群/重复审核的幂等、并发计数、软删除后重新入群建立表驱动测试(详见 §5)。
第 3 步:可维护性与治理(P3 级,1 人日)
- 角色/状态收敛到单一包 + proto enum;清理重复常量与魔法数字。
- 删除死代码(
GroupRes、filed(basic)、grpcConns、未用配置段、cmd/cli),或在 README 标注 roadmap。 - 用代码现状重写模块 README(当前仅
# group)并同步wiki/api/14-group.md,明确每个 RPC 的实现状态;把docs/audit/README.md的索引与汇总 TODO 回填。
5. TODO 清单
- P1-1(问题1) 重写
Basic.Modify:白名单Updates、去掉identity重写、所有权条件改passport_id、校验RowsAffected|验收:改群资料后identity/passport_id/member_limit/member_total均不变,非群主调用返回ErrPermissionDenied,0 行受影响时返回ErrRecordNotFound|涉及:module/social/group/internal/logic/basic/modify.go:24,:33,:35 - P1-2(问题2) 重写
GetMyGroup:表名改relation_group_member/group_basic(或改 GORM 查询)、列加表别名、回填Groups|验收:任一用户调用Basic.Fetch返回其全部群且Groups非空、Total与条数一致|涉及:module/social/group/internal/models/query.go:16,internal/logic/basic/fetch.go:27 - P1-3(问题3)
Basic.Get改查models.GroupBasic并映射 pb,不再丢弃结果|验收:按 identity 查询返回真实群资料(name/avatar/notice/member_total 与库一致),不存在的 identity 返回ErrRecordNotFound|涉及:module/social/group/internal/logic/basic/get.go:20,:25 - P1-4(问题4)
Member.Fetch修正表名、回填Members、加"调用者须为该群成员"校验与分页|验收:非成员调用返回ErrPermissionDenied;成员调用返回分页数据且Total为真实总数;单次查询有 LIMIT|涉及:module/social/group/internal/models/query.go:31,internal/logic/member/fetch.go:31 - P1-5(问题5)
JoinFetch改Where("group_id IN ?", ids)、批量查申请人资料、每项独立对象|验收:管理 1 个及以上群的用户可获得正确申请列表,每个from卡片对应各自申请人(无覆盖/错乱),SQL 条数不随申请数增长|涉及:module/social/group/internal/logic/member/join_fetch.go:24,:29,:36 - P1-6(问题6)
DoKick权限查询改group_identity+passport_id(整型),踢人目标语义统一并检查RowsAffected|验收:群主/管理员可踢普通成员,普通成员调用返回ErrPermissionDenied,踢不存在的成员返回ErrRecordNotFound且不改计数|涉及:module/social/group/internal/logic/member/do_kick.go:24,:32 - P1-7(问题7)
DoSetManager目标成员加group_identity限定、检查错误与RowsAffected、非法 direction 报错|验收:群主只能设置本群成员;跨群 identity 调用返回ErrPermissionDenied/ErrRecordNotFound;direction=foo返回ErrInvalidArgument|涉及:module/social/group/internal/logic/member/do_set_manager.go:37,:40 - P1-8(问题8)
JoinDoHandle加"调用者在申请所属群 role<>0"校验、申请状态机与事务、幂等(唯一约束/OnConflict DoNothing)|验收:非管理员调用返回ErrPermissionDenied;重复 PASS 不产生第二个成员行、计数只加一次;非法 direction 返回ErrInvalidArgument|涉及:module/social/group/internal/logic/member/join_do_handle.go:19,:43,:49,:52 - P1-9(问题9)
DoQuit改用passport_id判群主、补群主转让前置条件、RowsAffected门控计数|验收:群主未转让时退群返回明确错误;普通成员退群成功且计数减 1;非成员调用不改计数|涉及:module/social/group/internal/logic/member/do_quit.go:24,:33,:39 - P1-10(问题10) 实现
Basic.Disband(群主校验 + 事务级联软删群/成员/申请);在实现前返回ErrUnimplemented|验收:非群主调用返回ErrPermissionDenied;群主调用后群、成员、申请全部不可见(软删)且不再出现在列表中|涉及:module/social/group/internal/logic/basic/disband.go:26,:28 - P1-11(问题11) 修
UpsetGroupMemberTotal(gorm.Expr("member_total + ?", 1)、返回 error、按RowsAffected门控)并把成员增删与计数纳入同一事务|验收:直接入群/审核通过/踢人/退群后member_total与COUNT(*)一致;并发 50 次入群后计数误差为 0|涉及:module/social/group/internal/models/query.go:45,internal/logic/member/do_kick.go:38,do_quit.go:39,join_do_handle.go:52,do_join.go:28 - P1-12(问题12)
DoJoin返回值与落库 identity 统一;只接受group_identity并由服务端取id;加去重、群存在性、member_limit校验;修GetGroupEnableAnyJoin(identity条件 + 错误检查 + 上界)|验收:返回值可直接用于JoinDoHandle;同一用户重复入群不产生第二行;超上限返回Errcode类业务错误;enable_any_join=true的群为直接入群|涉及:module/social/group/internal/logic/member/do_join.go:23,:26,:33,:47,internal/models/query.go:41 - P1-13(问题13)
Create加事务、生成唯一群号、按请求解析member_limit(带上界)、校验name非空/长度|验收:群与创建者成员同事务提交(失败全回滚);每个群number<>0且唯一;空名/超长名返回ErrInvalidArgument;member_limit为请求值与默认值的一致结果|涉及:module/social/group/internal/logic/basic/create.go:23,:28,:36,:51 - P2-14(问题14) 补
service/expose.go创建并注册 grpc-gateway handler(或关闭Gateway.Enable)|验收:curl -XPOST :12425/group.Basic/Fetch返回业务响应而非 panic/空响应;进程日志无http: panic serving|涉及:module/social/group/internal/server/new.go:13,cmd/main/main.go:33,etc/group_prod.yaml:19 - P2-15(问题15) 拆分 dev/test/prod 配置(DB/Redis/端口/密钥),把
Gateway.Port换成未被占用的值,明确MicroService.Enable取舍|验收:三份配置差异化且互不冲突;prod 不再指向bsm_dev/CHANGE_ME;同机启动 group/passport/relation 无端口绑定失败|涉及:module/social/group/etc/group_prod.yaml:7,:10,:14,:21 - P2-16(问题16) 生产关闭 GORM Debug 并配置慢查询阈值|验收:prod 日志不再出现成功 SQL 明细,仅保留 ≥ 阈值的慢查询;
Debug由环境变量控制|涉及:module/social/group/internal/impl/impl.go:23 - P2-17(问题17) 为所有多表写入加事务|验收:
Create、JoinDoHandle、Disband注入中途失败后数据完整回滚|涉及:module/social/group/internal/logic/basic/create.go:36,internal/logic/member/join_do_handle.go:43 - P2-18(问题18)
GetMasterGroup/GetGroupEnableAnyJoin/UpsetGroupMemberTotal返回 error,所有UpdateColumn检查错误|验收:注入 DB 错误后调用方返回ErrDB而非空结果/OK;grep -n "_ = impl.DBService"无匹配|涉及:module/social/group/internal/models/query.go:26,:41,:46,internal/logic/member/do_set_manager.go:37 - P2-19(问题19) 全部 DB 调用注入
ctx并加超时|验收:取消 gRPC 请求后数据库查询随之中断(可在 pg_stat_activity 观察);grep WithoutContext无非WithContext的业务查询|涉及:module/social/group/internal/logic/basic/fetch.go:20,internal/logic/basic/modify.go:35 - P2-20(问题20) 列表接口加分页;模型补索引标签与 DDL 迁移脚本|验收:成员/申请/群列表均支持 page_no/page_size 且有上界;迁移脚本包含
(group_identity, passport_id)等索引;EXPLAIN显示索引扫描而非全表扫描|涉及:module/social/group/internal/logic/member/fetch.go:24,internal/models/group_member.go:11,internal/models/group_apply.go:11 - P2-21(问题21) 读接口补成员/可见性校验,并把
enable_search_by_name/number用于检索条件|验收:非成员调用成员列表被拒;关闭开关的群不出现于搜索结果|涉及:module/social/group/internal/logic/member/fetch.go:15,internal/logic/basic/search.go:21 - P2-22(问题22) 入参加长度/非空/内容校验|验收:超长
name/message/notice返回ErrInvalidArgument而非ErrDB;输入被 trim 且拒绝空名|涉及:module/social/group/internal/logic/basic/create.go:24,internal/logic/member/do_join.go:45 - P2-23(问题23) 用 Redis 做入群/建群限流与幂等键|验收:同一用户 1 分钟内
DoJoin超过阈值被拒;重复提交(同幂等键)只落一条申请|涉及:module/social/group/internal/impl/impl.go:22,internal/logic/member/do_join.go:22 - P2-24(问题24) 明确退出/踢出与唯一约束的语义(物理删除或状态位复活),join 查询统一
gm.deleted_at IS NULL|验收:退出后可再次加入且不产生重复行;成员列表无重复|涉及:module/social/group/internal/logic/member/do_kick.go:32,do_quit.go:33,internal/models/query.go:34 - P2-25(问题25) 结构化日志 +
Health/Ready+ 关闭 reflection/TLS + 优雅退出|验收:日志含 request_id 与操作者 identity;grpc.health.v1.Health/Check可反映 DB/Redis 状态;SIGTERM 时先摘除 etcd 注册再GracefulStop|涉及:module/social/group/cmd/main/main.go:37,internal/server/new.go:31,internal/logic/member/do_kick.go:25 - P3-26(问题26) 角色/状态常量单一来源 +
direction改 proto enum|验收:全模块只有一个Role定义;OP_UNSPECIFIED一律报错;grep -rn "ROLE_MEMBER"只命中定义处|涉及:module/social/group/internal/logic/basic/const.go:4,internal/logic/member/const.go:4,proto/member.proto:87 - P3-27(问题27) 清理死代码与未用配置/字段|验收:
GroupRes、basic 包filed、grpcConns、WeChat/Kyc/Rpc/Apm/Token配置、master/tags/background字段各有"删除或接线"结论|涉及:module/social/group/internal/models/group_res.go:8,internal/logic/basic/const.go:14,internal/server/new.go:17,internal/config/config.go:15 - P3-28(问题28) 修正 proto 命名并同步 README/wiki|验收:
cretor_identity改为creator_identity(保留json_name兼容);模块 README 逐 RPC 标注实现状态;wiki 路由与实际网关一致|涉及:module/social/group/proto/group.proto:34,README.md:1,wiki/api/14-group.md:11 - P3-29(问题29) 修正错误码与
Total语义|验收:记录不存在返回ErrRecordNotFound;Total为全量总数(分页下与Count(*)一致)|涉及:module/social/group/internal/logic/basic/get.go:23,internal/logic/basic/fetch.go:28 - P3-30(问题30) 加入凭证与对外标识分离(可选邀请码/可吊销),并强校验
id/identity一致性|验收:不再存在"持有 identity 即可加入"的隐式凭证;id与identity不匹配时返回ErrInvalidArgument|涉及:module/social/group/internal/logic/member/do_join.go:28,D:\work\bsm-sdk\core\utils\identity.go:8 - P3-31(问题31) 未实现方法不覆写(保留
Unimplemented默认行为)并同步protoc-gen-slc模板(Mux 初始化 + gateway 注册)|验收:未实现 RPC 返回codes.Unimplemented;新生成模块自带Mux初始化与 handler 注册|涉及:module/social/group/internal/server/basic_server.go:19,internal/server/member_server.go:19,internal/server/new.go:20
测试用例清单(test/ 目前只有空 lint/ 目录,0 个测试)
- P2-T1 权限矩阵表驱动测试:13 个 RPC × {群主, 管理员, 普通成员, 非成员, 未登录}|验收:每个组合断言预期错误码(
ErrPermissionDenied/成功),覆盖 P1-6/7/8/9|涉及:module/social/group/internal/logic/member/ - P2-T2 幂等/去重测试:重复
DoJoin、重复 PASS 同一申请、重复Create同名群|验收:成员表与申请表不产生重复行,计数只加一次|涉及:module/social/group/internal/logic/member/do_join.go:37,join_do_handle.go:43 - P2-T3 并发计数测试:并行 N 次入群/退群后
member_total == COUNT(*)|验收:无丢失更新(配合 P1-11 的事务修复)|涉及:module/social/group/internal/models/query.go:45 - P2-T4 契约测试:
DoJoin返回值可用于JoinDoHandle;Basic.Get/Fetch/Member.Fetch返回真实数据|验收:端到端流程(建群→申请→审核→列表→踢人→退群)断言成功|涉及:module/social/group/internal/logic/ - P2-T5 软删除/唯一约束回归:退群或被踢后重新入群|验收:不报唯一键冲突且成员列表无重复|涉及:
module/social/group/internal/logic/member/do_join.go:37 - P3-T6 配置/网关冒烟:
gofmt -l、go vet ./...、启动后curlgateway 端口与 gRPC reflection 检查|验收:无 panic 日志、gateway 路由按预期可用或已明确关闭|涉及:module/social/group/internal/server/new.go:13
6. 审计摘要(供汇总使用)
- 问题数:P0=0 P1=13 P2=12 P3=6(合计 31;另有 6 条测试 TODO)
- 最高风险(一句话):13 个 RPC 中 12 个存在确定性缺陷——群列表/群详情/成员列表因表名与列名错误 + 结果被丢弃而恒失败或恒空、
Disband是返回 OK 的模板 TODO、踢人/设管理员/退群/审核的权限与 SQL 全错,唯一能落库的Create还不写群号且无事务;同时Modify的UpdateColumns(整个结构体)一旦所有权列被修正就会随机重写群identity(并清零passport_id/member_total),使全群成员与申请关系断裂。 - 最优先 3 个动作:
- 修权限与 SQL 的正确性基线:
DoKick/DoSetManager/DoQuit/JoinDoHandle的角色校验改为group_identity + passport_id并检查RowsAffected,同时把GetMyGroup/GetGroupMember/JoinFetch的 raw SQL 全部换成带表名与IN ?的 GORM 查询(internal/models/query.go、internal/logic/member/*),让每个接口"要么正确、要么明确报错"。 - 修
Basic.Modify的破坏性更新与Create的事务/群号,并把member_total的计数更新放进同一事务、不再吞错误(modify.go:33-35、create.go:36,51、query.go:45)。 - 补
service/expose.go(或关闭Gateway.Enable)并统一creator_id→passport_id,避免"配置声明开启但网关必 panic、代码引用不存在的列"这两类系统性误导(internal/server/new.go、三份 etc)。
- 修权限与 SQL 的正确性基线:
- 未能覆盖/无法验证的部分:
- 真实数据库表结构与索引/唯一约束(仓库内无 DDL、无迁移,
IsAutoMigrate=false):creator_id列是否存在、number是否唯一、成员表是否有唯一索引均按"分支 A/B"表述,未连接生产库验证; - 运行时行为:未真正执行 SQL/启动服务,"SQL 报错、
http: panic serving"等结论基于 GORM v1.31.2 与 grpc-gateway v2.30.0 源码推演(正文已标注「推测」); - 生产发布流程是否外部覆盖
etc/、平台网关的实际鉴权/匿名白名单配置(D:\work\bsm-infra\gateway)未纳入验证; - 前端渲染方式未知,群名/公告/留言的存储型 XSS 仅为风险面提示(未证实);
gofmt -l(无输出)与go vet ./...(exit=0)通过,但两者均无法发现本报告中任何一类缺陷。
- 真实数据库表结构与索引/唯一约束(仓库内无 DDL、无迁移,