topfans/docs/superpowers/plans/2026-07-21-auth-boundary.md
2026-07-21 21:14:22 +08:00

38 KiB
Raw Blame History

鉴权边界 (批次2) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 消除后端"信任请求体里的身份"造成的越权/刷量/隐私泄露——所有 Dubbo RPC provider 统一从 ctx(attachment/metadata) 提取 user_id/star_id,强制覆盖 req 内同名字段;并修复 socialService 的 3 条正确性/隐私 bugValidateToken 从公开 /auth 组移除。

Architecture:backend/pkg/authctx/ 沉淀统一的身份提取与覆盖工具(替代 5 处散落的 extractUserInfoFromDubboAttachments/extractUserInfo 副本);逐个 providermoderation / asset / social / gallery / task改成"从 ctx 取身份 → 写回 req → 调 service"的统一模式social 三条CheckFriendship starID / OR 括号 / 随机用户)用真 SQL 修复。ValidateToken 路由加 AuthMiddleware。整套不引入新依赖,依赖 dubbo-go/constant.AttachmentKey + grpc/metadata + pkg/jwt(与 pkg/userService/middleware/auth_interceptor.go 现有实现同源)。

Tech Stack: Go 1.25 (go.work 多模块)Dubbo TriplegRPC metadata + attachmentsGORM社交 repositoryPostgreSQL本地库 top-fans@localhost:15432

Global Constraints

  • 不引入新依赖:用现有的 dubbo-go/constant.AttachmentKeygrpc/metadatapkg/jwt
  • 不自动 git commit(仓库规矩:需用户明确指示)。步骤里的 commit 命令仅在用户批准后执行。
  • 每个 Task 结束跑 cd backend && go build ./... 通过。
  • TDD每个 Task 第一步"写失败测试"(伪造身份应被覆盖/拦截),第二步"最小实现",第三步"测试通过"。Provider 端的拦截通过单测构造带 x-user-id/x-star-id 的 metadata 的 ctx 与带伪造 req.UserId/req.StarId/req.ReporterId/req.SharerUserId 的请求,断言调用 service 时传入的是覆盖后的真实身份。
  • 不引入 lint 黑魔法:仅用现有 go vet + go build
  • 行为兼容:所有已有合法调用方不变;只是禁止攻击者在 req 里塞伪造身份。
  • 不改 proto 字段(不动契约);所有变更在 Go 层完成。
  • 任何 service.go 内部函数签名变更(如 CheckFriendship 增加 starID 参数)必须在 provider 处同步补传,避免破坏调用方。

当前现状速查pre-plan 排查,作为 plan 的事实基线)

来自 mcp__code-review-graph + grep 双重核对,基线 commit b7f8f1b724e5

A. 信任 req.*Id 的点(本次必须全部覆盖

服务 文件:行 字段 风险
moderation services/moderationService/provider/moderation_provider.go:35 (SubmitReport) req.ReporterId 透传 冒用他人身份举报、刷量、读取他人举报
moderation services/moderationService/provider/moderation_provider.go:39,43,49,53,57 (List/Get) req.ReporterId/req.UserId 读取他人举报/反馈详情
moderation services/moderationService/service/report_service.go:107,116,123,130,170,220,251 req.ReporterId 业务逻辑按 req 执行
moderation services/moderationService/service/feedback_service.go:53,64,109,135 req.UserId/req.StarId 同上
asset services/assetService/provider/asset_provider.go:483-486 (CheckAssetLike) req.UserId/req.StarId 点赞隐私预言机
asset services/assetService/service/share_service.go:139,172,175,185,187,211,233,250,266,276,298,308 req.SharerUserId 分享归因伪造、无限 OSS 写入
social services/socialService/service/friend_service.go:668,683 (CheckFriendship) req.UserId + starID=0 TODO 好友关系隐私预言机 + starID 永远 0
gallery services/galleryService/provider/gallery_provider.go:403 (GetUserExhibitedAssets) req.UserId 当 target_uid应为合法入参 此处是查询参数,不覆盖(仅注释澄清)
task services/taskService/provider/task_internal_provider.go:32-48 (InitUserTasks) req.UserId/req.StarId(内部 RPC 内部 RPC保留 req 作合法入参,但加 ctx 校验一致性
asset services/assetService/service/ranking_service.go:51,89-90,113-123,176,214-248 req.UserId/req.StarId 排行榜按别人维度查(隐私泄露)
asset services/assetService/provider/castlove_config_provider.go:35-39 "本服务信任 ctx 透传的 user_id" 当前 GetConfig 不读身份;文档要求统一来源

B. 现有的身份提取实现5 份散落副本)

服务 函数 文件
userService ValidateTokenAndExtractClaims / ExtractUserInfoFromContext services/userService/middleware/auth_interceptor.go:171,155
notification extractUserInfo services/notificationService/provider/notification_provider.go:161
social extractUserInfo services/socialService/provider/social_provider.go (本文件已有 10+ 调用)
gallery extractUserInfoFromDubboAttachments services/galleryService/provider/gallery_provider.go:463
aiChat extractUserInfoFromDubboAttachments services/aiChatService/provider/ai_chat_provider.go:367
task extractUserInfoFromDubboAttachments services/taskService/provider/task_mobile_provider.go:35

5 份实现都做同一件事:从 attachment 或 metadata 取 user_id/star_idx-user-id/x-star-id/authorization),但没有写回 req 的能力,因此 provider 端继续读 req.*Id 时仍中招。

