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.
704 lines
72 KiB
Markdown
704 lines
72 KiB
Markdown
# 审计报告:module/social/group
|
||
|
||
## 1. 模块概览
|
||
|
||
- **路径**:`D:\work\bsm-infra\full\module\social\group`(Go module `bsm/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`)。
|
||
|
||
**无法覆盖/无法验证**
|
||
|
||
1. **真实表结构**:仓库内**没有**任何 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"。
|
||
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_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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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)。
|
||
|
||
- **影响**:
|
||
1. `UpdateColumns(struct)` 的语义是"更新**全部**字段(含零值)"——依据本模块实际使用的 GORM `v1.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 一旦变化,该群的全部成员与申请关系立即断裂**,且不可恢复。
|
||
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-22`、`internal/logic/basic/fetch.go:20-29`
|
||
- **证据**:
|
||
|
||
```go
|
||
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
|
||
```
|
||
|
||
```go
|
||
27 return &pb.GroupsReply{
|
||
28 Total: int32(len(groups)), // groups 未被赋值给 Groups
|
||
29 }, nil
|
||
```
|
||
|
||
- **影响**:
|
||
1. `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。
|
||
2. 即便 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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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`
|
||
```
|
||
|
||
```go
|
||
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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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.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)`。
|
||
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-39`、`internal/models/query.go:31`(对外成员身份的来源)
|
||
- **证据**:
|
||
|
||
```go
|
||
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:31` `pe.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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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 时直接返回 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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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` 用请求里的 `id` 与 `identity`,见 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`/唯一约束保证幂等;`switch` 加 `default` 返回 `ErrInvalidArgument`。
|
||
|
||
#### 9. `Member.DoQuit`(退群):依赖不存在的 `creator_id`,群主校验失效且没有转让机制,删除 0 行仍扣减计数
|
||
|
||
- **位置**:`module/social/group/internal/logic/member/do_quit.go:23-41`
|
||
- **证据**:
|
||
|
||
```go
|
||
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`;群主退群必须走"先转让"(新增 `Transfer` RPC 或在退群入参中要求继承人并校验其为成员);用 `RowsAffected` 决定是否更新计数。
|
||
|
||
#### 10. `Basic.Disband`(解散群组)是模板 TODO 占位:校验后直接返回 OK,不解散任何数据
|
||
|
||
- **位置**:`module/social/group/internal/logic/basic/disband.go:16-31`
|
||
- **证据**:
|
||
|
||
```go
|
||
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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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`)也无从得知失败。
|
||
|
||
- **影响**:
|
||
1. 计数从未被正确更新(错误 100% 被吞),并且 `do_join.go:28-37` 的"免验证直接入群"分支**根本不调用**计数更新 → 计数与实际成员数**永久不一致**;`Create` 写死的 `MemberTotal: 1`(`create.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-60`、`internal/models/query.go:39-43`
|
||
- **证据**:
|
||
|
||
```go
|
||
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 与返回值不同
|
||
```
|
||
|
||
调查询开关的实现:
|
||
|
||
```go
|
||
41 impl.DBService.Model(&GroupBasic{}).Select("enable_any_join").Where("createor_id=? and identity=?", passport_id, group_identity).Scan(&is)
|
||
```
|
||
|
||
- **影响**:
|
||
1. **契约断裂**:接口返回的 `Data` 是一个从未入库的 UUIDv7,客户端无法用它做后续 `JoinDoHandle`(会 `ErrRecordNotFound`);`data` 字段实际是"无效凭据"。
|
||
2. **无去重**:同一用户可对同一群无限次 `DoJoin`(申请表中无数一约束、代码无存在性检查),形成申请刷量/骚扰;若走直接入群分支则产生重复成员行,而 `DoQuit`/`DoKick` 的软删除 `Delete` 会一次删掉所有匹配行,计数却只减 1。
|
||
3. **无群存在性校验、无 (id, identity) 一致性校验**:`GroupID` 与 `GroupIdentity` 都直接来自请求,二者可任意组合,构造出的申请会在审核通过后生成"`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`)与模型默认 100(`group_basic.go:15`)也不一致。
|
||
5. **`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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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. **群号恒为 0**:`Number` 声明为 `column:number;not null`(`group_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 报错 → `ErrDB`);`Introduce`/`Notice`(text)与 `Avatar`/`Background`(varchar 255)同样无长度/内容校验;`Master`/`Tags`/`Number` 等请求字段被静默丢弃(`GroupItem.cretor_identity` 拼写错误,见 P3-28)。
|
||
5. 无频率限制:任意登录用户可无限建群。
|
||
- **建议**:`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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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())
|
||
```
|
||
|
||
```yaml
|
||
Gateway:
|
||
Enable: true
|
||
Port: 12425
|
||
```
|
||
|
||
```go
|
||
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`
|
||
- **证据**:
|
||
|
||
```yaml
|
||
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: 12425` 与 `module/base/passport/etc/passport_prod.yaml`、`module/social/relation/etc/relation_prod.yaml` 相同(同一主机启动必然有一个绑定失败),而绑定失败在 SDK 里**只打一条 `printer.Error` 不退出**(`service.go:126-128`)→ 网关静默缺失。
|
||
3. `MicroService.Enable: false`(`service.go:66-90`)→ 不向 etcd 注册路由表,平台网关(`gateway/internal/rpc/rpc.go:202` 的 `RpcPool.RandomConn`)无法发现该服务;配合 P2-14,本模块实际只有"直连 gRPC 端口"一种可达方式。
|
||
4. `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`
|
||
- **证据**:
|
||
|
||
```go
|
||
23 DBService = with.Databases(config.Spec.Databases, nil) // opts = nil
|
||
```
|
||
|
||
```go
|
||
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` 无匹配)
|
||
- **证据**:
|
||
|
||
```go
|
||
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` 未检查)
|
||
- **证据**:
|
||
|
||
```go
|
||
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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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`
|
||
- **证据**:
|
||
|
||
```go
|
||
24 Name: in.GetName(), // varchar(255) not null,无长度/非空校验
|
||
26 Notice: in.GetNotice(), // text,无任何过滤
|
||
```
|
||
|
||
```go
|
||
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`
|
||
- **证据**:
|
||
|
||
```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`
|
||
- **证据**:
|
||
|
||
```go
|
||
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())`
|
||
- **证据**:
|
||
|
||
```go
|
||
3 func main() {
|
||
4 }
|
||
```
|
||
|
||
```go
|
||
31 reflection.Register(srv.Grpc)
|
||
```
|
||
|
||
- **影响**:`cmd/cli` 是空实现(顶层 README 也把它当作"命令行辅助入口");日志只有裸 `err.Error()`,没有 request_id/操作者 `passport_id`/群标识等结构化字段,而这些问题恰恰发生在权限判定路径上,线上无法复盘"谁对哪个群做了什么";无 `Health`/`Ready` 探针(无法区分进程存活与 DB/Redis 可用);gRPC `reflection` 在生产开放(顶层 `README.md:288` 建议评估关闭),配合无 TLS 的明文 gRPC(`grpc.NewServer()` 无凭据)会暴露完整接口面并让 JWT 走明文(`BindIP` 取自本机地址,`conf.CheckIP`)。`service.Stop()` 只做 `GracefulStop`,没有先从 etcd 摘除注册(`service.go:142-144`)。
|
||
- **建议**:日志改用带 request_id/操作者的结构化输出(SDK `printer` 支持格式串);增加 `Health/Ready` RPC;按环境关闭 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`
|
||
- **证据**:
|
||
|
||
```go
|
||
4 ROLE_MEMBER = 0 // 普通会员
|
||
5 ROLE_MASTER = 1 // 管理员
|
||
7 ROLE_CREATEOR = 10 // 创建者
|
||
```
|
||
|
||
```proto
|
||
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` 字段无落库逻辑)
|
||
- **证据**:
|
||
|
||
```go
|
||
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`)
|
||
- **证据**:
|
||
|
||
```go
|
||
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` = 本页/本次返回条数)
|
||
- **证据**:
|
||
|
||
```go
|
||
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)
|
||
- **证据**:
|
||
|
||
```go
|
||
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 人日)
|
||
|
||
1. **统一数据访问层**:删掉全部 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`。
|
||
2. **统一"所有权/角色"判定**:新增一个内部函数 `membership(tx, groupIdentity, passportID) (role, err)`,把 `creator_id` 全部替换为 `passport_id`(或补 `CreatorID` 字段并让 `Create` 写入),并按"角色矩阵"落地:解散/改资料=群主;设置管理员/踢人=群主或管理员(管理员不可操作群主与同级);退群=本人(群主须先转让);审核申请=群主或管理员。
|
||
3. **修返回值与契约**:`Fetch`/`Get`/`Member.Fetch` 补回数据;`DoJoin` 返回落库 identity;`Disband`/`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 人日)
|
||
|
||
6. **入群链路完整化**:`DoJoin` 只接受 `group_identity`(服务端查 `id`),`(group_identity, passport_id)` 加唯一约束 + `FirstOrCreate`;申请去重与限流;`JoinDoHandle` 加权限校验 + 申请状态机(仅 `status=0` 可处理)+ 事务;实现 `enable_any_join`(修正 `createor_id` 拼写与语义)与 `member_limit` 上限校验。
|
||
7. **群生命周期**:`Create` 生成唯一群号(唯一索引 + 冲突重试)、事务写入群与创建者成员、校验名称非空/长度;新增群主转让 RPC 后再允许群主退群;实现 `Disband`(事务级联软删)。
|
||
8. **列表与检索**:`Fetch`/`Member.Fetch`/`JoinFetch`/`Search` 全部加分页(复用 `const.proto:10-14` 的 `FetchRequest` 或新增分页消息)并实现 `Search`(按 `enable_search_by_name/number` 过滤)。
|
||
9. **接入与配置**:补 `service/expose.go`(对齐 `module/finance/wallet/service/expose.go`),或明确"仅直连 gRPC"并把 `Gateway.Enable` 关掉;区分 dev/test/prod 配置与端口;`Debug` 日志按环境关闭;按 `README.md:288` 打开 TLS、关闭 reflection。
|
||
10. **测试**:为权限矩阵(群主/管理员/成员 × 13 个 RPC)、重复入群/重复审核的幂等、并发计数、软删除后重新入群建立表驱动测试(详见 §5)。
|
||
|
||
### 第 3 步:可维护性与治理(P3 级,1 人日)
|
||
|
||
11. 角色/状态收敛到单一包 + proto enum;清理重复常量与魔法数字。
|
||
12. 删除死代码(`GroupRes`、`filed`(basic)、`grpcConns`、未用配置段、`cmd/cli`),或在 README 标注 roadmap。
|
||
13. 用代码现状重写模块 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 ./...`、启动后 `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` 还不写群号且无事务;同时 `Modify` 的 `UpdateColumns(整个结构体)` 一旦所有权列被修正就会随机重写群 `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.go`、`internal/logic/member/*`),让每个接口"要么正确、要么明确报错"。
|
||
2. 修 `Basic.Modify` 的破坏性更新与 `Create` 的事务/群号,并把 `member_total` 的计数更新放进同一事务、不再吞错误(`modify.go:33-35`、`create.go:36,51`、`query.go:45`)。
|
||
3. 补 `service/expose.go`(或关闭 `Gateway.Enable`)并统一 `creator_id`→`passport_id`,避免"配置声明开启但网关必 panic、代码引用不存在的列"这两类系统性误导(`internal/server/new.go`、三份 etc)。
|
||
- **未能覆盖/无法验证的部分**:
|
||
1. 真实数据库表结构与索引/唯一约束(仓库内无 DDL、无迁移,`IsAutoMigrate=false`):`creator_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)通过,但两者均无法发现本报告中任何一类缺陷。
|