fix(file): 修复商品详情截图与提现打款凭证等私有图片 403 权限问题

- 放宽公开商品截图 key 提取规则,支持任意合规 object key 转换为免鉴权公开端点
- 补充后台截图上传 listing_screenshot 与 operating-expense 场景识别
- dependencies 授权器支持已审核上架商品截图兜底放行、提现人打款凭证鉴权及变体后缀匹配
- /api/files 路由组支持用户与管理员双重鉴权 (AuthUserOrAdmin)
- 前端收款账号凭证与提现详情凭证统一升级为 AuthImage 组件并支持大图预览
This commit is contained in:
yml2213
2026-09-16 20:33:09 +08:00
parent 64a4e5bb16
commit 69774458b0
13 changed files with 215 additions and 27 deletions
+67
View File
@@ -96,6 +96,73 @@ func Auth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.Hand
}
}
// AuthUserOrAdmin 允许普通用户或管理员访问共享资源(如私有文件流)。
func AuthUserOrAdmin(jwtManager *auth.JWTManager, userValidate UserTokenValidatorFunc, adminValidate AdminTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 优先尝试管理员鉴权(Header Bearer 或 Admin Cookie)
if tokenText, tokenSource := extractAdminToken(c); tokenText != "" {
if claims, err := jwtManager.ParseSubject(tokenText, "access", "admin"); err == nil {
username := claims.Phone
passwordMustChange := false
validAdmin := true
if adminValidate != nil {
tokenContext, err := adminValidate(c.Request.Context(), claims.UserID, claims.TokenVersion)
if err != nil {
RecordAdminAuthFailure(c, "invalid", tokenSource, 0, 0)
validAdmin = false
} else {
username = tokenContext.Username
passwordMustChange = tokenContext.PasswordMustChange
}
}
if validAdmin {
c.Set(ContextAdminID, claims.UserID)
c.Request = c.Request.WithContext(logging.WithAdminID(c.Request.Context(), claims.UserID))
c.Set(ContextUsername, username)
c.Set(ContextPasswordMustChange, passwordMustChange)
c.Next()
return
}
}
}
// 2. 尝试普通用户鉴权(Header Bearer 或 User Cookie)
tokenText := extractBearerToken(c)
if tokenText == "" {
if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
}
}
if tokenText == "" {
response.Unauthorized(c, "缺少访问令牌")
c.Abort()
return
}
claims, err := jwtManager.ParseSubject(tokenText, "access", "user")
if err != nil {
response.Unauthorized(c, "访问令牌无效或已过期")
c.Abort()
return
}
if userValidate != nil {
if err := userValidate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
if errors.Is(err, auth.ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
} else {
response.Unauthorized(c, "登录状态已失效,请重新登录")
}
c.Abort()
return
}
}
c.Set(ContextUserID, claims.UserID)
c.Set(ContextPhone, claims.Phone)
c.Next()
}
}
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
tokenText, tokenSource := extractAdminToken(c)
+5 -1
View File
@@ -118,8 +118,12 @@ func fileURLForScene(scene string, key string) string {
func normalizeScene(scene string) string {
scene = strings.TrimSpace(strings.ToLower(scene))
switch scene {
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement":
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement", "operating-expense":
return scene
case "listing_screenshot", "listing-screenshot":
return "listing"
case "operating_expense":
return "operating-expense"
default:
return "misc"
}
@@ -76,3 +76,19 @@ func TestCooperationFeedbackSceneUsesPublicFileURL(t *testing.T) {
t.Fatalf("cooperation-feedback file url = %q, want public file endpoint", got)
}
}
func TestNormalizeSceneListingScreenshotAndOperatingExpense(t *testing.T) {
if got := normalizeScene("listing_screenshot"); got != "listing" {
t.Fatalf("normalize listing_screenshot scene = %q, want listing", got)
}
if got := normalizeScene("listing-screenshot"); got != "listing" {
t.Fatalf("normalize listing-screenshot scene = %q, want listing", got)
}
if got := normalizeScene("operating-expense"); got != "operating-expense" {
t.Fatalf("normalize operating-expense scene = %q, want operating-expense", got)
}
if got := normalizeScene("operating_expense"); got != "operating-expense" {
t.Fatalf("normalize operating_expense scene = %q, want operating-expense", got)
}
}
@@ -452,11 +452,11 @@ func extractListingObjectKey(fileURL string) string {
if err != nil {
return ""
}
key := parsed.Query().Get("key")
if key == "" {
if !strings.HasSuffix(parsed.Path, "/files/object") {
return ""
}
if !strings.HasPrefix(key, "listing/") || strings.Contains(key, "..") {
key := strings.TrimSpace(parsed.Query().Get("key"))
if key == "" || strings.Contains(key, "..") || strings.HasPrefix(key, "/") || strings.HasPrefix(key, "\\") {
return ""
}
return key
@@ -769,6 +769,44 @@ func TestApplyPublicScreenshotGroups(t *testing.T) {
}
}
func TestPublicScreenshotURLsWithMiscAndListingKeys(t *testing.T) {
rawURLs := []string{
"/api/files/object?key=misc%2F2026%2F09%2F16%2F984a80162e94e3b89090d1f8.webp",
"/api/files/object?key=listing%2Fnormal.webp",
"https://example.com/external.jpg",
}
publicURLs := publicScreenshotURLs(29969, rawURLs, "published", "approved")
if len(publicURLs) != 3 {
t.Fatalf("expected 3 public URLs, got %d", len(publicURLs))
}
if publicURLs[0] != "/api/listings/29969/screenshots/0" {
t.Fatalf("expected /api/listings/29969/screenshots/0, got %q", publicURLs[0])
}
if publicURLs[1] != "/api/listings/29969/screenshots/1" {
t.Fatalf("expected /api/listings/29969/screenshots/1, got %q", publicURLs[1])
}
if publicURLs[2] != "https://example.com/external.jpg" {
t.Fatalf("external URL should remain unchanged, got %q", publicURLs[2])
}
coverURL := publicCoverURL(29969, rawURLs, "published", "approved")
if coverURL != "/api/listings/29969/cover" {
t.Fatalf("expected /api/listings/29969/cover, got %q", coverURL)
}
summary := map[string]any{
"screenshot_groups": map[string]any{
"coin": []string{"/api/files/object?key=misc%2F2026%2F09%2F16%2F984a80162e94e3b89090d1f8.webp"},
},
}
applyPublicScreenshotGroups(29969, summary, rawURLs, publicURLs)
groups := summary["screenshot_groups"].(map[string]any)
coinList := groups["coin"].([]string)
if len(coinList) != 1 || coinList[0] != "/api/listings/29969/screenshots/0" {
t.Fatalf("expected mapped /api/listings/29969/screenshots/0, got %#v", coinList)
}
}
func TestUpdateScreenshots(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
+38 -14
View File
@@ -25,6 +25,17 @@ func privateFileAuthorizer(db *gorm.DB) func(context.Context, uint64, string) (b
return false, nil
}
encodedKey := url.QueryEscape(key)
// 若请求的是变体图片(.medium.jpg 或 .thumb.jpg),提取原始前缀,便于在存有原图的业务表中匹配
basePrefix := key
for _, suffix := range []string{".medium.jpg", ".thumb.jpg"} {
if strings.HasSuffix(basePrefix, suffix) {
basePrefix = strings.TrimSuffix(basePrefix, suffix)
break
}
}
encodedBasePrefix := url.QueryEscape(basePrefix)
var count int64
queries := []struct {
sql string
@@ -36,50 +47,63 @@ func privateFileAuthorizer(db *gorm.DB) func(context.Context, uint64, string) (b
},
{
sql: `SELECT COUNT(1) FROM game_accounts a
JOIN rental_listings l ON l.account_id = a.id
WHERE l.status = 'published' AND l.review_status = 'approved'
AND (INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0)`,
args: []any{key, encodedKey, basePrefix, encodedBasePrefix},
},
{
sql: `SELECT COUNT(1) FROM game_accounts a
WHERE a.owner_id = ?
AND (INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0)`,
args: []any{userID, key, encodedKey},
AND (INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0)`,
args: []any{userID, key, encodedKey, basePrefix, encodedBasePrefix},
},
{
sql: `SELECT COUNT(1) FROM game_accounts a
JOIN rental_orders o ON o.account_id = a.id
WHERE (o.owner_id = ? OR o.renter_id = ?)
AND (INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0)`,
args: []any{userID, userID, key, encodedKey},
AND (INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0)`,
args: []any{userID, userID, key, encodedKey, basePrefix, encodedBasePrefix},
},
{
sql: `SELECT COUNT(1) FROM order_checkouts c
JOIN rental_orders o ON o.id = c.order_id
WHERE (o.owner_id = ? OR o.renter_id = ?)
AND (INSTR(c.evidence_urls, ?) > 0 OR INSTR(c.evidence_urls, ?) > 0)`,
args: []any{userID, userID, key, encodedKey},
AND (INSTR(c.evidence_urls, ?) > 0 OR INSTR(c.evidence_urls, ?) > 0 OR INSTR(c.evidence_urls, ?) > 0 OR INSTR(c.evidence_urls, ?) > 0)`,
args: []any{userID, userID, key, encodedKey, basePrefix, encodedBasePrefix},
},
{
sql: `SELECT COUNT(1) FROM disputes d
JOIN rental_orders o ON o.id = d.order_id
WHERE (o.owner_id = ? OR o.renter_id = ?)
AND (INSTR(d.evidence_urls, ?) > 0 OR INSTR(d.evidence_urls, ?) > 0)`,
args: []any{userID, userID, key, encodedKey},
AND (INSTR(d.evidence_urls, ?) > 0 OR INSTR(d.evidence_urls, ?) > 0 OR INSTR(d.evidence_urls, ?) > 0 OR INSTR(d.evidence_urls, ?) > 0)`,
args: []any{userID, userID, key, encodedKey, basePrefix, encodedBasePrefix},
},
{
sql: `SELECT COUNT(1) FROM handoff_records h
JOIN rental_orders o ON o.id = h.order_id
WHERE (o.owner_id = ? OR o.renter_id = ?)
AND (INSTR(h.attachment_urls, ?) > 0 OR INSTR(h.attachment_urls, ?) > 0)`,
args: []any{userID, userID, key, encodedKey},
AND (INSTR(h.attachment_urls, ?) > 0 OR INSTR(h.attachment_urls, ?) > 0 OR INSTR(h.attachment_urls, ?) > 0 OR INSTR(h.attachment_urls, ?) > 0)`,
args: []any{userID, userID, key, encodedKey, basePrefix, encodedBasePrefix},
},
{
sql: `SELECT COUNT(1) FROM chat_messages m
JOIN chat_participants p ON p.conversation_id = m.conversation_id
WHERE p.participant_type = 'user' AND p.participant_id = ?
AND (INSTR(m.attachment_urls, ?) > 0 OR INSTR(m.attachment_urls, ?) > 0)`,
args: []any{userID, key, encodedKey},
AND (INSTR(m.attachment_urls, ?) > 0 OR INSTR(m.attachment_urls, ?) > 0 OR INSTR(m.attachment_urls, ?) > 0 OR INSTR(m.attachment_urls, ?) > 0)`,
args: []any{userID, key, encodedKey, basePrefix, encodedBasePrefix},
},
{
sql: `SELECT COUNT(1) FROM user_payment_accounts p
WHERE p.user_id = ?
AND (INSTR(p.certificate_urls, ?) > 0 OR INSTR(p.certificate_urls, ?) > 0)`,
args: []any{userID, key, encodedKey},
AND (INSTR(p.certificate_urls, ?) > 0 OR INSTR(p.certificate_urls, ?) > 0 OR INSTR(p.certificate_urls, ?) > 0 OR INSTR(p.certificate_urls, ?) > 0)`,
args: []any{userID, key, encodedKey, basePrefix, encodedBasePrefix},
},
{
sql: `SELECT COUNT(1) FROM withdrawal_requests w
WHERE w.user_id = ?
AND (INSTR(w.payment_proof_url, ?) > 0 OR INSTR(w.payment_proof_url, ?) > 0 OR INSTR(w.payment_proof_url, ?) > 0 OR INSTR(w.payment_proof_url, ?) > 0)`,
args: []any{userID, key, encodedKey, basePrefix, encodedBasePrefix},
},
}
for _, query := range queries {
+17 -1
View File
@@ -39,6 +39,18 @@ func TestPrivateFileAuthorizer(t *testing.T) {
"VALUES (1, 41, 'alipay', '测试户名', 'test@example.com', ?)", `["/api/files/object?key=payment-cert%2Fqr.jpg"]`).Error; err != nil {
t.Fatalf("create payment account error = %v", err)
}
if err := db.Exec("INSERT INTO game_accounts (id, owner_id, server_region, login_platform, title, screenshot_urls) "+
"VALUES (3, 61, '国服', 'steam', '上架商品', ?)", `["/api/files/object?key=misc%2F2026%2F09%2F16%2F984a80162e94e3b89090d1f8.webp"]`).Error; err != nil {
t.Fatalf("create published account error = %v", err)
}
if err := db.Exec("INSERT INTO rental_listings (id, listing_no, account_id, owner_id, status, review_status) " +
"VALUES (3, 'LIS-3', 3, 61, 'published', 'approved')").Error; err != nil {
t.Fatalf("create published listing error = %v", err)
}
if err := db.Exec("INSERT INTO withdrawal_requests (id, withdraw_no, user_id, amount_cent, actual_amount_cent, account_type, account_name, account_no, status, payment_proof_url) " +
"VALUES (1, 'W-1', 51, 1000, 990, 'alipay', '张三', 'test@example.com', 'paid', '/api/files/object?key=payment-proof%2Freceipt.jpg')").Error; err != nil {
t.Fatalf("create withdrawal request error = %v", err)
}
authorize := privateFileAuthorizer(db)
cases := []struct {
@@ -52,7 +64,11 @@ func TestPrivateFileAuthorizer(t *testing.T) {
{name: "订单参与人可读取截图", userID: 22, key: "listing/account.jpg", allowed: true},
{name: "聊天成员可读取附件", userID: 31, key: "chat/message.jpg", allowed: true},
{name: "收款账号所有者可读取凭证", userID: 41, key: "payment-cert/qr.jpg", allowed: true},
{name: "无关联用户被拒绝", userID: 99, key: "listing/account.jpg", allowed: false},
{name: "无关联用户不可读取未上架商品截图", userID: 99, key: "listing/no-order.jpg", allowed: false},
{name: "已审核上架商品截图对任意登录用户开放", userID: 99, key: "misc/2026/09/16/984a80162e94e3b89090d1f8.webp", allowed: true},
{name: "已审核上架商品中等图变体对任意登录用户开放", userID: 99, key: "misc/2026/09/16/984a80162e94e3b89090d1f8.medium.jpg", allowed: true},
{name: "提现申请人可读取自己的打款凭证", userID: 51, key: "payment-proof/receipt.jpg", allowed: true},
{name: "其他用户不可读取他人的打款凭证", userID: 99, key: "payment-proof/receipt.jpg", allowed: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+2 -1
View File
@@ -418,6 +418,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
}
}
requireAdmin := middleware.AdminAuth(jwtManager, validateAdminToken)
requireFileAuth := middleware.AuthUserOrAdmin(jwtManager, validateUserToken, validateAdminToken)
requireRealname := middleware.RequireRealname(userRepo)
requirePerm := func(code string) gin.HandlerFunc {
return middleware.RequirePermission(code, deps.Redis)
@@ -570,7 +571,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
withdrawalRoutes.POST("/:id/cancel", withdrawalHandler.Cancel)
}
fileRoutes := api.Group("/files", requireAuth)
fileRoutes := api.Group("/files", requireFileAuth)
{
fileRoutes.POST("/upload", fileHandler.Upload)
fileRoutes.GET("/object", fileHandler.Object)