C. social 三条正确性

  • social_repository.go:461GetRandomUsersByStarrand.Int63n(total) + OFFSET 取连续段,非随机且可预测。
  • social_repository.go:648 (count)、:672 (data) — Where("a.deleted_at IS NULL AND a.is_active = ?", true).Where("((e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') != 'claimed') OR lbr.status = 'claimable'") — GORM 的多个 Where(...) 链式调用 会拼 AND,但第二个 Where 内部的 OR 没有显式分组,生成的 SQL 是 AND (X OR Y),而 OR 与前一个 Where 的预期是 AND X AND Y,优先级错位会让"展览已删除 + 押注已 claimable"的资产漏进列表。
  • social_repository.go:903, 923 — 同样 patternWhere("...deleted_at IS NULL...").Where("(e.id IS NULL OR e.deleted_at IS NULL) AND e.expire_at > ?") 同样 OR 优先级问题。
  • friend_service.go:680, 683starID := int64(0); // TODOCheckFriendship(req.UserId, req.FriendUserId, 0) 永远返回跨明星的聚合,好友关系隐私预言机

D. ValidateToken 路由

backend/gateway/router/router.go:151auth.POST("/validate", authCtrl.ValidateToken) 在公开 /auth 组,调用方无需登录即可验证任意 token 是否有效,配合 c.ShouldBindJSON(&req) 接受 {token: "..."},是探测 JWT 是否存在的低成本接口。auth_controller.go:289 的实现也确实只用 req.Token 字段。修复:移到 authProtected 组(已带 AuthMiddleware


File Structure

  • backend/pkg/authctx/新建包。统一身份提取与 req 覆盖。
    • backend/pkg/authctx/authctx.goExtractIdentity(ctx) (uid, sid int64, err error),合并 5 份散落副本gRPC metadata x-user-id/x-star-id 优先fallback 到 Dubbo constant.AttachmentKey)。
    • backend/pkg/authctx/authctx.goOverrideUser(req UserIDSetter, ctx) error / OverrideStar(...) / OverrideReporter(req ReporterIDSetter, ctx) / OverrideSharer(req SharerUserIDSetter, ctx):从 ctx 取身份覆盖 req 同名字段ctx 无身份则返回错误(拒绝继续走)。
    • backend/pkg/authctx/authctx_test.go — 单测覆盖 metadata 优先 / attachment fallback / req 已被攻击者改成 999 时仍被覆盖为 ctx 中的真实身份。
  • backend/services/userService/middleware/auth_interceptor.go:删 ExtractUserIDFromContext/ExtractStarIDFromContext/ValidateTokenAndExtractClaims(迁移到 pkg/authctx),保留 extractTokenFromMetadata 内部逻辑(或一并迁移)。
  • backend/services/notificationService/provider/notification_provider.go:删本地 extractUserInfo/parseIntValue/readInt64FromMD,改用 pkg/authctx.ExtractIdentity
  • backend/services/socialService/provider/social_provider.go:同上 + CheckFriendship/GetRandomUsersByStar 链路用覆盖后的 ctx。
  • backend/services/galleryService/provider/gallery_provider.go:同上。
  • backend/services/aiChatService/provider/ai_chat_provider.go:同上。
  • backend/services/taskService/provider/task_mobile_provider.go:同上。task_internal_provider.go(内部 RPC保留 req 透传,加注释说明。
  • backend/services/moderationService/provider/moderation_provider.go5 个 RPC 入口全部 OverrideReporter/OverrideUser,禁止读 req。
  • backend/services/moderationService/service/report_service.goSubmitReport 接收 reporterID 由 provider 显式传入service 内不再读 req.ReporterId
  • backend/services/moderationService/service/feedback_service.go:同上。
  • backend/services/assetService/provider/asset_provider.goCheckAssetLikeExtractIdentity + 覆盖 req。
  • backend/services/assetService/service/share_service.goGetAssetQrcode/TrackShare 接收 sharerUserID 由 provider 显式传入。
  • backend/services/assetService/provider/share_provider.go新建(若 proto 有 ShareService RPC或在 asset_provider.go 中加 GetAssetQrcode/TrackShare provider 方法(如已有 provider 文件则改)。明确从 ctx 取 sharer_user_id 覆盖 req.SharerUserId,禁止读 req。
  • backend/services/assetService/service/ranking_service.goreq.UserId 改为 ctx 注入参数(userID, starID int64 已由 provider 传入;内部全部用入参,不再读 req
  • backend/services/socialService/service/friend_service.goCheckFriendship 签名加 userID, starID int64provider 传),删除 TODO。
  • backend/services/socialService/repository/social_repository.go
    • GetRandomUsersByStarORDER BY random()(小表);保留 offset 实现作为 deprecated 备份但默认 random。
    • GetUserLikedAssets :642-650, 657-677GetMyWeekLikedAssets :897-907, 909-928 的 OR 改成显式分组:db.Where("a.deleted_at IS NULL AND a.is_active = ?", true).Where(db.Where("(e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') != 'claimed'").Or("lbr.status = ?", "claimable"))
  • backend/gateway/router/router.go:删除公开组的 auth.POST("/validate", ...),在 authProtected 组加 authProtected.POST("/validate", authCtrl.ValidateToken)
  • backend/gateway/controller/auth_controller.go:不动实现(已只读 req.Token),但加注释"受 AuthMiddleware 保护,调用方需已登录"。

Task 1: 抽 pkg/authctx 公共身份提取工具

Files:

  • Create: backend/pkg/authctx/authctx.go
  • Create: backend/pkg/authctx/authctx_test.go

Interfaces:

// pkg/authctx/authctx.go
package authctx

import (
    "context"
    "errors"
    "strconv"

    "dubbo.apache.org/dubbo-go/v3/common/constant"
    "google.golang.org/grpc/metadata"
)

type ctxKey int
const (
    userIDKey ctxKey = iota
    starIDKey
)

var (
    ErrIdentityMissing = errors.New("authctx: identity not found in context")
    ErrInvalidIdentity = errors.New("authctx: identity must be positive")
)

// ExtractIdentity returns (userID, starID) from the context.
//
// Priority:
//  1. gRPC metadata "x-user-id" / "x-star-id" (Dubbo Triple 把 HTTP header 转过来)
//  2. Dubbo attachments via constant.AttachmentKey, key "user_id" / "star_id"
//     (支持 string / int / int64 / []string / []interface{})
//
// 任何路径都拿不到则返回 ErrIdentityMissing。
// 拿到但 < 0 也算错;== 0 视作"未设",继续 fallback。
func ExtractIdentity(ctx context.Context) (int64, int64, error) {
    uid, sid := readFromGRPCMetadata(ctx)
    if uid > 0 && sid > 0 {
        return uid, sid, nil
    }
    uid2, sid2 := readFromDubboAttachments(ctx)
    if uid2 > 0 && sid2 > 0 {
        return uid2, sid2, nil
    }
    if uid == 0 && uid2 == 0 {
        return 0, 0, ErrIdentityMissing
    }
    if sid == 0 && sid2 == 0 {
        return 0, 0, ErrIdentityMissing
    }
    // uid 有但 sid 没fallback 合并
    if uid == 0 { uid = uid2 }
    if sid == 0 { sid = sid2 }
    if uid <= 0 || sid <= 0 {
        return 0, 0, ErrInvalidIdentity
    }
    return uid, sid, nil
}

// 必须有 user_idstar_id 可为 0比如内部 RPC 不需要 star
func ExtractUserID(ctx context.Context) (int64, error) { ... }

// 覆盖器:把 ctx 里的身份写回 req。req 必须实现对应的小接口,
// 编译期断言在调用方加。覆盖是"无条件的"——只要 ctx 有身份,就覆盖 req 同名字段。
type UserIDSetter interface{ SetUserID(int64) }
type StarIDSetter interface{ SetStarID(int64) }
type ReporterIDSetter interface{ SetReporterID(int64) }
type SharerUserIDSetter interface{ SetSharerUserID(int64) }

func OverrideUser(req UserIDSetter, ctx context.Context) error {
    uid, _, err := ExtractIdentity(ctx)
    if err != nil { return err }
    req.SetUserID(uid)
    return nil
}
func OverrideStar(req StarIDSetter, ctx context.Context) error { ... }
func OverrideReporter(req ReporterIDSetter, ctx context.Context) error { ... }
func OverrideSharer(req SharerUserIDSetter, ctx context.Context) error { ... }

// FromJWTContext 把 ParseToken 后的 (uid, sid) 灌进 ctx 给业务层用
func WithIdentity(ctx context.Context, uid, sid int64) context.Context { ... }
  • Step 1.1 写失败测试backend/pkg/authctx/authctx_test.go
    • 测试 ExtractIdentity
      • TestExtractIdentity_FromGRPCMetadata:构造 metadata.NewIncomingContext(ctx, metadata.Pairs("x-user-id", "100", "x-star-id", "200")),断言返回 (100, 200, nil)
      • TestExtractIdentity_FromDubboAttachments:构造 context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{"user_id": int64(100), "star_id": int64(200)}),断言同上。
      • TestExtractIdentity_Missing:空 ctx断言返回 ErrIdentityMissing
    • 测试覆盖器(用 mock 实现接口):
      • TestOverrideUser_OverridesAttackerValuectx 有真实身份 uid=42req 的 SetUserID(999) 表示"攻击者已塞 999",调用 OverrideUser 后断言 req 的真实值变 42。
      • TestOverrideReporter_WithoutCtxctx 无身份,调用 OverrideReporter 断言返回错误req 的 SetReporterID(7) 调用次数为 0未覆盖 req)。
  • Step 1.2 实现(如上接口),跑 cd backend && go test ./pkg/authctx/... 通过。
  • Step 1.3 cd backend && go build ./... 通过。

