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 }}