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.
811 lines
67 KiB
Markdown
811 lines
67 KiB
Markdown
# 审计报告: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|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(如 `<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`
|
||
- **证据**:
|
||
```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` 为空目录,无可审内容。
|