Task 2: 接入 moderationService最高风险举报冒用

Files:

  • Edit: backend/services/moderationService/provider/moderation_provider.go
  • Edit: backend/services/moderationService/service/report_service.go
  • Edit: backend/services/moderationService/service/feedback_service.go
  • Create (test): backend/services/moderationService/provider/moderation_provider_test.go

Interfaces:

report_service.go 新签名service 层也不再读 req 的身份):

// 旧SubmitReport(ctx, req)
// 新SubmitReport(ctx, reporterID, req)  // reporterID 由 provider 注入
func (s *ReportService) SubmitReport(ctx context.Context, reporterID int64, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error)
// 内部所有 req.ReporterId → reporterID
// ListMyReports(ctx, userID, status, page, pageSize) — 签名不变userID 由 provider 传入
// GetReport(ctx, userID, id) — 同上

feedback_service.go 同理:

SubmitFeedback(ctx, userID, starID, req)
ListMyFeedbacks(ctx, userID, status, page, pageSize)
GetFeedback(ctx, userID, id)

moderation_provider.go 每个 RPC

func (p *ModerationProvider) SubmitReport(ctx context.Context, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error) {
    uid, _, err := authctx.ExtractIdentity(ctx)
    if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") }
    return p.report.SubmitReport(ctx, uid, req)
}
func (p *ModerationProvider) ListMyReports(ctx context.Context, req *pb.ListMyReportsRequest) (*pb.ListMyReportsResponse, error) {
    uid, _, err := authctx.ExtractIdentity(ctx)
    if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") }
    return p.report.ListMyReports(ctx, uid, req.Status, int(req.Page), int(req.PageSize))
}
func (p *ModerationProvider) GetReport(ctx context.Context, req *pb.GetReportRequest) (*pb.GetReportResponse, error) {
    uid, _, err := authctx.ExtractIdentity(ctx)
    if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") }
    return p.report.GetReport(ctx, uid, req.Id)
}
// SubmitFeedback/ListMyFeedbacks/GetFeedback 同模式
  • Step 2.1 写失败测试 moderation_provider_test.go
    • TestSubmitReport_RejectsForgedReporterIdctx 有真实身份 uid=100req.ReporterId=999模拟攻击者伪造。调用 SubmitReport断言
      • 返回的 report 里 ReporterID == 100(不是 999
      • report_service.SubmitReport 被调用时收到的 reporterID 参数 == 100。
      • 用 mock ReportServicetestify mock拦截 SubmitReport 入参。
    • TestSubmitReport_NoIdentity_ReturnsUnauthenticatedctx 无身份,断言返回 codes.Unauthenticated
    • TestGetReport_PreventReadingOthersReportctx uid=100req.Id=被另一用户 (uid=200) 创建的 report id。断言 service.GetReport 入参是 (100, id)(不再用 req.ReporterId 作 userID 判 ownerservice 层已有的 report.ReporterID != userID → ErrReportNotFound 正确触发。
  • Step 2.2 改 report_service.go 签名:所有 req.ReporterIdreporterID 参数;GetReportuserID 参数;删除所有 req.ReporterId == ... 比较。
  • Step 2.3 改 feedback_service.go 签名同上userID/starID 由参数传入)。
  • Step 2.4 改 moderation_provider.go5 个 RPC 全部走 authctx.ExtractIdentity,不再读 req 身份。
  • Step 2.5 go test ./services/moderationService/... 通过 + go build ./... 通过。

Task 3: 接入 assetService 的 CheckAssetLike + ShareService

Files:

  • Edit: backend/services/assetService/provider/asset_provider.go:477-517CheckAssetLike
  • Edit: backend/services/assetService/service/share_service.goGetAssetQrcode / TrackShare
  • Edit/Add: backend/services/assetService/provider/ 中的 share provider如 proto 已定义 ShareService handler 则改对应文件;否则在 asset_provider.go 同文件加 wrapper

Interfaces:

asset_provider.go:

func (p *AssetProvider) CheckAssetLike(ctx context.Context, req *pb.CheckAssetLikeRequest) (*pb.CheckAssetLikeResponse, error) {
    uid, sid, err := authctx.ExtractIdentity(ctx)
    if err != nil {
        return &pb.CheckAssetLikeResponse{ Base: unauthBase() }, status.Error(codes.Unauthenticated, "identity required")
    }
    // req.UserId/StarId 一律不读
    isLiked, err := p.assetLikeService.CheckAssetLike(ctx, req.AssetId, uid, sid)
    ...
}

share_service.go:

// 旧GetAssetQrcode(ctx, req) — 内部读 req.SharerUserId
// 新GetAssetQrcode(ctx, sharerUserID, req) — provider 注入
func (s *ShareService) GetAssetQrcode(ctx context.Context, sharerUserID int64, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error)
// 内部所有 req.SharerUserId → sharerUserID
// TrackShare(ctx, sharerUserID, req) 同上

share provider如已有

func (p *ShareProvider) GetAssetQrcode(ctx context.Context, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error) {
    uid, _, err := authctx.ExtractIdentity(ctx)
    if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") }
    return p.shareSvc.GetAssetQrcode(ctx, uid, req)
}
  • Step 3.1 写失败测试 asset_provider_test.go
    • TestCheckAssetLike_RejectsForgedUserIdctx uid=100/sid=200req.UserId=999/req.StarId=888。断言调 service 时传入 (100, 200)。
    • TestGetAssetQrcode_RejectsForgedSharerctx uid=100req.SharerUserId=999。断言落盘 share_events.sharer_user_id == 100 且 OSS key 含 _100_
    • TestGetAssetQrcode_NoIdentityctx 无身份,断言返回 Unauthenticated。
  • Step 3.2 改 asset_provider.go CheckAssetLike:删 userID := req.UserId / starID := req.StarId,改 authctx.ExtractIdentity
  • Step 3.3 改 share_service.go 两个方法签名 + 内部所有 req.SharerUserIdsharerUserID
  • Step 3.4 改 share provider5 个 RPC如果有 GetAssetQrcode/TrackShare全走 ctx 注入。
  • Step 3.5 go test ./services/assetService/... 通过 + go build ./... 通过。

Task 4: 接入 socialService 的 CheckFriendshipstarID=0 修复)

