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

614 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 鉴权边界 (批次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` 副本);逐个 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.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_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_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=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.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=200req.UserId=999/req.StarId=888。断言调 service 时传入 (100, 200)。
- `TestGetAssetQrcode_RejectsForgedSharer`ctx uid=100req.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=200req.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` 同样 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-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`(或脚本)伪造 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 1`pkg/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.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/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 步骤需用户明确指示。