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

811 lines
67 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 审计报告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_idP0-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_idauth 被丢弃(下划线)
_, 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(&note).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:33DeletedAt 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(&note).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` 由客户端提供或退化为随机 UUIDREADME: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\lint0 个文件;全模块无 *_test.go
```
- **影响**README:338-355 描述的 `make test` / `make test-grpc` / 覆盖率 / 安全扫描在本模块无对应产物。所有归属校验、路径重写、配额、加解密逻辑全靠人工评审。
- **建议**:按第 6 节"关键缺失用例清单"补齐,优先 P0/P1 对应的越权与路径一致性用例。
#### P2-13. 文档与实现大面积不一致
- **位置**`module/base/cloud/README.md:119-142``:461-464``:469-499``:556-619``:310`
- **证据**
```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"、"移动目录到自身后代必须失败"、"路径重写只影响真实子树"四类用例。
- 影响面与风险:需引入可用的 DBtestcontainers 或 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 recoverP1-1、P1-2、P1-3
3. 修复隐私加解密(服务端密钥派生、拒绝补零/截断/客户端 `IsEncrypted`)与分享口令(哈希 + 常量时间比较 + 限流P0-4、P0-5
- **未能覆盖/无法验证的部分**
- 模块内 **0 个测试**、无迁移脚本、无可用数据库所有运行期行为GORM 自关联 `Parent` preload 语义、并发竞态的实际复现、索引缺失的实际执行计划)均为静态推断,其中 P3-3 的自关联 tag 问题已明确标注为「推测」。
- `go vet ./...` 未执行(依赖 replace 到本地 `bsm-sdk/core`,按效率约束跳过);`gofmt -l .` 已执行,退出码 0、无输出。
- 生成代码 `pb/*.pb.go``*_grpc.pb.go` 与除 `disk.pb.gw.go` 外的 gateway 文件未逐行审阅。
- 模块外部:`module/base/all` 等调用方如何装配 `service.Expose`、是否在网关层额外挂了 JWT/限流中间件未审计——若上游已补限流P0-5 的爆破风险等级可下调,但口令明文存储与明文比较的问题不受影响。
- `cmd/cli` 为空壳,`test/lint` 为空目录,无可审内容。