Files:

  • Edit: backend/services/socialService/service/friend_service.go:665-700 (CheckFriendship)
  • Edit: backend/services/socialService/provider/social_provider.goCheckFriendship provider 段)
  • Edit: backend/services/socialService/repository/social_repository.go CheckFriendship(保留方法,签名补 starID;调用方全部用真 starID

Interfaces:

friend_service.go:

// 旧CheckFriendship(ctx, req) — req.UserId, starID=0
// 新CheckFriendship(ctx, userID, starID, friendUserID) — provider 注入
func (s *friendService) CheckFriendship(ctx context.Context, userID, starID, friendUserID int64) (*pb.CheckFriendshipResponse, error)

social_provider.go:

func (p *SocialProvider) CheckFriendship(ctx context.Context, req *pb.CheckFriendshipRequest) (*pb.CheckFriendshipResponse, error) {
    uid, sid, err := extractUserInfo(ctx) // 改用 authctx.ExtractIdentity
    if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") }
    if req.FriendUserId == 0 { return nil, status.Error(codes.InvalidArgument, "friend_user_id required") }
    return p.friendService.CheckFriendship(ctx, uid, sid, req.FriendUserId)
}

social_repository.go CheckFriendship(userID, friendUserID, starID int64) 签名已含 starIDfriend_service.go:683 调用已传 0无需大改——只需把调用点改为传真 sid。

  • Step 4.1 写失败测试 friend_service_test.go
    • TestCheckFriendship_UsesCtxStarIDctx uid=100/sid=200req.FriendUserId=200。断言调 socialRepo.CheckFriendship 时第 3 参数是 200 而非 0。
    • TestCheckFriendship_NoIdentityctx 无身份,断言 Unauthenticated。
  • Step 4.2 改 friend_service.go CheckFriendship 签名:删 starID := int64(0) // TODO,改 provider 注入。
  • Step 4.3 改 social_provider.go CheckFriendship provider 方法:走 ctx。
  • Step 4.4 go test ./services/socialService/... 通过 + go build ./... 通过。

Task 5: 接入 galleryService / taskService / aiChatService统一替换散落实现

Files:

  • Edit: backend/services/galleryService/provider/gallery_provider.go:462 删本地 extractUserInfoFromDubboAttachments,改 import pkg/authctx;所有调用点 extractUserInfoFromDubboAttachments(ctx)authctx.ExtractIdentity(ctx)gallery_provider.go:403req.UserIdtarget_uid是合法入参查询他人列表不覆盖,仅加注释"这是 target_uid 而非调用方身份"。

  • Edit: backend/services/aiChatService/provider/ai_chat_provider.go:367 同上。

  • Edit: backend/services/taskService/provider/task_mobile_provider.go:35 同上;task_internal_provider.go:32-48(内部 RPC保留 req 透传,但加注释"内部 RPC需由调用方保证 user_id 来自可信源"。

  • Edit: backend/services/notificationService/provider/notification_provider.go:161 同上。

  • Step 5.1 写失败测试(已有覆盖可跳过,新服务至少加 1 个):

    • gallery_provider_test.go::TestGetMyGallery_NoIdentityctx 无身份,断言 Unauthenticated。
  • Step 5.2 替换 5 个文件的本地 extractUserInfo*authctx.ExtractIdentity,删除已无用的 parseIntValue/readInt64FromMD 等内部辅助。

  • Step 5.3 go build ./... 通过。


Task 6: 删 userService/middleware/auth_interceptor.go 已迁移函数

Files:

  • Edit: backend/services/userService/middleware/auth_interceptor.go

  • Step 6.1 验证无外部引用:

    grep -rn "auth_interceptor\." backend/services --include="*.go"
    grep -rn "ExtractUserIDFromContext\|ExtractStarIDFromContext\|ExtractUserInfoFromContext\|ValidateTokenAndExtractClaims" backend --include="*.go"
    

    预期所有调用方都在 Task 1-5 已切到 pkg/authctx

  • Step 6.2 删除 ExtractUserIDFromContext/ExtractStarIDFromContext/ExtractUserInfoFromContext/ValidateTokenAndExtractClaims 函数本体,保留 extractTokenFromMetadata(作为内部 helper仅供 pkg/authctx 通过 parse JWT 时使用)或一并迁过去。需用户确认:是否要把 pkg/jwt.ParseToken 也挪进 pkg/authctx;默认保留原文件,把 extractTokenFromMetadata 迁移到 pkg/authctx 私有。

  • Step 6.3 go build ./... 通过。


Task 7: socialService — OR 子句括号优先级修复

Files:

  • Edit: backend/services/socialService/repository/social_repository.go
    • GetUserLikedAssets:642-650 count 和 :657-677 data 两个查询):(line 648 和 672)
    • GetMyWeekLikedAssets:897-907 count 和 :909-928 data(line 903 和 923)
  • Create (test): backend/services/socialService/repository/social_repository_test.go

Interfaces:

修复前(错):

db.Where("a.deleted_at IS NULL AND a.is_active = ?", true).
   Where("((e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') != 'claimed') OR lbr.status = 'claimable'")

修复后(显式分组 OR

sub := r.db.Where("(e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status, '') <> 'claimed'").
    Or("lbr.status = ?", "claimable")
db.Where("a.deleted_at IS NULL AND a.is_active = ?", true).
   Where(sub)

等价 SQL

WHERE a.deleted_at IS NULL AND a.is_active = $1
  AND (
    (e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') <> 'claimed'
    OR lbr.status = 'claimable'
  )

GetMyWeekLikedAssets 同样 patternOR 是单条件 e.expire_at > ?不需要修——它实际只有 AND没有错。但 line 903/923 的 (e.id IS NULL OR e.deleted_at IS NULL) AND e.expire_at > ? 整段嵌在外层 Where 链里,需要确认 GORM 行为:

  • gorm.io/gorm 测出当前行为:多个 Where(...) 链式会拼 AND,所以最终 SQL 是 ... AND <last_where>括号优先级没问题。但若担心(审计明确指出"OR 括号优先级问题,可能让已删除资产漏进点赞列表"),加显式分组保险。

实际可改:用 db.Where(sub) 显式取代第二个 Where(...),避免任何隐式 AND 拼接。

  • Step 7.1 写失败测试 social_repository_test.go
    • TestGetUserLikedAssets_ExcludesClaimedOnly:构造数据:资产 A 的 exhibition 已被软删 + lbr.status='claimed';资产 B 的 exhibition 软删 + lbr.status='claimable'。调用 GetUserLikedAssets,断言:返回 [B],不返回 A修复前 A 会因 OR 优先级问题漏进)。
    • TestGetMyWeekLikedAssets_ExcludesDeletedExhibition:构造数据:本周点赞 + 资产 active 但 exhibition 软删 + lbr 无 claimable。断言被排除。
  • Step 7.2 改 social_repository.go 两处 OR 为显式分组。
  • Step 7.3 go test ./services/socialService/... 通过。

Task 8: socialService — GetRandomUsersByStar 真随机

Files:

  • Edit: backend/services/socialService/repository/social_repository.go:461-515GetRandomUsersByStar

Interfaces:

修复前:

rand.Seed(time.Now().UnixNano())
randomOffset := rand.Int63n(total)
db.Order("id ASC").Limit(count).Offset(int(randomOffset))

修复后小表场景PostgreSQL TABLESAMPLE 不可控分布,直接用 ORDER BY random() + LIMIT

err = r.db.Model(&models.FanProfile{}).
    Select("user_id", "nickname").
    Where("star_id = ? AND is_active = ?", starID, true).
    Order("random()").
    Limit(count).
    Find(&profiles).Error

注:ORDER BY random() 在大表上性能差10 万+ 行)。当前 fan_profiles 在 star 维度规模可控(千级),可接受;若后续规模上升,切换 TABLESAMPLE SYSTEM (n) 或预生成 random_user_pool Redis 集合。

  • Step 8.1 写失败测试 social_repository_test.go
    • TestGetRandomUsersByStar_NotContinuousSegment:构造 100 个 fan_profile重复调用 GetRandomUsersByStar(starID, 5) 20 次,断言:返回的 (user_id) 集合不连续(修复前 OFFSET 取连续 5 个)。
    • TestGetRandomUsersByStar_ReproducibilityNotRequired:连续调用两次,结果应不同(修复前同纳秒随机种子相同)。
  • Step 8.2 改 social_repository.goOrder("random()"),删 rand.Seed/rand.Int63n/Offset
  • Step 8.3 go test ./services/socialService/... 通过。

Task 9: gateway — ValidateToken 移出公开 /auth

Files:

  • Edit: backend/gateway/router/router.go:144-179
  • Edit: backend/gateway/controller/auth_controller.go:280-312(仅注释)

Interfaces:

修复前 router.go:147-157

auth := v1.Group("/auth")
{
    auth.POST("/register", authCtrl.Register)
    ...
    auth.POST("/validate", authCtrl.ValidateToken)   // 公开!
    ...
}

修复后:

auth := v1.Group("/auth")
{
    auth.POST("/register", authCtrl.Register)
    auth.POST("/login", authCtrl.Login)
    // validate / refresh / logout 全部移到 authProtected
    auth.POST("/check-nickname", authCtrl.CheckNickname)
    auth.POST("/check-mobile", authCtrl.CheckMobile)
    auth.POST("/send-code", authCtrl.SendCode)
    auth.POST("/verify-code", authCtrl.VerifyCode)
    auth.POST("/reset-password", authCtrl.ResetPassword)
}

authProtected := v1.Group("/auth")
authProtected.Use(middleware.AuthMiddleware())
{
    authProtected.GET("/me", userCtrl.GetCurrentUser)
    authProtected.POST("/refresh", authCtrl.RefreshToken)
    authProtected.POST("/logout", authCtrl.Logout)
    authProtected.POST("/validate", authCtrl.ValidateToken)  // 受保护
}
  • Step 9.1 写失败测试gateway router 集成测试或 curl + 集成):
    • TestValidateTokenRoute_RequiresAuth:构造不带 token 的 HTTP POST /api/v1/auth/validate,断言 401。
    • TestValidateTokenRoute_WithToken_Succeeds:带有效 JWT断言 200 + 验证结果。
  • Step 9.2 改 router.go:从公开组移除 /validate,加入 authProtected 组。
  • Step 9.3 改 auth_controller.go ValidateToken:加注释"本接口已被 AuthMiddleware 保护"。
  • Step 9.4 go build ./... 通过 + 跑集成测试或本地 curl 验证。

