diff --git a/internal/finance/service.go b/internal/finance/service.go index 32c7930..e1ffd0b 100644 --- a/internal/finance/service.go +++ b/internal/finance/service.go @@ -42,6 +42,7 @@ type Repository interface { CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) + PaymentAccountDetails(context.Context, int64, int64) ([]store.PaymentAccount, error) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error) @@ -92,6 +93,19 @@ type PaymentAccount struct { UpdatedAt string `json:"updated_at,omitempty"` } +type PaymentAccountDetail struct { + ID int64 `json:"id"` + BankName string `json:"bank_name"` + HolderName string `json:"holder_name"` + IdentityNo string `json:"identity_no"` + AccountNo string `json:"account_no"` + DigitalRMBCode string `json:"digital_rmb_code"` + WechatQRURL string `json:"wechat_qr_url"` + AlipayQRURL string `json:"alipay_qr_url"` + VerifyStatus string `json:"verify_status"` + IsDefault bool `json:"is_default"` +} + type QuoteInput struct { PaymentAccountID int64 `json:"payment_account_id"` Amount int64 `json:"amount"` @@ -282,6 +296,48 @@ func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) return items, nil } +func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID int64) ([]PaymentAccountDetail, error) { + if !s.ready || s.repository == nil { + return nil, ErrUnavailable + } + rows, err := s.repository.PaymentAccountDetails(ctx, tenantID, memberID) + if err != nil { + return nil, err + } + items := make([]PaymentAccountDetail, 0, len(rows)) + for _, row := range rows { + holderName, err := s.decrypt(row.HolderCipher) + if err != nil { + return nil, store.ErrPaymentAccountInvalid + } + accountNo, err := s.decrypt(row.AccountNoCipher) + if err != nil { + return nil, store.ErrPaymentAccountInvalid + } + identityNo := "" + if row.IdentityNoCipher != "" { + identityNo, err = s.decrypt(row.IdentityNoCipher) + if err != nil { + return nil, store.ErrPaymentAccountInvalid + } + } + digitalRMBCode := "" + if row.DigitalRMBCodeCipher != "" { + digitalRMBCode, err = s.decrypt(row.DigitalRMBCodeCipher) + if err != nil { + return nil, store.ErrPaymentAccountInvalid + } + } + items = append(items, PaymentAccountDetail{ + ID: row.ID, BankName: row.BankName, HolderName: holderName, + IdentityNo: identityNo, AccountNo: accountNo, DigitalRMBCode: digitalRMBCode, + WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL, + VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, + }) + } + return items, nil +} + func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) { if !s.ready { return nil, ErrUnavailable diff --git a/internal/finance/service_test.go b/internal/finance/service_test.go index c584bc5..0098cf4 100644 --- a/internal/finance/service_test.go +++ b/internal/finance/service_test.go @@ -15,6 +15,7 @@ import ( type fakeRepository struct { config json.RawMessage account *store.PaymentAccount + details []store.PaymentAccount channel *store.WithdrawalChannel wallet *store.WithdrawalWallet orders []store.WithdrawalOrder @@ -43,6 +44,9 @@ func (f *fakeRepository) AccountSecurity(context.Context, int64, int64) (*store. func (f *fakeRepository) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) { return nil, nil } +func (f *fakeRepository) PaymentAccountDetails(context.Context, int64, int64) ([]store.PaymentAccount, error) { + return f.details, nil +} func (f *fakeRepository) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) { return nil, errors.New("not implemented") } @@ -139,6 +143,31 @@ func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) { } } +func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) { + repository := &fakeRepository{} + service := New(repository, &redis.Client{}, config.AuthConfig{ + FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters", + }) + holder, _ := service.encrypt("张三") + identity, _ := service.encrypt("11010119900101123X") + account, _ := service.encrypt("6222000000001234") + digital, _ := service.encrypt("123456") + repository.details = []store.PaymentAccount{{ + ID: 7, BankName: "中国工商银行", HolderCipher: holder, IdentityNoCipher: identity, + AccountNoCipher: account, DigitalRMBCodeCipher: digital, + WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png", VerifyStatus: 2, + }} + items, err := service.PaymentAccountDetails(context.Background(), 1, 2) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].HolderName != "张三" || items[0].IdentityNo != "11010119900101123X" || + items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" || + items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" { + t.Fatalf("unexpected account details: %#v", items) + } +} + func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) { repository := &paymentAccountRepository{} service := New(repository, &redis.Client{}, config.AuthConfig{ diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index 1e4d661..49c242d 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -55,6 +55,7 @@ func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("/v1/manual-products", a.manualProducts) mux.HandleFunc("/v1/manual-products/", a.manualProduct) mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts) + mux.HandleFunc("/v1/payment-accounts/details", a.paymentAccountDetails) mux.HandleFunc("/v1/attachments/images", a.uploadImage) mux.HandleFunc("/v1/wallet/ledgers", a.walletLedgers) mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels) diff --git a/internal/httpapi/finance.go b/internal/httpapi/finance.go index cb2ec56..c42c7ea 100644 --- a/internal/httpapi/finance.go +++ b/internal/httpapi/finance.go @@ -41,6 +41,24 @@ func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) { writeSuccess(w, r, result) } +func (a *API) paymentAccountDetails(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + principal, ok := a.authenticateFinanceMember(w, r) + if !ok { + return + } + w.Header().Set("Cache-Control", "no-store") + result, err := a.finance.PaymentAccountDetails(r.Context(), principal.TenantID, principal.MemberID) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) +} + func (a *API) walletLedgers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w, r, http.MethodGet) diff --git a/internal/store/withdrawals.go b/internal/store/withdrawals.go index 3c36af9..5854e0c 100644 --- a/internal/store/withdrawals.go +++ b/internal/store/withdrawals.go @@ -30,6 +30,8 @@ type PaymentAccount struct { QRAttachmentID int64 `db:"qr_attachment_id"` WechatQRAttachmentID int64 `db:"wechat_qr_attachment_id"` AlipayQRAttachmentID int64 `db:"alipay_qr_attachment_id"` + WechatQRURL string `db:"wechat_qr_url"` + AlipayQRURL string `db:"alipay_qr_url"` VerifyStatus int `db:"verify_status"` IsDefault int `db:"is_default"` CreatedAt string `db:"created_at"` @@ -154,6 +156,29 @@ func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ( return items, err } +// PaymentAccountDetails returns encrypted fields only for the authenticated account owner. +func (s *Store) PaymentAccountDetails(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) { + items := make([]PaymentAccount, 0) + err := s.db.SelectContext(ctx, &items, `SELECT a.id,a.account_type,a.bank_code,a.bank_name, + COALESCE(a.holder_cipher,'') AS holder_cipher, + COALESCE(a.identity_no_cipher,'') AS identity_no_cipher, + COALESCE(a.account_no_cipher,'') AS account_no_cipher, + COALESCE(a.digital_rmb_code_cipher,'') AS digital_rmb_code_cipher, + COALESCE(a.wechat_qr_attachment_id,0) AS wechat_qr_attachment_id, + COALESCE(a.alipay_qr_attachment_id,0) AS alipay_qr_attachment_id, + COALESCE(wechat.file_url,'') AS wechat_qr_url, + COALESCE(alipay.file_url,'') AS alipay_qr_url, + a.verify_status,a.is_default + FROM hg_app_member_payment_account a + LEFT JOIN hg_sys_attachment wechat ON wechat.id=a.wechat_qr_attachment_id + AND wechat.member_id=a.member_id AND wechat.app_id='olons_server' AND wechat.status=1 + LEFT JOIN hg_sys_attachment alipay ON alipay.id=a.alipay_qr_attachment_id + AND alipay.member_id=a.member_id AND alipay.app_id='olons_server' AND alipay.status=1 + WHERE a.tenant_id=? AND a.member_id=? AND a.deleted_at IS NULL + ORDER BY a.is_default ASC,a.id DESC`, tenantID, memberID) + return items, err +} + func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCreation) (*PaymentAccount, error) { tx, err := s.db.BeginTxx(ctx, nil) if err != nil { diff --git a/web/src/mobile/styles.css b/web/src/mobile/styles.css index 21dab5d..f691d7e 100644 --- a/web/src/mobile/styles.css +++ b/web/src/mobile/styles.css @@ -1772,9 +1772,120 @@ body { background: #fff; } .m-subpage--profile { - background-color: #fffaf5; - background-size: 100% auto; - background-repeat: no-repeat; + background-color: #f2f6fc; +} +.m-profile-info { + display: grid; + gap: 18px; + padding: 20px 12px 32px; +} +.m-profile-info-card { + padding: 0 18px; + border-radius: 16px; + background: #fff; +} +.m-profile-info-row { + min-height: 66px; + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto 20px; + align-items: center; + gap: 12px; + border-bottom: 1px solid #e9eef5; + color: #303846; + text-decoration: none; + font-size: 16px; +} +.m-profile-info-row:last-child { + border-bottom: 0; +} +.m-profile-info-row > .n-icon:first-child { + font-size: 24px; +} +.m-profile-info-row > .n-icon:last-child, +.m-profile-avatar-row > .n-icon:nth-last-child(2) { + grid-column: 4; + font-size: 21px; +} +.m-profile-info-row > b { + grid-column: 3 / 5; + font-weight: 400; + white-space: nowrap; +} +.m-profile-avatar-row { + min-height: 94px; + cursor: pointer; +} +.m-profile-avatar-row input { + display: none; +} +.m-profile-avatar-preview { + grid-column: 3; + display: flex; + align-items: center; + gap: 8px; +} +.m-profile-avatar-preview > img, +.m-profile-avatar-preview > .n-icon { + width: 52px; + height: 52px; + border-radius: 50%; + object-fit: cover; + font-size: 52px; +} +.m-profile-avatar-preview small { + color: var(--m-muted); + white-space: nowrap; +} +.m-profile-info > .m-form-error { + margin: -10px 6px 0; +} +.m-bound-accounts { + display: grid; + gap: 10px; + margin: 18px 0; +} +.m-bound-accounts h2, +.m-bank-form-title { + margin: 0 20px 4px; + font-size: 16px; +} +.m-bound-account { + display: grid; + gap: 12px; + margin: 0 12px; + padding: 16px; + color: var(--m-ink); +} +.m-bound-account > div:not(.m-bound-account-qr) { + display: grid; + gap: 6px; +} +.m-bound-account span { + color: var(--m-muted); +} +.m-bound-account strong { + overflow-wrap: anywhere; + font-weight: 500; +} +.m-bound-account-qr { + display: flex; + flex-wrap: wrap; + gap: 16px; +} +.m-bound-account-qr figure { + margin: 0; + text-align: center; + color: var(--m-muted); +} +.m-bound-account-qr img { + display: block; + width: 100px; + height: 100px; + margin-bottom: 6px; + object-fit: contain; +} +.m-bank-form-title { + padding: 12px 0 0; } .m-profile-edit { text-align: center; diff --git a/web/src/mobile/views/FormView.vue b/web/src/mobile/views/FormView.vue index c3cfe04..61e3338 100644 --- a/web/src/mobile/views/FormView.vue +++ b/web/src/mobile/views/FormView.vue @@ -2,11 +2,6 @@
-
-
用户头像
-
-
昵称{{ me?.username }}
手机号码{{ me?.phone }}
邀请码{{ me?.invite_code }}
- 密码管理 - 交易密码管理 +
+
+ +
+

{{ avatarError }}

+
+
+ + 我的手机号 + {{ me?.phone }} +
+ + + 取出信息 + + + + + 密码管理 + + + + + 支付密码管理 + +
-
+
+
+

已绑定的取出信息

+ +
+ +

添加银行卡

@@ -88,6 +111,7 @@ submitting ? '提交中...' : '确认绑定' }} +
String(route.meta.form || '')); @@ -244,7 +270,20 @@ const loading = ref(false); const submitting = ref(false); const error = ref(''); + const avatarError = ref(''); + const avatarUploading = ref(false); const me = ref(); + interface PaymentAccountDetail { + id: number; + bank_name: string; + holder_name: string; + identity_no: string; + account_no: string; + digital_rmb_code: string; + wechat_qr_url: string; + alipay_qr_url: string; + } + const paymentAccounts = ref([]); const content = ref(); const resetPassword = ref(''); const bank = reactive({ @@ -274,6 +313,45 @@ loading.value = false; } } + async function uploadAvatar(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (!file || avatarUploading.value) return; + avatarError.value = ''; + if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 5 * 1024 * 1024) { + avatarError.value = '头像仅支持 5MB 以内的 JPG、PNG 或 WebP 图片'; + return; + } + avatarUploading.value = true; + try { + const data = new FormData(); + data.append('file', file); + const uploaded = await request<{ id: number; url: string }>({ + url: '/v1/attachments/images', + method: 'POST', + data, + }); + const avatar = await request<{ attachment_id: number; avatar_url: string }>({ + url: '/v1/me/avatar', + method: 'PATCH', + data: { attachment_id: uploaded.id }, + }); + me.value = { ...me.value, avatar_url: avatar.avatar_url || uploaded.url }; + window.$message?.success('头像更新成功'); + } catch (err) { + avatarError.value = errorText(err); + } finally { + avatarUploading.value = false; + } + } + async function loadPaymentAccounts() { + try { + paymentAccounts.value = await request({ url: '/v1/payment-accounts/details' }); + } catch (err) { + error.value = errorText(err); + } + } async function saveBank() { error.value = ''; const identityNo = bank.identity_no.replace(/[ -]/g, ''); @@ -302,7 +380,7 @@ try { await request({ url: '/v1/payment-accounts', method: 'POST', data: bank }); window.$message?.success('银行卡绑定成功'); - await router.replace('/app/profile/withdrawal'); + await router.replace(route.query.from === 'profile' ? '/app/profile/info' : '/app/profile/withdrawal'); } catch (err) { error.value = errorText(err); } finally { @@ -431,6 +509,7 @@ } onMounted(() => { if (formType.value === 'profile') loadProfile(); + else if (formType.value === 'bank') loadPaymentAccounts(); else if (formType.value === 'content') loadContent(); }); diff --git a/web/src/router/index.ts b/web/src/router/index.ts index c5ba6cd..91af3c8 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -86,7 +86,7 @@ const routes: RouteRecordRaw[] = [ path: 'profile/bank-card', name: 'MobileBankCard', component: () => import('../mobile/views/FormView.vue'), - meta: { title: '添加银行卡', form: 'bank' }, + meta: { title: '绑定信息', form: 'bank' }, }, { path: 'profile/withdrawal',