学习目标
学完本章你应该能够:
- 串起分享模块"创建 → 访问校验 → 详情 → 保存 → 取消 → 列表"六环节主流程,说清每环节的输入输出。
- 讲清访问分享时的"四关校验"(状态 → 有效期 → 提取码 → 密码),并说明密码为何用 bcrypt 哈希存储而非明文。
- 权限模型:解释创建分享时为什么必须校验文件归属(防越权分享他人文件),以及访问分享为什么不能越权访问到非分享的文件。
- 分享标识安全:说清分享 UUID 与提取码的随机性要求,为什么
crypto/rand不能降级为可预测时间戳,以及为什么AccessShare通过验证后必须下发短期签名令牌而非把 UUID 当永久凭证。 - 防爆破与限流:讲清提取码/密码为什么需要失败计数 + 锁定 + 验证码,以及为什么限流的客户端 IP 不能信任可伪造的
X-Forwarded-For。 - 过期与清理:说清"访问时懒校验过期"与"定时调度器批量清理"如何互补,为什么只靠懒校验会导致表无限膨胀与悬空分享。
- 保存到网盘的一致性:讲清"复制元数据 + 扣减配额"两步必须原子化(事务或补偿回滚),以及为什么已过期/带密码的分享不能被直接保存。
- 审计与可观测:说明分享访问/保存为什么应留审计(谁、何时、访问了什么),以及删除源文件时如何联动取消分享避免悬空链接。
前置知识:
- Go 基础、
crypto/rand与math/rand的区别、bcrypt基本用法。 - GORM / MySQL(
Create、Updates、gorm.Expr、Where)、Kratos 分层(Service / Biz / Data)。 - JWT / HMAC 签名基本概念(见《用户模块》令牌章节)。
本章你会动手做的事:
- 给
CreateShare加一段"查fileRepo.FindByID校验UserID“的归属检查,用别人的item_id调一次,观察是否被ErrForbidden拦下。 - 把
AccessShare返回的Token从share.UUID改成 HMAC 短期签名令牌,再用verifyShareToken在GetShareDetail里解出 UUID,体会"一次验证、短期有效"的设计。 - 写一个
time.Ticker调度器,定期调用"删除expire_at < now且status=1“的分享,模拟过期清理。
一、技术栈与中间件
分享模块基于项目整体 Kratos 分层(Service → Biz → Data),用到的核心技术如下表所示:
| 技术 / 中间件 | 所属层 | 用途说明 |
|---|---|---|
| Kratos(go-kratos/v3) | 框架 | 微服务框架、错误码(perrors)、日志(log);Service 层实现 v1.ShareServiceServer |
| GORM(gorm.io/gorm) | Data | ORM 操作 MySQL,支持链式查询、Updates、UpdateColumn、gorm.Expr 原子表达式 |
| MySQL | 持久化 | 分享记录表 model.Share(uuid 唯一索引、share_code 唯一索引、user_id/item_id/expire_at 普通索引) |
| bcrypt(golang.org/x/crypto/bcrypt) | Biz | 分享密码哈希:GenerateFromPassword / CompareHashAndPassword,抗暴力破解 |
| crypto/rand + encoding/hex | Biz | 生成分享 UUID(newUUID,16 字节)与提取码(generateShareCode),保证不可预测 |
| HMAC-SHA256(crypto/hmac) | Biz(修正) | 生成短期签名访问令牌,替代直接把 UUID 当永久凭证(见 5.2) |
| JWTAuthMiddleware | 服务端 | 鉴权;AccessShare / GetShareDetail 故意加入白名单免登录(分享链接需被未登录用户访问) |
| RateLimitMiddleware | 服务端 | 进程内滑动窗口限流;修正点:客户端 IP 须取不可伪造的 X-Real-IP,而非 X-Forwarded-For 首值 |
| Redis 缓存 / 计数器(规划) | Biz | 热门分享信息缓存、提取码/密码失败计数(share:err:{uuid})、审计日志异步落库 |
| EventPublisher | Biz | 创建/取消/访问/保存事件异步发布,解耦统计、通知、审计下游 |
| Scheduler(新增) | 进程 | time.Ticker 定时批量清理过期分享,与"访问时懒校验"互补 |
⚠️ 关于"分享接口免登录”:
http.go把/share.v1.ShareService/AccessShare与GetShareDetail加入了 JWT 白名单,这是正确且必要的——分享链接本就要让未登录的陌生人也能打开。正因如此,这两个公开接口的安全性完全落在「UUID 不可猜测 + 密码/提取码 + 限流 + 审计」上,绝不能把 UUID 当成可信凭证直接放行后续操作(见 5.2、5.3)。
二、实现思路流程(总体)
分享模块围绕"创建 → 访问校验 → 详情 → 保存 → 取消 → 列表"六个核心环节展开:
类比:分享就像你给别人一把"开你柜子的钥匙”。创建分享 = 配钥匙并决定要不要加密码;访问校验 = 对方拿钥匙来开柜门前的四道安检;保存到网盘 = 对方在自己的柜子里贴一张"指向同一件实物"的标签(原件不复制);取消分享 = 你收回这把钥匙。整个过程围绕"一把钥匙"的生命周期转。区别在于:这把钥匙(UUID)必须足够随机、且验证通过后才发一张短期通行证,陌生人不能凭钥匙永久免检进出。
flowchart LR
C[创建分享
校验归属+生成UUID/提取码] --> A[访问校验
四关+下发短期令牌]
A --> D[分享详情
验令牌+过期+不回显码]
A --> S[保存到网盘
验令牌+事务复制]
C --> X[取消分享
归属校验后置0]
C --> L[分享列表
游标分页]
T[调度器] -.定时.-> X2[清理过期分享]- 创建分享:用户选文件/文件夹,指定类型(
link/code)、可选密码、可选有效期。系统先校验资源归属(只能分享自己的文件),再生成全局唯一UUID;code型生成随机提取码;密码 bcrypt 哈希后存储;拼接 URL 返回。 - 访问校验:访问者凭 UUID 访问,依次过四关(状态 → 有效期 → 提取码 → 密码)。通过后递增访问计数并下发短期签名访问令牌(HMAC,30 分钟有效),后续详情/保存凭此令牌,不再重复校验密码。
- 分享详情:凭短期令牌取出 UUID,查分享记录并补过期校验,按
ItemType返回文件或文件夹内容;不把提取码回显给访问者。 - 保存到网盘:访问者凭短期令牌 + 校验通过的分享,把内容复制到自己空间:仅复制元数据、复用同一物理
Path,再在同一事务/补偿内扣减配额。 - 取消分享:仅创建者可取消,校验归属后
Status置 0(软删除,留审计);源文件被删时应联动CancelByItemID取消关联分享。 - 分享列表:按
created_at游标分页,返回当前用户自己创建的有效分享及关联文件名。
三、面试常问知识点与难点
1. 权限模型:创建分享必须校验归属,访问不能越权
创建分享时,item_id 是客户端传入的。若 biz 层不校验该文件/文件夹是否属于当前用户,攻击者可传别人的 item_id 把它分享出去——等于把别人的私有文件变公开。本项目 file.go 里 Download/Copy/Move/Preview 都做了 if file.UserID != userID { return ErrForbidden },但 CreateShare 漏了这道关,是越权漏洞(见 5.1 修正)。
访问侧也有一条边界:详情查询只返回该分享指向的文件内容,绝不能让访问者通过分享接口读到分享范围之外的文件——本模块用 share.ItemID 限定查询范围,正确;但务必配合"分享有效 + 未过期 + 已通过密码"的校验,否则仍可越权访问私密文件(见 5.3、5.4)。
2. 分享标识随机性:UUID 够强,提取码要提熵
分享 UUID 由 newUUID() 用 crypto/rand 生成 16 字节并格式化为标准 UUID v4(≈2^122 的熵),遍历/爆破不可行,这是安全的。提取码则不同:原实现 generateShareCode 用 4 字节随机数编码为 6 位十六进制(仅 ~1600 万组合),且 rand.Read 失败时降级为 time.Now().UnixNano()[:6]——纳秒前 6 位高位固定、可被预测和遍历,等于给暴力破解开了窗。商用应提高提取码熵(≥8 字节)且失败时直接报错,绝不用可预测值兜底(见 5.1 修正)。
3. 密码哈希与防爆破
分享密码用 bcrypt(带 salt 的慢哈希)存储,即使 DB 泄露也无法反推明文,正确。但仅有哈希不够:提取码/密码接口是公开且免登录的,必须加失败计数 + 锁定 + 验证码防爆破(见 5.2、5.9)。另需注意历史明文兼容分支(checkPassword 对 len<10 或非 $ 开头的哈希走 hashed == password 明文比对)——若库里存在老明文,DB 泄露即泄露明文,商用应触发"下次访问时重新哈希"迁移掉。
4. 过期清理:懒校验 + 调度器互补
访问时 time.Now().After(*ExpireAt) 实时拒绝过期访问(懒校验),保证"刚过期立即失效"。但它不删除记录,过期分享会永久堆积、且仍可被"保存到网盘"类路径误用(见 5.4)。商用必须再配一个定时调度器批量清理 expire_at < now 的记录,控制表体积并消除悬空分享(见 5.7)。
5. 短期签名访问令牌(关键修正)
原实现 AccessShare 把 share.UUID 直接作为 Token 返回,而 GetShareDetail/SaveShare 凭 UUID 即可操作。后果:陌生人只要拿到链接(UUID),第一次输对提取码/密码后,UUID 就成了永久免密通行证,后续再也不校验密码——密码保护形同虚设。正确做法:四关通过后下发HMAC 短期签名令牌(含 UUID + 过期时间),详情/保存只认这个短期令牌(见 5.2、5.3)。
6. 保存到网盘的事务一致性
保存涉及"创建文件元数据 + 扣减存储配额"两步。原代码两步分开、且扣减失败仅记日志,恰恰制造了不一致(文件已建、配额没扣,出现"有文件无配额")。正确做法是把两步包进同一个 DB 事务,或在扣减失败时补偿回滚已建的文件记录(见 5.4)。
7. 审计:谁在何时访问/保存了什么
原 AccessShare 只 IncrementVisit 计数,没有"访问者身份 + IP + 时间 + 动作"的结构化审计。分享常被用于外发敏感文件,商用必须留痕(见 5.8)。
8. 越权边界小结
| 操作 | 原实现 | 商用修正 |
|---|---|---|
| 分享他人文件 | ❌ 未校验归属 | ✅ CreateShare 校验 UserID |
| 凭 UUID 永久免密访问 | ❌ Token=UUID | ✅ 短期签名令牌 |
| 已过期分享仍可保存 | ❌ 详情不校验过期 | ✅ 详情/保存补过期校验 |
| 提取码爆破 | ❌ 无失败计数 | ✅ share:err:{uuid} 计数 + 锁定 |
| 删除文件留悬空分享 | ❌ 无联动 | ✅ 删除时 CancelByItemID |
四、亿级流量优化思路
- 分享信息多级缓存:热门分享
Share对象校验后基本不变,可缓存share:{uuid}(Redis TTL 5 分钟 + 本地内存二级缓存);取消/过期时主动失效。 - CDN 加速:详情里的缩略图、预览图等静态资源走 CDN 边缘节点;分享页可做边缘缓存。
- 限流与防刷:单 IP / 单分享维度的
AccessShare限流(Redis + Lua 分布式);提取码/密码连续错误超限后要求图形验证码(见 5.9)。 - 错误计数与短期令牌:错误尝试计数缓存(
share:err:{uuid}),阈值后增加延迟或验证码;验证通过下发短期访问令牌,避免每次重复校验。 - 异步保存到网盘:
SaveShare投消息队列异步消费,先返任务 ID,WebSocket 推进度,提升响应速度。 - 访问计数异步聚合:
IncrementVisit改为先 RedisINCR,定时批量回写,聚合高频小写为低频批量写。 - 冷热分离与分表:历史/已取消分享归档冷库;活跃分享按
user_id取模分表;访问计数等高频字段拆表。 - 过期清理定时任务:访问时懒判断保证实时性,调度器每天低峰批量清理过期 N 天的记录,控制表体积(见 5.7)。
五、详细实现流程与代码解析
源码分布在 internal/biz/share.go(业务)、internal/data/share.go(数据)、internal/service/share.go(服务)、internal/server/{http,middleware}.go(路由与中间件)。下面按子功能拆解,修正处均标注「修正点」。
5.1 创建分享(链接/提取码、密码、有效期 + 归属校验)
实现思路
service从 context 取当前用户 ID(创建分享必须登录,不在白名单),解析ExpireAt。biz.CreateShare:生成 UUID → 若code型生成提取码(修正点:失败即报错)→ 校验分享类型与资源类型合法 → 校验资源归属(只能分享自己的文件/文件夹,且未被删除/回收) → 校验过期时间不早于当前 → 密码 bcrypt 哈希(修正点:失败即报错,不存明文兜底)→ 组装入库 → 异步发布创建事件 → 返回 URL。
权限模型类比:创建分享像"把自家柜子配钥匙给别人"。如果你能拿别人的柜子配钥匙,那这把钥匙就不是分享,是偷窃——所以配钥匙前必须先确认柜子确实是你的(
file.UserID == userID)。
flowchart TD
A[创建分享请求] --> B[解析类型/过期时间]
B --> C{类型合法?}
C -->|否| E1[拒绝]
C -->|是| D[查 file/folder 归属]
D -->|UserID 不符| E2[ErrForbidden 越权]
D -->|已删除/回收| E3[ErrNotFound]
D -->|归属正确| F[生成UUID+提取码+哈希密码]
F --> G{过期时间>=now?}
G -->|否| E4[ErrShareExpired]
G -->|是| H[入库+发事件+返回URL]关键代码(Biz 层 — internal/biz/share.go,修正后)
// CreateShare 创建分享链接或提取码分享(修正点:补归属校验/类型校验/过期校验/失败即报错)
func (uc *ShareUsecase) CreateShare(ctx context.Context, userID uint64, itemType, itemID uint64, shareType, password string, expireAt *time.Time) (*Share, string, error) {
// 修正点:校验分享类型与资源类型合法,拒绝脏输入
if shareType != "link" && shareType != "code" {
return nil, "", errors.BadRequest("SHARE_TYPE_INVALID", "分享类型仅支持 link/code")
}
if itemType != "file" && itemType != "folder" {
return nil, "", errors.BadRequest("ITEM_TYPE_INVALID", "资源类型仅支持 file/folder")
}
// 修正点:校验资源归属,禁止分享他人文件(防越权)
if itemType == "file" {
f, err := uc.fileRepo.FindByID(ctx, itemID)
if err != nil {
return nil, "", ErrFileNotFound
}
if f.UserID != userID { // 不是自己的文件
return nil, "", ErrForbidden
}
if f.Status != 0 { // 已移回收站/已删除的文件不可分享
return nil, "", ErrFileNotFound
}
} else {
folder, err := uc.folderRepo.FindByID(ctx, itemID)
if err != nil {
return nil, "", ErrFolderNotFound
}
if folder.UserID != userID {
return nil, "", ErrForbidden
}
}
// 修正点:过期时间不能早于当前,拒绝"创建即过期"
if expireAt != nil && expireAt.Before(time.Now()) {
return nil, "", ErrShareExpired
}
uuid, err := newUUID()
if err != nil {
return nil, "", err
}
shareCode := ""
if shareType == "code" {
// 修正点:生成失败直接返回错误,不再降级为可预测时间戳
shareCode, err = generateShareCode()
if err != nil {
return nil, "", err
}
}
hashedPassword := ""
if password != "" {
// 修正点:哈希失败直接返回错误,绝不让分享带着明文/伪哈希创建
hashedPassword, err = hashPassword(password)
if err != nil {
return nil, "", err
}
}
share := &Share{
UUID: uuid, UserID: userID, ItemType: itemType, ItemID: itemID,
ShareType: shareType, ShareCode: shareCode, Password: hashedPassword,
ExpireAt: expireAt, Status: 1,
}
created, err := uc.shareRepo.Create(ctx, share)
if err != nil {
return nil, "", err
}
if uc.eventPublisher != nil {
_ = uc.eventPublisher.Publish(ctx, EventShareCreated, &SharePayload{
UserID: userID, ShareID: created.ID, UUID: created.UUID, Action: "created",
})
}
url := fmt.Sprintf("/share/%s", uuid)
if shareCode != "" {
url += "?code=" + shareCode
}
return created, url, nil
}
关键代码(提取码与密码辅助函数 — 修正后)
// generateShareCode 生成提取码(修正点:8 字节≈2^64 熵,失败即报错不兜底)
func generateShareCode() (string, error) {
b := make([]byte, 8) // 修正点:从 4 字节提到 8 字节,字符集为 16 位十六进制
if _, err := rand.Read(b); err != nil {
return "", err // 修正点:crypto/rand 失败直接报错,不降级为可预测时间戳
}
return hex.EncodeToString(b), nil
}
// hashPassword 使用 bcrypt 对密码进行哈希(修正点:返回 error,失败不存明文)
func hashPassword(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err // 修正点:哈希失败直接失败,不让分享创建
}
return string(hashed), nil
}
// checkPassword 校验密码(保留历史明文兼容,但建议访问时迁移为 bcrypt)
func checkPassword(hashed, password string) bool {
if len(hashed) == 0 {
return false
}
if len(hashed) < 10 || hashed[0] != '$' {
return hashed == password // 历史明文兼容分支(应逐步迁移)
}
return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) == nil
}
⚠️
ShareCode唯一索引的碰撞:model.Share.ShareCode有uniqueIndex。8 字节随机几乎不会碰撞,但若Create返回唯一冲突,应在 biz 层重试生成提取码(最多几次)。生产可在应用层捕获 1062 后重试,避免并发下偶发冲突导致创建失败。
5.2 访问校验(四关验证 + 短期签名令牌 + 防爆破)
实现思路
访问者提交 UUID、提取码、密码(及客户端 IP)。AccessShare 依次过四关:① 状态(Status==0 拒绝)→ ② 有效期(过期拒绝)→ ③ 提取码(code 型才比对)→ ④ 密码(设了才比对)。修正点:
- 校验前先查
share:err:{uuid}失败计数,超阈值直接拒绝(触发验证码/锁定),防爆破; - 任一道关失败累加失败计数;
- 全部通过清空失败计数、递增访问计数,并下发 HMAC 短期签名访问令牌(30 分钟),后续详情/保存只认此令牌。
flowchart TD
START[访问请求
UUID/提取码/密码/IP] --> FC{失败次数超阈值?}
FC -->|是| LK[拒绝 需验证码/锁定]
FC -->|否| G1{状态有效?}
G1 -->|已取消| REJ1[拒绝]
G1 -->|有效| G2{未过期?}
G2 -->|过期| REJ2[拒绝]
G2 -->|未过期| G3{提取码匹配?}
G3 -->|不匹配| INC[失败计数+1] --> REJ3[拒绝]
G3 -->|匹配| G4{密码正确?}
G4 -->|错误| INC
G4 -->|正确| OK[清空计数+递增访问
下发短期签名令牌]关键代码(Biz 层 — internal/biz/share.go,修正后)
// signShareToken 生成短期分享访问令牌:HMAC-SHA256(uuid.exp)+签名,Base64URL 编码。
// 修正点:用短期签名令牌替代直接返回 UUID,避免 UUID 成为永久免密凭证。
func signShareToken(uuid string, secret []byte, ttl time.Duration) (string, error) {
exp := time.Now().Add(ttl).Unix()
payload := uuid + "." + strconv.FormatInt(exp, 10)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload + "." + sig)), nil
}
// verifyShareToken 校验短期令牌,返回其中的 UUID(过期/篡改均拒绝)。
func verifyShareToken(token string, secret []byte) (string, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return "", ErrShareInvalidToken
}
parts := strings.Split(string(raw), ".")
if len(parts) != 3 {
return "", ErrShareInvalidToken
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(parts[0] + "." + parts[1]))
if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts[2])) {
return "", ErrShareInvalidToken // 签名不符 → 篡改/伪造
}
exp, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || time.Now().Unix() > exp {
return "", ErrShareExpired // 令牌过期
}
return parts[0], nil
}
const shareMaxFails = 5 // 提取码/密码连续错误上限
// AccessShare 验证分享访问权限,依次执行四关校验,通过后下发短期令牌
func (uc *ShareUsecase) AccessShare(ctx context.Context, shareUUID, shareCode, password, clientIP string) (*Share, string, error) {
share, err := uc.shareRepo.FindByUUID(ctx, shareUUID)
if err != nil {
return nil, "", err
}
if share.Status == 0 {
return nil, "", ErrShareCancelled
}
if share.ExpireAt != nil && time.Now().After(*share.ExpireAt) {
return nil, "", ErrShareExpired
}
// 修正点:提取码/密码失败计数防爆破(Redis 维度按 UUID)
if uc.cache != nil {
if fails, _ := uc.cache.GetInt(ctx, "share:err:"+share.UUID); fails >= shareMaxFails {
return nil, "", ErrSharePasswordMismatch // 触发验证码/临时锁定
}
}
if share.ShareType == "code" && shareCode != share.ShareCode {
uc.incShareFail(ctx, share.UUID) // 修正点:失败计数 +1
return nil, "", ErrSharePasswordMismatch
}
if share.Password != "" && !checkPassword(share.Password, password) {
uc.incShareFail(ctx, share.UUID)
return nil, "", ErrSharePasswordMismatch
}
// 修正点:校验通过清空失败计数
if uc.cache != nil {
_ = uc.cache.Del(ctx, "share:err:"+share.UUID)
}
if err := uc.shareRepo.IncrementVisit(ctx, share.ID); err != nil {
log.Warn("share: failed to increment visit count", "shareID", share.ID, "err", err)
}
// 修正点:下发短期签名访问令牌(30 分钟),而非把 UUID 当长期凭证
token, err := signShareToken(share.UUID, uc.shareSecret, 30*time.Minute)
if err != nil {
return nil, "", err
}
return share, token, nil
}
// incShareFail 累加某分享的提取码/密码失败次数(带 TTL,自动衰减)
func (uc *ShareUsecase) incShareFail(ctx context.Context, uuid string) {
if uc.cache == nil {
return
}
key := "share:err:" + uuid
_ = uc.cache.Incr(ctx, key)
_ = uc.cache.Expire(ctx, key, 30*time.Minute)
}
关键代码(Service 层 — internal/service/share.go,修正后)
// AccessShare 验证对分享的访问权限,返回短期访问令牌。
func (s *ShareService) AccessShare(ctx context.Context, req *v1.AccessShareRequest) (*v1.AccessShareReply, error) {
// 修正点:传入客户端 IP 用于失败计数维度
clientIP := clientIPFromContext(ctx)
share, token, err := s.uc.AccessShare(ctx, req.Uuid, req.ShareCode, req.Password, clientIP)
if err != nil {
return nil, err
}
return &v1.AccessShareReply{
Valid: true,
Token: token, // 修正点:这是短期签名令牌,不再是裸 UUID
Share: toShareItem(share, false), // 修正点:不回显提取码
}, nil
}
5.3 分享详情查询(验令牌 + 补过期校验 + 不回显提取码)
实现思路
访问者凭短期令牌查详情。GetShareDetail 先 verifyShareToken 解出 UUID(拒绝伪造/过期令牌),再查分享记录;补过期校验(原实现只在 AccessShare 校验,导致已过期分享经此接口仍能看);按 ItemType 返回文件或文件夹内容。Service 层 toShareItem 不再把 share_code 回填给访问者(信息泄露修正),并补填 creator_name。
flowchart TD
A[GetShareDetail 请求
携带短期令牌] --> V[verifyShareToken 解出 UUID]
V -->|伪造/过期| E1[拒绝]
V -->|合法| F[FindByUUID]
F --> S{状态有效?}
S -->|已取消| E2[拒绝]
S -->|有效| P{未过期?}
P -->|过期| E3[拒绝]
P -->|未过期| D[返回文件/文件夹
不回显提取码]关键代码(Biz 层 — internal/biz/share.go,修正后)
// GetShareDetail 返回分享详情和内容(修正点:先验短期令牌 + 补过期校验)
func (uc *ShareUsecase) GetShareDetail(ctx context.Context, token string) (*Share, []*File, []*Folder, error) {
// 修正点:先校验短期签名令牌,拒绝伪造/过期,避免 UUID 被直接当凭证
uuid, err := verifyShareToken(token, uc.shareSecret)
if err != nil {
return nil, nil, nil, err
}
share, err := uc.shareRepo.FindByUUID(ctx, uuid)
if err != nil {
return nil, nil, nil, err
}
if share.Status == 0 {
return nil, nil, nil, ErrShareCancelled
}
// 修正点:补过期校验,原实现只在 AccessShare 校验,导致已过期分享经此接口仍可见
if share.ExpireAt != nil && time.Now().After(*share.ExpireAt) {
return nil, nil, nil, ErrShareExpired
}
var files []*File
var folders []*Folder
if share.ItemType == "file" {
file, err := uc.fileRepo.FindByID(ctx, share.ItemID)
if err == nil && file.Status == 0 {
files = append(files, file)
}
} else {
pid := share.ItemID
fileList, _, err := uc.fileRepo.ListByParent(ctx, share.UserID, &pid, 0, "", 1000, "name", "asc")
if err == nil {
files = fileList
}
folderList, _, err := uc.folderRepo.ListByParent(ctx, share.UserID, &pid, "", 1000, "name", "asc")
if err == nil {
folders = folderList
}
}
return share, files, folders, nil
}
关键代码(Service 层 — internal/service/share.go,修正点:不回显提取码)
// toShareItem 将 biz.Share 转为 v1.ShareItem(修正点:extractCode=false 时不回显提取码)
func toShareItem(share *biz.Share, withCode bool) *v1.ShareItem {
if share == nil {
return nil
}
item := &v1.ShareItem{
Id: share.ID, Uuid: share.UUID, ItemType: share.ItemType, ItemId: share.ItemID,
ShareType: share.ShareType, HasPassword: share.Password != "",
Status: share.Status, VisitCount: share.VisitCount,
CreatedAt: share.CreatedAt.Format("2006-01-02 15:04:05"),
}
if share.ExpireAt != nil {
item.ExpireAt = share.ExpireAt.Format("2006-01-02 15:04:05")
}
// 修正点:访问者不应看到提取码本身(防信息泄露)
if withCode {
item.ShareCode = share.ShareCode
}
return item
}
// GetShareDetail 返回分享详情(修正点:补填 creator_name)
func (s *ShareService) GetShareDetail(ctx context.Context, req *v1.GetShareDetailRequest) (*v1.GetShareDetailReply, error) {
share, files, folders, err := s.uc.GetShareDetail(ctx, req.Token)
if err != nil {
return nil, err
}
items := make([]*v1.FileItem, 0, len(files)+len(folders))
for _, f := range files {
items = append(items, &v1.FileItem{Id: f.ID, Uuid: f.UUID, Name: f.Name, IsFolder: false, Size: f.Size, Type: f.Type})
}
for _, f := range folders {
items = append(items, &v1.FileItem{Id: f.ID, Name: f.Name, IsFolder: true})
}
// 修正点:补充创建者昵称(需 userUC 查,避免暴露 UserID 亦可)
creatorName := ""
if s.uc.userUC != nil {
if u, err := s.uc.userUC.GetUserInfo(ctx, share.UserID); err == nil {
creatorName = u.Nickname
}
}
return &v1.GetShareDetailReply{
Share: toShareItem(share, false),
CreatorName: creatorName, // 修正点:proto 已定义但未填,此处补上
Items: items,
}, nil
}
5.4 保存到网盘(验令牌 + 事务/补偿 + 过期/密码保护)
实现思路
保存前 SaveShare 收短期令牌,经 verifyShareToken → GetShareDetail(已含状态/过期/归属校验)拿到文件列表;累计大小 → 校验目标用户配额 → 逐个复制元数据(复用 Path)→ 在事务(或补偿回滚)内扣减配额。原实现"复制成功、扣减失败仅记日志"恰恰制造不一致,修正为:扣减失败时删除已建的文件记录(补偿),保证"要么都有、要么都没有"。
类比:保存分享是在你自己柜子里贴一张"指向同一件实物"的标签。原件只有一份(
Path复用),你们各记一笔元数据。但"贴标签"和"记账(扣配额)“必须一起成功或一起失败——只贴了标签没记账,就会出现"显示有文件却不占配额"的黑账。
flowchart TD
A[SaveShare 请求
短期令牌] --> V[verifyShareToken+GetShareDetail
校验有效/未过期]
V -->|失效| E[拒绝]
V -->|有效| C[累计大小]
C --> D{CheckStorageAvailable?}
D -->|不足| ERR[拒绝 存储不足]
D -->|充足| LOOP[循环创建文件记录
复用同一 Path]
LOOP --> ADD[扣减配额 AddUsedStorage]
ADD -->|成功| OK[返回成功]
ADD -->|失败| RB[补偿: 删除已建文件记录
回滚一致]关键代码(Biz 层 — internal/biz/share.go,修正后)
// SaveShare 将分享的文件/文件夹复制到目标用户空间(修正点:先验令牌/过期 + 失败补偿)
func (uc *ShareUsecase) SaveShare(ctx context.Context, targetUserID uint64, token string, targetParentID *uint64) error {
// 修正点:先校验短期令牌并取出详情(GetShareDetail 内含状态/过期/归属校验),
// 已过期或带密码未通过的分享无法拿到 token,自然不能保存。
share, files, _, err := uc.GetShareDetail(ctx, token)
if err != nil {
return err
}
_ = share
if len(files) == 0 {
return ErrFileNotFound
}
var totalSize int64
for _, f := range files {
totalSize += f.Size
}
if uc.userUC != nil {
if err := uc.userUC.CheckStorageAvailable(ctx, targetUserID, totalSize); err != nil {
return err
}
}
// 记录已创建的文件 ID,用于补偿回滚
createdIDs := make([]uint64, 0, len(files))
for _, f := range files {
uuid, err := newUUID()
if err != nil {
uc.rollbackCreatedFiles(ctx, createdIDs) // 修正点:补偿
return err
}
copyFile := &File{
UUID: uuid, UserID: targetUserID, Name: f.Name, Size: f.Size,
Type: f.Type, Hash: f.Hash, Path: f.Path, // 复用同一物理文件
ParentID: targetParentID, Status: 0,
}
created, err := uc.fileRepo.Create(ctx, copyFile)
if err != nil {
uc.rollbackCreatedFiles(ctx, createdIDs) // 修正点:补偿
return err
}
createdIDs = append(createdIDs, created.ID)
}
// 修正点:扣减配额失败则补偿回滚已建文件,保证一致性(理想是同一 DB 事务)
if uc.userUC != nil {
if err := uc.userUC.AddUsedStorage(ctx, targetUserID, totalSize); err != nil {
uc.rollbackCreatedFiles(ctx, createdIDs) // 关键:回滚,避免"有文件无配额"
return err
}
}
return nil
}
// rollbackCreatedFiles 补偿回滚:删除已复制的文件元数据,撤销部分成功
func (uc *ShareUsecase) rollbackCreatedFiles(ctx context.Context, ids []uint64) {
for _, id := range ids {
if f, err := uc.fileRepo.FindByID(ctx, id); err == nil {
_ = uc.fileRepo.Delete(ctx, f) // 仅删元数据;物理文件因被分享者复用,不删
}
}
}
💡 更优方案:由于
fileRepo.Create与userUC.AddUsedStorage跨两个 repo,最干净的是在 data 层提供一个SaveShareTx(db *gorm.DB, ...)把"复制 + 扣配额"包进db.Transaction。补偿回滚是仓库结构限制下的可行近似,生产推荐单事务。
5.5 取消分享(归属校验正确,保留 + 删除联动)
实现思路
取消采用软删除(Status=0),保留记录便于审计。CancelShare 已正确校验归属(share.UserID != userID 返回 ErrForbidden),保留。修正点:删除源文件/文件夹到回收站或彻底删除时,应联动 CancelByItemID 取消其所有有效分享,避免悬空分享(访问者仍可打开一个指向已删除文件的分享)。
关键代码(Biz 层 — internal/biz/share.go)
// CancelShare 取消分享(归属校验已实现,保留)
func (uc *ShareUsecase) CancelShare(ctx context.Context, userID, shareID uint64) error {
share, err := uc.shareRepo.FindByID(ctx, shareID)
if err != nil {
return err
}
if share.UserID != userID { // 已正确:仅创建者可取消
return ErrForbidden
}
if err := uc.shareRepo.Cancel(ctx, shareID); err != nil {
return err
}
if uc.eventPublisher != nil {
_ = uc.eventPublisher.Publish(ctx, EventShareCancelled, &SharePayload{
UserID: userID, ShareID: shareID, UUID: share.UUID, Action: "cancelled",
})
}
return nil
}
关键代码(删除源文件时联动取消 — 修正点,建议在 FileUsecase 删除路径调用)
// CancelSharesByItem 当文件/文件夹被删除时,取消其所有有效分享(修正点:消除悬空分享)
func (uc *ShareUsecase) CancelSharesByItem(ctx context.Context, itemType string, itemID uint64) error {
return uc.shareRepo.CancelByItemID(ctx, itemType, itemID)
}
(internal/data/share.go 已有 CancelByItemID:WHERE item_type=? AND item_id=? AND status=1 UPDATE status=0,直接复用即可,关键是把它接到文件删除的调用链上。)
5.6 分享列表查询(游标分页)
实现思路
按 created_at 游标倒序查询当前用户自己的有效分享。Data 层 Limit(limit+1) 多查一条判断是否 hasMore,并截断到 limit;Biz 层循环关联查询每个分享的文件名。原 Biz 层的 hasMore := len(shares) >= pageSize 与 Data 层逻辑冗余(Data 已截断,截断后 len==pageSize 即还有下一页),保留其一即可,不影响正确性。
关键代码(Data 层游标分页 — internal/data/share.go)
func (r *shareRepo) ListByUser(ctx context.Context, userID uint64, cursor string, limit int) ([]*biz.Share, string, error) {
query := r.db.WithContext(ctx).Model(&model.Share{}).
Where("user_id = ? AND status = 1", userID)
if cursor != "" {
query = query.Where("created_at < ?", cursor)
}
var pos []model.Share
if err := query.Order("created_at DESC").Limit(limit + 1).Find(&pos).Error; err != nil { // 多查1条
return nil, "", err
}
hasMore := len(pos) > limit
if hasMore {
pos = pos[:limit] // 截断
}
nextCursor := ""
if len(pos) > 0 {
nextCursor = pos[len(pos)-1].CreatedAt.Format("2006-01-02 15:04:05")
}
shares := make([]*biz.Share, len(pos))
for i := range pos {
shares[i] = toBizShare(&pos[i])
}
return shares, nextCursor, nil
}
📌 为什么用游标而非
OFFSET:深翻页时OFFSET 100000要让 MySQL 先扫描并丢弃前 10 万行,越翻越慢;游标created_at < ?走索引范围扫描,性能稳定。created_at需保证唯一/单调,若存在同毫秒可加id作次级排序键。
5.7 过期清理调度器(新增,补齐缺失)
为什么需要:只靠访问时懒校验,过期分享记录永不被删,表无限膨胀;且"已过期但仍可被保存"的边界(5.3/5.4 已堵)依赖每次都校验。定时清理是兜底与瘦身手段。
flowchart TD
T[time.Ticker 每天低峰] --> Q[SELECT id FROM shares
WHERE status=1 AND expire_at < now]
Q --> D[批量 status=0 或 DELETE]
D --> R[返回 清理完成 记审计]// CleanExpiredShares 批量清理过期分享(修正点:新增调度逻辑)
func (r *shareRepo) CleanExpiredShares(ctx context.Context, before time.Time, batch int) (int64, error) {
var n int64
for {
// 分批更新,避免长时间锁表
res := r.db.WithContext(ctx).Model(&model.Share{}).
Where("status = 1 AND expire_at IS NOT NULL AND expire_at < ?", before).
Limit(batch).Update("status", 0) // 软取消即可,保留审计
if res.Error != nil {
return n, res.Error
}
n += res.RowsAffected
if res.RowsAffected < int64(batch) {
break
}
}
return n, nil
}
// StartShareCleanupScheduler 启动定时清理(在 main/wire 里调用一次)
func StartShareCleanupScheduler(repo biz.ShareRepo, interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
if n, err := repo.CleanExpiredShares(ctx, time.Now(), 500); err != nil {
log.Error("share: cleanup expired failed", "err", err)
} else if n > 0 {
log.Info("share: cleaned expired shares", "count", n)
}
cancel()
}
}()
}
说明:清理采用"软取消
status=0“而非物理删除,因为分享记录可能含审计价值;且不影响源文件(源文件归创建者所有)。若需彻底删除记录,改用Delete并谨慎评估审计需求。
5.8 分享访问审计(新增,补齐缺失)
为什么需要:分享常用于外发文件,安全复盘、合规都要求知道"谁、何时、访问/保存了什么”。原实现只有
visit_count计数,无身份/时间/IP 留痕。
flowchart LR
A[访问/保存成功] --> E[发布 ShareAccessed 事件]
E --> C[审计消费者
写 share_access_log]
C --> S[结构化日志
visitor/ip/uuid/action/time]// ShareAccessLog 分享访问审计记录
type ShareAccessLog struct {
VisitorUserID uint64 // 访问者(登录用户则有,匿名为 0)
VisitorIP string // 客户端 IP
UUID string // 被访问的分享 UUID
Action string // "access" / "save"
CreatedAt time.Time
}
// RecordShareAccess 记录一次分享访问审计(修正点:新增)
func (uc *ShareUsecase) RecordShareAccess(ctx context.Context, visitorUserID uint64, ip, uuid, action string) {
log.Info("share.access",
"visitor", visitorUserID, "ip", ip, "uuid", uuid, "action", action,
"time", time.Now().Format(time.RFC3339))
if uc.eventPublisher != nil {
_ = uc.eventPublisher.Publish(ctx, EventShareAccessed, &ShareAccessPayload{
VisitorUserID: visitorUserID, IP: ip, UUID: uuid, Action: action,
})
}
}
在 AccessShare/SaveShare 成功后调用 RecordShareAccess(匿名访问 visitorUserID=0,但 ip 必填),下游消费者落 share_access_log 表或日志系统。
5.9 限流与防爆破(新增,修正 XFF 可伪造 + 按分享维度计数)
实现思路
- 客户端 IP 不能信
X-Forwarded-For首值:原RateLimitMiddleware优先取X-Forwarded-For,该头客户端可伪造(攻击者把自己真实 IP 藏到首值之后)。应取 nginx 注入的不可伪造的X-Real-IP(与用户模块 5.10 一致)。 - 按分享维度失败计数:已在 5.2 的
share:err:{uuid}实现——提取码/密码连续错 5 次即拒绝并要求验证码/临时锁定。 - 分布式限流:进程内
map限流在扩多实例后各算各的,应升级为 Redis + Lua 共享计数(同用户模块 5.7)。
关键代码(修正 IP 提取 — internal/server/middleware.go)
// RateLimitMiddleware 修正点:客户端 IP 取不可伪造的 X-Real-IP,而非 X-Forwarded-For 首值
func RateLimitMiddleware(maxRequests int, window time.Duration) middleware.Middleware {
// ... 同原结构,仅替换 IP 提取:
var clientIP string
if tr, ok := transport.FromServerContext(ctx); ok {
if ht, ok := tr.(interface{ Request() *http.Request }); ok {
// 修正点:优先 X-Real-IP(由 nginx 从 $remote_addr 填,客户端不可伪造)
clientIP = ht.Request().Header.Get("X-Real-IP")
if clientIP == "" {
clientIP = ht.Request().RemoteAddr
}
}
}
if clientIP == "" {
clientIP = "unknown"
}
// ... 后续计数逻辑不变
}
⚠️ 由于
AccessShare/GetShareDetail在 JWT 白名单内,限流中间件仍会对它们生效(限流不依赖登录),但 IP 维度必须可靠。配合 5.2 的"按 UUID 失败计数”,即使攻击者轮换 IP,单一分享的提取码/密码试错次数仍被锁死。
自测题与动手练习
自测题(合上书能答出来,才算懂):
- 创建分享时如果不校验
item_id的归属,会产生什么后果?为什么file.go里Download/Copy都校验了UserID,分享创建却必须单独补这道关? AccessShare原实现把share.UUID直接当Token返回有什么隐患?正确的短期签名令牌应包含哪些信息、如何防伪造与防永久有效?- 提取码用 4 字节随机数编码成 6 位十六进制约多少种组合?
generateShareCode在rand.Read失败时降级为UnixNano()[:6]为什么危险?商用怎么改? - 已过期分享为什么不能被"保存到网盘"?原
GetShareDetail只在AccessShare校验过期,这条路径是怎么漏掉的?怎么补? SaveShare复制元数据 + 扣减配额,为什么"扣减失败仅记日志"反而制造了不一致?补偿回滚或单事务分别怎么写?- 为什么分享接口要加入 JWT 白名单(免登录)?免登录后安全性靠哪几道防线兜底?限流的客户端 IP 为什么不能取
X-Forwarded-For首值? - 只靠"访问时懒校验过期"够不够?定时清理调度器解决的是什么问题?为什么清理用软取消而非物理删除?
toShareItem把share_code回显给访问者属于哪类问题?还有哪些字段/场景在分享模块里可能信息泄露?
动手练习(建议真做一遍):
- 给
CreateShare加一段fileRepo.FindByID归属校验,用别人的item_id调一次,确认返回ErrForbidden;再去掉校验,验证可越权分享他人文件。 - 实现
signShareToken/verifyShareToken,把AccessShare的返回从 UUID 改为短期令牌,并在GetShareDetail用verifyShareToken解出 UUID;用过期令牌访问观察被拒。 - 写一个
time.Ticker调度器周期性调用CleanExpiredShares,插入几条expire_at在过去的有效分享,观察被批量清理;并用 Redis 给某 UUID 连续错 6 次提取码,验证第 6 次被拒(触发锁定)。
本章小结
- 分享模块围绕"创建 → 访问校验 → 详情 → 保存 → 取消 → 列表"六环节,采用 Service → Biz → Data 分层;
AccessShare/GetShareDetail故意免登录以便陌生人访问,安全性因此完全依赖 UUID 不可猜测 + 密码/提取码 + 限流 + 审计。 - 权限模型是商用底线:创建分享必须校验文件归属(
file.UserID == userID),否则可越权分享他人私有文件;访问侧用share.ItemID限定查询范围,配合状态/过期/密码校验防越权访问。 - 四关校验守门(状态 → 有效期 → 提取码 → 密码);密码用 bcrypt 哈希存储、URL 不携带密码;验证通过后必须下发短期签名令牌(HMAC),绝不能把 UUID 当永久免密凭证。
- 提取码要提熵(≥8 字节)且
crypto/rand失败即报错,不能降级为可预测时间戳;失败计数 + 锁定 + 验证码防爆破;限流 IP 取不可伪造的X-Real-IP。 - 过期双保险:访问时懒校验保证实时失效,定时调度器批量清理控制表体积与悬空分享;取消分享用软删除
status=0保留审计。 - 保存到网盘一致性:复制元数据 + 扣减配额必须原子(单事务或补偿回滚),避免"有文件无配额";已过期/带密码分享因拿不到短期令牌而无法保存。
- 审计与联动:分享访问/保存留结构化审计(谁、何时、什么);删除源文件时联动
CancelByItemID取消悬空分享;toShareItem不再回显提取码、补填creator_name。 - 下一篇可进入「文件/回收站模块」,看上传下载如何复用这里的归属校验与存储配额扣减能力。