Files
full/docs/audit/module-base-cloud.md
yanweidong 63aeedc2fe docs: add per-module audit reports (18 modules)
Add static security/quality audit reports for all 18 Go service modules under module/, plus a consolidated index (docs/audit/README.md) with per-module statistics, top risks, cross-module systemic defects and a phased TODO list (T1-T19). No production code is modified.
2026-09-14 22:16:27 +08:00

67 KiB
Raw Permalink Blame History

审计报告module/base/cloud

1. 模块概览

module/base/cloud 是 BSM 全量单体仓库中的「个人云空间」业务模块,对外暴露 gRPC 服务,并通过 gRPC-gateway 暴露 HTTP默认 POST /cloud.<Service>/<Method>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.goprotoc-gen-slc 生成的转发层,无逻辑)→ internal/logic/<domain>/*.go1 RPC = 1 文件)→ internal/models/*.goGORM 模型)→ 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.IDpassport_id/auth.Identity。鉴权本身是统一的,问题出在授权(归属校验)与所有权落库上。注意 opts == nil,因此既没有角色校验也没有 MustPrivateAllow 限制。


2. 审计范围与方法

已覆盖子域(逐文件通读 100%

  • Diskinternal/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
  • Albuminternal/logic/album/ 全部 12 个文件(相册 CRUD + GetDirTree 类比 + 照片上传/删除/移动/封面)。
  • Noteinternal/logic/note/ 全部 10 个文件(含 attachment 的插入与删除)。
  • Bookmarkinternal/logic/bookmark/ 全部 6 个文件(含 ImportBookmarks
  • Privateinternal/logic/private/ 全部 9 个文件(含 EncryptData / DecryptData 加解密实现)。
  • Shareinternal/logic/share/ 全部 5 个文件(含 ValidateSharePassword
  • Spaceinternal/logic/space/ 2 个文件。
  • 模型层internal/models/ 全部 10 个文件 + internal/models/query.go
  • 服务层/入口/配置internal/server/new.go + 7 个 serverinternal/impl/impl.gointernal/config/config.goservice/dependencies.goservice/expose.gocmd/main/main.gocmd/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.goParseMetaCtxcore\types\db.goStd_IICUDS/Std_Passport确认软删除与 uniqueIndexcore\utils\identity.goUUID 实现)、core\database\new.gocore\database\sql\postgresql.gocore\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.sumservice/expose.go 之外的模块级装配代码(module/base/all 等调用方如何注入 Dependencies、网关如何挂载认证中间件未审计——这会影响「是否为网关层补齐了 JWT」的最终结论。
  • 无任何可执行验证:模块内无测试、无 fixture、无 SQL 迁移脚本,数据库/Redis 不可用因此所有「运行期行为」类判断GORM 关联 preload 语义、并发竞态)均为静态推断,已在文中标注。
  • cmd/cli/main.goHello 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/corereplace ../../../../../bsm-sdk/core,且执行环境未确认可完整构建;按效率约束跳过(不做 go mod tidy
grep_ =/err == nil/TODO/panic(、`Transaction Begin()PassportIDMaxStorage

3. 问题清单

P0

P0-1. 照片上传从不落 passport_id照片归属被彻底丢弃导致跨用户照片越权IDOR

  • 位置module/base/cloud/internal/logic/album/upload_photo.go:54module/base/cloud/internal/models/cloud_photo.go:11
  • 证据
// models/cloud_photo.go:11  —— 模型里根本没有 Std_Passport
type CloudPhoto struct {
	types.Std_IICUDS
	CloudBase
	AlbumID     uint      `gorm:"index" json:"album_id"`
// 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:30album/update_photo.go:31album/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-3DeleteAlbum(见 P0-4只按 album_id 批量操作,而 album_id 无归属写入约束。
    2. 功能必然损坏space/get.go:74space/get_by_key_identifier.go:65 统计照片数用同一 JOIN逻辑上仍然能算出来任何将来按 passport_id 直查 cloud_photos 的代码/报表/清理任务都会永远漏掉所有照片(字段恒为 0。同理 CloudSpace.PhotoCount 语义与模型不一致。
  • 建议:给 CloudPhototypes.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
  • 证据
// 验证照片是否存在且属于该相册 —— 注意:只按 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_idP0-1任何一张 photo 只要 album_id 落在攻击者自己的相册里,就会被当作攻击者的照片,其 FileSize/Tags/Location 等元数据可被读取并写进相册封面(album.CoverPhoto = photo.FilePathset_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. DeleteAlbumalbum_id 无条件批量删除照片,且整个删除动作没有事务

  • 位置module/base/cloud/internal/logic/album/delete_album.go:45
  • 证据
// 删除相册下的所有照片
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: trueD:\work\bsm-sdk\core\database\new.go:90photo 删成功而 album 删失败时会留下空相册/数据不一致。
  • 建议:包一层 impl.DBService.Transaction(func(tx *gorm.DB) error {...});删除条件补 passport_id

P0-4. 隐私数据加解密接口接受客户端任意密钥并做零填充/截断,且 IsEncrypted 由客户端自由设置

  • 位置module/base/cloud/internal/logic/private/encrypt_data.go:36decrypt_data.go:39create_private_data.go:54
  • 证据
// 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] }
}
// 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:43internal/models/cloud_share.go:18
  • 证据
// validate_share_password.go:32 —— 只按 identity 查,不看 passport_idauth 被丢弃(下划线)
_, err = service.ParseMetaCtx(ctx, nil)
...
if err := impl.DBService.Where("identity = ?", in.Identity).First(&share).Error; err != nil {
// validate_share_password.go:43 —— 明文 == 比较,非常量时间
if share.Password != "" && share.Password != in.Password {
// 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
  • 证据
// 检查是否会形成循环引用
if dir.ID == parentDir.ID {
	return nil, errcode.ErrInvalidArgument
}
  • 影响:把 /a 移动到 /a/bb 是 a 的后代)不会报错:dir.ParentID 指向 b、dir.Path 变为 /a/b/a。此后 GetDirTreeget_dir_tree.go:38Preload("Subdirectories") + :44-61loadSubdirs 递归)会在 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:96internal/logic/disk/update_dir.go:77
  • 证据
// 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:91internal/logic/disk/get_dir_tree.go:51:97internal/server/new.go:23
  • 证据
// get_dir.go:91 —— 根目录 ParentID == nil直接解引用
ParentId: uint64(*dir.ParentID),
// get_dir_tree.go:51 —— 递归里同样解引用
ParentId: uint64(*subdir.ParentID),
// server/new.go:23 —— 无任何 UnaryInterceptor / recovery
grpcServ = grpc.NewServer()
  • 影响CreateDir{ParentId: 0} 创建的目录 ParentID 为 nilcreate_dir.go:71-74 只在 ParentId > 0 时赋值)。此后对该目录调用 GetDir(或它出现在 GetDirTree 子树中)必然 panicListDirs 的作者显然知道要判空(list_dirs.go:52-55说明这是遗漏而非设计。gRPC 无 recover 拦截器,printer/env 也未确认有兜底 → 按 Go 语义,未捕获 panic 会终止整个服务进程(除非调用方另起 recover单个越权/畸形请求即可打挂服务。
  • 建议:所有 *dir.ParentID/*file.DirectoryID 解引用改为 if dir.ParentID != nil 守卫(把 list_dirs.go 的写法统一抽成 helperserver.New 加一个 recover 拦截器panic 转 errcode.ErrInternal 并记录堆栈。

P1-4. storage_path / file_path 完全由客户端决定,服务端零校验

  • 位置module/base/cloud/internal/logic/disk/upload_file.go:33:74internal/logic/disk/update_file.go:61internal/logic/album/upload_photo.go:30
  • 证据
// upload_file.go:33
if strings.TrimSpace(in.StoragePath) == "" {
	return nil, errcode.ErrInvalidArgument
}
// upload_file.go:74 —— 原样落库
StoragePath:  in.StoragePath,
// 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<root>/<passport_id>/<ulid>),完全忽略客户端该字段;若必须接受,用 filepath.Clean + 校验落在允许根目录内并拒绝 ..UpdateFile 只允许改 name/original_name/mime_type。

P1-5. 目录/文件名不做任何清洗,可注入路径分隔符与 ..

  • 位置module/base/cloud/internal/logic/disk/create_dir.go:44internal/logic/disk/update_dir.go:57internal/logic/disk/upload_file.go:30
  • 证据
// create_dir.go:44 —— in.Name 直接参与路径拼接,只校验了非空(:28
fullPath = filepath.Join(parentDir.Path, in.Name)
// 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:50internal/logic/disk/copy_file.go:52internal/logic/disk/update_file.go:52internal/logic/disk/move_file.go:47
  • 证据
// 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_idcloud_disk_file.go:9-13),唯一性判定天然缺失所有者维度。虽然插入前校验了目录归属(upload_file.go:43),但目录归属与文件归属会在 MoveFile 后错位MoveFile 只校验目标目录属于自己(:40)和文件"通过 JOIN 目录"属于自己(:31),一旦文件被移动,其归属完全由所在目录动态决定 —— 这意味着任何能修改自己目录归属的操作都会连带改变文件归属。此外 CopyFilecopy_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:15internal/logic/space/get.go:36-38internal/logic/disk/upload_file.go:19
  • 证据
// cloud_space.go:15
MaxStorage    int64  `json:"max_storage"`    // 最大存储空间
// get.go:36-38 —— 100GB 硬编码,且仅用于回显
TotalStorage:  100 * 1024 * 1024 * 1024, // 100GB
UsedStorage:   0,
MaxStorage:    100 * 1024 * 1024 * 1024, // 100GB
// upload_file.go:19 起 —— 全函数没有任何 size / 配额校验in.Size 直接落库
  • 影响MaxStorage/UsedStoragedisk.UploadFilealbum.UploadPhotonote.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-68internal/logic/space/get_by_key_identifier.go:52-59internal/logic/disk/get_dir_tree.go:38:44-61
  • 证据
// 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 }
// 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:42internal/logic/note/search_notes.go:40internal/logic/private/search_private_data.go:40、各 list_*.go:25:42
  • 证据
// 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)
// list_dirs.go:25-27 —— 只设下限,无上限
if in.GetPageSize() < 10 {
	in.PageSize = 50
}
  • 影响(a) LOWER(col) LIKE '%kw%' 前导通配符 + 函数包裹,任何普通 B-tree 索引都不可用,必然全表扫描;note.title/content/tagsprivate.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 为例
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:45internal/logic/note/delete_note.go:45internal/logic/disk/move_dir.go:69internal/logic/disk/update_dir.go:75
  • 证据
// 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(&note).Error; err != nil { ... }

grep "Transaction|Begin\(\)" module/base/cloud → 无任何命中SDK 侧 SkipDefaultTransaction: trueD:\work\bsm-sdk\core\database\new.go:90

  • 影响:第二步失败时留下孤儿附件/空相册;move_dir/update_dir 的批量 path 改写在同一次调用内可能部分成功(叠加 P1-2 的错误吞掉,静默不一致)。批量操作(ImportBookmarksCreateInBatches)也无事务,中途失败只回滚当前批次。
  • 建议:凡"父删子删"、"主记录 + 子节点路径更新"一律包 TransactionImportBookmarks 用单事务 + 幂等键。

P1-12. 软删除与唯一性/唯一索引的交互未处理

  • 位置module/base/cloud/internal/models/cloud_disk_dir.go:10internal/logic/disk/create_dir.go:51internal/logic/disk/upload_file.go:50
  • 证据
// cloud_disk_dir.go:10 内嵌 Std_IICUDS含 gorm.DeletedAt
// D:\work\bsm-sdk\core\types\db.go:33DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;..."`
// 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 上有 uniqueIndexdb.go:30)——注意该唯一索引不包含 deleted_at,因此"软删后重建同 identity"会撞唯一键;当前 identity 用 UUID 生成,撞键概率低,但任何手工/迁移脚本重建 identity 都会失败。另外 path 上根本没有唯一索引path 唯一性完全靠"先查后插",并发下可插入两条同 path 记录。(b) 无回收站/恢复接口,DeleteDir/DeleteFile 失败后数据只能靠 DBA 从 deleted_atREADME 未提及回收站,功能缺口)。
  • 建议:给 (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-35internal/models/cloud_space.go:12
  • 证据
// 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",
// cloud_space.go:12
KeyIdentifier string `gorm:"uniqueIndex;size:32" json:"key_identifier"`
  • 影响key_identifier全局唯一索引且被硬编码为常量 "default",只有第一个用户能创建成功;第二个未初始化用户调用 Space.GetCreate 会因唯一键冲突返回 ErrDB云盘首页对该用户永久 500/报错。同时 Identity 也用 "default",与 Std_IICUDSuniqueIndex 冲突。这是确定性的线上故障(只要存在两个空间记录尚未创建的用户)。GetByKeyIdentifierkey_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:48internal/logic/note/increment_views.go:45
  • 证据
// validate_share_password.go:47-49
share.ViewCount++
if err := impl.DBService.Save(&share).Error; err != nil {
// increment_views.go:45-47
note.Views++
if err := impl.DBService.Save(&note).Error; err != nil {
  • 影响SELECTSave 整行写回,并发请求互相覆盖,计数丢失(经典的 lost updateSave 还会把整行(含 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-71internal/models/cloud_share.go:17internal/logic/bookmark/import_bookmarks.go:38:46
  • 证据
// create_share.go:39-42 —— 客户端可直接指定 token
shareToken := in.ShareToken
if shareToken == "" { shareToken = utils.UUID() }
// create_share.go:68-71 —— share_type/resource_id 完全不校验资源是否存在、是否属于自己
ShareType:  in.ShareType,
ResourceID: uint(in.ResourceId),
// 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: 等。
  • 建议CreateShareshare_type 反查资源并要求 passport_id = auth.IDShareToken 一律服务端生成且校验格式;ImportBookmarks 限制条数(如 ≤5000与总字节数URL 只允许 http/httpsFormat 白名单。

P2

P2-1. GetByKeyIdentifierGet 的统计块完全重复

  • 位置module/base/cloud/internal/logic/space/get.go:52-100internal/logic/space/get_by_key_identifier.go:43-91
  • 证据:两段代码逐字符几乎相同(Space.GetSpace.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-28internal/logic/space/get.go:102-116
  • 证据
// cloud_space.go:23-28
CloudDiskDirectorys []CloudDiskDir  `gorm:"foreignKey:PassportID" json:"cloud_disk_dirs"`
Albums              []CloudAlbum    `gorm:"foreignKey:PassportID" json:"cloud_albums"`
// 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 拼写错误)与 protocloud_disk_dirs)不一致。
  • 建议:要么在 Get 里显式分页 Preload要么从 proto 删除这些字段;修正字段拼写。

P2-3. DeleteAttachment 的 identity 分支被自我覆盖identity 参数完全无效

  • 位置module/base/cloud/internal/logic/note/delete_attachment.go:37
  • 证据
} 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. InsertAttachmentstring(rune(id)) 拼接返回详情,产出乱码

  • 位置module/base/cloud/internal/logic/note/insert_attachment.go:59
  • 证据
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-448module/base/cloud/internal/impl/impl.go:13-16module/base/cloud/service/dependencies.go:20-31
  • 证据
// 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 小时"缓存策略在代码中不存在,全部请求直打 PostgreSQLRedisService/MemorySerice 是死变量(MemorySerice 还拼错了 Service。同时文档承诺的缓存失效策略、连接复用在代码里无从谈起。
  • 建议:要么实现缓存(热点:Space.Get 统计、目录树、用户信息),要么从 README 删除该章节清理未使用依赖redis/go-cache/etcd 若仅用于装配则保留但需注明)。

P2-6. 可观测性与运维面缺失:无健康检查、无 metrics、无 APM、无结构化日志

  • 位置module/base/cloud/etc/cloud_prod.yaml:32-35APM 全被注释)、module/base/cloud/README.md:457-465internal/logic/** 的日志调用
  • 证据
# cloud_prod.yaml:32-35
# 链路追踪,性能监控,日志收集
# APM:
#   Platform: elasticAPM
// 全部错误日志形如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 探活)与 /metricsPrometheus启用 APM 并在 prod 配置中固化统一结构化日志trace_id/passport_id/latency

P2-7. GORM 默认 Debug: true 且非参数化查询,可能把分享口令/隐私密文写进标准输出

  • 位置D:\work\bsm-sdk\core\database\sql\postgresql.go:12-21:46-48D:\work\bsm-sdk\core\with\databases.go:18
  • 证据
// postgresql.go:13-20 —— options == nil 时的默认值cloud 调用 with.Databases(cfg, nil)
options = &types.SqlOptions{
	MaxIdleConns: ..., MaxOpenConns: ..., ConnMaxLifetime: ...,
	IsAutoMigrate:   false,
	LogStdout:       false,
	Debug:           true,      // ← 默认打开
}
// postgresql.go:46-48
if options.Debug { gormDb = gormDb.Debug() }
// 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:24internal/config/config.go:35cmd/main/main.go:18
  • 证据
# 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
// config.go:35 —— 只校验了 Service 和 Cache
conf.NotNil(Spec.Service, Spec.Cache)
  • 影响(a) 生产配置三处占位密钥 + PostgreSQL sslmode=disable(数据库链路明文);(b) DatabasesGatewayEtcdRpc 均未纳入 NotNil 校验,漏配时会在运行期 panicwith.Databasespanic("No Database Source Found !")D:\work\bsm-sdk\core\with\databases.go:14),属于"启动即崩"的配置耦合;(c) Service: initialServiceKey = "cloud"cmd/main/main.go:15)不一致,注册/路由 key 与实际服务名存在漂移风险;(d) 无 TLS 配置项,网关 http.ListenAndServeD:\work\bsm-sdk\core\service\service.go:126)明文 HTTP全模块无任何限流中间件。
  • 建议:密钥走 Secret 管理(环境变量/配置中心)而非 yaml 占位;生产强制 sslmode=require;补齐配置校验并在启动时失败-快;网关加 TLS 终止 + 限流(尤其 ValidateSharePassword)。

P2-9. 写入路径无幂等性设计

  • 位置internal/logic/disk/create_dir.go:76internal/logic/disk/upload_file.go:78internal/logic/album/upload_photo.go:75internal/logic/note/insert_attachment.go:53internal/logic/bookmark/import_bookmarks.go:83
  • 证据
// upload_file.go:56-59 —— "hash 去重"名不副实:没传 hash 就用 UUID 当 hash
fileHash := in.Hash
if fileHash == "" {
	fileHash = utils.UUID() // 使用UUID作为默认哈希
}
  • 影响:所有创建接口都没有幂等键(无 request_id/客户端幂等 token网络重试/网关重发会产生重复目录、重复照片、重复附件;UploadFilehash 由客户端提供或退化为随机 UUIDREADME:140 宣称的"文件哈希去重"实际没有任何去重逻辑(全模块没有按 hash 查重的代码)。
  • 建议:写接口引入幂等键((passport_id, request_id) 唯一索引 + 命中即返回原结果hash 由服务端对内容计算,并据此实现真正的秒传/去重。

P2-10. 无优雅退出gRPC 仅 GracefulStopHTTP 网关无关闭路径etcd 租约未释放

  • 位置module/base/cloud/cmd/main/main.go:37D:\work\bsm-sdk\core\service\service.go:142-144:114
  • 证据
// main.go:37
defer srv.Stop()
// service.go:142-144
func (s *Service) Stop() { s.GrpcSrv.GracefulStop() }
// service.go:113-115 —— Start 永久阻塞HTTP server 引用未保存
	// 阻塞主线程
	select {}
  • 影响deferselect{} 之后永远不执行(Run() 不返回),srv.Stop() 实际是死代码HTTP 网关用 http.ListenAndServe 且句柄未保留无法优雅关闭etcd 注册的租约监听 goroutine 无退出信号。K8s 滚动发布时会直接掐断在途请求。
  • 建议Start 返回 stop chan/接收 context,注册 SIGTERM 处理:先停 HTTPsrv.Shutdown)、再 GracefulStop、最后释放 etcd lease。

P2-11. 魔法数字与不一致的默认值散落各处

  • 位置internal/logic/space/get.go:36-38100GB ×2、所有 list_*.gopageSize<10 → 5014 处)、internal/logic/share/create_share.go:50:537 * 24 * time.Hour ×2internal/logic/album/upload_photo.go:45-51TakenAt 解析失败静默替换为 time.Now()
  • 证据
// create_share.go:50 与 :53 同一常量写了两遍
expiresAt = time.Now().Add(7 * 24 * time.Hour) // 默认7天过期
...
expiresAt = time.Now().Add(7 * 24 * time.Hour) // 默认7天过期
// upload_photo.go:45-49 —— 解析失败不报错,用当前时间掩盖
if t, err := time.Parse(time.RFC3339, in.TakenAt); err == nil { takenAt = t } else { takenAt = time.Now() }
  • 影响:配额、分页、过期策略、拍摄时间兜底值无法统一调整;TakenAt 非法时静默写入错误元数据(用户看到"拍摄于今天"),是数据质量问题。
  • 建议:抽 internal/constsDefaultPageSize / MaxPageSize / DefaultQuota / DefaultShareTTL时间解析失败应返回 ErrInvalidArgument 而非静默兜底。

P2-12. 模块内 0 个测试文件

  • 位置module/base/cloud/test/lint/(空目录)
  • 证据
Get-ChildItem -Path "...\module\base\cloud\test" -Recurse -Force
→ 仅一个空目录 test\lint0 个文件;全模块无 *_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
  • 证据
# README.md:461-464 —— 接口不存在(模块内 grep 无任何 health/metrics 实现)
curl http://localhost:12102/health
curl http://localhost:12102/metrics
# 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:54internal/logic/share/list_shares.go:57internal/models/cloud_share.go:18
  • 证据
// 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-47list_files.go:21-52search_files.go:22-59internal/logic/album/list_albums.go:21-47list_photos.go:21-51internal/logic/note/list_notes.go:21-47search_notes.go:22-57internal/logic/bookmark/list_bookmarks.go:21-46internal/logic/private/list_private_data.go:21-46search_private_data.go:22-56get_private_data_by_type.go:23-57internal/logic/share/list_shares.go:21-46internal/logic/space/get.go:52-100(共 14 处分页块 + 7 处各不相同的转换函数)
  • 证据
// 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 等消息(CmsSearchRequestMallFetchRequestOrderSummaryItemFeedPostItemRelationItem…),而 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-20internal/models/cloud_space.go:23-28internal/models/cloud_note_attach.go:10-18
  • 证据
// 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"`
// cloud_space.go:23 —— 字段名拼写错误
CloudDiskDirectorys []CloudDiskDir  `gorm:"foreignKey:PassportID" json:"cloud_disk_dirs"`
// cloud_note_attach.go:10-18 —— NoteAttachment 只有 ID没有 Std_IICUDS
type NoteAttachment struct {
	ID        uint      `gorm:"primaryKey" json:"id"`
  • 影响Parent/Subdirectoriesmgt 模块的同类自关联写法不一致(缺 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
  • 证据
func main() {
	log.Println("Hello World!")
}
  • 影响README:79-80 将 cmd/cli 描述为"命令行工具",实际无任何功能;该二进制会被编译进发布产物(增加无用体积与攻击面)。
  • 建议:删除该目录,或实现预期的运维命令(如空间重建、软删清理)。

P3-5. 统计字段/计数器写入即持久化,且部分字段永不更新

  • 位置internal/logic/space/get.go:97internal/models/cloud_share.go:21internal/logic/share/create_share.go:74
  • 证据
// get.go:97 —— 读接口里做写操作(且失败被忽略,:98-100
if err := impl.DBService.Save(&space).Error; err != nil {
	printer.Error("Update space error: %v", err)
	// 不返回错误,继续执行
}
// cloud_share.go:21 / create_share.go:74 —— DownloadCount 有字段、有初始化,但全模块无任何自增逻辑
DownloadCount int `gorm:"default:0" json:"download_count"`
  • 影响:读接口产生写放大(每次打开首页都 UPDATE 一行);DownloadCount 是死字段README:264 宣称"访问统计"只有一半实现);ViewCountSave 失败被吞(validate_share_password.go:51)。
  • 建议:统计改为异步/定时任务,或在读接口里先算后比、仅在变化时写;补下载计数或删字段。

4. 推荐优化方案

  1. 补齐所有权数据模型(最高优先)

    • 目标:让每一张表都能自证归属。
    • 做法:CloudDiskFileCloudPhotoNoteAttachment 增加 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 Scopedb.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=falseShare.Password 改 bcrypt/argon2 哈希 + ConstantTimeCompare + 失败退避(复用已注入的 Redis
    • 影响面与风险:破坏现有客户端契约(EncryptData/DecryptDatakey 入参语义变更),需版本化兼容或提供迁移窗口;口令哈希后无法回显,会影响 P2-14 的接口行为。
  5. 配额与统计的原子化

    • 目标:让 max_storage/used_storage 真正生效且不拖垮读接口。
    • 做法:上传前用带条件的原子 UPDATE 做准入;used_storage 改为 SUM(size) 聚合(或异步物化 + 定时校准);计数类字段统一 gorm.Expr("x + 1")
    • 影响面与风险:需要 size 由服务端计算(当前客户端自报),要改上传协议;物化统计需处理与真实值漂移。
  6. 事务与 context 治理(一次性铺开)

    • 目标:消灭部分失败与不可取消查询。
    • 做法:所有 .DBService 调用加 .WithContext(ctx);多步写包 Transactionmove_dir/update_dir 的静默 err == nil 全部改为返回错误。
    • 影响面与风险:全模块机械改造,回归面大;建议配合 7 中提到的测试用例。
  7. 建立最小测试与安全回归网

    • 目标:把本次发现固化为可执行断言。
    • 做法:见第 5 节 TODO 的验收标准;优先写"跨用户访问必须 403/NotFound"、"根目录 GetDir 不 panic"、"移动目录到自身后代必须失败"、"路径重写只影响真实子树"四类用例。
    • 影响面与风险:需引入可用的 DBtestcontainers 或 sqlite 内存),当前模块完全无测试基建,前期投入较大。
  8. 可观测性与运维闭环

    • 目标:故障可发现、可定位。
    • 做法:补 /healthz+/metricsprod 打开 APM关闭 GORM Debug、脱敏 DSNStart 支持优雅退出与 SIGTERM。
    • 影响面与风险:低风险,独立于业务改造,可并行推进。
  9. 配置与文档对齐

    • 目标:消除"照着 README 部署必踩坑"。
    • 做法:重写 README 的 API/表结构/缓存/监控章节prod 配置移除 CHANGE_ME、强制 sslmodeService 名与 ServiceKey 对齐;NotNil 补齐 Gateway/Databases。
    • 影响面与风险:纯文档/配置变更,风险最低,建议立即执行。

5. TODO 清单

  • P0-1CloudPhotoStd_Passport 并在 UploadPhoto/MovePhoto 落库,历史数据回填|验收:上传一张照片后 SELECT passport_id FROM cloud_photo WHERE id=? 等于上传者 ID另一用户按该 id 调用 DeletePhoto 返回失败|涉及:module/base/cloud/internal/models/cloud_photo.go:11module/base/cloud/internal/logic/album/upload_photo.go:54module/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 返回 ErrInvalidArgumentis_encrypted=false 的创建请求被拒绝或强制置真|涉及:module/base/cloud/internal/logic/private/encrypt_data.go:36module/base/cloud/internal/logic/private/decrypt_data.go:39module/base/cloud/internal/logic/private/create_private_data.go:54
  • P0-5 分享口令改哈希存储 + 常量时间比较 + 失败限流验收DB 中 password 不是明文;同 IP 连续 10 次错误口令后返回限流错误|涉及:module/base/cloud/internal/models/cloud_share.go:18module/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:46module/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:71module/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:91module/base/cloud/internal/logic/disk/get_dir_tree.go:51module/base/cloud/internal/server/new.go:23
  • P1-4 storage_path 改由服务端生成并校验落盘根目录|验收:请求携带 ../../etc/passwd 时返回值与传入值不同且落在允许根之下|涉及:module/base/cloud/internal/logic/disk/upload_file.go:74module/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:44module/base/cloud/internal/logic/disk/update_dir.go:57
  • P1-6 CloudDiskFilepassport_id;重名校验与插入加复合唯一索引兜底|验收:并发同名上传只有一条成功;跨用户同名上传互不影响|涉及:module/base/cloud/internal/models/cloud_disk_file.go:9module/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:19module/base/cloud/internal/models/cloud_space.go:15
  • P1-8 used_storage 改聚合 SQLGetDirTree 改单次查询建树;目录详情 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:42module/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:45module/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:15module/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 并发 ValidateSharePasswordview_count 精确等于 100涉及module/base/cloud/internal/logic/share/validate_share_password.go:48module/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:39module/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:19D:\work\bsm-sdk\core\with\databases.go:18
  • P2-8 生产配置去除占位密钥、启用 sslmode=require补齐配置校验验收prod 配置无 CHANGE_ME;缺失 Gateway/Databases 时启动即报明确错误|涉及:module/base/cloud/etc/cloud_prod.yaml:7module/base/cloud/internal/config/config.go:35
  • P2-6/healthz/metrics,启用 APM验收curl /healthz 返回 200 且含 DB/Redis 状态;/metrics 暴露 Prometheus 指标|涉及:module/base/cloud/README.md:461module/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 InsertAttachmentstring(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:37D:\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 并修正拼写;NoteAttachmentStd_IICUDS|验收:GetDir 返回的 parent 的 id 等于该目录的 parent_id(集成测试断言)|涉及:module/base/cloud/internal/models/cloud_disk_dir.go:18module/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:440module/base/cloud/README.md:461module/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/NoteAttachmentStd_Passport 并在全部写路径落库 + 历史数据回填,照片/文件级查询改为直接按 passport_id 过滤P0-1、P0-2、P0-3、P1-6
    2. MoveDir 环检测与子树路径重写(前缀匹配 + 单条 SQL + 事务 + 错误返回),并给 *ParentID 解引用加守卫与 gRPC recoverP1-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 为空目录,无可审内容。