fix: allow optional digital RMB code and inline payment password setup

This commit is contained in:
richjohnrambo
2026-10-15 23:03:59 +08:00
parent 6e223e537b
commit 66121f48e9
8 changed files with 184 additions and 36 deletions
+2 -2
View File
@@ -44,14 +44,14 @@ func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _
func TestProfileMapsMemberCategory(t *testing.T) { func TestProfileMapsMemberCategory(t *testing.T) {
repository := &profileRepository{overview: &store.MemberOverview{ repository := &profileRepository{overview: &store.MemberOverview{
MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2, 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} service := &Service{repository: repository, ready: true}
profile, err := service.Profile(context.Background(), 0, 7) profile, err := service.Profile(context.Background(), 0, 7)
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("unexpected profile states: %+v", profile)
} }
} }
+2
View File
@@ -84,6 +84,7 @@ type Profile struct {
Phone string `json:"phone"` Phone string `json:"phone"`
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url"`
InviteCode string `json:"invite_code"` InviteCode string `json:"invite_code"`
HasTradePassword bool `json:"has_trade_password"`
Level ProfileLevel `json:"level"` Level ProfileLevel `json:"level"`
MemberStatus string `json:"member_status"` MemberStatus string `json:"member_status"`
MemberCategory string `json:"member_category"` MemberCategory string `json:"member_category"`
@@ -268,6 +269,7 @@ func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profi
return &Profile{ return &Profile{
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username, MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode, Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
HasTradePassword: row.HasTradePassword != 0,
Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName, Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName,
IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate}, IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate},
MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory), MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory),
+9 -4
View File
@@ -301,18 +301,23 @@ func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID i
if err != nil { if err != nil {
return nil, err return nil, err
} }
digitalRMBCodeCipher, err := s.encrypt(input.DigitalRMBCode) digitalRMBCodeCipher, digitalRMBCodeHash, digitalRMBCodeMask := "", "", ""
if input.DigitalRMBCode != "" {
digitalRMBCodeCipher, err = s.encrypt(input.DigitalRMBCode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
digitalRMBCodeHash = s.lookupHash(input.DigitalRMBCode)
digitalRMBCodeMask = maskAccount(input.DigitalRMBCode)
}
row, err := s.repository.CreatePaymentAccount(ctx, store.PaymentAccountCreation{ row, err := s.repository.CreatePaymentAccount(ctx, store.PaymentAccountCreation{
TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName, TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName,
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), HolderCipher: holderCipher, HolderMask: maskName(input.HolderName),
IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo), IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo),
IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher, IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher,
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo), AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: s.lookupHash(input.DigitalRMBCode), DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash,
DigitalRMBCodeMask: maskAccount(input.DigitalRMBCode), DigitalRMBCodeMask: digitalRMBCodeMask,
WechatQRAttachmentID: input.WechatQRAttachmentID, WechatQRAttachmentID: input.WechatQRAttachmentID,
AlipayQRAttachmentID: input.AlipayQRAttachmentID, AlipayQRAttachmentID: input.AlipayQRAttachmentID,
}) })
@@ -759,7 +764,7 @@ func normalizePaymentAccount(input *PaymentAccountInput) error {
if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) || if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) ||
utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 || utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) || 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 <= 0 || input.AlipayQRAttachmentID <= 0 ||
input.WechatQRAttachmentID == input.AlipayQRAttachmentID { input.WechatQRAttachmentID == input.AlipayQRAttachmentID {
return ErrAccountFieldsInvalid return ErrAccountFieldsInvalid
+41
View File
@@ -9,6 +9,7 @@ import (
"git.118im.com/im_team/olons_server/internal/config" "git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/store" "git.118im.com/im_team/olons_server/internal/store"
"github.com/redis/go-redis/v9"
) )
type fakeRepository struct { type fakeRepository struct {
@@ -23,6 +24,16 @@ type fakeRepository struct {
createdRecharge store.RechargeCreation 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) { func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
return f.config, nil 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) { func TestMaskAccountShortValueDoesNotLeak(t *testing.T) {
if got := maskAccount("abcde"); got != "a***e" { if got := maskAccount("abcde"); got != "a***e" {
t.Fatalf("unexpected mask %q", got) t.Fatalf("unexpected mask %q", got)
+3 -1
View File
@@ -13,6 +13,7 @@ type MemberOverview struct {
Username string `db:"username"` Username string `db:"username"`
Mobile string `db:"mobile"` Mobile string `db:"mobile"`
InviteCode string `db:"invite_code"` InviteCode string `db:"invite_code"`
HasTradePassword int `db:"has_trade_password"`
MemberStatus int `db:"member_status"` MemberStatus int `db:"member_status"`
MemberCategory int `db:"member_category"` MemberCategory int `db:"member_category"`
CreditScore int `db:"credit_score"` CreditScore int `db:"credit_score"`
@@ -49,7 +50,8 @@ func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (*
} }
var row MemberOverview 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, 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(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.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, COALESCE(l.name,'') AS level_name,COALESCE(l.icon_url,'') AS level_icon_url,
+10
View File
@@ -2744,6 +2744,16 @@ body {
padding: 0 12px; padding: 0 12px;
outline: 0; 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 { .m-finance-records {
padding-inline: 0; padding-inline: 0;
+21 -11
View File
@@ -65,7 +65,7 @@
v-model.trim="bank.digital_rmb_code" v-model.trim="bank.digital_rmb_code"
inputmode="numeric" inputmode="numeric"
maxlength="32" maxlength="32"
placeholder="请输入数字编码" placeholder="请输入数字编码(选填)"
/></label> /></label>
<section class="m-qr-field"> <section class="m-qr-field">
<h3>微信二维码</h3> <h3>微信二维码</h3>
@@ -276,16 +276,26 @@
} }
async function saveBank() { async function saveBank() {
error.value = ''; error.value = '';
if ( const identityNo = bank.identity_no.replace(/[ -]/g, '');
!bank.holder_name || const accountNo = bank.account_no.replace(/[ -]/g, '');
!/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(bank.identity_no.replace(/[ -]/g, '')) || const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, '');
!/^[0-9 -]{8,36}$/.test(bank.account_no) || const validationError =
!bank.bank_name || bank.holder_name.length < 2
!/^\d{6,32}$/.test(bank.digital_rmb_code.replace(/[ -]/g, '')) || ? '请输入至少 2 个字符的姓名'
!bank.wechat_qr_attachment_id || : !/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(identityNo)
!bank.alipay_qr_attachment_id ? '请输入正确的 15 位或 18 位身份证号'
) { : !/^\d{8,32}$/.test(accountNo)
error.value = '请完整填写绑卡信息并上传微信、支付宝二维码'; ? '请输入 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; return;
} }
submitting.value = true; submitting.value = true;
+83 -5
View File
@@ -47,12 +47,39 @@
:trap-focus="false" :trap-focus="false"
@after-enter="focusTradePassword" @after-enter="focusTradePassword"
> >
<form class="m-withdraw-confirm m-card" @submit.prevent="submitWithdrawal"> <form v-if="showTradePasswordSetup" class="m-withdraw-confirm m-card" @submit.prevent="setTradePassword">
<h2>设置支付密码</h2>
<p class="m-withdraw-password-notice">设置完成后,请返回确认申请。</p>
<label>
<span>新支付密码</span>
<input
v-model="setupForm.newTradePassword"
type="password"
inputmode="numeric"
maxlength="6"
autocomplete="new-password"
placeholder="请输入 6 位数字"
/>
</label>
<label>
<span>登录密码确认</span>
<input
v-model="setupForm.loginPassword"
type="password"
autocomplete="current-password"
placeholder="请输入登录密码"
/>
</label>
<p v-if="setupError" class="m-form-error">{{ setupError }}</p>
<button class="m-primary-btn" :disabled="submitting">{{ submitting ? '设置中...' : '确认设置' }}</button>
<button class="m-outline-btn m-withdraw-back-btn" type="button" @click="showTradePasswordSetup = false">返回确认申请</button>
</form>
<form v-else class="m-withdraw-confirm m-card" @submit.prevent="submitWithdrawal">
<h2>确认取出</h2> <h2>确认取出</h2>
<div><span>申请金额</span><b>{{ money(quote?.request_amount) }}</b></div> <div><span>申请金额</span><b>{{ money(quote?.request_amount) }}</b></div>
<div><span>手续费</span><b>{{ money(quote?.fee_amount) }}</b></div> <div><span>手续费</span><b>{{ money(quote?.fee_amount) }}</b></div>
<div><span>预计到账</span><strong>{{ money(quote?.actual_amount) }}</strong></div> <div><span>预计到账</span><strong>{{ money(quote?.actual_amount) }}</strong></div>
<label> <label v-if="hasTradePassword">
<span>支付密码</span> <span>支付密码</span>
<input <input
ref="tradePasswordInput" ref="tradePasswordInput"
@@ -65,8 +92,10 @@
placeholder="请输入 6 位支付密码" placeholder="请输入 6 位支付密码"
/> />
</label> </label>
<p v-else class="m-withdraw-password-notice">尚未设置支付密码,请先完成设置。</p>
<button v-if="!hasTradePassword" class="m-outline-btn m-withdraw-setup-btn" type="button" @click="openTradePasswordSetup">去设置支付密码</button>
<p v-if="actionError" class="m-form-error">{{ actionError }}</p> <p v-if="actionError" class="m-form-error">{{ actionError }}</p>
<button class="m-primary-btn" :disabled="submitting">确认申请</button> <button class="m-primary-btn" :disabled="submitting || !hasTradePassword">确认申请</button>
</form> </form>
</n-modal> </n-modal>
</section> </section>
@@ -78,7 +107,7 @@
import { ChevronForwardOutline } from '@vicons/ionicons5'; import { ChevronForwardOutline } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.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'; import { mobileAssets } from '../assets';
const router = useRouter(); const router = useRouter();
@@ -90,9 +119,13 @@
const accounts = ref<any[]>([]); const accounts = ref<any[]>([]);
const quote = ref<any>(); const quote = ref<any>();
const showConfirm = ref(false); const showConfirm = ref(false);
const showTradePasswordSetup = ref(false);
const setupError = ref('');
const withdrawalKey = ref(''); const withdrawalKey = ref('');
const tradePasswordInput = ref<HTMLInputElement>(); const tradePasswordInput = ref<HTMLInputElement>();
const form = reactive({ paymentAccountId: 0, amountYuan: '', tradePassword: '' }); const form = reactive({ paymentAccountId: 0, amountYuan: '', tradePassword: '' });
const setupForm = reactive({ newTradePassword: '', loginPassword: '' });
const hasTradePassword = computed(() => me.value?.has_trade_password === true);
const selectedAccount = computed(() => const selectedAccount = computed(() =>
accounts.value.find((item) => item.id === form.paymentAccountId) accounts.value.find((item) => item.id === form.paymentAccountId)
); );
@@ -112,7 +145,44 @@
} }
async function focusTradePassword() { async function focusTradePassword() {
await nextTick(); 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() { async function load() {
loading.value = true; loading.value = true;
@@ -152,6 +222,7 @@
}); });
withdrawalKey.value = idempotencyKey(); withdrawalKey.value = idempotencyKey();
form.tradePassword = ''; form.tradePassword = '';
showTradePasswordSetup.value = false;
showConfirm.value = true; showConfirm.value = true;
} catch (err) { } catch (err) {
actionError.value = errorText(err); actionError.value = errorText(err);
@@ -161,6 +232,10 @@
} }
async function submitWithdrawal() { async function submitWithdrawal() {
if (!quote.value || submitting.value) return; if (!quote.value || submitting.value) return;
if (!hasTradePassword.value) {
actionError.value = '请先设置支付密码';
return;
}
if (!/^\d{6}$/.test(form.tradePassword)) { if (!/^\d{6}$/.test(form.tradePassword)) {
actionError.value = '请输入 6 位支付密码'; actionError.value = '请输入 6 位支付密码';
return; return;
@@ -184,6 +259,9 @@
await router.replace('/app/profile/records?tab=withdrawal'); await router.replace('/app/profile/records?tab=withdrawal');
} catch (err) { } catch (err) {
actionError.value = errorText(err); actionError.value = errorText(err);
if (err instanceof MobileApiError && err.reason === 'TRADE_PASSWORD_NOT_SET') {
me.value = { ...me.value, has_trade_password: false };
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }