分享模块实现教程

2025-07-25T14:11:02+08:00 | 25分钟阅读 | 更新于 2025-07-26T14:11:02+08:00

@

学习目标

学完本章你应该能够:

  1. 串起分享模块"创建 → 访问校验 → 详情 → 保存 → 取消 → 列表"六环节主流程,说清每环节的输入输出。
  2. 讲清访问分享时的"四关校验"(状态 → 有效期 → 提取码 → 密码),并说明密码为何用 bcrypt 哈希存储而非明文。
  3. 权限模型:解释创建分享时为什么必须校验文件归属(防越权分享他人文件),以及访问分享为什么不能越权访问到非分享的文件。
  4. 分享标识安全:说清分享 UUID 与提取码的随机性要求,为什么 crypto/rand 不能降级为可预测时间戳,以及为什么 AccessShare 通过验证后必须下发短期签名令牌而非把 UUID 当永久凭证。
  5. 防爆破与限流:讲清提取码/密码为什么需要失败计数 + 锁定 + 验证码,以及为什么限流的客户端 IP 不能信任可伪造的 X-Forwarded-For
  6. 过期与清理:说清"访问时懒校验过期"与"定时调度器批量清理"如何互补,为什么只靠懒校验会导致表无限膨胀与悬空分享。
  7. 保存到网盘的一致性:讲清"复制元数据 + 扣减配额"两步必须原子化(事务或补偿回滚),以及为什么已过期/带密码的分享不能被直接保存。
  8. 审计与可观测:说明分享访问/保存为什么应留审计(谁、何时、访问了什么),以及删除源文件时如何联动取消分享避免悬空链接。

前置知识

  • Go 基础、crypto/randmath/rand 的区别、bcrypt 基本用法。
  • GORM / MySQL(CreateUpdatesgorm.ExprWhere)、Kratos 分层(Service / Biz / Data)。
  • JWT / HMAC 签名基本概念(见《用户模块》令牌章节)。

本章你会动手做的事

  1. CreateShare 加一段"查 fileRepo.FindByID 校验 UserID“的归属检查,用别人的 item_id 调一次,观察是否被 ErrForbidden 拦下。
  2. AccessShare 返回的 Tokenshare.UUID 改成 HMAC 短期签名令牌,再用 verifyShareTokenGetShareDetail 里解出 UUID,体会"一次验证、短期有效"的设计。
  3. 写一个 time.Ticker 调度器,定期调用"删除 expire_at < nowstatus=1“的分享,模拟过期清理。

一、技术栈与中间件

分享模块基于项目整体 Kratos 分层(Service → Biz → Data),用到的核心技术如下表所示:

技术 / 中间件所属层用途说明
Kratos(go-kratos/v3)框架微服务框架、错误码(perrors)、日志(log);Service 层实现 v1.ShareServiceServer
GORM(gorm.io/gorm)DataORM 操作 MySQL,支持链式查询、UpdatesUpdateColumngorm.Expr 原子表达式
MySQL持久化分享记录表 model.Shareuuid 唯一索引、share_code 唯一索引、user_id/item_id/expire_at 普通索引)
bcrypt(golang.org/x/crypto/bcrypt)Biz分享密码哈希:GenerateFromPassword / CompareHashAndPassword,抗暴力破解
crypto/rand + encoding/hexBiz生成分享 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})、审计日志异步落库
EventPublisherBiz创建/取消/访问/保存事件异步发布,解耦统计、通知、审计下游
Scheduler(新增)进程time.Ticker 定时批量清理过期分享,与"访问时懒校验"互补

⚠️ 关于"分享接口免登录”http.go/share.v1.ShareService/AccessShareGetShareDetail 加入了 JWT 白名单,这是正确且必要的——分享链接本就要让未登录的陌生人也能打开。正因如此,这两个公开接口的安全性完全落在「UUID 不可猜测 + 密码/提取码 + 限流 + 审计」上,绝不能把 UUID 当成可信凭证直接放行后续操作(见 5.2、5.3)。


二、实现思路流程(总体)

分享模块围绕"创建 → 访问校验 → 详情 → 保存 → 取消 → 列表"六个核心环节展开:

类比:分享就像你给别人一把"开你柜子的钥匙”。创建分享 = 配钥匙并决定要不要加密码;访问校验 = 对方拿钥匙来开柜门前的四道安检;保存到网盘 = 对方在自己的柜子里贴一张"指向同一件实物"的标签(原件不复制);取消分享 = 你收回这把钥匙。整个过程围绕"一把钥匙"的生命周期转。区别在于:这把钥匙(UUID)必须足够随机、且验证通过后才发一张短期通行证,陌生人不能凭钥匙永久免检进出。

flowchart LR
    C[创建分享
校验归属+生成UUID/提取码] --> A[访问校验
四关+下发短期令牌] A --> D[分享详情
验令牌+过期+不回显码] A --> S[保存到网盘
验令牌+事务复制] C --> X[取消分享
归属校验后置0] C --> L[分享列表
游标分页] T[调度器] -.定时.-> X2[清理过期分享]
  1. 创建分享:用户选文件/文件夹,指定类型(link/code)、可选密码、可选有效期。系统先校验资源归属(只能分享自己的文件),再生成全局唯一 UUIDcode 型生成随机提取码;密码 bcrypt 哈希后存储;拼接 URL 返回。
  2. 访问校验:访问者凭 UUID 访问,依次过四关(状态 → 有效期 → 提取码 → 密码)。通过后递增访问计数下发短期签名访问令牌(HMAC,30 分钟有效),后续详情/保存凭此令牌,不再重复校验密码。
  3. 分享详情:凭短期令牌取出 UUID,查分享记录并补过期校验,按 ItemType 返回文件或文件夹内容;不把提取码回显给访问者。
  4. 保存到网盘:访问者凭短期令牌 + 校验通过的分享,把内容复制到自己空间:仅复制元数据、复用同一物理 Path,再在同一事务/补偿内扣减配额。
  5. 取消分享:仅创建者可取消,校验归属后 Status 置 0(软删除,留审计);源文件被删时应联动 CancelByItemID 取消关联分享。
  6. 分享列表:按 created_at 游标分页,返回当前用户自己创建的有效分享及关联文件名。

三、面试常问知识点与难点

1. 权限模型:创建分享必须校验归属,访问不能越权

创建分享时,item_id 是客户端传入的。若 biz 层不校验该文件/文件夹是否属于当前用户,攻击者可传别人的 item_id 把它分享出去——等于把别人的私有文件变公开。本项目 file.goDownload/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)。另需注意历史明文兼容分支(checkPasswordlen<10 或非 $ 开头的哈希走 hashed == password 明文比对)——若库里存在老明文,DB 泄露即泄露明文,商用应触发"下次访问时重新哈希"迁移掉。

4. 过期清理:懒校验 + 调度器互补

访问时 time.Now().After(*ExpireAt) 实时拒绝过期访问(懒校验),保证"刚过期立即失效"。但它不删除记录,过期分享会永久堆积、且仍可被"保存到网盘"类路径误用(见 5.4)。商用必须再配一个定时调度器批量清理 expire_at < now 的记录,控制表体积并消除悬空分享(见 5.7)。

5. 短期签名访问令牌(关键修正)

原实现 AccessShareshare.UUID 直接作为 Token 返回,而 GetShareDetail/SaveShare 凭 UUID 即可操作。后果:陌生人只要拿到链接(UUID),第一次输对提取码/密码后,UUID 就成了永久免密通行证,后续再也不校验密码——密码保护形同虚设。正确做法:四关通过后下发HMAC 短期签名令牌(含 UUID + 过期时间),详情/保存只认这个短期令牌(见 5.2、5.3)。

6. 保存到网盘的事务一致性

保存涉及"创建文件元数据 + 扣减存储配额"两步。原代码两步分开、且扣减失败仅记日志,恰恰制造了不一致(文件已建、配额没扣,出现"有文件无配额")。正确做法是把两步包进同一个 DB 事务,或在扣减失败时补偿回滚已建的文件记录(见 5.4)。

7. 审计:谁在何时访问/保存了什么

AccessShareIncrementVisit 计数,没有"访问者身份 + IP + 时间 + 动作"的结构化审计。分享常被用于外发敏感文件,商用必须留痕(见 5.8)。

8. 越权边界小结

操作原实现商用修正
分享他人文件❌ 未校验归属CreateShare 校验 UserID
凭 UUID 永久免密访问❌ Token=UUID✅ 短期签名令牌
已过期分享仍可保存❌ 详情不校验过期✅ 详情/保存补过期校验
提取码爆破❌ 无失败计数share:err:{uuid} 计数 + 锁定
删除文件留悬空分享❌ 无联动✅ 删除时 CancelByItemID

