From 5f5a57b81fc94c0b19e1c9edd6a3d7a09d779be8 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 16 Sep 2026 14:37:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(withdrawal):=20=E4=BF=AE=E5=A4=8D=E6=8F=90?= =?UTF-8?q?=E7=8E=B0=E8=AF=A6=E6=83=85=E6=94=B6=E6=AC=BE=E4=BA=8C=E7=BB=B4?= =?UTF-8?q?=E7=A0=81=E4=BD=BF=E7=94=A8=E5=85=AC=E7=BD=91URL=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E9=89=B4=E6=9D=83=E5=A4=B1=E8=B4=A5=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: withdrawal presenter 增加 privateCertificateURLs 兼容转换历史公网 payment-cert 路径 - frontend: AuthImage 与 fetchFileBlobByURL 兼容私有凭证 key,后台上下文通过管理员私有对象接口安全加载 - frontend: 按对象接口路径与 key 前缀精确判定,避免外链带 ?key= 被误代理到后台私有对象接口 - tests: 补充 TestPrivateCertificateURLs 单元测试 --- .../internal/modules/withdrawal/dto_test.go | 17 +++++++++++++++++ .../internal/modules/withdrawal/presenter.go | 19 ++++++++++++++++++- frontend/src/shared/api/files.ts | 8 ++++++++ .../shared/components/business/AuthImage.vue | 19 ++++++++++++++----- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/backend/internal/modules/withdrawal/dto_test.go b/backend/internal/modules/withdrawal/dto_test.go index f86edac..26ac2d9 100644 --- a/backend/internal/modules/withdrawal/dto_test.go +++ b/backend/internal/modules/withdrawal/dto_test.go @@ -39,3 +39,20 @@ func TestReviewWithdrawalRequestBindMissingApproved(t *testing.T) { t.Fatal("缺少 approved 字段时应该参数绑定失败") } } + +func TestPrivateCertificateURLs(t *testing.T) { + values := privateCertificateURLs([]string{ + "/api/public/files/object?key=payment-cert%2F2026%2F09%2F16%2Fqr.webp", + "/api/public/files/object?key=avatar%2Fuser.jpg", + "/api/files/object?key=payment-cert%2Falready_private.webp", + }) + if values[0] != "/api/files/object?key=payment-cert%2F2026%2F09%2F16%2Fqr.webp" { + t.Fatalf("expected private URL for payment-cert, got %q", values[0]) + } + if values[1] != "/api/public/files/object?key=avatar%2Fuser.jpg" { + t.Fatalf("non-payment-cert URL should not be changed, got %q", values[1]) + } + if values[2] != "/api/files/object?key=payment-cert%2Falready_private.webp" { + t.Fatalf("already private URL should not be changed, got %q", values[2]) + } +} diff --git a/backend/internal/modules/withdrawal/presenter.go b/backend/internal/modules/withdrawal/presenter.go index 928ec75..979194d 100644 --- a/backend/internal/modules/withdrawal/presenter.go +++ b/backend/internal/modules/withdrawal/presenter.go @@ -3,6 +3,8 @@ package withdrawal import ( "context" "encoding/json" + "net/url" + "strings" "hfb_sys/backend/internal/model" ) @@ -72,6 +74,7 @@ func (r *Repository) toDetailDTO(ctx context.Context, w model.WithdrawalRequest) if paymentAccount.CertificateURLs != nil { json.Unmarshal(paymentAccount.CertificateURLs, &certificateURLs) } + certificateURLs = privateCertificateURLs(certificateURLs) } } @@ -118,4 +121,18 @@ func (r *Repository) toDetailDTO(ctx context.Context, w model.WithdrawalRequest) }, nil } -// 生成提现单号 +// privateCertificateURLs 兼容历史公开收款凭证 URL,统一经私有对象接口读取。 +func privateCertificateURLs(values []string) []string { + for index, value := range values { + parsed, err := url.Parse(value) + if err != nil || parsed.Path != "/api/public/files/object" { + continue + } + if !strings.HasPrefix(parsed.Query().Get("key"), "payment-cert/") { + continue + } + parsed.Path = "/api/files/object" + values[index] = parsed.String() + } + return values +} diff --git a/frontend/src/shared/api/files.ts b/frontend/src/shared/api/files.ts index a487646..4e085ab 100644 --- a/frontend/src/shared/api/files.ts +++ b/frontend/src/shared/api/files.ts @@ -57,6 +57,14 @@ export async function fetchFileBlobByURL(fileURL: string) { if (apiPath.startsWith('/api/')) { apiPath = apiPath.slice(4) } + // 历史收款凭证存的是公开 URL,但 payment-cert 已转为私有,需精确识别 key 前缀后改走私有对象接口。 + if (apiPath.startsWith('/public/files/object')) { + const queryIndex = apiPath.indexOf('?') + const search = new URLSearchParams(queryIndex >= 0 ? apiPath.slice(queryIndex) : '') + if ((search.get('key') || '').startsWith('payment-cert/')) { + apiPath = `/files/object${queryIndex >= 0 ? apiPath.slice(queryIndex) : ''}` + } + } const { data } = await apiClient.get(apiPath, { responseType: 'blob', }) diff --git a/frontend/src/shared/components/business/AuthImage.vue b/frontend/src/shared/components/business/AuthImage.vue index 2d6adc8..63675d1 100644 --- a/frontend/src/shared/components/business/AuthImage.vue +++ b/frontend/src/shared/components/business/AuthImage.vue @@ -67,17 +67,26 @@ function withAuthImageLoadSlot(task: () => Promise): Promise { }) } -function extractObjectKey(value: string) { +function tryParseURL(value: string): URL | null { try { - const parsed = new URL(value, window.location.origin) - return parsed.searchParams.get('key') || '' + return new URL(value, window.location.origin) } catch { - return '' + return null } } +function extractObjectKey(value: string) { + return tryParseURL(value)?.searchParams.get('key') || '' +} + function shouldFetchWithAuth(value: string) { - return value.includes('/api/files/object') || value.includes('/api/admin/files/object') + const parsed = tryParseURL(value) + if (!parsed) return false + // 公开对象接口仅对已转私有的收款凭证做鉴权兜底,其余公开资源维持直连,避免外链被误代理。 + if (parsed.pathname.endsWith('/public/files/object')) { + return (parsed.searchParams.get('key') || '').startsWith('payment-cert/') + } + return parsed.pathname.endsWith('/files/object') } function shouldFetchAsAdmin(value: string) {