Task 10: 全局回归 + lint 自审

  • Step 10.1 cd backend && go build ./... 通过。
  • Step 10.2 cd backend && go test ./... 全部通过mock 服务可能因 signature 变更需要 fix
  • Step 10.3 全局 grep 自审"未覆盖点"
    grep -rn "req.UserId\|req.StarId\|req.ReporterId\|req.SharerUserId" \
      backend/services/{moderation,asset,social,gallery,task,notification,aiChat}Service \
      --include="*.go" | grep -v "_test.go"
    
    预期:只剩
    • gallery_provider.go:403target_user_id(合法入参,注释已加)
    • task_internal_provider.go(内部 RPC注释已加
    • ranking_service.go(仅当 provider 已改用 ctx 注入并签名变更后,不再有 req.UserId 读点)
  • Step 10.4 端到端冒烟(本地 top-fans 库):
    • 启动 assetService + userService + gateway。
    • grpcurl(或脚本)伪造 RPCctx 不带 x-user-id,带 req.UserId=999,断言返回 Unauthenticated
    • 用合法 JWT 发起 SubmitReport,构造 req.ReporterId=888,断言落库 reports.reporter_id == JWT 中的真实 uid(不是 888
    • GET /api/v1/auth/validate 不带 token断言 401。
  • Step 10.5 文档同步:若有任何行为变更,更新 docs/specs/2026-07-21-backend-remediation-plan.md 批次 2 章节的"修复方案"为"已实施"。

Self-Review

CLAUDE.md 全局自审规则(章节通读清单 + 跨章节引用一致性)。

修改的章节(来自 docs/specs/2026-07-21-backend-remediation-plan.md

  • §二 批次 2 主条目("修复方案" 1/2/3→ Task 1/2-5/9 覆盖
  • §二 批次 2.1 social 三条 → Task 4/7/8 覆盖
  • §五 回归验证清单(伪造身份拦截、端口配置通过)→ Task 10 覆盖

未改动但通读的章节

  • §一 方案概述确认批次排序、关键决策、MVP 先行原则未被破坏——本批次未引入新的 Provider 抽象)
  • §二 批次 0/1/3/4确认未跨批次耦合本批次不动财务、不动 mint、不动 MQ端口/探针/starbook 决断未触)
  • §二 批次 5 路线图(确认"加 lint 禁止新增跨服务 import"未在本批次落地——属批次 5 治理)
  • §三 问题 → 修复映射表(核对 P0-3、§三 P1 social 三条均已映射)
  • §四 存量数据修复脚本规范(本批次无 DB 变更,无需 setval
  • §六 备注(与本批次兼容)

跨章节引用一致性

  • Task 1pkg/authctx)→ Task 2-5/65 份散落副本删除):一致
  • Task 2moderation service 签名变更)→ provider 必须传 userID/reporterIDTask 2.4):一致
  • Task 3share service 签名变更)→ provider 必须传 sharerUserIDTask 3.4):一致
  • Task 4friend_service.CheckFriendship 签名加 starID→ provider 已传Task 4.3):一致
  • Task 6删除 userService/middleware 的已迁函数)→ Task 1 的 pkg/authctx 替代Task 1.1):一致
  • Task 9router 移动 /validate)→ auth_controller 已只读 req.TokenTask 9.3 注释):一致

