# 鉴权边界 (批次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 条正确性/隐私 bug;`ValidateToken` 从公开 `/auth` 组移除。 **Architecture:** 在 `backend/pkg/authctx/` 沉淀统一的身份提取与覆盖工具(替代 5 处散落的 `extractUserInfoFromDubboAttachments`/`extractUserInfo` 副本);逐个 provider(moderation / 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 Triple(gRPC metadata + attachments),GORM(社交 repository),PostgreSQL;本地库 `top-fans`@`localhost:15432`。 ## Global Constraints - **不引入新依赖**:用现有的 `dubbo-go/constant.AttachmentKey`、`grpc/metadata`、`pkg/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_id`(`x-user-id`/`x-star-id`/`authorization`),但**没有**写回 `req` 的能力,因此 provider 端继续读 `req.*Id` 时仍中招。 ### C. social 三条正确性 - `social_repository.go:461` — `GetRandomUsersByStar`:`rand.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` — 同样 pattern:`Where("...deleted_at IS NULL...").Where("(e.id IS NULL OR e.deleted_at IS NULL) AND e.expire_at > ?")` 同样 OR 优先级问题。 - `friend_service.go:680, 683` — `starID := int64(0); // TODO` → `CheckFriendship(req.UserId, req.FriendUserId, 0)` 永远返回跨明星的聚合,**好友关系隐私预言机**。 ### D. `ValidateToken` 路由 `backend/gateway/router/router.go:151` — `auth.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.go` — `ExtractIdentity(ctx) (uid, sid int64, err error)`,合并 5 份散落副本(gRPC metadata `x-user-id`/`x-star-id` 优先,fallback 到 Dubbo `constant.AttachmentKey`)。 - `backend/pkg/authctx/authctx.go` — `OverrideUser(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.go` — **改**:5 个 RPC 入口全部 `OverrideReporter`/`OverrideUser`,禁止读 req。 - `backend/services/moderationService/service/report_service.go` — **改**:`SubmitReport` 接收 `reporterID` 由 provider 显式传入,service 内不再读 `req.ReporterId`。 - `backend/services/moderationService/service/feedback_service.go` — **改**:同上。 - `backend/services/assetService/provider/asset_provider.go` — **改**:`CheckAssetLike` 改 `ExtractIdentity` + 覆盖 req。 - `backend/services/assetService/service/share_service.go` — **改**:`GetAssetQrcode`/`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.go` — **改**:`req.UserId` 改为 ctx 注入参数(`userID, starID int64` 已由 provider 传入;内部全部用入参,不再读 req)。 - `backend/services/socialService/service/friend_service.go` — **改**:`CheckFriendship` 签名加 `userID, starID int64`(provider 传),删除 TODO。 - `backend/services/socialService/repository/social_repository.go` — **改**: - `GetRandomUsersByStar` 用 `ORDER BY random()`(小表);保留 offset 实现作为 deprecated 备份但默认 random。 - `GetUserLikedAssets` `:642-650, 657-677`、`GetMyWeekLikedAssets` `: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:** ```go // 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_id(star_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_OverridesAttackerValue`:ctx 有真实身份 `uid=42`,req 的 `SetUserID(999)` 表示"攻击者已塞 999",调用 `OverrideUser` 后断言 req 的真实值变 42。 - `TestOverrideReporter_WithoutCtx`:ctx 无身份,调用 `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 的身份): ```go // 旧: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` 同理: ```go SubmitFeedback(ctx, userID, starID, req) ListMyFeedbacks(ctx, userID, status, page, pageSize) GetFeedback(ctx, userID, id) ``` `moderation_provider.go` 每个 RPC: ```go 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_RejectsForgedReporterId`:ctx 有真实身份 `uid=100`,req.ReporterId=999(模拟攻击者伪造)。调用 SubmitReport,断言: - 返回的 report 里 `ReporterID == 100`(不是 999)。 - `report_service.SubmitReport` 被调用时收到的 reporterID 参数 == 100。 - 用 mock `ReportService`(testify mock)拦截 `SubmitReport` 入参。 - `TestSubmitReport_NoIdentity_ReturnsUnauthenticated`:ctx 无身份,断言返回 `codes.Unauthenticated`。 - `TestGetReport_PreventReadingOthersReport`:ctx uid=100,req.Id=被另一用户 (uid=200) 创建的 report id。断言 service.GetReport 入参是 (100, id)(不再用 req.ReporterId 作 userID 判 owner),service 层已有的 `report.ReporterID != userID → ErrReportNotFound` 正确触发。 - [ ] **Step 2.2 改 `report_service.go` 签名**:所有 `req.ReporterId` → `reporterID` 参数;`GetReport` 加 `userID` 参数;删除所有 `req.ReporterId == ...` 比较。 - [ ] **Step 2.3 改 `feedback_service.go` 签名**:同上(userID/starID 由参数传入)。 - [ ] **Step 2.4 改 `moderation_provider.go`**:5 个 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-517`(CheckAssetLike) - Edit: `backend/services/assetService/service/share_service.go`(GetAssetQrcode / TrackShare) - Edit/Add: `backend/services/assetService/provider/` 中的 share provider(如 proto 已定义 ShareService handler 则改对应文件;否则在 `asset_provider.go` 同文件加 wrapper) **Interfaces:** `asset_provider.go`: ```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`: ```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(如已有): ```go 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_RejectsForgedUserId`:ctx uid=100/sid=200,req.UserId=999/req.StarId=888。断言调 service 时传入 (100, 200)。 - `TestGetAssetQrcode_RejectsForgedSharer`:ctx uid=100,req.SharerUserId=999。断言落盘 `share_events.sharer_user_id == 100` 且 OSS key 含 `_100_`。 - `TestGetAssetQrcode_NoIdentity`:ctx 无身份,断言返回 Unauthenticated。 - [ ] **Step 3.2 改 `asset_provider.go`** `CheckAssetLike`:删 `userID := req.UserId` / `starID := req.StarId`,改 `authctx.ExtractIdentity`。 - [ ] **Step 3.3 改 `share_service.go`** 两个方法签名 + 内部所有 `req.SharerUserId` → `sharerUserID`。 - [ ] **Step 3.4 改 share provider**:5 个 RPC(如果有 GetAssetQrcode/TrackShare)全走 ctx 注入。 - [ ] **Step 3.5** `go test ./services/assetService/...` 通过 + `go build ./...` 通过。 --- ## Task 4: 接入 socialService 的 `CheckFriendship`(starID=0 修复) **Files:** - Edit: `backend/services/socialService/service/friend_service.go:665-700` (CheckFriendship) - Edit: `backend/services/socialService/provider/social_provider.go`(`CheckFriendship` provider 段) - Edit: `backend/services/socialService/repository/social_repository.go` `CheckFriendship`(保留方法,签名补 `starID`;调用方全部用真 starID) **Interfaces:** `friend_service.go`: ```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`: ```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)` 签名已含 starID(`friend_service.go:683` 调用已传 0),无需大改——只需把调用点改为传真 sid。 - [ ] **Step 4.1 写失败测试** `friend_service_test.go`: - `TestCheckFriendship_UsesCtxStarID`:ctx uid=100/sid=200,req.FriendUserId=200。断言调 `socialRepo.CheckFriendship` 时第 3 参数是 200 而非 0。 - `TestCheckFriendship_NoIdentity`:ctx 无身份,断言 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:403` 的 `req.UserId`(target_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_NoIdentity`:ctx 无身份,断言 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** 验证无外部引用: ```bash 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:** 修复前(错): ```go 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): ```go 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: ```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` 同样 pattern(OR 是单条件 `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 `,**括号优先级没问题**。但若担心(审计明确指出"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-515`(`GetRandomUsersByStar`) **Interfaces:** 修复前: ```go rand.Seed(time.Now().UnixNano()) randomOffset := rand.Int63n(total) db.Order("id ASC").Limit(count).Offset(int(randomOffset)) ``` 修复后(小表场景,PostgreSQL `TABLESAMPLE` 不可控分布,直接用 `ORDER BY random()` + `LIMIT`): ```go 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.go`** 用 `Order("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`: ```go auth := v1.Group("/auth") { auth.POST("/register", authCtrl.Register) ... auth.POST("/validate", authCtrl.ValidateToken) // 公开! ... } ``` 修复后: ```go 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 自审"未覆盖点": ```bash 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:403` 的 `target_user_id`(合法入参,注释已加) - `task_internal_provider.go`(内部 RPC,注释已加) - `ranking_service.go`(仅当 provider 已改用 ctx 注入并签名变更后,不再有 `req.UserId` 读点) - [ ] **Step 10.4** 端到端冒烟(本地 `top-fans` 库): - 启动 assetService + userService + gateway。 - 用 `grpcurl`(或脚本)伪造 RPC:ctx 不带 `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 1(`pkg/authctx`)→ Task 2-5/6(5 份散落副本删除):一致 - Task 2(moderation service 签名变更)→ provider 必须传 userID/reporterID(Task 2.4):一致 - Task 3(share service 签名变更)→ provider 必须传 sharerUserID(Task 3.4):一致 - Task 4(friend_service.CheckFriendship 签名加 starID)→ provider 已传(Task 4.3):一致 - Task 6(删除 userService/middleware 的已迁函数)→ Task 1 的 `pkg/authctx` 替代(Task 1.1):一致 - Task 9(router 移动 `/validate`)→ auth_controller 已只读 `req.Token`(Task 9.3 注释):一致 ### Go 编译验证清单 - `pkg/authctx` 新包:导入路径 `github.com/topfans/backend/pkg/authctx`,需 `backend/go.mod` 已有 `dubbo.apache.org/dubbo-go/v3` 和 `google.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/9(5 个核心鉴权边界);Task 7/8(social 正确性审计明确列出)。 - **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 步骤需用户明确指示。