diff --git a/internal/accounts/profile_test.go b/internal/accounts/profile_test.go index e563f4e..fa07a19 100644 --- a/internal/accounts/profile_test.go +++ b/internal/accounts/profile_test.go @@ -44,14 +44,14 @@ func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _ func TestProfileMapsMemberCategory(t *testing.T) { repository := &profileRepository{overview: &store.MemberOverview{ MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2, - Mobile: "13800138000", Currency: "CNY", WalletStatus: 1, + Mobile: "13800138000", Currency: "CNY", WalletStatus: 1, HasTradePassword: 1, }} service := &Service{repository: repository, ready: true} profile, err := service.Profile(context.Background(), 0, 7) if err != nil { t.Fatal(err) } - if profile.Phone != "13800138000" || profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" { + if profile.Phone != "13800138000" || profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" || !profile.HasTradePassword { t.Fatalf("unexpected profile states: %+v", profile) } } diff --git a/internal/accounts/service.go b/internal/accounts/service.go index 5bee648..5ee0762 100644 --- a/internal/accounts/service.go +++ b/internal/accounts/service.go @@ -78,17 +78,18 @@ type Result struct { } type Profile struct { - MemberID int64 `json:"member_id"` - MemberNo string `json:"member_no"` - Username string `json:"username"` - Phone string `json:"phone"` - AvatarURL string `json:"avatar_url"` - InviteCode string `json:"invite_code"` - Level ProfileLevel `json:"level"` - MemberStatus string `json:"member_status"` - MemberCategory string `json:"member_category"` - CreditScore int `json:"credit_score"` - Wallet ProfileWallet `json:"wallet"` + MemberID int64 `json:"member_id"` + MemberNo string `json:"member_no"` + Username string `json:"username"` + Phone string `json:"phone"` + AvatarURL string `json:"avatar_url"` + InviteCode string `json:"invite_code"` + HasTradePassword bool `json:"has_trade_password"` + Level ProfileLevel `json:"level"` + MemberStatus string `json:"member_status"` + MemberCategory string `json:"member_category"` + CreditScore int `json:"credit_score"` + Wallet ProfileWallet `json:"wallet"` } type ProfileLevel struct { @@ -268,6 +269,7 @@ func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profi return &Profile{ MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username, Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode, + HasTradePassword: row.HasTradePassword != 0, Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName, IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate}, MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory), diff --git a/internal/finance/service.go b/internal/finance/service.go index 6918620..32c7930 100644 --- a/internal/finance/service.go +++ b/internal/finance/service.go @@ -301,9 +301,14 @@ func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID i if err != nil { return nil, err } - digitalRMBCodeCipher, err := s.encrypt(input.DigitalRMBCode) - if err != nil { - return nil, err + digitalRMBCodeCipher, digitalRMBCodeHash, digitalRMBCodeMask := "", "", "" + if input.DigitalRMBCode != "" { + digitalRMBCodeCipher, err = s.encrypt(input.DigitalRMBCode) + if err != nil { + return nil, err + } + digitalRMBCodeHash = s.lookupHash(input.DigitalRMBCode) + digitalRMBCodeMask = maskAccount(input.DigitalRMBCode) } row, err := s.repository.CreatePaymentAccount(ctx, store.PaymentAccountCreation{ TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName, @@ -311,8 +316,8 @@ func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID i IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo), IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher, AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo), - DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: s.lookupHash(input.DigitalRMBCode), - DigitalRMBCodeMask: maskAccount(input.DigitalRMBCode), + DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash, + DigitalRMBCodeMask: digitalRMBCodeMask, WechatQRAttachmentID: input.WechatQRAttachmentID, AlipayQRAttachmentID: input.AlipayQRAttachmentID, }) @@ -759,7 +764,7 @@ func normalizePaymentAccount(input *PaymentAccountInput) error { if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) || utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) || - len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode) || + (input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) || input.WechatQRAttachmentID <= 0 || input.AlipayQRAttachmentID <= 0 || input.WechatQRAttachmentID == input.AlipayQRAttachmentID { return ErrAccountFieldsInvalid diff --git a/internal/finance/service_test.go b/internal/finance/service_test.go index 156ba25..c584bc5 100644 --- a/internal/finance/service_test.go +++ b/internal/finance/service_test.go @@ -9,6 +9,7 @@ import ( "git.118im.com/im_team/olons_server/internal/config" "git.118im.com/im_team/olons_server/internal/store" + "github.com/redis/go-redis/v9" ) type fakeRepository struct { @@ -23,6 +24,16 @@ type fakeRepository struct { createdRecharge store.RechargeCreation } +type paymentAccountRepository struct { + fakeRepository + created store.PaymentAccountCreation +} + +func (f *paymentAccountRepository) CreatePaymentAccount(_ context.Context, input store.PaymentAccountCreation) (*store.PaymentAccount, error) { + f.created = input + return &store.PaymentAccount{ID: 1, AccountType: input.AccountType}, nil +} + func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) { return f.config, nil } @@ -115,6 +126,36 @@ func TestNormalizePaymentAccount(t *testing.T) { } } +func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) { + input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三", + IdentityNo: "11010119900101123X", AccountNo: "6222000000001234", + WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9} + if err := normalizePaymentAccount(&input); err != nil { + t.Fatalf("empty digital RMB code should be optional: %v", err) + } + input.DigitalRMBCode = "1234" + if err := normalizePaymentAccount(&input); !errors.Is(err, ErrAccountFieldsInvalid) { + t.Fatalf("short non-empty digital RMB code should be rejected: %v", err) + } +} + +func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) { + repository := &paymentAccountRepository{} + service := New(repository, &redis.Client{}, config.AuthConfig{ + FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters", + }) + _, err := service.CreatePaymentAccount(context.Background(), 0, 1, PaymentAccountInput{ + BankName: "中国工商银行", HolderName: "张三", IdentityNo: "11010119900101123X", + AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, + }) + if err != nil { + t.Fatal(err) + } + if repository.created.DigitalRMBCodeCipher != "" || repository.created.DigitalRMBCodeHash != "" || repository.created.DigitalRMBCodeMask != "" { + t.Fatalf("empty digital RMB code must not be encrypted, hashed or masked: %#v", repository.created) + } +} + func TestMaskAccountShortValueDoesNotLeak(t *testing.T) { if got := maskAccount("abcde"); got != "a***e" { t.Fatalf("unexpected mask %q", got) diff --git a/internal/store/profile.go b/internal/store/profile.go index 44ff8f7..9656cbd 100644 --- a/internal/store/profile.go +++ b/internal/store/profile.go @@ -13,6 +13,7 @@ type MemberOverview struct { Username string `db:"username"` Mobile string `db:"mobile"` InviteCode string `db:"invite_code"` + HasTradePassword int `db:"has_trade_password"` MemberStatus int `db:"member_status"` MemberCategory int `db:"member_category"` CreditScore int `db:"credit_score"` @@ -49,7 +50,8 @@ func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (* } var row MemberOverview err := s.db.GetContext(ctx, &row, `SELECT m.tenant_id,m.id AS member_id,m.member_no,m.username,m.mobile, - m.invite_code,m.member_status,m.risk_status AS member_category,m.credit_score, + m.invite_code,CASE WHEN m.trade_password_hash <> '' THEN 1 ELSE 0 END AS has_trade_password, + m.member_status,m.risk_status AS member_category,m.credit_score, COALESCE(p.avatar_attachment_id,0) AS avatar_attachment_id,COALESCE(a.file_url,'') AS avatar_url, COALESCE(l.id,0) AS level_id,COALESCE(l.level_code,'') AS level_code, COALESCE(l.name,'') AS level_name,COALESCE(l.icon_url,'') AS level_icon_url, diff --git a/web/src/mobile/styles.css b/web/src/mobile/styles.css index 5959b18..0119eab 100644 --- a/web/src/mobile/styles.css +++ b/web/src/mobile/styles.css @@ -2744,6 +2744,16 @@ body { padding: 0 12px; outline: 0; } +.m-withdraw-password-notice { + margin: 16px 0; + color: var(--m-muted); + line-height: 1.5; + text-align: center; +} +.m-withdraw-confirm .m-withdraw-setup-btn, +.m-withdraw-confirm .m-withdraw-back-btn { + margin: 8px 0 14px; +} .m-finance-records { padding-inline: 0; diff --git a/web/src/mobile/views/FormView.vue b/web/src/mobile/views/FormView.vue index e31ca40..c3cfe04 100644 --- a/web/src/mobile/views/FormView.vue +++ b/web/src/mobile/views/FormView.vue @@ -65,7 +65,7 @@ v-model.trim="bank.digital_rmb_code" inputmode="numeric" maxlength="32" - placeholder="请输入数字编码" + placeholder="请输入数字编码(选填)" />

微信二维码

@@ -276,16 +276,26 @@ } async function saveBank() { error.value = ''; - if ( - !bank.holder_name || - !/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(bank.identity_no.replace(/[ -]/g, '')) || - !/^[0-9 -]{8,36}$/.test(bank.account_no) || - !bank.bank_name || - !/^\d{6,32}$/.test(bank.digital_rmb_code.replace(/[ -]/g, '')) || - !bank.wechat_qr_attachment_id || - !bank.alipay_qr_attachment_id - ) { - error.value = '请完整填写绑卡信息并上传微信、支付宝二维码'; + const identityNo = bank.identity_no.replace(/[ -]/g, ''); + const accountNo = bank.account_no.replace(/[ -]/g, ''); + const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, ''); + const validationError = + bank.holder_name.length < 2 + ? '请输入至少 2 个字符的姓名' + : !/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(identityNo) + ? '请输入正确的 15 位或 18 位身份证号' + : !/^\d{8,32}$/.test(accountNo) + ? '请输入 8 至 32 位银行卡号' + : bank.bank_name.length < 2 + ? '请输入开户行' + : digitalRMBCode && !/^\d{6,32}$/.test(digitalRMBCode) + ? '数字人民币编码须为 6 至 32 位数字,也可留空' + : !bank.wechat_qr_attachment_id || !bank.alipay_qr_attachment_id + ? '请上传微信和支付宝二维码' + : ''; + if (validationError) { + error.value = validationError; + window.$message?.error(validationError); return; } submitting.value = true; diff --git a/web/src/mobile/views/WithdrawalView.vue b/web/src/mobile/views/WithdrawalView.vue index b360fd3..ce84305 100644 --- a/web/src/mobile/views/WithdrawalView.vue +++ b/web/src/mobile/views/WithdrawalView.vue @@ -47,12 +47,39 @@ :trap-focus="false" @after-enter="focusTradePassword" > -
+ +

设置支付密码

+

设置完成后,请返回确认申请。

+ + +

{{ setupError }}

+ + +
+

确认取出

申请金额{{ money(quote?.request_amount) }}
手续费{{ money(quote?.fee_amount) }}
预计到账{{ money(quote?.actual_amount) }}
-
@@ -78,7 +107,7 @@ import { ChevronForwardOutline } from '@vicons/ionicons5'; import PageHeader from '../components/PageHeader.vue'; import AsyncState from '../components/AsyncState.vue'; - import { errorText, idempotencyKey, money, request } from '../api'; + import { MobileApiError, errorText, idempotencyKey, money, request } from '../api'; import { mobileAssets } from '../assets'; const router = useRouter(); @@ -90,9 +119,13 @@ const accounts = ref([]); const quote = ref(); const showConfirm = ref(false); + const showTradePasswordSetup = ref(false); + const setupError = ref(''); const withdrawalKey = ref(''); const tradePasswordInput = ref(); const form = reactive({ paymentAccountId: 0, amountYuan: '', tradePassword: '' }); + const setupForm = reactive({ newTradePassword: '', loginPassword: '' }); + const hasTradePassword = computed(() => me.value?.has_trade_password === true); const selectedAccount = computed(() => accounts.value.find((item) => item.id === form.paymentAccountId) ); @@ -112,7 +145,44 @@ } async function focusTradePassword() { await nextTick(); - tradePasswordInput.value?.focus(); + if (hasTradePassword.value && !showTradePasswordSetup.value) tradePasswordInput.value?.focus(); + } + function openTradePasswordSetup() { + setupError.value = ''; + actionError.value = ''; + setupForm.newTradePassword = ''; + setupForm.loginPassword = ''; + showTradePasswordSetup.value = true; + } + async function setTradePassword() { + if (submitting.value) return; + setupError.value = ''; + if (!/^\d{6}$/.test(setupForm.newTradePassword) || setupForm.loginPassword.length < 6) { + setupError.value = '请输入 6 位数字支付密码及登录密码'; + return; + } + submitting.value = true; + try { + await request({ + url: '/v1/me/trade-password', + method: 'PUT', + data: { + new_trade_password: setupForm.newTradePassword, + login_password: setupForm.loginPassword, + }, + }); + me.value = { ...me.value, has_trade_password: true }; + setupForm.newTradePassword = ''; + setupForm.loginPassword = ''; + form.tradePassword = ''; + showTradePasswordSetup.value = false; + window.$message?.success('支付密码设置成功,请确认申请'); + await focusTradePassword(); + } catch (err) { + setupError.value = errorText(err); + } finally { + submitting.value = false; + } } async function load() { loading.value = true; @@ -152,6 +222,7 @@ }); withdrawalKey.value = idempotencyKey(); form.tradePassword = ''; + showTradePasswordSetup.value = false; showConfirm.value = true; } catch (err) { actionError.value = errorText(err); @@ -161,6 +232,10 @@ } async function submitWithdrawal() { if (!quote.value || submitting.value) return; + if (!hasTradePassword.value) { + actionError.value = '请先设置支付密码'; + return; + } if (!/^\d{6}$/.test(form.tradePassword)) { actionError.value = '请输入 6 位支付密码'; return; @@ -184,6 +259,9 @@ await router.replace('/app/profile/records?tab=withdrawal'); } catch (err) { actionError.value = errorText(err); + if (err instanceof MobileApiError && err.reason === 'TRADE_PASSWORD_NOT_SET') { + me.value = { ...me.value, has_trade_password: false }; + } } finally { submitting.value = false; }