fix: allow optional digital RMB code and inline payment password setup
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
v-model.trim="bank.digital_rmb_code"
|
||||
inputmode="numeric"
|
||||
maxlength="32"
|
||||
placeholder="请输入数字编码"
|
||||
placeholder="请输入数字编码(选填)"
|
||||
/></label>
|
||||
<section class="m-qr-field">
|
||||
<h3>微信二维码</h3>
|
||||
@@ -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;
|
||||
|
||||
@@ -47,12 +47,39 @@
|
||||
:trap-focus="false"
|
||||
@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>
|
||||
<div><span>申请金额</span><b>{{ money(quote?.request_amount) }}</b></div>
|
||||
<div><span>手续费</span><b>{{ money(quote?.fee_amount) }}</b></div>
|
||||
<div><span>预计到账</span><strong>{{ money(quote?.actual_amount) }}</strong></div>
|
||||
<label>
|
||||
<label v-if="hasTradePassword">
|
||||
<span>支付密码</span>
|
||||
<input
|
||||
ref="tradePasswordInput"
|
||||
@@ -65,8 +92,10 @@
|
||||
placeholder="请输入 6 位支付密码"
|
||||
/>
|
||||
</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>
|
||||
<button class="m-primary-btn" :disabled="submitting">确认申请</button>
|
||||
<button class="m-primary-btn" :disabled="submitting || !hasTradePassword">确认申请</button>
|
||||
</form>
|
||||
</n-modal>
|
||||
</section>
|
||||
@@ -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<any[]>([]);
|
||||
const quote = ref<any>();
|
||||
const showConfirm = ref(false);
|
||||
const showTradePasswordSetup = ref(false);
|
||||
const setupError = ref('');
|
||||
const withdrawalKey = ref('');
|
||||
const tradePasswordInput = ref<HTMLInputElement>();
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user