# 审计报告:module/base/cloud ## 1. 模块概览 `module/base/cloud` 是 BSM 全量单体仓库中的「个人云空间」业务模块,对外暴露 gRPC 服务,并通过 gRPC-gateway 暴露 HTTP(默认 `POST /cloud./`,JSON body)。 **规模** | 项 | 数量 | |---|---| | 非 pb Go 文件 | 84 个(约 4500 行) | | proto 服务定义 | 7 个(Album / Bookmark / Disk / Note / Private / Share / Space) | | etc 配置 | 3 个(cloud_dev.yaml / cloud_test.yaml / cloud_prod.yaml,内容基本一致) | | 测试文件 | 0 个(`test/lint/` 为空目录) | **分层结构**:`cmd/main`(入口)→ `internal/server/*_server.go`(protoc-gen-slc 生成的转发层,无逻辑)→ `internal/logic//*.go`(1 RPC = 1 文件)→ `internal/models/*.go`(GORM 模型)→ `internal/impl/impl.go`(全局单例 DB/Redis/Etcd/Cache 句柄)。 **核心数据模型与归属字段**(这是本模块权限模型的骨架): | 模型 | 文件 | 归属字段 | 结果 | |---|---|---|---| | CloudDiskDir | `internal/models/cloud_disk_dir.go` | `Std_Passport` | ✅ 有 passport_id | | CloudDiskFile | `internal/models/cloud_disk_file.go` | 无 `Std_Passport` | ❌ 无 passport_id,只能 JOIN 目录 | | CloudAlbum | `internal/models/cloud_album.go` | `Std_Passport` | ✅ | | CloudPhoto | `internal/models/cloud_photo.go` | **无归属字段** | ❌ 无 passport_id(且写入时也没赋值) | | CloudNote | `internal/models/cloud_note.go` | `Std_Passport` | ✅ | | NoteAttachment | `internal/models/cloud_note_attach.go` | **无归属字段** | ❌ 仅 note_id | | CloudBookmark | `internal/models/cloud_bookmark.go` | `Std_Passport` | ✅ | | CloudPrivate | `internal/models/cloud_private.go` | `Std_Passport` | ✅ | | CloudShare | `internal/models/cloud_share.go` | `Std_Passport` | ✅ | | CloudSpace | `internal/models/cloud_space.go` | `Std_Passport` | ✅ | **认证方式**:所有 logic 入口第一行统一调用 `service.ParseMetaCtx(ctx, nil)`(`D:\work\bsm-sdk\core\service\meta.go:19`),从 gRPC metadata 的 `authorization` 头解析 JWT,得到 `auth.ID`(passport_id)/`auth.Identity`。鉴权本身是统一的,问题出在**授权(归属校验)与所有权落库**上。注意 `opts == nil`,因此既没有角色校验也没有 `MustPrivateAllow` 限制。 --- ## 2. 审计范围与方法 ### 已覆盖子域(逐文件通读 100%) - **Disk**:`internal/logic/disk/` 全部 15 个文件(create_dir、get_dir、update_dir、delete_dir、list_dirs、get_dir_tree、move_dir、upload_file、get_file、update_file、delete_file、list_files、move_file、copy_file、search_files)。 - **Album**:`internal/logic/album/` 全部 12 个文件(相册 CRUD + GetDirTree 类比 + 照片上传/删除/移动/封面)。 - **Note**:`internal/logic/note/` 全部 10 个文件(含 attachment 的插入与删除)。 - **Bookmark**:`internal/logic/bookmark/` 全部 6 个文件(含 ImportBookmarks)。 - **Private**:`internal/logic/private/` 全部 9 个文件(含 EncryptData / DecryptData 加解密实现)。 - **Share**:`internal/logic/share/` 全部 5 个文件(含 ValidateSharePassword)。 - **Space**:`internal/logic/space/` 2 个文件。 - **模型层**:`internal/models/` 全部 10 个文件 + `internal/models/query.go`。 - **服务层/入口/配置**:`internal/server/`(new.go + 7 个 server)、`internal/impl/impl.go`、`internal/config/config.go`、`service/dependencies.go`、`service/expose.go`、`cmd/main/main.go`、`cmd/cli/main.go`、3 个 etc yaml、README.md、7 个 proto、`proto/const.proto`。 - **pb 层抽样**:`pb/disk.pb.gw.go`(确认 gateway 绑定路径、请求体与 content-type 行为、明文 HTTP)。 - **依赖侧(只读,用于验证调用方语义)**:`D:\work\bsm-sdk\core\service\meta.go`(ParseMetaCtx)、`core\types\db.go`(Std_IICUDS/Std_Passport,确认软删除与 uniqueIndex)、`core\utils\identity.go`(UUID 实现)、`core\database\new.go`、`core\database\sql\postgresql.go`、`core\service\service.go`(网关启动、无 TLS/中间件)、`core\with\databases.go`。 ### 抽样方式与判据 - 对 51 个 logic 文件做全量精读(非抽样),因为每个文件都很短(25~120 行)。 - 服务层 `internal/server/*_server.go` 为生成代码,仅确认「无额外校验、无 recover」。 - 对 `pb/` 只抽样 `disk.pb.gw.go`,因为 7 个 gateway 文件由同一模板生成,抽样可代表。 ### 未覆盖部分(诚实声明) - `pb/*.pb.go`、`*_grpc.pb.go`、除 disk 外的 6 个 `*.pb.gw.go`:生成代码,未逐行读。 - `go.sum`、`service/expose.go` 之外的模块级装配代码(`module/base/all` 等调用方如何注入 Dependencies、网关如何挂载认证中间件)未审计——这会影响「是否为网关层补齐了 JWT」的最终结论。 - **无任何可执行验证**:模块内无测试、无 fixture、无 SQL 迁移脚本,数据库/Redis 不可用,因此所有「运行期行为」类判断(GORM 关联 preload 语义、并发竞态)均为静态推断,已在文中标注。 - `cmd/cli/main.go` 是 `Hello World` 空壳,无功能可审。 ### 执行过的命令及结果 | 命令 | 结果 | |---|---| | `Get-ChildItem -Recurse`(模块文件清单/目录树) | 成功,得到 84 个非 pb Go 文件清单 | | `Get-ChildItem test -Recurse -Force` | **`test/lint/` 为空目录,模块内 0 个测试文件** | | `gofmt -l .`(在 `module/base/cloud`) | **退出码 0,无输出**,即所有 Go 文件格式合规 | | `go vet ./...` | **未执行**:依赖 `bsm-sdk/core` 走 `replace ../../../../../bsm-sdk/core`,且执行环境未确认可完整构建;按效率约束跳过(不做 `go mod tidy`) | | `grep`(`_ =`/`err == nil`/`TODO`/`panic(`、`Transaction|Begin()`、`PassportID`、`MaxStorage|quota`、`RedisService|MemorySerice` 等) | 成功,见各条证据 | --- ## 3. 问题清单 ### P0 #### P0-1. 照片上传从不落 `passport_id`,照片归属被彻底丢弃,导致跨用户照片越权(IDOR) - **位置**:`module/base/cloud/internal/logic/album/upload_photo.go:54`、`module/base/cloud/internal/models/cloud_photo.go:11` - **证据**: ```go // models/cloud_photo.go:11 —— 模型里根本没有 Std_Passport type CloudPhoto struct { types.Std_IICUDS CloudBase AlbumID uint `gorm:"index" json:"album_id"` ``` ```go // upload_photo.go:54 —— 写入时也没有任何 PassportID/PassportIdentity record := models.CloudPhoto{ Std_IICUDS: types.Std_IICUDS{Identity: utils.UUID()}, CloudBase: models.CloudBase{CloudID: album.CloudID, CloudIdentity: album.CloudIdentity}, AlbumID: uint(in.AlbumId), ``` - **影响**:两条独立且都很严重的后果。 1. **越权(可被利用)**:所有照片级接口(GetPhoto/UpdatePhoto/DeletePhoto `album/get_photo.go:30`、`album/update_photo.go:31`、`album/delete_photo.go:31`)都使用 `Joins("JOIN cloud_albums ON cloud_photos.album_id = cloud_albums.id").Where("cloud_albums.passport_id = ?", auth.ID)`。 归属完全靠 album_id 反查,而 album_id 是客户端可控的整数。攻击者只要把自己的相册 id 猜/传到别人的照片所归属的相册上——不,实际路径是:删除/更新照片时不校验 `in.Id` 是否属于自己,只要该 photo 所属 album 的 owner 是自己即可;反过来,**任何 photo 只要其 album_id 指向攻击者的相册,攻击者就能改/删**。由于 UploadPhoto 未校验照片归属,攻击者可先调用 `UploadPhoto{album_id: 自己的相册}`,但更关键的是 `SetCoverPhoto`(见 P0-3)与 `DeleteAlbum`(见 P0-4)只按 `album_id` 批量操作,而 album_id 无归属写入约束。 2. **功能必然损坏**:`space/get.go:74` 与 `space/get_by_key_identifier.go:65` 统计照片数用同一 JOIN,逻辑上仍然能算出来;但**任何将来按 `passport_id` 直查 cloud_photos 的代码/报表/清理任务都会永远漏掉所有照片**(字段恒为 0)。同理 `CloudSpace.PhotoCount` 语义与模型不一致。 - **建议**:给 `CloudPhoto` 加 `types.Std_Passport`,在 `UploadPhoto`/`MovePhoto` 中按 `auth.ID`/`auth.Identity` 落库;同时为已有数据写一次性回填(`UPDATE cloud_photos p SET passport_id = a.passport_id FROM cloud_albums a WHERE p.album_id = a.id`)。所有照片级查询改为直接 `WHERE passport_id = ?`,JOIN 仅用于取相册信息。 #### P0-2. `SetCoverPhoto` 只校验照片属于「该相册」,而相册归属校验存在但与照片归属解耦,可跨用户设置封面并读取他人照片路径 - **位置**:`module/base/cloud/internal/logic/album/set_cover_photo.go:39` - **证据**: ```go // 验证照片是否存在且属于该相册 —— 注意:只按 album_id 过滤,没有 passport_id var photo models.CloudPhoto if err := impl.DBService.Where("id = ? AND album_id = ?", in.PhotoId, in.AlbumId).First(&photo).Error; err != nil { ``` - **影响**:`album` 已确认属于当前用户,但 `photo` 只校验 `album_id = in.AlbumId`。因为 cloud_photos 没有 passport_id(P0-1),任何一张 photo 只要 `album_id` 落在攻击者自己的相册里,就会被当作攻击者的照片,其 `FileSize`/`Tags`/`Location` 等元数据可被读取并写进相册封面(`album.CoverPhoto = photo.FilePath`,`set_cover_photo.go:45`)。这是一条**无 passport_id 兜底的越权读路径**,也是 P0-1 的可利用入口。 - **建议**:同 P0-1,照片落 passport_id 后在此处加 `AND passport_id = ?`;短期可在 `First(&photo)` 后追加 `if photo.AlbumID != album.ID` 之外的 album 归属断言(当前已有,但不足以覆盖),根本解仍是照片自带归属。 #### P0-3. `DeleteAlbum` 按 `album_id` 无条件批量删除照片,且整个删除动作没有事务 - **位置**:`module/base/cloud/internal/logic/album/delete_album.go:45` - **证据**: ```go // 删除相册下的所有照片 if err := impl.DBService.Where("album_id = ?", album.ID).Delete(&models.CloudPhoto{}).Error; err != nil { printer.Error("Delete photos error: %v", err) return nil, errcode.ErrDB } // 删除相册 if err := impl.DBService.Delete(&album).Error; err != nil { ``` - **影响**:(a) 批量删除语句不带 `passport_id`,在照片归属缺失(P0-1)的前提下,只要有任何 photo 的 `album_id` 等于该值就会被删,构成跨用户数据破坏路径;(b) 两条 `Delete` 之间无事务,且 SDK 连接是 `SkipDefaultTransaction: true`(`D:\work\bsm-sdk\core\database\new.go:90`),photo 删成功而 album 删失败时会留下空相册/数据不一致。 - **建议**:包一层 `impl.DBService.Transaction(func(tx *gorm.DB) error {...})`;删除条件补 `passport_id`。 #### P0-4. 隐私数据加解密接口接受客户端任意密钥并做零填充/截断,且 `IsEncrypted` 由客户端自由设置 - **位置**:`module/base/cloud/internal/logic/private/encrypt_data.go:36`、`decrypt_data.go:39`、`create_private_data.go:54` - **证据**: ```go // encrypt_data.go:36 —— 客户端传 key,长度不足补 0x00,超长直接截断 key := []byte(in.Key) if len(key) != 32 { if len(key) < 32 { for len(key) < 32 { key = append(key, 0) } } else { key = key[:32] } } ``` ```go // create_private_data.go:54 —— 是否加密完全听客户端的 IsEncrypted: in.IsEncrypted, ``` - **影响**:(a) 密钥空间被严重削弱:"a"、"a\x00...\x00"、"a ... 任意补零" 全部映射到同一密钥,且服务端**从不校验密钥强度**,短口令可被离线暴力破解(GCM 密文 + 已知明文可验证猜测);(b) 超长密钥静默截断会让用户以为用了强口令、实际只用前 32 字节;(c) 服务端用 `EncryptData` 返回 base64 但不下发/保管密钥,等于把密钥管理整体推给客户端,而 `IsEncrypted=false` 时明文直接落库(`Data string gorm:"type:text"`,`cloud_private.go:16`),"加密私人数据" 的核心承诺(README:11)在服务端没有任何强制。 - **建议**:密钥必须来自服务端 KMS/用户主密钥派生(PBKDF2/Argon2 + 每记录 salt),拒绝客户端直传原始密钥;`IsEncrypted` 改为服务端强制 `true`;密钥长度非法应显式报错而不是补零/截断;若要保留客户端密钥模式,至少加 HKDF + 最小长度与口令强度校验。 #### P0-5. 分享口令明文存储 + 明文比较 + 无归属校验 + 无限流 - **位置**:`module/base/cloud/internal/logic/share/validate_share_password.go:32`、`:43`、`internal/models/cloud_share.go:18` - **证据**: ```go // validate_share_password.go:32 —— 只按 identity 查,不看 passport_id;auth 被丢弃(下划线) _, err = service.ParseMetaCtx(ctx, nil) ... if err := impl.DBService.Where("identity = ?", in.Identity).First(&share).Error; err != nil { ``` ```go // validate_share_password.go:43 —— 明文 == 比较,非常量时间 if share.Password != "" && share.Password != in.Password { ``` ```go // cloud_share.go:18 Password string `gorm:"size:100" json:"password"` // 可选分享密码 ``` - **影响**:(a) 口令明文入库,一次 DB 泄露/备份泄露即全量分享口令泄露;(b) `!=` 逐字节比较存在时间侧信道,且服务端**没有任何失败次数限制/锁定/验证码**,38 位以内口令可被在线暴力枚举(接口无需知道分享者,任何登录用户都能打);(c) 该接口对**任意 authenticated 用户**开放,不校验调用者是否为分享者,等于给出一把免费的在线爆破 + 存在性探测(返回值区分"分享不存在/已过期/口令错"三类错误)。 - **建议**:口令用 bcrypt/argon2id 哈希存储;比较用 `subtle.ConstantTimeCompare`;对 `(share_identity, caller_id)` 做失败计数与指数退避(Redis 已注入但完全未使用,见 P1-15/P2-8);统一错误码避免存在性泄露。 ### P1 #### P1-1. `MoveDir` 只防「移到自己」,不防「移到自己的后代」,目录树成环后 `GetDirTree` 递归爆炸 - **位置**:`module/base/cloud/internal/logic/disk/move_dir.go:46` - **证据**: ```go // 检查是否会形成循环引用 if dir.ID == parentDir.ID { return nil, errcode.ErrInvalidArgument } ``` - **影响**:把 `/a` 移动到 `/a/b` 下(b 是 a 的后代)不会报错:`dir.ParentID` 指向 b、`dir.Path` 变为 `/a/b/a`。此后 `GetDirTree`(`get_dir_tree.go:38` 的 `Preload("Subdirectories")` + `:44-61` 的 `loadSubdirs` 递归)会在 a↔b 之间无限展开,**没有深度上限、没有 visited 集合、没有分页**,结果是栈溢出/内存耗尽/连接池打满。`UpdateDir` 改父级虽不直接产生环,但一旦已有环也会放大。 - **建议**:移动前自底向上遍历 `new_parent` 的祖先链(或递归 CTE)判断 `dir.ID` 是否在其中;`GetDirTree` 增加最大深度与总节点数上限,或改为「一次查询全部目录 + 内存组树」避免逐层 N+1。 #### P1-2. 子目录路径用 `oldPath+"%"` 模糊匹配 + `strings.Replace(...,1)` 全量重写,且错误被吞掉 - **位置**:`module/base/cloud/internal/logic/disk/move_dir.go:71`、`:96`、`internal/logic/disk/update_dir.go:77` - **证据**: ```go // move_dir.go:71 —— 注意 if err == nil 才进循环,查询失败静默跳过 if err := impl.DBService.Where("path LIKE ? AND passport_id = ?", oldPath+"%", auth.ID).Find(&subdirs).Error; err == nil { for _, subdir := range subdirs { newSubPath := strings.Replace(subdir.Path, oldPath, newPath, 1) impl.DBService.Model(&subdir).Update("path", newSubPath) ``` - **影响**:(a) `LIKE '/a%'` 会匹配到 `/abc`,把无关目录的 path 也一起改写(应为 `oldPath + "/%"`);(b) `WHERE path LIKE ?` 在 `path` 上无索引(模型只对 `parent_id`/`identity` 建了索引,`cloud_disk_dir.go:13-15`),是每层全表扫描;(c) 循环内逐条 `Update` 且**返回值被完全丢弃**,单条失败无感知,路径树进入半更新状态;(d) 整段没有事务;(e) `err == nil` 这种写法把 DB 错误当成「无子目录」处理。 - **建议**:用 `oldPath || '/' || '%'` 精确前缀匹配(或改用 parent_id 递归 CTE);批量 `UPDATE ... SET path = replace(path, ?, ?) WHERE passport_id = ? AND path LIKE ?` 单条 SQL 完成;错误必须返回;整体包事务。 #### P1-3. 无 panic 恢复 + 根目录上 `GetDir/GetDirTree` 必然空指针崩溃(进程级 DoS) - **位置**:`module/base/cloud/internal/logic/disk/get_dir.go:49`、`:91`、`internal/logic/disk/get_dir_tree.go:51`、`:97`、`internal/server/new.go:23` - **证据**: ```go // get_dir.go:91 —— 根目录 ParentID == nil,直接解引用 ParentId: uint64(*dir.ParentID), ``` ```go // get_dir_tree.go:51 —— 递归里同样解引用 ParentId: uint64(*subdir.ParentID), ``` ```go // server/new.go:23 —— 无任何 UnaryInterceptor / recovery grpcServ = grpc.NewServer() ``` - **影响**:`CreateDir{ParentId: 0}` 创建的目录 `ParentID` 为 nil(`create_dir.go:71-74` 只在 `ParentId > 0` 时赋值)。此后对该目录调用 `GetDir`(或它出现在 `GetDirTree` 子树中)**必然 panic**;`ListDirs` 的作者显然知道要判空(`list_dirs.go:52-55`),说明这是遗漏而非设计。gRPC 无 recover 拦截器,`printer`/`env` 也未确认有兜底 → 按 Go 语义,未捕获 panic 会终止整个服务进程(除非调用方另起 recover),单个越权/畸形请求即可打挂服务。 - **建议**:所有 `*dir.ParentID`/`*file.DirectoryID` 解引用改为 `if dir.ParentID != nil` 守卫(把 `list_dirs.go` 的写法统一抽成 helper);在 `server.New` 加一个 recover 拦截器,panic 转 `errcode.ErrInternal` 并记录堆栈。 #### P1-4. `storage_path` / `file_path` 完全由客户端决定,服务端零校验 - **位置**:`module/base/cloud/internal/logic/disk/upload_file.go:33`、`:74`、`internal/logic/disk/update_file.go:61`、`internal/logic/album/upload_photo.go:30` - **证据**: ```go // upload_file.go:33 if strings.TrimSpace(in.StoragePath) == "" { return nil, errcode.ErrInvalidArgument } ``` ```go // upload_file.go:74 —— 原样落库 StoragePath: in.StoragePath, ``` ```go // update_file.go:61 —— 更新时也允许任意改写,且不校验新路径是否被同账号其他文件占用 file.StoragePath = in.StoragePath ``` - **影响**:`cloud_disk_file.storage_path` / `cloud_photo.file_path` 是下游取文件事实上的权威指针(proto 注释即 "实际存储路径",`disk.proto:102`)。接口允许传入 `../../etc/passwd`、绝对路径、或另一个用户的存储路径,既不 normalize 也不校验前缀。文件服务(本模块外)若以该字段拼装本地路径即为**任意文件读取/覆盖**;`UpdateFile` 还允许在不换目录的前提下把路径指向别人的文件,等于把自己的记录"指向"他人数据。此外 UpdateFile 未校验 `size`/`hash` 一致性,去重哈希可被伪造。 - **建议**:服务端生成 storage_path(如 `//`),完全忽略客户端该字段;若必须接受,用 `filepath.Clean` + 校验落在允许根目录内并拒绝 `..`;`UpdateFile` 只允许改 name/original_name/mime_type。 #### P1-5. 目录/文件名不做任何清洗,可注入路径分隔符与 `..` - **位置**:`module/base/cloud/internal/logic/disk/create_dir.go:44`、`internal/logic/disk/update_dir.go:57`、`internal/logic/disk/upload_file.go:30` - **证据**: ```go // create_dir.go:44 —— in.Name 直接参与路径拼接,只校验了非空(:28) fullPath = filepath.Join(parentDir.Path, in.Name) ``` ```go // update_dir.go:57 dir.Path = filepath.Join(parentDir.Path, in.Name) ``` - **影响**:`Name = "../../x"` 会被 `filepath.Join` 归一化成 `../../x` 或越出父目录的路径,从而在逻辑路径空间中绕过目录层级(例如把节点挂到别的逻辑子树下),破坏"路径唯一/树形一致"的不变量;`Name` 含 `/` 时还能一次性构造多层目录。路径后续若被下游用于真实文件系统操作,则升级为目录穿越。 - **建议**:统一校验 `Name`:拒绝 `""`、`.`、`..`、含 `/` 或 `\`、含控制字符、长度超限(模型 `size:100`/`size:255`);路径由服务端用 `path.Join` 重建并断言以父路径为前缀。 #### P1-6. 文件/照片的「重名/重复」校验不带 passport_id,跨用户互相阻塞并泄露存在性 - **位置**:`module/base/cloud/internal/logic/disk/upload_file.go:50`、`internal/logic/disk/copy_file.go:52`、`internal/logic/disk/update_file.go:52`、`internal/logic/disk/move_file.go:47` - **证据**: ```go // upload_file.go:50 —— 无 passport_id、无 directory 归属二次确认 if err := impl.DBService.Where("name = ? AND directory_id = ?", in.Name, in.DirectoryId).First(&existingFile).Error; err == nil { return nil, errcode.ErrAlreadyExists } ``` - **影响**:`cloud_disk_file` 没有 passport_id(`cloud_disk_file.go:9-13`),唯一性判定天然缺失所有者维度。虽然插入前校验了目录归属(`upload_file.go:43`),但**目录归属与文件归属会在 MoveFile 后错位**:`MoveFile` 只校验目标目录属于自己(`:40`)和文件"通过 JOIN 目录"属于自己(`:31`),一旦文件被移动,其归属完全由所在目录动态决定 —— 这意味着**任何能修改自己目录归属的操作都会连带改变文件归属**。此外 `CopyFile`(`copy_file.go:57-72`)复制出的新记录同样不落 passport_id,语义上"复制"出的文件归属未定义。 - **建议**:`cloud_disk_file` 增加 `Std_Passport` 并在所有写路径赋值;唯一性校验改为 `(passport_id, directory_id, name)` 复合唯一索引 + `(passport_id, hash)` 去重索引,让数据库兜底而不是靠先查后插(当前先查后插存在 TOCTOU 竞态,并发同名上传可双双成功)。 #### P1-7. 容量/配额字段存在但从不参与写入校验 - **位置**:`module/base/cloud/internal/models/cloud_space.go:15`、`internal/logic/space/get.go:36-38`、`internal/logic/disk/upload_file.go:19` - **证据**: ```go // cloud_space.go:15 MaxStorage int64 `json:"max_storage"` // 最大存储空间 ``` ```go // get.go:36-38 —— 100GB 硬编码,且仅用于回显 TotalStorage: 100 * 1024 * 1024 * 1024, // 100GB UsedStorage: 0, MaxStorage: 100 * 1024 * 1024 * 1024, // 100GB ``` ```go // upload_file.go:19 起 —— 全函数没有任何 size / 配额校验,in.Size 直接落库 ``` - **影响**:`MaxStorage`/`UsedStorage` 在 `disk.UploadFile`、`album.UploadPhoto`、`note.InsertAttachment` 中**从未被读取**(全模块 grep `MaxStorage|Quota|quota` 只命中 space 的读/写与 limit 分页),配额形同虚设,单用户可无限写入;同时 `in.Size` 由客户端自报,即使将来加配额校验也可被伪造。 - **建议**:上传前 `SELECT max_storage FROM cloud_space WHERE passport_id = ?` 并在事务内用 `UPDATE cloud_space SET used_storage = used_storage + ? WHERE passport_id = ? AND used_storage + ? <= max_storage` 做原子准入;size 由服务端按实际字节流统计/校验,不能信任客户端。 #### P1-8. `used_storage` 统计把用户全部文件读入内存求和;`GetDirTree` 逐层递归 Preload - **位置**:`module/base/cloud/internal/logic/space/get.go:61-68`、`internal/logic/space/get_by_key_identifier.go:52-59`、`internal/logic/disk/get_dir_tree.go:38`、`:44-61` - **证据**: ```go // get.go:61-68 —— 先 Count,再把所有行 Find 到 slice 里在 Go 里累加 var files []models.CloudDiskFile impl.DBService.Joins("JOIN cloud_disk_dirs ON cloud_disk_files.directory_id = cloud_disk_dirs.id"). Where("cloud_disk_dirs.passport_id = ?", auth.ID).Find(&files) for _, file := range files { usedStorage += file.Size } ``` ```go // get_dir_tree.go:38 —— 只 Preload 一层,靠 loadSubdirs 递归时按需 lazy load if err := query.Preload("Parent").Preload("Subdirectories").Preload("Files").First(&dir).Error; err != nil { ``` - **影响**:(a) `used_storage` 本可 `SELECT COALESCE(SUM(size),0)` 一条聚合完成,现在是 **O(N) 行 + O(N) 内存**的搬运,文件数十万级时单次 `Space.Get` 就能吃掉大量内存与带宽;该接口还是"每次打开云盘首页"必调的。(b) `GetDirTree` 的递归里访问 `subdir.Subdirectories` 会触发 GORM lazy load,**深度 d 的树产生 1+d 次查询**,宽树则每次查询都返回全部列(含未用字段),是典型的 N+1 + 大对象拷贝。(c) `Files` Preload 无分页,单目录下 10 万文件一次性载入。 - **建议**:聚合 SQL 化;`GetDirTree` 改为一次 `SELECT id,parent_id,name,path FROM cloud_disk_dir WHERE passport_id=?` + 内存建树(或 PostgreSQL 递归 CTE),并设最大深度/节点数;目录详情里的 Files 单独分页接口获取。 #### P1-9. 搜索与服务端分页 - **位置**:`module/base/cloud/internal/logic/disk/search_files.go:42`、`internal/logic/note/search_notes.go:40`、`internal/logic/private/search_private_data.go:40`、各 `list_*.go:25` 与 `:42` - **证据**: ```go // search_files.go:42 searchKeyword := "%" + strings.ToLower(keyword) + "%" query = query.Where("LOWER(cloud_disk_files.name) LIKE ? OR LOWER(cloud_disk_files.original_name) LIKE ?", searchKeyword, searchKeyword) ``` ```go // list_dirs.go:25-27 —— 只设下限,无上限 if in.GetPageSize() < 10 { in.PageSize = 50 } ``` - **影响**:(a) `LOWER(col) LIKE '%kw%'` 前导通配符 + 函数包裹,任何普通 B-tree 索引都不可用,必然全表扫描;`note.title/content/tags`、`private.title/description/tags` 同样如此(`content` 还是 `type:text`)。(b) 全模块 14 处分页都是 `LIMIT/OFFSET`,深翻页性能随 offset 线性劣化,且 `page_size` 没有上限,`page_size=1e18` 会被截断成 `int` 后作为巨大 LIMIT 下发。(c) `offset` 由 `(page_no-1)*page_size` 计算,两个 int64 相乘存在溢出可能(虽非常规输入)。 - **建议**:搜索改用 PostgreSQL `pg_trgm` GIN 索引(`name gin_trgm_ops`)或独立全文检索服务;`page_size` 上限(如 100);深分页改游标(`created_at,id` > last)。 #### P1-10. 全部数据库调用不透传 context,取消/超时/链路追踪失效 - **位置**:全模块 51 个 logic 文件(`impl.DBService` 调用点无一使用 `.WithContext(ctx)`) - **证据**:以 `internal/logic/disk/list_dirs.go:34-45` 为例 ```go if err := impl.DBService.Model(&models.CloudDiskDir{}).Where("passport_id = ?", auth.ID).Count(&total).Error; err != nil { ``` (`grep "WithContext" module/base/cloud` → 无任何命中) - **影响**:客户端断连或 deadline 到期后,正在执行的 SQL 不会被取消,长查询/全表扫描会继续占用连接直到自然结束;连接池被慢查询拖满后引发级联故障。同时因为没有 request-scoped context,任何 DB 层的 trace/超时治理都无法落地。 - **建议**:统一改为 `impl.DBService.WithContext(ctx)`;给每个 RPC 设默认 statement_timeout。 #### P1-11. 多步写操作无事务(全模块 `Transaction` 0 处) - **位置**:`module/base/cloud/internal/logic/album/delete_album.go:45`、`internal/logic/note/delete_note.go:45`、`internal/logic/disk/move_dir.go:69`、`internal/logic/disk/update_dir.go:75` - **证据**: ```go // delete_note.go:45-54 —— 先删附件再删笔记,两步无事务 if err := impl.DBService.Where("note_id = ?", note.ID).Delete(&models.NoteAttachment{}).Error; err != nil { ... } if err := impl.DBService.Delete(¬e).Error; err != nil { ... } ``` (`grep "Transaction|Begin\(\)" module/base/cloud` → 无任何命中;SDK 侧 `SkipDefaultTransaction: true`,`D:\work\bsm-sdk\core\database\new.go:90`) - **影响**:第二步失败时留下孤儿附件/空相册;`move_dir`/`update_dir` 的批量 path 改写在同一次调用内可能部分成功(叠加 P1-2 的错误吞掉,静默不一致)。批量操作(`ImportBookmarks` 的 `CreateInBatches`)也无事务,中途失败只回滚当前批次。 - **建议**:凡"父删子删"、"主记录 + 子节点路径更新"一律包 `Transaction`;`ImportBookmarks` 用单事务 + 幂等键。 #### P1-12. 软删除与唯一性/唯一索引的交互未处理 - **位置**:`module/base/cloud/internal/models/cloud_disk_dir.go:10`、`internal/logic/disk/create_dir.go:51`、`internal/logic/disk/upload_file.go:50` - **证据**: ```go // cloud_disk_dir.go:10 内嵌 Std_IICUDS,含 gorm.DeletedAt // (D:\work\bsm-sdk\core\types\db.go:33)DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;..."` ``` ```go // create_dir.go:51 —— 默认 scope 自动带 deleted_at IS NULL if err := impl.DBService.Where("path = ? AND passport_id = ?", fullPath, auth.ID).First(&existingDir).Error; err == nil { ``` - **影响**:(a) 应用层重名判断被软删除 scope 过滤,而 `identity` 上有 `uniqueIndex`(`db.go:30`)——注意该唯一索引不包含 `deleted_at`,因此"软删后重建同 identity"会撞唯一键;当前 identity 用 UUID 生成,撞键概率低,但**任何手工/迁移脚本重建 identity 都会失败**。另外 `path` 上根本没有唯一索引,path 唯一性完全靠"先查后插",并发下可插入两条同 path 记录。(b) 无回收站/恢复接口,`DeleteDir`/`DeleteFile` 失败后数据只能靠 DBA 从 `deleted_at` 捞(README 未提及回收站,功能缺口)。 - **建议**:给 `(passport_id, path)` 与 `(passport_id, directory_id, name)` 建带 `WHERE deleted_at IS NULL` 的部分唯一索引;identity 若参与业务唯一性,索引需含 `deleted_at`;明确回收站语义并补齐恢复/彻底删除接口。 #### P1-13. `CloudSpace` 创建时 `identity`/`key_identifier` 硬编码为 "default",而 `key_identifier` 是全局唯一索引 - **位置**:`module/base/cloud/internal/logic/space/get.go:27-35`、`internal/models/cloud_space.go:12` - **证据**: ```go // get.go:27-35 space = models.CloudSpace{ Std_IICUDS: types.Std_IICUDS{Identity: "default"}, Std_Passport: types.Std_Passport{PassportID: auth.ID, PassportIdentity: auth.Identity}, KeyIdentifier: "default", ``` ```go // cloud_space.go:12 KeyIdentifier string `gorm:"uniqueIndex;size:32" json:"key_identifier"` ``` - **影响**:`key_identifier` 是**全局唯一**索引且被硬编码为常量 "default",只有第一个用户能创建成功;第二个未初始化用户调用 `Space.Get` 时 `Create` 会因唯一键冲突返回 ErrDB,**云盘首页对该用户永久 500/报错**。同时 `Identity` 也用 "default",与 `Std_IICUDS` 的 `uniqueIndex` 冲突。这是确定性的线上故障(只要存在两个空间记录尚未创建的用户)。`GetByKeyIdentifier` 用 `key_identifier` 查询(`get_by_key_identifier.go:35`),硬编码后该接口失去意义。 - **建议**:`Identity = utils.UUID()`、`KeyIdentifier = utils.ULID()`(或 `"default-" + passportIdentity`);用 `FirstOrCreate`/`INSERT ... ON CONFLICT DO NOTHING` 做并发安全初始化;把 `key_identifier` 的唯一索引改为 `(passport_id, key_identifier)`。 #### P1-14. 计数器读-改-写竞态(分享浏览量、笔记浏览量) - **位置**:`module/base/cloud/internal/logic/share/validate_share_password.go:48`、`internal/logic/note/increment_views.go:45` - **证据**: ```go // validate_share_password.go:47-49 share.ViewCount++ if err := impl.DBService.Save(&share).Error; err != nil { ``` ```go // increment_views.go:45-47 note.Views++ if err := impl.DBService.Save(¬e).Error; err != nil { ``` - **影响**:`SELECT` 后 `Save` 整行写回,并发请求互相覆盖,计数丢失(经典的 lost update)。`Save` 还会把整行(含 content/data 大字段)写回,产生不必要的大对象写放大。附带语义问题:`IncrementViews` 只有笔记作者本人能调用(`WHERE passport_id = auth.ID`),因此"浏览次数"统计的其实是**作者自阅次数**,与 README:193 "浏览次数统计"的预期不符;且 `IsPrivate=true` 的笔记没有任何访问控制实现(`list_notes.go:40` 不带 `is_private` 过滤,但也没有"他人访问"入口,属未实现)。 - **建议**:改为 `Update("view_count", gorm.Expr("view_count + 1"))` 原子自增;`IncrementViews` 应允许非作者浏览计数(按资源 id + 限流),或明确语义并改名。 #### P1-15. 分享创建缺乏资源归属校验与参数约束;`ImportBookmarks` 无任何规模上限 - **位置**:`module/base/cloud/internal/logic/share/create_share.go:39`、`:68-71`、`internal/models/cloud_share.go:17`、`internal/logic/bookmark/import_bookmarks.go:38`、`:46` - **证据**: ```go // create_share.go:39-42 —— 客户端可直接指定 token shareToken := in.ShareToken if shareToken == "" { shareToken = utils.UUID() } ``` ```go // create_share.go:68-71 —— share_type/resource_id 完全不校验资源是否存在、是否属于自己 ShareType: in.ShareType, ResourceID: uint(in.ResourceId), ``` ```go // import_bookmarks.go:38-46 —— 客户端 JSON 直接 unmarshal 成切片,无长度上限 var bookmarks []map[string]interface{} if err := json.Unmarshal([]byte(in.Data), &bookmarks); err != nil { ``` - **影响**:(a) `resource_id` 可为任意用户的资源,`CreateShare` 会为其生成分享令牌 → 只要下游分享访问按 `resource_id` 取资源,即是**越权分享他人文件**;(b) 客户端指定的 `ShareToken` 未做格式/长度校验,超 32 字符会在 `size:32` 写入时报 DB 错,且可构造可枚举 token;(c) `ImportBookmarks` 无条数/字节上限,超大 JSON 造成内存放大(gRPC 默认 4MB 消息上限是唯一约束,但仍可放大数倍),`CreateInBatches` 无事务、无幂等;(d) 客户端传入的 URL 未做 scheme 校验,可存入 `javascript:` 等。 - **建议**:`CreateShare` 按 `share_type` 反查资源并要求 `passport_id = auth.ID`;`ShareToken` 一律服务端生成且校验格式;`ImportBookmarks` 限制条数(如 ≤5000)与总字节数,URL 只允许 http/https,`Format` 白名单。 ### P2 #### P2-1. `GetByKeyIdentifier` 与 `Get` 的统计块完全重复 - **位置**:`module/base/cloud/internal/logic/space/get.go:52-100`、`internal/logic/space/get_by_key_identifier.go:43-91` - **证据**:两段代码逐字符几乎相同(`Space.Get` 与 `Space.GetByKeyIdentifier` 各自维护一份 6 次 Count + 1 次全表求和),仅查询条件不同。 - **影响**:任何统计口径调整都需要改两处,必然漂移(例如新增 bookmark 统计时漏改一处)。这是本模块唯一的"重复大段逻辑",其余 CRUD 的重复(分页/转换)见 P3-1。 - **建议**:抽出 `func refreshSpaceStats(ctx, passportID) (space, error)`,两个入口共用。 #### P2-2. `CloudSpace` 的 6 个关联字段从不 Preload,永远返回空数组 - **位置**:`module/base/cloud/internal/models/cloud_space.go:23-28`、`internal/logic/space/get.go:102-116` - **证据**: ```go // cloud_space.go:23-28 CloudDiskDirectorys []CloudDiskDir `gorm:"foreignKey:PassportID" json:"cloud_disk_dirs"` Albums []CloudAlbum `gorm:"foreignKey:PassportID" json:"cloud_albums"` ``` ```go // get.go:102 起的 reply 构造里没有任何 Preload(...),proto 的 14~19 字段永远是空 reply = &pb.CloudSpace{ Id: ..., TotalStorage: ..., } ``` - **影响**:`space.proto:38-43` 声明了 `cloud_disk_dirs/cloud_albums/cloud_notes/...` 六个 repeated 字段,客户端若依赖它们会永远拿到空数组;且这些字段名(`CloudDiskDirectorys` 拼写错误)与 proto(`cloud_disk_dirs`)不一致。 - **建议**:要么在 `Get` 里显式分页 Preload,要么从 proto 删除这些字段;修正字段拼写。 #### P2-3. `DeleteAttachment` 的 identity 分支被自我覆盖,identity 参数完全无效 - **位置**:`module/base/cloud/internal/logic/note/delete_attachment.go:37` - **证据**: ```go } else { // 注意:NoteAttachment模型没有identity字段,这里假设通过ID删除 query = query.Where("note_attachments.id = ?", in.Id) } ``` - **影响**:注释即自认的缺陷。客户端按 identity 删除时 `in.Id == 0`,查询变成 `id = 0`,**永远匹配不到**(返回 ErrInvalidArgument),且没有报错提示;`NoteAttachment` 缺 Identity 字段导致其无法按身份引用(对比其它模型的 `Std_IICUDS`)。 - **建议**:`NoteAttachment` 增加 Identity,或让 `DeleteAttachment` 接受 `(note_id, attachment_id)` 二元组并改为按 id 语义(同时修正 proto)。 #### P2-4. `InsertAttachment` 用 `string(rune(id))` 拼接返回详情,产出乱码 - **位置**:`module/base/cloud/internal/logic/note/insert_attachment.go:59` - **证据**: ```go Details: string(rune(attachment.ID)), ``` - **影响**:`@typescript-eslint` 式的 go vet 会报 `conversion from int to string yields a string of one rune`。id=1 返回 `"\x01"`,id=65 返回 `"A"`——返回给客户端的 `details` 是控制字符而非数字,调用方无法按此字段回调/关联,是确定的接口缺陷(`StatusReply.details` 在其它接口里承载 identity/OK 等字符串语义)。 - **建议**:`strconv.FormatUint(uint64(attachment.ID), 10)`,或与其它接口一致返回 `attachment.Identity`(需先补字段)。 #### P2-5. 缓存层完全未使用(README 承诺的缓存策略为零实现) - **位置**:`module/base/cloud/README.md:440-448`、`module/base/cloud/internal/impl/impl.go:13-16`、`module/base/cloud/service/dependencies.go:20-31` - **证据**: ```go // impl.go:13-16 —— 声明了三个缓存/注册中心句柄 RedisService *redis.RedisClient EtcdService *clientv3.Client MemorySerice *cache.Cache ``` ``` grep "impl\.(RedisService|MemorySerice|EtcdService)" module/base/cloud/internal → 无任何命中 ``` - **影响**:README 表格列出的"文件元数据 30 分钟 / 相册 1 小时 / 书签 6 小时"缓存策略**在代码中不存在**,全部请求直打 PostgreSQL;`RedisService`/`MemorySerice` 是死变量(`MemorySerice` 还拼错了 Service)。同时文档承诺的缓存失效策略、连接复用在代码里无从谈起。 - **建议**:要么实现缓存(热点:`Space.Get` 统计、目录树、用户信息),要么从 README 删除该章节;清理未使用依赖(redis/go-cache/etcd 若仅用于装配则保留但需注明)。 #### P2-6. 可观测性与运维面缺失:无健康检查、无 metrics、无 APM、无结构化日志 - **位置**:`module/base/cloud/etc/cloud_prod.yaml:32-35`(APM 全被注释)、`module/base/cloud/README.md:457-465`、`internal/logic/**` 的日志调用 - **证据**: ```yaml # cloud_prod.yaml:32-35 # 链路追踪,性能监控,日志收集 # APM: # Platform: elasticAPM ``` ```go // 全部错误日志形如(disk/get_file.go:40): printer.Error("File not found: %v", err) ``` - **影响**:README:461-464 宣称 `curl http://localhost:12102/health` 和 `/metrics` 可用,但模块内 `grep health|metrics` **只命中 README**,接口不存在。APM 在 prod 配置中被注释,等于生产无链路追踪。日志仅 40 余处 `printer.Error`,无请求 ID、无结构化字段、无耗时,且错误信息把 DB 错误直接 `%v` 打出(可能含 SQL 片段与参数,见 P2-7)。所有列表/查询接口无慢查询日志。 - **建议**:补 `/healthz`(含 DB/Redis 探活)与 `/metrics`(Prometheus);启用 APM 并在 prod 配置中固化;统一结构化日志(trace_id/passport_id/latency)。 #### P2-7. GORM 默认 `Debug: true` 且非参数化查询,可能把分享口令/隐私密文写进标准输出 - **位置**:`D:\work\bsm-sdk\core\database\sql\postgresql.go:12-21`、`:46-48`、`D:\work\bsm-sdk\core\with\databases.go:18` - **证据**: ```go // postgresql.go:13-20 —— options == nil 时的默认值(cloud 调用 with.Databases(cfg, nil)) options = &types.SqlOptions{ MaxIdleConns: ..., MaxOpenConns: ..., ConnMaxLifetime: ..., IsAutoMigrate: false, LogStdout: false, Debug: true, // ← 默认打开 } ``` ```go // postgresql.go:46-48 if options.Debug { gormDb = gormDb.Debug() } ``` ```go // with/databases.go:18 —— 另外整份 DBConf(含 DSN 与密码)被直接打印 printer.Info("[BSM - %s] Databases: %v", vars.ServiceKey, cfg) ``` - **影响**:(a) `Debug()` 让 GORM 打印所有 SQL 到 stdout,包含 `INSERT ... cloud_share(password)`(明文口令)、`cloud_private.data`(用户隐私密文/明文)、`cloud_note.content`;且 `postgres.Config` 未启用 `PreferSimpleProtocol`(第 34 行注释掉的正是该项)→ 参数内联,敏感值直接出现在日志。(b) 启动日志打印完整 DSN,密码进日志系统。 - **建议**:`cloud` 侧显式传入 `&types.SqlOptions{IsAutoMigrate: true, Debug: false, ...}`;脱敏 DSN 后再打印;日志中禁止出现 password/data/content 字段。 #### P2-8. 不安全默认配置与配置校验缺失 - **位置**:`module/base/cloud/etc/cloud_prod.yaml:7`、`:10`、`:24`、`internal/config/config.go:35`、`cmd/main/main.go:18` - **证据**: ```yaml # cloud_prod.yaml:7 —— sslmode=disable + 占位密码 - host=127.0.0.1 user=postgres password=CHANGE_ME dbname=rst_dev port=5432 sslmode=disable TimeZone=Asia/Shanghai # cloud_prod.yaml:10 Cache: redis://null:CHANGE_ME@127.0.0.1:6379/ # cloud_prod.yaml:24 SecretKey: CHANGE_ME ``` ```go // config.go:35 —— 只校验了 Service 和 Cache conf.NotNil(Spec.Service, Spec.Cache) ``` - **影响**:(a) 生产配置三处占位密钥 + PostgreSQL `sslmode=disable`(数据库链路明文);(b) `Databases`、`Gateway`、`Etcd`、`Rpc` 均未纳入 `NotNil` 校验,漏配时会在运行期 panic(`with.Databases` 里 `panic("No Database Source Found !")`,`D:\work\bsm-sdk\core\with\databases.go:14`),属于"启动即崩"的配置耦合;(c) `Service: initial` 与 `ServiceKey = "cloud"`(`cmd/main/main.go:15`)不一致,注册/路由 key 与实际服务名存在漂移风险;(d) 无 TLS 配置项,网关 `http.ListenAndServe`(`D:\work\bsm-sdk\core\service\service.go:126`)明文 HTTP,全模块无任何限流中间件。 - **建议**:密钥走 Secret 管理(环境变量/配置中心)而非 yaml 占位;生产强制 `sslmode=require`;补齐配置校验并在启动时失败-快;网关加 TLS 终止 + 限流(尤其 `ValidateSharePassword`)。 #### P2-9. 写入路径无幂等性设计 - **位置**:`internal/logic/disk/create_dir.go:76`、`internal/logic/disk/upload_file.go:78`、`internal/logic/album/upload_photo.go:75`、`internal/logic/note/insert_attachment.go:53`、`internal/logic/bookmark/import_bookmarks.go:83` - **证据**: ```go // upload_file.go:56-59 —— "hash 去重"名不副实:没传 hash 就用 UUID 当 hash fileHash := in.Hash if fileHash == "" { fileHash = utils.UUID() // 使用UUID作为默认哈希 } ``` - **影响**:所有创建接口都没有幂等键(无 `request_id`/客户端幂等 token),网络重试/网关重发会产生重复目录、重复照片、重复附件;`UploadFile` 的 `hash` 由客户端提供或退化为随机 UUID,README:140 宣称的"文件哈希去重"**实际没有任何去重逻辑**(全模块没有按 hash 查重的代码)。 - **建议**:写接口引入幂等键(`(passport_id, request_id)` 唯一索引 + 命中即返回原结果);hash 由服务端对内容计算,并据此实现真正的秒传/去重。 #### P2-10. 无优雅退出:gRPC 仅 `GracefulStop`,HTTP 网关无关闭路径,etcd 租约未释放 - **位置**:`module/base/cloud/cmd/main/main.go:37`、`D:\work\bsm-sdk\core\service\service.go:142-144`、`:114` - **证据**: ```go // main.go:37 defer srv.Stop() // service.go:142-144 func (s *Service) Stop() { s.GrpcSrv.GracefulStop() } ``` ```go // service.go:113-115 —— Start 永久阻塞,HTTP server 引用未保存 // 阻塞主线程 select {} ``` - **影响**:`defer` 在 `select{}` 之后永远不执行(`Run()` 不返回),`srv.Stop()` 实际是死代码;HTTP 网关用 `http.ListenAndServe` 且句柄未保留,无法优雅关闭;etcd 注册的租约监听 goroutine 无退出信号。K8s 滚动发布时会直接掐断在途请求。 - **建议**:`Start` 返回 `stop chan`/接收 `context`,注册 `SIGTERM` 处理:先停 HTTP(`srv.Shutdown`)、再 `GracefulStop`、最后释放 etcd lease。 #### P2-11. 魔法数字与不一致的默认值散落各处 - **位置**:`internal/logic/space/get.go:36-38`(100GB ×2)、所有 `list_*.go` 的 `pageSize<10 → 50`(14 处)、`internal/logic/share/create_share.go:50` 与 `:53`(`7 * 24 * time.Hour` ×2)、`internal/logic/album/upload_photo.go:45-51`(TakenAt 解析失败静默替换为 `time.Now()`) - **证据**: ```go // create_share.go:50 与 :53 同一常量写了两遍 expiresAt = time.Now().Add(7 * 24 * time.Hour) // 默认7天过期 ... expiresAt = time.Now().Add(7 * 24 * time.Hour) // 默认7天过期 ``` ```go // upload_photo.go:45-49 —— 解析失败不报错,用当前时间掩盖 if t, err := time.Parse(time.RFC3339, in.TakenAt); err == nil { takenAt = t } else { takenAt = time.Now() } ``` - **影响**:配额、分页、过期策略、拍摄时间兜底值无法统一调整;`TakenAt` 非法时静默写入错误元数据(用户看到"拍摄于今天"),是数据质量问题。 - **建议**:抽 `internal/consts`(DefaultPageSize / MaxPageSize / DefaultQuota / DefaultShareTTL);时间解析失败应返回 `ErrInvalidArgument` 而非静默兜底。 #### P2-12. 模块内 0 个测试文件 - **位置**:`module/base/cloud/test/lint/`(空目录) - **证据**: ``` Get-ChildItem -Path "...\module\base\cloud\test" -Recurse -Force → 仅一个空目录 test\lint,0 个文件;全模块无 *_test.go ``` - **影响**:README:338-355 描述的 `make test` / `make test-grpc` / 覆盖率 / 安全扫描在本模块无对应产物。所有归属校验、路径重写、配额、加解密逻辑全靠人工评审。 - **建议**:按第 6 节"关键缺失用例清单"补齐,优先 P0/P1 对应的越权与路径一致性用例。 #### P2-13. 文档与实现大面积不一致 - **位置**:`module/base/cloud/README.md:119-142`、`:461-464`、`:469-499`、`:556-619`、`:310` - **证据**: ```markdown # README.md:461-464 —— 接口不存在(模块内 grep 无任何 health/metrics 实现) curl http://localhost:12102/health curl http://localhost:12102/metrics ``` ```markdown # README.md:570 —— SQL 语句本身写错了 CREATE TABLE-cloud_disk_file ( ``` - **影响**:README 声称的"Redis 缓存""健康检查/APM""文件哈希去重""EXIF 信息提取"(`:170`)、`/cloud.swagger.json`(`:310`,但 proto **没有任何 `google.api.http` 注解**,见 `grep google.api.http proto/ → 无命中`,因此不会生成 swagger)、表结构(README 用复数表名,实现是 `SingularTable: true` 的单数表名,字段也对不上)均与实现不符;README:230-232 的 proto 消息名(`ListPrivateResponse`/`GetPrivateDataByTypeRequest`)与实际(`ListPrivateDataResponse`/`FetchRequest`)不一致。运维照文档操作会直接踩空。 - **建议**:以代码为准重写 README 的 API/表结构/配置章节;删掉未实现特性或补实现;给 proto 加 http 注解以生成 swagger(若确实需要)。 #### P2-14. 分享 / 隐私数据的敏感字段无脱敏 - **位置**:`internal/logic/share/get_share.go:54`、`internal/logic/share/list_shares.go:57`、`internal/models/cloud_share.go:18` - **证据**: ```go // get_share.go:54 Password: share.Password, ``` - **影响**:`GetShare`/`ListShares` 把明文分享口令回传给调用方(虽为本人,但会进入客户端缓存/日志/浏览器历史);`CloudShareItem.password` 在 proto 中也没有标注为只写字段。配合 P2-7 的 SQL 日志,口令会有多个泄露面。 - **建议**:出参不回传 password(或只回传 `has_password bool`);口令入参标记为敏感并从审计日志中排除。 ### P3 #### P3-1. 大量复制粘贴:分页校验 + offset 计算 + 模型→pb 转换在 14+ 处重复 - **位置**:`internal/logic/disk/list_dirs.go:21-47`、`list_files.go:21-52`、`search_files.go:22-59`、`internal/logic/album/list_albums.go:21-47`、`list_photos.go:21-51`、`internal/logic/note/list_notes.go:21-47`、`search_notes.go:22-57`、`internal/logic/bookmark/list_bookmarks.go:21-46`、`internal/logic/private/list_private_data.go:21-46`、`search_private_data.go:22-56`、`get_private_data_by_type.go:23-57`、`internal/logic/share/list_shares.go:21-46`、`internal/logic/space/get.go:52-100`(共 14 处分页块 + 7 处各不相同的转换函数) - **证据**: ```go // list_dirs.go:22-27,在 12 个文件里逐字重复 if in.GetPageNo() < 1 { in.PageNo = 1 } if in.GetPageSize() < 10 { in.PageSize = 50 } offset := (in.PageNo - 1) * in.PageSize ``` - **影响**:分页语义/默认值变更需改 14 处;模型→pb 的字段搬运(每个文件 30~90 行)无任何复用,`CloudPhotoItem` 的构造在 `list_photos.go`/`get_photo.go`/`list_albums.go`/`get_album.go` 里出现了 4 次。 - **建议**:抽 `pagination.Normalize(in)` 与 `converter` 包(`ToPhotoItem(models.CloudPhoto) *pb.CloudPhotoItem`),或引入 mapstruct 风格的集中转换。 #### P3-2. proto 中大量与本模块无关的消息,且缺少 HTTP 注解 - **位置**:`module/base/cloud/proto/const.proto:37-326` - **证据**:`const.proto` 里包含 CMS/Mall/Market/Order/Feed/Group/Relation 等消息(`CmsSearchRequest`、`MallFetchRequest`、`OrderSummaryItem`、`FeedPostItem`、`RelationItem`…),而 cloud 模块只是共用该文件;所有 proto 均无 `google.api.http` 注解。 - **影响**:const.proto 成为跨模块共享的"杂物间",任何模块改动都会触发 cloud 的 pb 重新生成(耦合);无 http 注解意味着无法生成 swagger/自定义路由(README 却宣称有 swagger)。 - **建议**:cloud 只保留自用的 `Empty/FetchRequest/IdentRequest/StatusReply` 等,其余下沉到各模块自己的 const。 #### P3-3. 模型字段与索引设计不一致 - **位置**:`internal/models/cloud_disk_dir.go:18-20`、`internal/models/cloud_space.go:23-28`、`internal/models/cloud_note_attach.go:10-18` - **证据**: ```go // cloud_disk_dir.go:18-19 —— 自关联未写 references:ID(同仓库 mgt 模块的写法是 foreignKey:ParentID;references:ID) Parent *CloudDiskDir `gorm:"foreignKey:ParentID" json:"parent"` Subdirectories []CloudDiskDir `gorm:"foreignKey:ParentID" json:"subdirectories"` ``` ```go // cloud_space.go:23 —— 字段名拼写错误 CloudDiskDirectorys []CloudDiskDir `gorm:"foreignKey:PassportID" json:"cloud_disk_dirs"` ``` ```go // cloud_note_attach.go:10-18 —— NoteAttachment 只有 ID,没有 Std_IICUDS type NoteAttachment struct { ID uint `gorm:"primaryKey" json:"id"` ``` - **影响**:`Parent`/`Subdirectories` 与 `mgt` 模块的同类自关联写法不一致(缺 `references:ID`)——**推测**在 GORM 中该写法会让 `Parent` 关联的查询条件缺少预期约束(GORM 默认以主键作 reference,自引用场景需要显式 references 才可靠),`GetDir`/`GetDirTree` 返回的 `Parent` 可能是非父节点;此结论**未能运行期验证**,建议以集成测试确认。`CloudDiskDirectorys` 拼写错误会外泄到 JSON tag 之外(tag 是 `cloud_disk_dirs` 正确,Go 字段名错误影响可读性)。 - **建议**:统一为 `gorm:"foreignKey:ParentID;references:ID"`;修正拼写;`NoteAttachment` 补齐 `Std_IICUDS`(同时解决 P2-3)。 #### P3-4. `cmd/cli` 是 Hello World 空壳 - **位置**:`module/base/cloud/cmd/cli/main.go:1-7` - **证据**: ```go func main() { log.Println("Hello World!") } ``` - **影响**:README:79-80 将 `cmd/cli` 描述为"命令行工具",实际无任何功能;该二进制会被编译进发布产物(增加无用体积与攻击面)。 - **建议**:删除该目录,或实现预期的运维命令(如空间重建、软删清理)。 #### P3-5. 统计字段/计数器写入即持久化,且部分字段永不更新 - **位置**:`internal/logic/space/get.go:97`、`internal/models/cloud_share.go:21`、`internal/logic/share/create_share.go:74` - **证据**: ```go // get.go:97 —— 读接口里做写操作(且失败被忽略,:98-100) if err := impl.DBService.Save(&space).Error; err != nil { printer.Error("Update space error: %v", err) // 不返回错误,继续执行 } ``` ```go // cloud_share.go:21 / create_share.go:74 —— DownloadCount 有字段、有初始化,但全模块无任何自增逻辑 DownloadCount int `gorm:"default:0" json:"download_count"` ``` - **影响**:读接口产生写放大(每次打开首页都 UPDATE 一行);`DownloadCount` 是死字段(README:264 宣称"访问统计"只有一半实现);`ViewCount` 的 `Save` 失败被吞(`validate_share_password.go:51`)。 - **建议**:统计改为异步/定时任务,或在读接口里先算后比、仅在变化时写;补下载计数或删字段。 --- ## 4. 推荐优化方案 1. **补齐所有权数据模型(最高优先)** - 目标:让每一张表都能自证归属。 - 做法:`CloudDiskFile`、`CloudPhoto`、`NoteAttachment` 增加 `types.Std_Passport`;所有写路径用 `auth.ID/auth.Identity` 赋值;写一次性数据回填脚本(file 从目录回填、photo 从相册回填)。 - 影响面与风险:触及所有 disk/album/note 写路径与模型,需同步迁移与回填;风险是历史数据中 photo 的 album 可能已被删(无法回填),需保留 NULL 并做隔离策略。 2. **统一「归属校验中间件」替代 51 处手写 WHERE** - 目标:消除"某个 handler 忘了加 passport_id"这类漏网。 - 做法:抽 `func owned(db *gorm.DB, table string, id uint, passportID uint) *gorm.DB`,或引入 GORM Scope(`db.Scopes(models.WithPassport(auth.ID))`)强制注入;对 P0/P1 涉及的全部查询逐条改造。 - 影响面与风险:机械但面广;风险是 JOIN 场景需要表名限定(当前 `search_files.go:37` 就是靠 `cloud_disk_dirs.passport_id` 限定),改造时须保留 qualifier。 3. **目录树正确性:环检测 + 单次查询建树 + 批量路径更新** - 目标:消除成环、`LIKE` 误匹配、逐条 UPDATE 与 N+1。 - 做法:`MoveDir` 用递归 CTE/祖先链校验;路径前缀匹配带分隔符;子树 path 用单条 `UPDATE ... WHERE path LIKE '/a/%'`;`GetDirTree` 一次查全量后内存组树并限制深度/节点数。 - 影响面与风险:需要 PostgreSQL 递归 CTE(当前驱动是 postgres,可行);风险是超大目录的一次性加载,需要上限与截断标记。 4. **加解密与分享口令的密码学修复** - 目标:让"加密存储/口令保护"名副其实。 - 做法:`Private` 改服务端密钥派生(Argon2id + 每记录 salt),拒绝客户端裸密钥与 `IsEncrypted=false`;`Share.Password` 改 bcrypt/argon2 哈希 + `ConstantTimeCompare` + 失败退避(复用已注入的 Redis)。 - 影响面与风险:破坏现有客户端契约(`EncryptData/DecryptData` 的 `key` 入参语义变更),需版本化兼容或提供迁移窗口;口令哈希后无法回显,会影响 P2-14 的接口行为。 5. **配额与统计的原子化** - 目标:让 `max_storage/used_storage` 真正生效且不拖垮读接口。 - 做法:上传前用带条件的原子 UPDATE 做准入;`used_storage` 改为 `SUM(size)` 聚合(或异步物化 + 定时校准);计数类字段统一 `gorm.Expr("x + 1")`。 - 影响面与风险:需要 size 由服务端计算(当前客户端自报),要改上传协议;物化统计需处理与真实值漂移。 6. **事务与 context 治理(一次性铺开)** - 目标:消灭部分失败与不可取消查询。 - 做法:所有 `.DBService` 调用加 `.WithContext(ctx)`;多步写包 `Transaction`;`move_dir/update_dir` 的静默 `err == nil` 全部改为返回错误。 - 影响面与风险:全模块机械改造,回归面大;建议配合 7 中提到的测试用例。 7. **建立最小测试与安全回归网** - 目标:把本次发现固化为可执行断言。 - 做法:见第 5 节 TODO 的验收标准;优先写"跨用户访问必须 403/NotFound"、"根目录 GetDir 不 panic"、"移动目录到自身后代必须失败"、"路径重写只影响真实子树"四类用例。 - 影响面与风险:需引入可用的 DB(testcontainers 或 sqlite 内存),当前模块完全无测试基建,前期投入较大。 8. **可观测性与运维闭环** - 目标:故障可发现、可定位。 - 做法:补 `/healthz`+`/metrics`;prod 打开 APM;关闭 GORM `Debug`、脱敏 DSN;`Start` 支持优雅退出与 SIGTERM。 - 影响面与风险:低风险,独立于业务改造,可并行推进。 9. **配置与文档对齐** - 目标:消除"照着 README 部署必踩坑"。 - 做法:重写 README 的 API/表结构/缓存/监控章节;prod 配置移除 `CHANGE_ME`、强制 `sslmode`;`Service` 名与 `ServiceKey` 对齐;`NotNil` 补齐 Gateway/Databases。 - 影响面与风险:纯文档/配置变更,风险最低,建议立即执行。 --- ## 5. TODO 清单 - [ ] **P0-1** 为 `CloudPhoto` 补 `Std_Passport` 并在 `UploadPhoto`/`MovePhoto` 落库,历史数据回填|验收:上传一张照片后 `SELECT passport_id FROM cloud_photo WHERE id=?` 等于上传者 ID;另一用户按该 id 调用 `DeletePhoto` 返回失败|涉及:`module/base/cloud/internal/models/cloud_photo.go:11`、`module/base/cloud/internal/logic/album/upload_photo.go:54`、`module/base/cloud/internal/logic/album/move_photo.go:47` - [ ] **P0-2** `SetCoverPhoto` 增加照片/相册归属断言|验收:用户 B 用用户 A 的 photo_id + 自己的 album_id 调用必须失败|涉及:`module/base/cloud/internal/logic/album/set_cover_photo.go:39` - [ ] **P0-3** `DeleteAlbum` 包事务并给照片删除条件补 `passport_id`|验收:注入 album 删除失败时照片仍存在(事务回滚)|涉及:`module/base/cloud/internal/logic/album/delete_album.go:45` - [ ] **P0-4** `EncryptData/DecryptData` 改为服务端密钥派生,拒绝补零/截断;`IsEncrypted` 服务端强制|验收:传 8 字节 key 返回 `ErrInvalidArgument`;`is_encrypted=false` 的创建请求被拒绝或强制置真|涉及:`module/base/cloud/internal/logic/private/encrypt_data.go:36`、`module/base/cloud/internal/logic/private/decrypt_data.go:39`、`module/base/cloud/internal/logic/private/create_private_data.go:54` - [ ] **P0-5** 分享口令改哈希存储 + 常量时间比较 + 失败限流|验收:DB 中 `password` 不是明文;同 IP 连续 10 次错误口令后返回限流错误|涉及:`module/base/cloud/internal/models/cloud_share.go:18`、`module/base/cloud/internal/logic/share/validate_share_password.go:43` - [ ] **P1-1** `MoveDir` 增加祖先链环检测;`GetDirTree` 增加深度/节点上限|验收:把 `/a` 移到 `/a/b` 返回 `ErrInvalidArgument`;构造 1000 层目录树调用不再无限递归|涉及:`module/base/cloud/internal/logic/disk/move_dir.go:46`、`module/base/cloud/internal/logic/disk/get_dir_tree.go:44` - [ ] **P1-2** 子树 path 更新改为带分隔符的前缀匹配 + 单条 SQL + 错误返回 + 事务|验收:存在 `/a` 与 `/abc` 时移动 `/a`,`/abc` 的 path 不变;注入单条更新失败时整体回滚|涉及:`module/base/cloud/internal/logic/disk/move_dir.go:71`、`module/base/cloud/internal/logic/disk/update_dir.go:77` - [ ] **P1-3** 消除 `*ParentID` 空指针并加 gRPC recover 拦截器|验收:对 `ParentId=0` 创建的根目录调用 `GetDir` 返回正常响应;人为 panic 时进程不退出|涉及:`module/base/cloud/internal/logic/disk/get_dir.go:91`、`module/base/cloud/internal/logic/disk/get_dir_tree.go:51`、`module/base/cloud/internal/server/new.go:23` - [ ] **P1-4** `storage_path` 改由服务端生成并校验落盘根目录|验收:请求携带 `../../etc/passwd` 时返回值与传入值不同且落在允许根之下|涉及:`module/base/cloud/internal/logic/disk/upload_file.go:74`、`module/base/cloud/internal/logic/disk/update_file.go:61` - [ ] **P1-5** 名称字段统一清洗(拒绝 `/`、`\`、`..`、控制字符、超长)|验收:`Name="../x"`、`Name="a/b"` 均返回 `ErrInvalidArgument`|涉及:`module/base/cloud/internal/logic/disk/create_dir.go:44`、`module/base/cloud/internal/logic/disk/update_dir.go:57` - [ ] **P1-6** `CloudDiskFile` 补 `passport_id`;重名校验与插入加复合唯一索引兜底|验收:并发同名上传只有一条成功;跨用户同名上传互不影响|涉及:`module/base/cloud/internal/models/cloud_disk_file.go:9`、`module/base/cloud/internal/logic/disk/upload_file.go:50` - [ ] **P1-7** 上传路径实现配额原子准入,size 由服务端计算|验收:超过 `max_storage` 的上传返回配额错误且 `used_storage` 不变|涉及:`module/base/cloud/internal/logic/disk/upload_file.go:19`、`module/base/cloud/internal/models/cloud_space.go:15` - [ ] **P1-8** `used_storage` 改聚合 SQL;`GetDirTree` 改单次查询建树;目录详情 Files 分页|验收:10 万文件账号调用 `Space.Get` 不再加载全量行(用 SQL 日志验证只出现 1 条 SUM)|涉及:`module/base/cloud/internal/logic/space/get.go:61` - [ ] **P1-9** 搜索加 `pg_trgm` GIN 索引;`page_size` 设上限|验收:`page_size=100000` 被拒绝或截断到上限;搜索走索引(EXPLAIN 不再是 Seq Scan)|涉及:`module/base/cloud/internal/logic/disk/search_files.go:42`、`module/base/cloud/internal/logic/note/list_notes.go:25` - [ ] **P1-10** 全模块 DB 调用改 `WithContext(ctx)`|验收:`grep -c "WithContext" module/base/cloud/internal` 与 DBService 调用点数一致;客户端取消后 DB 侧查询被终止|涉及:`module/base/cloud/internal/logic/disk/list_dirs.go:34`(代表全部 51 个文件) - [ ] **P1-11** 多步写操作包事务|验收:`DeleteAlbum`/`DeleteNote` 第二步失败时第一步回滚|涉及:`module/base/cloud/internal/logic/album/delete_album.go:45`、`module/base/cloud/internal/logic/note/delete_note.go:45` - [ ] **P1-12** 增加 `(passport_id,path)`、`(passport_id,directory_id,name)` 部分唯一索引|验收:并发插入同 path 只有一条成功;软删后可重建同名|涉及:`module/base/cloud/internal/models/cloud_disk_dir.go:15`、`module/base/cloud/internal/logic/disk/create_dir.go:51` - [ ] **P1-13** `CloudSpace` 初始化改用 UUID/ULID 并做并发安全 upsert|验收:连续两个新用户调用 `Space.Get` 均成功且各自有独立 `key_identifier`|涉及:`module/base/cloud/internal/logic/space/get.go:27` - [ ] **P1-14** 计数器改原子自增|验收:100 并发 `ValidateSharePassword` 后 `view_count` 精确等于 100|涉及:`module/base/cloud/internal/logic/share/validate_share_password.go:48`、`module/base/cloud/internal/logic/note/increment_views.go:45` - [ ] **P1-15** `CreateShare` 校验资源归属与 token 格式;`ImportBookmarks` 加条数/URL scheme 限制|验收:用他人 `resource_id` 创建分享失败;导入 10000 条被拒绝|涉及:`module/base/cloud/internal/logic/share/create_share.go:39`、`module/base/cloud/internal/logic/bookmark/import_bookmarks.go:38` - [ ] **P2-7** 显式关闭 GORM `Debug` 并脱敏 DSN 日志|验收:启动日志中不含 `password=`;SQL 日志中不出现 `cloud_share.password` 明文|涉及:`D:\work\bsm-sdk\core\database\sql\postgresql.go:19`、`D:\work\bsm-sdk\core\with\databases.go:18` - [ ] **P2-8** 生产配置去除占位密钥、启用 `sslmode=require`,补齐配置校验|验收:prod 配置无 `CHANGE_ME`;缺失 `Gateway`/`Databases` 时启动即报明确错误|涉及:`module/base/cloud/etc/cloud_prod.yaml:7`、`module/base/cloud/internal/config/config.go:35` - [ ] **P2-6** 补 `/healthz` 与 `/metrics`,启用 APM|验收:`curl /healthz` 返回 200 且含 DB/Redis 状态;`/metrics` 暴露 Prometheus 指标|涉及:`module/base/cloud/README.md:461`、`module/base/cloud/etc/cloud_prod.yaml:32` - [ ] **P2-12** 建立测试基建并补齐关键用例|验收:`go test ./...` 通过;覆盖"跨用户访问被拒"、"根目录 GetDir 不 panic"、"移动到后代失败"、"路径重写不误伤同前缀目录"四类用例|涉及:`module/base/cloud/test/`(当前为空) - [ ] **P2-3** `DeleteAttachment` 语义修正(补 Identity 或改二元组)|验收:按 identity 删除附件成功,或该分支被移除且接口文档同步|涉及:`module/base/cloud/internal/logic/note/delete_attachment.go:37` - [ ] **P2-4** `InsertAttachment` 的 `string(rune(id))` 修正|验收:返回值是十进制数字字符串|涉及:`module/base/cloud/internal/logic/note/insert_attachment.go:59` - [ ] **P2-10** 实现优雅退出(HTTP Shutdown + SIGTERM + etcd 租约释放)|验收:发送 SIGTERM 后在途请求正常完成且进程退出码为 0|涉及:`module/base/cloud/cmd/main/main.go:37`、`D:\work\bsm-sdk\core\service\service.go:142` - [ ] **P3-1** 抽公共分页与模型转换 helper|验收:14 处分页逻辑收敛为 1 个函数,`grep "PageSize < 10"` 只剩 1 处|涉及:`module/base/cloud/internal/logic/disk/list_dirs.go:22` - [ ] **P3-3** 统一自关联 gorm tag 并修正拼写;`NoteAttachment` 补 `Std_IICUDS`|验收:`GetDir` 返回的 `parent` 的 id 等于该目录的 `parent_id`(集成测试断言)|涉及:`module/base/cloud/internal/models/cloud_disk_dir.go:18`、`module/base/cloud/internal/models/cloud_space.go:23` - [ ] **P3-4** 删除或补全 `cmd/cli`|验收:模块内不存在无功能的 main 包|涉及:`module/base/cloud/cmd/cli/main.go:5` - [ ] **P3-5** 统计字段改异步/仅变化时写,补 `DownloadCount` 或删字段|验收:连续调用 `Space.Get` 不产生 UPDATE 语句(SQL 日志验证)|涉及:`module/base/cloud/internal/logic/space/get.go:97` - [ ] **P2-13** README 与实现对齐|验收:README 中列出的每个接口/配置项都能在代码中找到对应实现(逐条勾稽)|涉及:`module/base/cloud/README.md:440`、`module/base/cloud/README.md:461`、`module/base/cloud/README.md:570` --- ## 6. 审计摘要(供汇总使用) - **问题数**:P0=5 P1=15 P2=14 P3=5(合计 39) - **最高风险(一句话)**:照片(CloudPhoto)从未写入 `passport_id` 且接口层用客户端可控的 `album_id` 反查归属,导致跨用户照片的读取/更新/删除与相册级联删除均无有效所有权边界(P0-1/P0-2/P0-3)。 - **最优先 3 个动作**: 1. 给 `CloudPhoto`/`CloudDiskFile`/`NoteAttachment` 补 `Std_Passport` 并在全部写路径落库 + 历史数据回填,照片/文件级查询改为直接按 `passport_id` 过滤(P0-1、P0-2、P0-3、P1-6)。 2. 修 `MoveDir` 环检测与子树路径重写(前缀匹配 + 单条 SQL + 事务 + 错误返回),并给 `*ParentID` 解引用加守卫与 gRPC recover(P1-1、P1-2、P1-3)。 3. 修复隐私加解密(服务端密钥派生、拒绝补零/截断/客户端 `IsEncrypted`)与分享口令(哈希 + 常量时间比较 + 限流)(P0-4、P0-5)。 - **未能覆盖/无法验证的部分**: - 模块内 **0 个测试**、无迁移脚本、无可用数据库,所有运行期行为(GORM 自关联 `Parent` preload 语义、并发竞态的实际复现、索引缺失的实际执行计划)均为静态推断,其中 P3-3 的自关联 tag 问题已明确标注为「推测」。 - `go vet ./...` 未执行(依赖 replace 到本地 `bsm-sdk/core`,按效率约束跳过);`gofmt -l .` 已执行,退出码 0、无输出。 - 生成代码 `pb/*.pb.go`、`*_grpc.pb.go` 与除 `disk.pb.gw.go` 外的 gateway 文件未逐行审阅。 - 模块外部:`module/base/all` 等调用方如何装配 `service.Expose`、是否在网关层额外挂了 JWT/限流中间件未审计——若上游已补限流,P0-5 的爆破风险等级可下调,但口令明文存储与明文比较的问题不受影响。 - `cmd/cli` 为空壳,`test/lint` 为空目录,无可审内容。