38 KiB
鉴权边界 (批次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 双重核对,基线 commitb7f8f1b724e5。
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 metadatax-user-id/x-star-id优先,fallback 到 Dubboconstant.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 有ShareServiceRPC)或在asset_provider.go中加GetAssetQrcode/TrackShareprovider 方法(如已有 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:
// 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 的身份):
// 旧: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_RejectsForgedReporterId:ctx 有真实身份uid=100,req.ReporterId=999(模拟攻击者伪造)。调用 SubmitReport,断言:- 返回的 report 里
ReporterID == 100(不是 999)。 report_service.SubmitReport被调用时收到的 reporterID 参数 == 100。- 用 mock
ReportService(testify mock)拦截SubmitReport入参。
- 返回的 report 里
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:
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_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.goCheckAssetLike:删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(CheckFriendshipprovider 段) - Edit:
backend/services/socialService/repository/social_repository.goCheckFriendship(保留方法,签名补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) 签名已含 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.goCheckFriendship签名:删starID := int64(0) // TODO,改 provider 注入。 - Step 4.3 改
social_provider.goCheckFriendship 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,改 importpkg/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 验证无外部引用:
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-650count 和:657-677data 两个查询):(line 648 和 672)GetMyWeekLikedAssets(:897-907count 和:909-928data):(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 同样 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 <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-515(GetRandomUsersByStar)
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_poolRedis 集合。
- 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:
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.goValidateToken:加注释"本接口已被 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: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:246TrackShare之前依赖req.ClientTs(客户端时间戳),不变;只换身份来源。asset_provider.go:483CheckAssetLike当前调用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 返回 401social_repository.GetRandomUsersByStar重复调用结果不连续exhibition软删 + lbr.status='claimed' 的资产不再出现在点赞列表
待用户确认的决策点
- 是否把
pkg/jwt.ParseToken调用也搬到pkg/authctx(默认保留在原处,authctx只做 metadata 提取与覆盖)。 GetRandomUsersByStar真随机策略:ORDER BY random()vs 预生成 Redis 随机池。默认random(),小表 OK。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 步骤需用户明确指示。