Go 编译验证清单

  • pkg/authctx 新包:导入路径 github.com/topfans/backend/pkg/authctx,需 backend/go.mod 已有 dubbo.apache.org/dubbo-go/v3google.golang.org/grpc(已有)。✓
  • provider 单测需 mock ReportService/ShareService/FriendService 等 interface用 testify mock已有 github.com/stretchr/testify)。✓
  • service 签名变更SubmitReport / SubmitFeedback / CheckFriendship / GetAssetQrcode / TrackShare会破坏现有调用方所有调用方都在被改的 provider 文件内grpc handler无外部 main.go 直接调 service。grep 验证:✓
  • Task 6 删除 ValidateTokenAndExtractClaims 等函数grep 无外部调用方userService 自用 + Task 1 替代)。✓

优先级

  • P0必做Task 1/2/3/4/95 个核心鉴权边界Task 7/8social 正确性审计明确列出)。
  • P1强烈建议Task 5替换散落副本不替换也能跑但违反代码一致性Task 6删除死代码否则 pkg/authctx 与 userService/middleware 双重实现不一致)。
  • P2可推迟Task 10.5 文档同步。

风险

  • share_service.go:246 TrackShare 之前依赖 req.ClientTs(客户端时间戳),不变;只换身份来源。
  • asset_provider.go:483 CheckAssetLike 当前调用 p.assetLikeService.CheckAssetLike(ctx, req.AssetId, userID, starID),签名一致;改 ctx 取值不影响下游。
  • social_provider.go 有 10+ 处 extractUserInfo(ctx) 调用Task 5.2 批量替换即可。
  • feedback_service.go 内部 req.StarId > 0 判断(:64)是给 star 可选的反馈业务Task 2.3 改成参数后保留 starID > 0 判断。

