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.
67 KiB
审计报告: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.go(protoc-gen-slc 生成的转发层,无逻辑)→ internal/logic/<domain>/*.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 |
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 - 证据:
// 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),
- 影响:两条独立且都很严重的后果。
- 越权(可被利用):所有照片级接口(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 无归属写入约束。 - 功能必然损坏:
space/get.go:74与space/get_by_key_identifier.go:65统计照片数用同一 JOIN,逻辑上仍然能算出来;但任何将来按passport_id直查 cloud_photos 的代码/报表/清理任务都会永远漏掉所有照片(字段恒为 0)。同理CloudSpace.PhotoCount语义与模型不一致。
- 越权(可被利用):所有照片级接口(GetPhoto/UpdatePhoto/DeletePhoto
- 建议:给
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 - 证据:
// 验证照片是否存在且属于该相册 —— 注意:只按 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 - 证据:
// 删除相册下的所有照片
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 - 证据:
// 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、:43、internal/models/cloud_share.go:18 - 证据:
// 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 {
// 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/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 - 证据:
// 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 - 证据:
// 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为 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 - 证据:
// 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:44、internal/logic/disk/update_dir.go:57、internal/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:50、internal/logic/disk/copy_file.go:52、internal/logic/disk/update_file.go:52、internal/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_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 - 证据:
// 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/UsedStorage在disk.UploadFile、album.UploadPhoto、note.InsertAttachment中从未被读取(全模块 grepMaxStorage|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 - 证据:
// 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)FilesPreload 无分页,单目录下 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 - 证据:
// 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/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_trgmGIN 索引(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:45、internal/logic/note/delete_note.go:45、internal/logic/disk/move_dir.go:69、internal/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(¬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 - 证据:
// 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;..."`
// 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 - 证据:
// 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.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 - 证据:
// 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(¬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 - 证据:
// 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:等。 - 建议:
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 - 证据:
// 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拼写错误)与 proto(cloud_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. InsertAttachment 用 string(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-448、module/base/cloud/internal/impl/impl.go:13-16、module/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 小时"缓存策略在代码中不存在,全部请求直打 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/**的日志调用 - 证据:
# 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 探活)与/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 - 证据:
// 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、:24、internal/config/config.go:35、cmd/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)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 - 证据:
// 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 - 证据:
// 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 {}
- 影响:
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()) - 证据:
// 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/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 - 证据:
# 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:54、internal/logic/share/list_shares.go:57、internal/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-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 处各不相同的转换函数) - 证据:
// 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 - 证据:
// 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/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 - 证据:
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 - 证据:
// 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 宣称"访问统计"只有一半实现);ViewCount的Save失败被吞(validate_share_password.go:51)。 - 建议:统计改为异步/定时任务,或在读接口里先算后比、仅在变化时写;补下载计数或删字段。
4. 推荐优化方案
-
补齐所有权数据模型(最高优先)
- 目标:让每一张表都能自证归属。
- 做法:
CloudDiskFile、CloudPhoto、NoteAttachment增加types.Std_Passport;所有写路径用auth.ID/auth.Identity赋值;写一次性数据回填脚本(file 从目录回填、photo 从相册回填)。 - 影响面与风险:触及所有 disk/album/note 写路径与模型,需同步迁移与回填;风险是历史数据中 photo 的 album 可能已被删(无法回填),需保留 NULL 并做隔离策略。
-
统一「归属校验中间件」替代 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。
-
目录树正确性:环检测 + 单次查询建树 + 批量路径更新
- 目标:消除成环、
LIKE误匹配、逐条 UPDATE 与 N+1。 - 做法:
MoveDir用递归 CTE/祖先链校验;路径前缀匹配带分隔符;子树 path 用单条UPDATE ... WHERE path LIKE '/a/%';GetDirTree一次查全量后内存组树并限制深度/节点数。 - 影响面与风险:需要 PostgreSQL 递归 CTE(当前驱动是 postgres,可行);风险是超大目录的一次性加载,需要上限与截断标记。
- 目标:消除成环、
-
加解密与分享口令的密码学修复
- 目标:让"加密存储/口令保护"名副其实。
- 做法:
Private改服务端密钥派生(Argon2id + 每记录 salt),拒绝客户端裸密钥与IsEncrypted=false;Share.Password改 bcrypt/argon2 哈希 +ConstantTimeCompare+ 失败退避(复用已注入的 Redis)。 - 影响面与风险:破坏现有客户端契约(
EncryptData/DecryptData的key入参语义变更),需版本化兼容或提供迁移窗口;口令哈希后无法回显,会影响 P2-14 的接口行为。
-
配额与统计的原子化
- 目标:让
max_storage/used_storage真正生效且不拖垮读接口。 - 做法:上传前用带条件的原子 UPDATE 做准入;
used_storage改为SUM(size)聚合(或异步物化 + 定时校准);计数类字段统一gorm.Expr("x + 1")。 - 影响面与风险:需要 size 由服务端计算(当前客户端自报),要改上传协议;物化统计需处理与真实值漂移。
- 目标:让
-
事务与 context 治理(一次性铺开)
- 目标:消灭部分失败与不可取消查询。
- 做法:所有
.DBService调用加.WithContext(ctx);多步写包Transaction;move_dir/update_dir的静默err == nil全部改为返回错误。 - 影响面与风险:全模块机械改造,回归面大;建议配合 7 中提到的测试用例。
-
建立最小测试与安全回归网
- 目标:把本次发现固化为可执行断言。
- 做法:见第 5 节 TODO 的验收标准;优先写"跨用户访问必须 403/NotFound"、"根目录 GetDir 不 panic"、"移动目录到自身后代必须失败"、"路径重写只影响真实子树"四类用例。
- 影响面与风险:需引入可用的 DB(testcontainers 或 sqlite 内存),当前模块完全无测试基建,前期投入较大。
-
可观测性与运维闭环
- 目标:故障可发现、可定位。
- 做法:补
/healthz+/metrics;prod 打开 APM;关闭 GORMDebug、脱敏 DSN;Start支持优雅退出与 SIGTERM。 - 影响面与风险:低风险,独立于业务改造,可并行推进。
-
配置与文档对齐
- 目标:消除"照着 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_trgmGIN 索引;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 个动作:
- 给
CloudPhoto/CloudDiskFile/NoteAttachment补Std_Passport并在全部写路径落库 + 历史数据回填,照片/文件级查询改为直接按passport_id过滤(P0-1、P0-2、P0-3、P1-6)。 - 修
MoveDir环检测与子树路径重写(前缀匹配 + 单条 SQL + 事务 + 错误返回),并给*ParentID解引用加守卫与 gRPC recover(P1-1、P1-2、P1-3)。 - 修复隐私加解密(服务端密钥派生、拒绝补零/截断/客户端
IsEncrypted)与分享口令(哈希 + 常量时间比较 + 限流)(P0-4、P0-5)。
- 给
- 未能覆盖/无法验证的部分:
- 模块内 0 个测试、无迁移脚本、无可用数据库,所有运行期行为(GORM 自关联
Parentpreload 语义、并发竞态的实际复现、索引缺失的实际执行计划)均为静态推断,其中 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为空目录,无可审内容。
- 模块内 0 个测试、无迁移脚本、无可用数据库,所有运行期行为(GORM 自关联