From 69774458b0bae526b71796589a9bf35c16594017 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 16 Sep 2026 20:33:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(file):=20=E4=BF=AE=E5=A4=8D=E5=95=86?= =?UTF-8?q?=E5=93=81=E8=AF=A6=E6=83=85=E6=88=AA=E5=9B=BE=E4=B8=8E=E6=8F=90?= =?UTF-8?q?=E7=8E=B0=E6=89=93=E6=AC=BE=E5=87=AD=E8=AF=81=E7=AD=89=E7=A7=81?= =?UTF-8?q?=E6=9C=89=E5=9B=BE=E7=89=87=20403=20=E6=9D=83=E9=99=90=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 放宽公开商品截图 key 提取规则,支持任意合规 object key 转换为免鉴权公开端点 - 补充后台截图上传 listing_screenshot 与 operating-expense 场景识别 - dependencies 授权器支持已审核上架商品截图兜底放行、提现人打款凭证鉴权及变体后缀匹配 - /api/files 路由组支持用户与管理员双重鉴权 (AuthUserOrAdmin) - 前端收款账号凭证与提现详情凭证统一升级为 AuthImage 组件并支持大图预览 --- backend/internal/middleware/auth.go | 67 +++++++++++++++++++ backend/internal/modules/file/service.go | 6 +- backend/internal/modules/file/storage_test.go | 16 +++++ backend/internal/modules/listing/presenter.go | 6 +- .../internal/modules/listing/service_test.go | 38 +++++++++++ backend/internal/router/dependencies.go | 52 ++++++++++---- backend/internal/router/dependencies_test.go | 18 ++++- backend/internal/router/router.go | 3 +- .../admin/components/ImageEditorDialog.vue | 2 +- .../components/WithdrawalDetailDialog.vue | 10 ++- .../components/PaymentAccountDialog.vue | 11 ++- .../wallet/views/MobileWithdrawalView.css | 4 +- .../wallet/views/MobileWithdrawalView.vue | 9 ++- 13 files changed, 215 insertions(+), 27 deletions(-) diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index fd15261..eec9e6d 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -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) diff --git a/backend/internal/modules/file/service.go b/backend/internal/modules/file/service.go index 181d84f..8a7c37b 100644 --- a/backend/internal/modules/file/service.go +++ b/backend/internal/modules/file/service.go @@ -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" } diff --git a/backend/internal/modules/file/storage_test.go b/backend/internal/modules/file/storage_test.go index da04e7a..c78455e 100644 --- a/backend/internal/modules/file/storage_test.go +++ b/backend/internal/modules/file/storage_test.go @@ -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) + } +} + diff --git a/backend/internal/modules/listing/presenter.go b/backend/internal/modules/listing/presenter.go index f57c94f..3756c05 100644 --- a/backend/internal/modules/listing/presenter.go +++ b/backend/internal/modules/listing/presenter.go @@ -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 diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index b8c1943..145a651 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -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 { diff --git a/backend/internal/router/dependencies.go b/backend/internal/router/dependencies.go index 8e7d77e..c9714ee 100644 --- a/backend/internal/router/dependencies.go +++ b/backend/internal/router/dependencies.go @@ -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 { diff --git a/backend/internal/router/dependencies_test.go b/backend/internal/router/dependencies_test.go index 77ae600..c96b3ac 100644 --- a/backend/internal/router/dependencies_test.go +++ b/backend/internal/router/dependencies_test.go @@ -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) { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index fbca3a5..6fed9ec 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/frontend/src/features/admin/components/ImageEditorDialog.vue b/frontend/src/features/admin/components/ImageEditorDialog.vue index 5cfdde2..263f66d 100644 --- a/frontend/src/features/admin/components/ImageEditorDialog.vue +++ b/frontend/src/features/admin/components/ImageEditorDialog.vue @@ -484,7 +484,7 @@ async function handleSave() { } const file = new File([blob], `edited_screenshot_${Date.now()}.jpg`, { type: 'image/jpeg' }) - const uploaded = await uploadAdminFile(file, 'listing_screenshot') + const uploaded = await uploadAdminFile(file, 'listing') if (!uploaded || !uploaded.url) { throw new Error('上传截图失败') } diff --git a/frontend/src/features/admin/components/WithdrawalDetailDialog.vue b/frontend/src/features/admin/components/WithdrawalDetailDialog.vue index 02795b0..e30b4ed 100644 --- a/frontend/src/features/admin/components/WithdrawalDetailDialog.vue +++ b/frontend/src/features/admin/components/WithdrawalDetailDialog.vue @@ -289,9 +289,13 @@ function accountTypeLabel(type: string) { {{ withdrawal.payment_remark }} - - 查看凭证 - + diff --git a/frontend/src/features/wallet/components/PaymentAccountDialog.vue b/frontend/src/features/wallet/components/PaymentAccountDialog.vue index 3d996c8..59e583c 100644 --- a/frontend/src/features/wallet/components/PaymentAccountDialog.vue +++ b/frontend/src/features/wallet/components/PaymentAccountDialog.vue @@ -13,6 +13,7 @@ import { type UpdatePaymentAccountRequest, } from '../api/withdrawal' import { uploadFile } from '@/shared/api/files' +import AuthImage from '@/shared/components/business/AuthImage.vue' const props = defineProps<{ modelValue: boolean @@ -255,7 +256,13 @@ function removeImage(index: number) {
- 凭证 + - 收款凭证 +