验证检查清单

  • 提交 go build ./... 无 error
  • 全部 service go test ./... 通过
  • grep -rn "req.UserId\|req.StarId\|req.ReporterId\|req.SharerUserId" backend/services/{moderation,asset,social}/... 只剩注释 + 合法入参
  • grpcurl 伪造身份 RPC 返回 Unauthenticated
  • /api/v1/auth/validate 不带 token 返回 401
  • social_repository.GetRandomUsersByStar 重复调用结果不连续
  • exhibition 软删 + lbr.status='claimed' 的资产不再出现在点赞列表

待用户确认的决策点

  1. 是否把 pkg/jwt.ParseToken 调用也搬到 pkg/authctx(默认保留在原处,authctx 只做 metadata 提取与覆盖)。
  2. GetRandomUsersByStar 真随机策略ORDER BY random() vs 预生成 Redis 随机池。默认 random(),小表 OK。
  3. ValidateToken 接口设计:保留接受 {token: "..."} body 的 RPC仅要求已登录才能调vs 改成基于调用方自身 JWT 自动校验。默认保留 body 形式(向后兼容)。

不在本次范围

  • 内部 RPC mTLS / 签名(批次 5 治理)
  • 跨服务 import 限制(批次 5
  • JWT 全局密钥治理(批次 3.5
  • Login 限流/枚举修复(批次 3.2

备注

  • 本 plan 严格遵循 CLAUDE.md 的"接口开发规范"分层、DTO、错误码、日志、测试
  • 批次 2 优先级低于批次 1财务资损但 P0-3 越权属于安全 P0可与批次 1 并行(无文件冲突)。
  • 任何 service 签名变更Task 2/3/4都属内部重构不动 proto 契约,向后兼容。
  • 所有 commit 步骤需用户明确指示。