Files
full/docs/audit/module-social-group.md
yanweidong 63aeedc2fe docs: add per-module audit reports (18 modules)
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.
2026-09-14 22:16:27 +08:00

72 KiB
Raw Blame History

审计报告module/social/group

1. 模块概览

  • 路径D:\work\bsm-infra\full\module\social\groupGo module bsm/full/module/social/groupgo.work 成员,见 go.work./module/social/group)。
  • 形态gRPC + gRPC-gateway 微服务,独立进程入口 cmd/main/main.goproto 定义 Basic6 个 RPCMember7 个 RPC两个 service13 个 RPCproto/group.proto:7-25proto/member.proto:7-30)。
  • 规模27 个非 pb Go 文件 / 805 行;另有 3 个 protogroup/member/const+ 7 个生成文件(const.pb.gogroup.pb.gogroup.pb.gw.gogroup_grpc.pb.gomember.pb.gomember.pb.gw.gomember_grpc.pb.go+ 4 个 etc 配置dev/test/prod/supervisor
  • 数据模型GroupBasicgroup_basicinternal/models/group_basic.go:28-30)、GroupMemberrelation_group_membergroup_member.go:18-20)、GroupApplyrelation_group_applygroup_apply.go:19-21)、GroupResrelation_group_resgroup_res.go:19-21全模块零引用)。三者均内嵌 types.Std_IICUDS(软删除 DeletedAt)与 types.Std_Passportpassport_id/passport_identity没有任何 creator_id 字段D:\work\bsm-sdk\core\types\db.go:28,52)。
  • 路由/鉴权模型:每个 handler 都调用 service.ParseMetaCtx(ctx, nil) 校验 authorization 元数据中的 JWTD:\work\bsm-sdk\core\service\meta.go:19-49opts==nil 因此不做角色限制;鉴权只区分"是否登录"群内角色(群主/管理员/成员)由各 logic 自行判断,这正是本模块问题最集中之处。
  • 接入状态MicroService.Enable: false(不注册 etcd 路由),Gateway.Enable: trueservice/ 目录存在但为空,未按 README.md:313 约定提供 service.Expose;顶层 README.md:45,231 明确"社交服务不接入 all/ecmall"。
  • 一句话结论13 个 RPC 中 12 个存在确定性缺陷SQL 表名/列名错误、结果被丢弃、权限查询恒假、模板 TODO 空实现),唯一"能落库"的 Create 仍缺群号、事务与校验;同时"成员上限""免验证入群""搜索开关"等开关全部未生效。

2. 审计范围与方法

已读完100%

  • 全部 27 个非 pb Go 文件、805 行:cmd/maincmd/cliinternal/{config,impl,server,models,logic/basic,logic/member}
  • proto/group.protoproto/member.protoproto/const.protopb/blocks_compat.go;生成代码按需阅读(group.pb.gw.go*_grpc.pb.gogroup.pb.go)。
  • etc/group_{dev,test,prod}.yamletc/supervisor.bsm-social-group.confgo.modREADME.md(模块)、顶层 README.mdwiki/api/14-group.mddocs/audit/README.md

外部交叉验证(用于判定"是模块缺陷还是平台约定"

  • SDKD:\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.goclause/set.gostatement.go:175-269scan.go:126-357finisher_api.goschema/schema.gocallbacks/update.gogrpc-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))}, nilinternal/logic/basic/fetch.go:27-29,未设置的 Groups 为零值 nilreturn &pb.GroupItem{}, nilinternal/logic/basic/get.go:25)、return &pb.GroupMemberReply{Total: int32(len(members))}, nilinternal/logic/member/fetch.go:31-33)。