四、亿级流量优化思路

  1. 分享信息多级缓存:热门分享 Share 对象校验后基本不变,可缓存 share:{uuid}(Redis TTL 5 分钟 + 本地内存二级缓存);取消/过期时主动失效。
  2. CDN 加速:详情里的缩略图、预览图等静态资源走 CDN 边缘节点;分享页可做边缘缓存。
  3. 限流与防刷:单 IP / 单分享维度的 AccessShare 限流(Redis + Lua 分布式);提取码/密码连续错误超限后要求图形验证码(见 5.9)。
  4. 错误计数与短期令牌:错误尝试计数缓存(share:err:{uuid}),阈值后增加延迟或验证码;验证通过下发短期访问令牌,避免每次重复校验。
  5. 异步保存到网盘SaveShare 投消息队列异步消费,先返任务 ID,WebSocket 推进度,提升响应速度。
  6. 访问计数异步聚合IncrementVisit 改为先 Redis INCR,定时批量回写,聚合高频小写为低频批量写。
  7. 冷热分离与分表:历史/已取消分享归档冷库;活跃分享按 user_id 取模分表;访问计数等高频字段拆表。
  8. 过期清理定时任务:访问时懒判断保证实时性,调度器每天低峰批量清理过期 N 天的记录,控制表体积(见 5.7)。

五、详细实现流程与代码解析

源码分布在 internal/biz/share.go(业务)、internal/data/share.go(数据)、internal/service/share.go(服务)、internal/server/{http,middleware}.go(路由与中间件)。下面按子功能拆解,修正处均标注「修正点」

5.1 创建分享(链接/提取码、密码、有效期 + 归属校验)

实现思路

  1. service 从 context 取当前用户 ID(创建分享必须登录,不在白名单),解析 ExpireAt
  2. 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.ShareCodeuniqueIndex。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 收短期令牌,经 verifyShareTokenGetShareDetail(已含状态/过期/归属校验)拿到文件列表;累计大小 → 校验目标用户配额 → 逐个复制元数据(复用 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.CreateuserUC.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 已有 CancelByItemIDWHERE 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 可伪造 + 按分享维度计数)

实现思路

  1. 客户端 IP 不能信 X-Forwarded-For 首值:原 RateLimitMiddleware 优先取 X-Forwarded-For,该头客户端可伪造(攻击者把自己真实 IP 藏到首值之后)。应取 nginx 注入的不可伪造的 X-Real-IP(与用户模块 5.10 一致)。
  2. 按分享维度失败计数:已在 5.2 的 share:err:{uuid} 实现——提取码/密码连续错 5 次即拒绝并要求验证码/临时锁定。
  3. 分布式限流:进程内 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,单一分享的提取码/密码试错次数仍被锁死。


自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 创建分享时如果不校验 item_id 的归属,会产生什么后果?为什么 file.goDownload/Copy 都校验了 UserID,分享创建却必须单独补这道关?
  2. AccessShare 原实现把 share.UUID 直接当 Token 返回有什么隐患?正确的短期签名令牌应包含哪些信息、如何防伪造与防永久有效?
  3. 提取码用 4 字节随机数编码成 6 位十六进制约多少种组合?generateShareCoderand.Read 失败时降级为 UnixNano()[:6] 为什么危险?商用怎么改?
  4. 已过期分享为什么不能被"保存到网盘"?原 GetShareDetail 只在 AccessShare 校验过期,这条路径是怎么漏掉的?怎么补?
  5. SaveShare 复制元数据 + 扣减配额,为什么"扣减失败仅记日志"反而制造了不一致?补偿回滚或单事务分别怎么写?
  6. 为什么分享接口要加入 JWT 白名单(免登录)?免登录后安全性靠哪几道防线兜底?限流的客户端 IP 为什么不能取 X-Forwarded-For 首值?
  7. 只靠"访问时懒校验过期"够不够?定时清理调度器解决的是什么问题?为什么清理用软取消而非物理删除?
  8. toShareItemshare_code 回显给访问者属于哪类问题?还有哪些字段/场景在分享模块里可能信息泄露?

动手练习(建议真做一遍)

  1. CreateShare 加一段 fileRepo.FindByID 归属校验,用别人的 item_id 调一次,确认返回 ErrForbidden;再去掉校验,验证可越权分享他人文件。
  2. 实现 signShareToken / verifyShareToken,把 AccessShare 的返回从 UUID 改为短期令牌,并在 GetShareDetailverifyShareToken 解出 UUID;用过期令牌访问观察被拒。
  3. 写一个 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
  • 下一篇可进入「文件/回收站模块」,看上传下载如何复用这里的归属校验与存储配额扣减能力。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!