无法覆盖/无法验证

  1. 真实表结构:仓库内没有任何 DDL/SQL 迁移文件,IsAutoMigrate 默认 falseD:\work\bsm-sdk\core\database\sql\postgresql.go:17,46)且本模块未调用 AutoMigrate(对照 module/ec/order/internal/models/impl.go:41),因此 group_basic 是否真有 creator_idnumber 是否有唯一索引、relation_group_member 是否有唯一约束均无法从仓库确认——相关结论已写成"分支 A/分支 B"。
  2. 运行时行为:未连接真实 PostgreSQL 执行 SQL也未启动服务所有"SQL 报错/panic"结论是基于所用库源码的推演(已标注)。
  3. 生产配置注入方式conf.New 只做 os.ExpandEnv(不支持 $VAR 之外的覆盖,etc/*.yaml 中也没有变量),但发布系统是否外部覆盖 etc/ 无法验证。
  4. 前端渲染方式(决定群名/公告是否构成存储型 XSS不在本仓库。

3. 问题清单

P0

无。

判定说明(逐条排除,避免误报):本次未发现"当前可被利用的 P0 级漏洞"。最接近的两个候选都被其他缺陷挡住,因而降级为 P1

  • Basic.Modify 的破坏性写入(改写群 identity、清零 passport_id/member_limit/member_total)本身足以构成数据损坏,但其 WHERE 引用了模型中不存在的 creator_idinternal/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.ModifyUpdateColumns 提交整个结构体:会连带写入零值字段并把群 identity 改成随机 UUID且所有权条件引用不存在的列

  • 位置module/social/group/internal/logic/basic/modify.go:24-35internal/models/group_basic.go:9-11D:\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_Passportpassport_id/passport_identity),全仓库(含 proto、其它模块模型检索不到 creator_id 列的定义(grep -r creator_id module/ 仅命中本模块 3 处逻辑代码 + 1 处 proto

  • 影响
    1. UpdateColumns(struct) 的语义是"更新全部字段(含零值)"——依据本模块实际使用的 GORM v1.31.2finisher_api.goUpdateColumns 仅设置 SkipHooks=true,而 callbacks/update.goConvertToAssignments!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 一旦变化,该群的全部成员与申请关系立即断裂,且不可恢复。
    2. 所有权条件 creator_id 在模型/迁移中不存在:分支 A库里无此列→ PostgreSQL column "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-22internal/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
  • 影响
    1. group_member 不是模型声明的表名(模型是 relation_group_membergroup_member.go:19group 也不是(模型是 group_basic),且 group 是 PostgreSQL 保留字,FROM group/Select group.* 无法解析;passport_id/deleted_at 在 join 后同时存在于成员表与群表,属于歧义列42702。推测该接口对所有用户恒返回 ErrDB(1501)(表不存在/语法错误),未连库无法给出确切 SQLSTATE。
    2. 即便 SQL 修好,返回值也丢掉了数据:Totallen(groups) 表示,Groups 字段始终为 nil —— 客户端"获取群组列表"永远拿到空列表(groups 变量在函数内被完全浪费)。
  • 建议:用 GORM 模型 + Joins 写查询(让表名/软删条件由 schema 决定),或至少把表名修正为 relation_group_member/group_basic 并给列加表别名前缀;返回时补 Groups: groupsTotal 改为独立 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/ModelSDK 的 NamingStrategy 是 SingularTable: trueD:\work\bsm-sdk\core\database\sql\postgresql.go:39-41),因此表名被推导为 group_item 而真实表是 group_basic → 推测该接口对任意入参恒返回 ErrDB;若表名恰好存在,代码也会丢弃已扫描到的 reply 并返回全零 GroupItemid=0、identity=""、name=""任何调用者都拿不到群资料。另外 in.Id 被完全忽略。
  • 建议var g models.GroupBasic; ...First(&g) 后显式映射到 pbId/Identity 二者至少校验其一或都支持。

4. Member.Fetch(成员列表)表名错误 + 结果被丢弃,且无成员校验、无分页

  • 位置module/social/group/internal/models/query.go:30-37internal/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-37internal/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	}
  • 影响
    1. Where("group_id in ", group_ids) 的 SQL 串不含 ?GORM 会把 group_ids 作为"多余变量"追加(clause/expression.goExpr.Build 尾部 → statement.go:182-183sql.NamedArg 只入参不写占位符)→ 渲染成 ... WHERE group_id in AND "relation_group_apply"."deleted_at" IS NULL。推测:只要用户管理着 ≥1 个群,该接口必报 ErrDB(语法错误/参数个数不符)。正确写法是 Where("group_id IN ?", group_ids)
    2. 每条申请一次 passport_extend 查询 = N+1。
    3. 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-39internal/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.IdentitystringD:\work\bsm-sdk\core\tokenClaims{ID uint; Identity string}),而 passport_id 是整型列;GroupMemberidentity 列是成员行自身的 UUID,群标识在 group_identity 列。

  • 影响:该权限查询把群 identity 比到了成员行的 identity 列上,同时把字符串 auth.Identity 比到 passport_idPostgreSQL 无 bigint = text 运算符)→ 推测恒定报错并返回 ErrDB群主与管理员都无法踢人fail-closed因此暂不构成越权。此外 Deleteidentity=in.Identity(成员行 UUID作为目标而对外唯一暴露成员身份的地方返回的是 passport identityquery.go:31 pe.identity AS identity)——即便权限查询修好,调用方也拿不到可用于踢人的 identityAPI 契约断裂。删除影响 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, ...
  • 影响
    1. Where("identity=?", in.Identity) 完全没有 group_identity 限定:只要 creator_id 分支可用,群创建者就能把任意群的成员行改成管理员(经典 IDOR。实际可达性受两点限制成员行 identity 无对外出口、creator_id 列缺失),故计 P1 而非 P0。
    2. 两次 UpdateColumn.Error 从未检查,改角色失败仍返回 OK配合上一行的 creator_id 缺失问题,可能出现"校验报错/静默无效"两种极端。
    3. Direction 非 SET/CANCEL 时直接返回 OKswitch 无 default非法状态流转被静默接受。
  • 建议Where("group_identity=? AND identity=?", in.GroupIdentity, in.Identity);检查 .ErrorRowsAffecteddefault: 返回 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, "+")
  • 影响
    1. 没有 in.GroupIdentity 相关的任何角色校验(对比 DoSetManager/DoQuit 至少查了 creator_id):任何已登录用户只要掌握某条申请的 identity,就能替任意群通过/拒绝该申请。当前可达性受"申请 identity 从不出现在任何响应中"限制(DoJoin 返回的是另一个随机 UUID见 P1-12因此判定为 P1 越权缺口而非 P0但一旦有任何接口含未来的管理端/日志/通知)泄露申请 identity即直接升级为 P0普通用户自批入群
    2. 非幂等:对同一条申请重复 PASS 会重复 Create 成员行并重复 member_total+1;申请的 status 是否已处理完全不检查(模型也没有"pending/approved"状态机约束)。
    3. apply.GroupID/apply.GroupIdentity 直接取自申请行,而申请行是客户端可控的(DoJoin 用请求里的 ididentity,见 P1-12审核动作因此可能给"另一个群"建成员、给"另一个群"加计数。
    4. 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/唯一约束保证幂等;switchdefault 返回 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, "-")
  • 影响:分支 Acreator_id 列)→ 推测恒 ErrDB用户永远无法退出群组;分支 B列存在但从未被写入、值为 0/NULLScan 不报错且 creator_id=0auth.ID==0 恒假,群主可以直接退群,而模块内没有任何转让群主/继任者机制proto 与 logic 均无),群将永久失去创建者:后续 DoSetManagerModifyDisband(若有实现)全部无人有权执行。另外 Delete 影响 0 行(非成员调用)仍会扣减 member_total
  • 建议:统一为 passport_id;群主退群必须走"先转让"(新增 Transfer RPC 或在退群入参中要求继承人并校验其为成员);用 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-39do_quit.go:33-41join_do_handle.go:52do_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:38do_quit.go:39)也无从得知失败。

  • 影响
    1. 计数从未被正确更新(错误 100% 被吞),并且 do_join.go:28-37 的"免验证直接入群"分支根本不调用计数更新 → 计数与实际成员数永久不一致Create 写死的 MemberTotal: 1create.go:30)是唯一可靠值。
    2. 即使 SQL 修好,协议也是错的:Delete 影响 0 行(踢不存在的成员、非成员退群)时仍会 -1,可把计数刷成负数(无 member_total > 0 守卫)。
    3. 计数器是反规范化字段,却没有与成员增删放在同一事务内(见问题 17并发下必然丢失更新。
  • 建议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-60internal/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)
  • 影响
    1. 契约断裂:接口返回的 Data 是一个从未入库的 UUIDv7客户端无法用它做后续 JoinDoHandle(会 ErrRecordNotFounddata 字段实际是"无效凭据"。
    2. 无去重:同一用户可对同一群无限次 DoJoin(申请表中无数一约束、代码无存在性检查),形成申请刷量/骚扰;若走直接入群分支则产生重复成员行,而 DoQuit/DoKick 的软删除 Delete 会一次删掉所有匹配行,计数却只减 1。
    3. 无群存在性校验、无 (id, identity) 一致性校验GroupIDGroupIdentity 都直接来自请求,二者可任意组合,构造出的申请会在审核通过后生成"group_id 属 A、group_identity 属 B"的错配成员行,并把计数加到 B 群头上(join_do_handle.go:35-36,52)。属于 IDOR 型数据污染。
    4. 成员上限完全未实现member_limit 只在 create.go:28 被写成固定 500任何路径都没有 member_total < member_limit 判断proto 注释"0代表不限制"group.proto:35)与模型默认 100group_basic.go:15)也不一致。
    5. enable_any_join 开关恒失效GetGroupEnableAnyJoin 的列名拼错为 createor_id(正确是 creator_id,且模型根本没有该列),Scan 错误被忽略,且语义错位(把申请人当作群主去匹配)——该函数必然返回 falsedo_join.go:28-37 的免验证直接入群分支是死代码,"免验证入群"这一产品能力从未生效(basic/const.go:10DefaultEnableAnyJoin = 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-55internal/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
  • 影响
    1. 无事务:群记录成功、创建者成员行失败时留下"无创建者成员的孤儿群"(群主自己都不在成员表里),而接口返回错误;反之成员行成功而群失败亦不可回滚。
    2. 群号恒为 0Number 声明为 column:number;not nullgroup_basic.go:11却从不赋值GORM 会把零值写库 → 所有群的 number=0。若库上对该列有唯一索引,第一个群之后所有创建请求都会失败;若无唯一索引,则"群号"完全失去标识意义(enable_search_by_number 也因此无意义)。分支取决于库外 DDL无法验证
    3. member_limit 固定 500 与模型默认 100 不一致,客户端无法设置;DefaultEnableAnyJoin=true 也只写进了库(写入后无人读取,见 P1-12
    4. 零校验:Name 允许为空串(not null 不拦空串)或超 255 字符DB 报错 → ErrDBIntroduce/NoticetextAvatar/Backgroundvarchar 255同样无长度/内容校验;Master/Tags/Number 等请求字段被静默丢弃(GroupItem.cretor_identity 拼写错误,见 P3-28
    5. 无频率限制:任意登录用户可无限建群。
  • 建议DBService.Transaction(...) 包住两步写入(或用 Association/同事务 Create);生成唯一群号(序列或随机不重复号 + 唯一索引 + 冲突重试);白名单读取 member_limit(限制上界)并统一默认值;校验 name 非空与长度、限制公告/简介长度并做内容过滤。

P2

14. 配置声明开启 HTTP gatewayMux 从未初始化、也没有任何 gateway handler 注册 → 请求必定 panic

  • 位置module/social/group/internal/server/new.go:13-33cmd/main/main.go:24-35etc/group_{dev,test,prod}.yaml:19-21D:\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 只注册 gRPCMux 恒 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:206pb/member.pb.gw.go:233)无 nil 防御;独立部署入口 cmd/main 又从不调用 Expose。因此"独立部署时 HTTP 网关不可用"是全仓模板缺陷,本模块只需决策"是否启用 Gateway",修复应在模板/聚合层统一进行。
  • 影响Mux 恒为 nil且全模块含生成的 pb/group.pb.gw.go:205pb/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.gos.unescapingMode 字段读取 → nil pointer panicnet/http 会按连接 recover 并打印 64KB 栈(http: panic serving ...),即每个请求都会产生一次 panic 日志放大,客户端拿到空响应。wiki/api/14-group.md:11 承诺的 POST /group.Basic/* 路由实际不存在。
  • 建议:按 module/finance/wallet/service/expose.goservice/expose.go(创建 runtime.NewServeMux() 并注册 RegisterBasicHandlerServer/RegisterMemberHandlerServer),或在未接入前把 Gateway.Enable 置 false同时确认 Gateway.Port 的唯一性(见 P2-15

15. 三份环境配置完全相同prod 指向本地 bsm_devCHANGE_ME,端口与其它模块冲突,且未注册 etcd

  • 位置etc/group_dev.yamletc/group_test.yamletc/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
  • 影响
    1. group_prod.yaml 与 dev 完全一致:生产指向 127.0.0.1/bsm_dev、凭据为 CHANGE_ME。顶层 README.md:286-288 明确"YAML 仅为结构示例,部署时必须注入真实值",但本模块连环境区分都没做(conf.New 只支持 os.ExpandEnv,而文件里没有 $VAR),一旦发布流程未覆盖 etc/,生产会连到开发库。
    2. Gateway.Port: 12425module/base/passport/etc/passport_prod.yamlmodule/social/relation/etc/relation_prod.yaml 相同(同一主机启动必然有一个绑定失败),而绑定失败在 SDK 里只打一条 printer.Error 不退出service.go:126-128)→ 网关静默缺失。
    3. MicroService.Enable: falseservice.go:66-90)→ 不向 etcd 注册路由表,平台网关(gateway/internal/rpc/rpc.go:202RpcPool.RandomConn)无法发现该服务;配合 P2-14本模块实际只有"直连 gRPC 端口"一种可达方式。
    4. supervisoruser=root 运行、只 redirect_stderr、无日志轮转(stdout_logfile=/data/app/logs/social-group.logEtcd 段在 YAML 中缺失(config.Spec.Etcd == nilToken/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-25D:\work\bsm-sdk\core\with\databases.go:12-26D:\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", ...}),或显式传入配好 LoggerLogLevelSlowThreshold的 options。

17. 多写路径全部没有事务,任一步失败即产生不一致数据

  • 位置internal/logic/basic/create.go:36,51internal/logic/member/join_do_handle.go:43,49,52internal/logic/member/do_kick.go:32,38internal/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: trueD:\work\bsm-sdk\core\database\new.goNewMysql),说明该团队默认依赖调用方自行控制事务边界。
  • 建议:所有涉及 ≥2 表写入的用例用 impl.DBService.Transaction(func(tx *gorm.DB) error {...}),并把计数更新纳入事务。

18. 错误被静默吞掉4 处),失败与成功对外不可区分

  • 位置internal/models/query.go:26Pluck 的 error 被丢弃)、internal/models/query.go:41Scan 的 error 被丢弃)、internal/models/query.go:46UpdateColumn 无返回值/未检查)、internal/logic/member/do_set_manager.go:37,40UpdateColumn 未检查)
  • 证据
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 一律检查 .ErrorRowsAffected

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-37internal/logic/basic/fetch.gointernal/logic/member/fetch.gointernal/logic/member/join_fetch.go(均无 LIMIT/OFFSETinternal/models/group_member.go:11group_apply.go:11-14
  • 证据
11	GroupID       uint   `gorm:"group_id;not null;" json:"group_id"`      // 未用 column: 前缀,也没有 index/uniqueIndex 标签
  • 影响protoFetch 只接受 blocks.Empty/blocks.IdentRequest,没有 page_no/page_size(对照 const.proto:10-14 已有的 FetchRequest 未被使用)→ 成员列表/申请列表/群列表都是"一次性全量",大群会把全部行读进内存([]*pb.PassportInfoSimpleCard)再整体序列化。同时模型层没有声明任何索引,group_id/passport_id/group_identity 是否建索引完全取决于库外 DDLIsAutoMigrate: false,本模块无 AutoMigrate 调用)。按条件列推断(WHERE group_identity=?WHERE passport_id=? AND role<>0WHERE 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.Fetchclaims 被丢弃
20	if in.Identity == "" { return nil, errcode.ErrInvalidArgument }
24	members, err := models.GetGroupMember(in.Identity)
  • 影响:群成员列表与群资料对所有"已登录用户"开放,只要知道群 identity 即可读取identity 为 UUIDv7不可枚举故计 P2enable_search_by_name/enable_search_by_number 两个开关(group_basic.go:22-23group.proto:41-42)在整个模块中没有任何读取点Basic.Search 又只做空返回,所以"按名称/群号搜索"这一产品能力实际不存在,开关形同虚设。
  • 建议:为读接口补成员校验(或明确"群资料公开"并在 proto/文档写清);实现 Search 时把两个开关作为查询条件,并加分页。

22. 无输入长度/内容校验,群名与公告存在内容注入面

  • 位置internal/logic/basic/create.go:24-27internal/logic/member/do_join.go:40-46internal/models/group_basic.go:13-20internal/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-25RedisService/MemorySerice 只被赋值,全模块无引用)、internal/logic/member/do_join.gointernal/logic/basic/create.go
  • 证据
13	RedisService *redis.RedisClient
16	MemorySerice *cache.Cache
22	RedisService = with.RedisCache(config.Spec.Cache)      // 之后无任何读取
  • 影响Create(建群)、DoJoin(申请加群)都没有频次限制、验证码或幂等键,恶意用户可无限建群/刷申请(写放大 + 存储膨胀);同时模块没有任何缓存,Basic.Get(群资料)这类热点读也无法缓存,纯靠数据库。
  • 建议:用已注入的 RedisServiceSETNX 幂等键与滑动窗口限流(按 passport_id + 接口维度);群资料/成员数等热点数据加短 TTL 缓存并明确失效点成员增删、Modify 时删除缓存)。

24. 软删除与唯一约束的冲突风险:踢人/退群是软删除,重新加入会新增一行

  • 位置internal/models/group_member.go:9,19(内嵌 Std_IICUDS,含 gorm.DeletedAt)、internal/logic/member/do_kick.go:32do_quit.go:33do_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:34deleted_at is null 在多表 join 下是歧义列(见 P1-4。DDL 不可见,冲突必然性标注为推测。
  • 建议:明确"退出"的语义——要么物理删除(Unscoped() + 唯一约束),要么改为状态字段(status=-1)并在重新入群时"复活"原行(OnConflict 更新 deleted_at=NULLjoin 查询统一使用带表别名的 gm.deleted_at IS NULL

25. 可观测性与生命周期缺口:日志无上下文、无健康检查、cmd/cli 空入口、优雅退出不完整

  • 位置cmd/cli/main.go:1-4(空 main)、cmd/main/main.go:37-44internal/server/new.go:22-31reflection.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 可用gRPC reflection 在生产开放(顶层 README.md:288 建议评估关闭),配合无 TLS 的明文 gRPCgrpc.NewServer() 无凭据)会暴露完整接口面并让 JWT 走明文(BindIP 取自本机地址,conf.CheckIP)。service.Stop() 只做 GracefulStop,没有先从 etcd 摘除注册(service.go:142-144)。
  • 建议:日志改用带 request_id/操作者的结构化输出SDK printer 支持格式串);增加 Health/Ready RPC按环境关闭 reflection 并启用 TLS/mTLSStop()Revoke 租约再 GracefulStop;删除空 cmd/cli 或补齐子命令。

P3

26. 角色/状态枚举与常量重复定义、魔法数字散落,direction 用字符串承载枚举

  • 位置internal/logic/basic/const.go:3-14internal/logic/member/const.go:3-14(两文件 15 行逐行重复)、internal/models/group_member.go:14proto/member.proto:87
  • 证据
4	ROLE_MEMBER       = 0  // 普通会员
5	ROLE_MASTER       = 1  // 管理员
7	ROLE_CREATEOR     = 10 // 创建者
87	    string direction=3; //操作方式Pass通过,Reject拒绝,Set设置,cancel取消
  • 影响:同一套角色常量在两个包各定义一份(basic.DefaultMemberLimitmember.DefaultMemberLimit 也重复),跨包使用同一含义的魔法数字 0join_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 enumOP_PASS/OP_REJECT/OP_SET/OP_CANCEL/OP_UNSPECIFIEDOP_UNSPECIFIED 一律返回 ErrInvalidArgument

27. 死代码与被静默丢弃的字段/配置

  • 位置internal/models/group_res.go:1-21relation_group_res 模型全模块零引用)、internal/logic/basic/const.go:14filed 仅在 member 包使用)、internal/server/new.go:17,24grpcConns 连接池只创建不使用)、internal/impl/impl.go:21-22MemorySerice/RedisService 零引用,且 MemorySerice 拼写错误)、internal/config/config.go:15-25WeChat/Kyc/Rpc/Apm/Token 配置段无任何读取点)、proto/group.proto:36-39master/background/tags 字段无落库逻辑)
  • 证据
14		filed string = "passport_identity as identity,nickname,avatar,sex,province,city,area,sign"
  • 影响:这些残留物让"能力"看起来比实现多(群文件/群主列表/背景/标签、第三方配置、连接池),会持续误导接入方与后续审计(顶层 README.md:276service 描述为聚合接入层,而本模块该目录为空,也属此类)。
  • 建议:删除或接线:GroupResrelation_group_res 要么补 RPC 要么删;filed 移入 member 包或删除 basic 包中的重复定义;未使用的配置段从 SrvConfig 移除或在 etc 中注释说明。

28. 命名/文档与实现不一致proto、README、wiki

  • 位置proto/group.proto:34cretor_identity 拼写错误)、:33creator_id 在模型中实为 passport_id)、:35"0代表不限制" vs 代码固定 500 / 模型默认 100wiki/api/14-group.md:11(承诺 grpc-gateway 路由 POST /group.Basic/*,实际无 handler见 P2-14README.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_identityproto 改字段名需保留 json_name 兼容);把各接口的真实实现状态写进模块 README现在只有一行 # groupservice/ 补空目录说明或补 expose.go

29. 错误码语义不当与"总数"语义错误

  • 位置internal/logic/basic/get.go:23internal/logic/member/join_do_handle.go:29(记录不存在返回 ErrDB)、internal/logic/member/do_quit.go:30"群主不能退群"用 ErrPermissionDenied)、internal/logic/basic/fetch.go:28internal/logic/member/fetch.go:32internal/logic/member/join_fetch.go:41Total = 本页/本次返回条数)
  • 证据
28		Total: int32(len(groups)),     // 语义上应为全量总数
  • 影响:调用方无法区分"参数不存在"(应为 ErrRecordNotFound/ErrInvalidArgument)与"数据库故障"ErrDB);分页引入后 Total 若继续用 len() 会直接算错总页数。
  • 建议gorm.ErrRecordNotFound 映射为 ErrRecordNotFoundTotal 用独立 Count(*)

30. 群 identity 兼作加入凭证,且缺少 id/identity 一致性校验UUIDv7 时间有序

  • 位置internal/logic/member/do_join.go:28-46(请求同时携带 ididentity,二者不校验)、D:\work\bsm-sdk\core\utils\identity.go:8-10utils.UUID() = UUIDv7
  • 证据
8	func UUID() string {
9		return uuid.Must(uuid.NewV7()).String()
10	}
  • 影响:模块没有独立的邀请码/邀请链接机制,group.identity 既是公开标识又是加入凭证持有即可申请入群一旦泄露无法吊销无过期、无重置接口UUIDv7 前 48 位是毫秒时间戳,可推断群/成员的创建时间与相对顺序(信息泄露面很小,但不符合"不可预测凭证"的最佳实践)。请求中的 ididentity 从不互相校验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:20New(addr string)addr 参数未使用)、internal/server/basic_server.go:11member_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 人日)

  1. 统一数据访问层:删掉全部 raw SQLinternal/models/query.go:14-22,30-37),改用 GORM 模型 + Joins/Where("... IN ?"),让表名(group_basic/relation_group_member/relation_group_apply)与软删条件由 schema 产生;所有查询 WithContext(ctx)、所有写操作检查 .ErrorRowsAffected
  2. 统一"所有权/角色"判定:新增一个内部函数 membership(tx, groupIdentity, passportID) (role, err),把 creator_id 全部替换为 passport_id(或补 CreatorID 字段并让 Create 写入),并按"角色矩阵"落地:解散/改资料=群主;设置管理员/踢人=群主或管理员(管理员不可操作群主与同级);退群=本人(群主须先转让);审核申请=群主或管理员。
  3. 修返回值与契约Fetch/Get/Member.Fetch 补回数据;DoJoin 返回落库 identityDisband/Search 未实现前返回 ErrUnimplemented;非法 direction 返回 ErrInvalidArgument
  4. 修计数gorm.Expr("member_total + ?", 1) + RowsAffected 门控,并把成员变更与计数放进同一事务;do_join.go 的直接入群分支补计数与上限校验。
  5. Modify 白名单更新Updates(map) + 删除 record.Identity = utils.UUID()

第 2 步补齐产品能力与安全护栏P1/P2 级2~3 人日)

  1. 入群链路完整化DoJoin 只接受 group_identity(服务端查 id(group_identity, passport_id) 加唯一约束 + FirstOrCreate;申请去重与限流;JoinDoHandle 加权限校验 + 申请状态机(仅 status=0 可处理)+ 事务;实现 enable_any_join(修正 createor_id 拼写与语义)与 member_limit 上限校验。
  2. 群生命周期Create 生成唯一群号(唯一索引 + 冲突重试)、事务写入群与创建者成员、校验名称非空/长度;新增群主转让 RPC 后再允许群主退群;实现 Disband(事务级联软删)。
  3. 列表与检索Fetch/Member.Fetch/JoinFetch/Search 全部加分页(复用 const.proto:10-14FetchRequest 或新增分页消息)并实现 Search(按 enable_search_by_name/number 过滤)。
  4. 接入与配置:补 service/expose.go(对齐 module/finance/wallet/service/expose.go),或明确"仅直连 gRPC"并把 Gateway.Enable 关掉;区分 dev/test/prod 配置与端口;Debug 日志按环境关闭;按 README.md:288 打开 TLS、关闭 reflection。
  5. 测试:为权限矩阵(群主/管理员/成员 × 13 个 RPC、重复入群/重复审核的幂等、并发计数、软删除后重新入群建立表驱动测试(详见 §5

第 3 步可维护性与治理P3 级1 人日)

  1. 角色/状态收敛到单一包 + proto enum清理重复常量与魔法数字。
  2. 删除死代码(GroupResfiled(basic)、grpcConns、未用配置段、cmd/cli),或在 README 标注 roadmap。
  3. 用代码现状重写模块 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 均不变,非群主调用返回 ErrPermissionDenied0 行受影响时返回 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 JoinFetchWhere("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/ErrRecordNotFounddirection=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问题11UpsetGroupMemberTotalgorm.Expr("member_total + ?", 1)、返回 error、按 RowsAffected 门控)并把成员增删与计数纳入同一事务|验收:直接入群/审核通过/踢人/退群后 member_totalCOUNT(*) 一致;并发 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 校验;修 GetGroupEnableAnyJoinidentity 条件 + 错误检查 + 上界)|验收:返回值可直接用于 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 且唯一;空名/超长名返回 ErrInvalidArgumentmember_limit 为请求值与默认值的一致结果|涉及:module/social/group/internal/logic/basic/create.go:23, :28, :36, :51
  • P2-14问题14service/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 为所有多表写入加事务|验收:CreateJoinDoHandleDisband 注入中途失败后数据完整回滚|涉及: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 而非空结果/OKgrep -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 与操作者 identitygrpc.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 包 filedgrpcConnsWeChat/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 语义|验收:记录不存在返回 ErrRecordNotFoundTotal 为全量总数(分页下与 Count(*) 一致)|涉及:module/social/group/internal/logic/basic/get.go:23, internal/logic/basic/fetch.go:28
  • P3-30问题30 加入凭证与对外标识分离(可选邀请码/可吊销),并强校验 id/identity 一致性|验收:不再存在"持有 identity 即可加入"的隐式凭证;ididentity 不匹配时返回 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 返回值可用于 JoinDoHandleBasic.Get/Fetch/Member.Fetch 返回真实数据|验收:端到端流程(建群→申请→审核→列表→踢人→退群)断言成功|涉及:module/social/group/internal/logic/
  • P2-T5 软删除/唯一约束回归:退群或被踢后重新入群|验收:不报唯一键冲突且成员列表无重复|涉及:module/social/group/internal/logic/member/do_join.go:37
  • P3-T6 配置/网关冒烟:gofmt -lgo vet ./...、启动后 curl gateway 端口与 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 还不写群号且无事务;同时 ModifyUpdateColumns(整个结构体) 一旦所有权列被修正就会随机重写群 identity(并清零 passport_id/member_total),使全群成员与申请关系断裂。
  • 最优先 3 个动作
    1. 修权限与 SQL 的正确性基线:DoKick/DoSetManager/DoQuit/JoinDoHandle 的角色校验改为 group_identity + passport_id 并检查 RowsAffected,同时把 GetMyGroup/GetGroupMember/JoinFetch 的 raw SQL 全部换成带表名与 IN ? 的 GORM 查询(internal/models/query.gointernal/logic/member/*),让每个接口"要么正确、要么明确报错"。
    2. Basic.Modify 的破坏性更新与 Create 的事务/群号,并把 member_total 的计数更新放进同一事务、不再吞错误(modify.go:33-35create.go:36,51query.go:45)。
    3. service/expose.go(或关闭 Gateway.Enable)并统一 creator_idpassport_id,避免"配置声明开启但网关必 panic、代码引用不存在的列"这两类系统性误导(internal/server/new.go、三份 etc
  • 未能覆盖/无法验证的部分
    1. 真实数据库表结构与索引/唯一约束(仓库内无 DDL、无迁移IsAutoMigrate=falsecreator_id 列是否存在、number 是否唯一、成员表是否有唯一索引均按"分支 A/B"表述,未连接生产库验证;
    2. 运行时行为:未真正执行 SQL/启动服务,"SQL 报错、http: panic serving"等结论基于 GORM v1.31.2 与 grpc-gateway v2.30.0 源码推演(正文已标注「推测」);
    3. 生产发布流程是否外部覆盖 etc/、平台网关的实际鉴权/匿名白名单配置(D:\work\bsm-infra\gateway)未纳入验证;
    4. 前端渲染方式未知,群名/公告/留言的存储型 XSS 仅为风险面提示(未证实);
    5. gofmt -l(无输出)与 go vet ./...exit=0通过但两者均无法发现本报告中任何一类缺陷。