diff --git a/README.md b/README.md index 2ad3e81..97390ce 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ - `GET /v1/manual-products/{product_id}` - `GET /v1/payment-accounts` - `POST /v1/payment-accounts` +- `PUT /v1/payment-accounts` +- `GET /v1/payment-accounts/details` - `POST /v1/withdrawal-orders/quote` - `POST /v1/withdrawal-orders` - `GET /v1/withdrawal-orders` diff --git a/docs/移动端任务与账户平台前后端对接文档.md b/docs/移动端任务与账户平台前后端对接文档.md index e5e6778..66c42b4 100644 --- a/docs/移动端任务与账户平台前后端对接文档.md +++ b/docs/移动端任务与账户平台前后端对接文档.md @@ -867,6 +867,8 @@ GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income ```http GET /v1/payment-accounts POST /v1/payment-accounts +PUT /v1/payment-accounts +GET /v1/payment-accounts/details POST /v1/attachments/images ``` @@ -883,6 +885,8 @@ POST /v1/attachments/images } ``` +在“取出信息”页面,`GET /v1/payment-accounts/details` 返回当前会员自己的原始绑定字段及二维码附件 ID;页面将首条默认账户回填到同一表单。修改时调用 `PUT /v1/payment-accounts`,请求体沿用新增字段并增加已有账户的 `id`。更新按租户、会员和账户 ID 校验归属,原记录 ID 保持不变;未更换二维码时可沿用已绑定的有效附件 ID。历史资料无法解密时不回填脱敏字符串,用户须重新填写敏感字段。 + 绑卡页面按原型固定为银行卡账户,字段规则如下: | 字段 | 是否必填 | 说明 | diff --git a/internal/finance/service.go b/internal/finance/service.go index fce6592..7cd4126 100644 --- a/internal/finance/service.go +++ b/internal/finance/service.go @@ -44,6 +44,7 @@ type Repository interface { PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) PaymentAccountDetails(context.Context, int64, int64) ([]store.PaymentAccount, error) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) + UpdatePaymentAccount(context.Context, int64, store.PaymentAccountCreation) (*store.PaymentAccount, error) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error) WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error) @@ -67,6 +68,7 @@ type Service struct { } type PaymentAccountInput struct { + ID int64 `json:"id"` BankName string `json:"bank_name"` HolderName string `json:"holder_name"` IdentityNo string `json:"identity_no"` @@ -94,17 +96,19 @@ type PaymentAccount struct { } 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"` - Masked bool `json:"masked"` + 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"` + WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"` + AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"` + VerifyStatus string `json:"verify_status"` + IsDefault bool `json:"is_default"` + Masked bool `json:"masked"` } type QuoteInput struct { @@ -335,6 +339,7 @@ func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID ID: row.ID, BankName: row.BankName, HolderName: holderName, IdentityNo: identityNo, AccountNo: accountNo, DigitalRMBCode: digitalRMBCode, WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL, + WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID, VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, Masked: masked, }) } @@ -342,34 +347,63 @@ func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID } func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) { + data, err := s.paymentAccountCreation(tenantID, memberID, input) + if err != nil { + return nil, err + } + row, err := s.repository.CreatePaymentAccount(ctx, data) + if err != nil { + return nil, err + } + result := paymentAccountFromStore(*row) + return &result, nil +} + +func (s *Service) UpdatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) { + if input.ID <= 0 { + return nil, ErrAccountFieldsInvalid + } + data, err := s.paymentAccountCreation(tenantID, memberID, input) + if err != nil { + return nil, err + } + row, err := s.repository.UpdatePaymentAccount(ctx, input.ID, data) + if err != nil { + return nil, err + } + result := paymentAccountFromStore(*row) + return &result, nil +} + +func (s *Service) paymentAccountCreation(tenantID, memberID int64, input PaymentAccountInput) (store.PaymentAccountCreation, error) { if !s.ready { - return nil, ErrUnavailable + return store.PaymentAccountCreation{}, ErrUnavailable } if err := normalizePaymentAccount(&input); err != nil { - return nil, err + return store.PaymentAccountCreation{}, err } holderCipher, err := s.encrypt(input.HolderName) if err != nil { - return nil, err + return store.PaymentAccountCreation{}, err } identityNoCipher, err := s.encrypt(input.IdentityNo) if err != nil { - return nil, err + return store.PaymentAccountCreation{}, err } accountCipher, err := s.encrypt(input.AccountNo) if err != nil { - return nil, err + return store.PaymentAccountCreation{}, err } digitalRMBCodeCipher, digitalRMBCodeHash, digitalRMBCodeMask := "", "", "" if input.DigitalRMBCode != "" { digitalRMBCodeCipher, err = s.encrypt(input.DigitalRMBCode) if err != nil { - return nil, err + return store.PaymentAccountCreation{}, err } digitalRMBCodeHash = s.lookupHash(input.DigitalRMBCode) digitalRMBCodeMask = maskAccount(input.DigitalRMBCode) } - row, err := s.repository.CreatePaymentAccount(ctx, store.PaymentAccountCreation{ + return store.PaymentAccountCreation{ TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName, HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo), @@ -379,12 +413,7 @@ func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID i DigitalRMBCodeMask: digitalRMBCodeMask, WechatQRAttachmentID: input.WechatQRAttachmentID, AlipayQRAttachmentID: input.AlipayQRAttachmentID, - }) - if err != nil { - return nil, err - } - result := paymentAccountFromStore(*row) - return &result, nil + }, nil } func (s *Service) Quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) { diff --git a/internal/finance/service_test.go b/internal/finance/service_test.go index 0d8b29e..2692f4b 100644 --- a/internal/finance/service_test.go +++ b/internal/finance/service_test.go @@ -27,7 +27,9 @@ type fakeRepository struct { type paymentAccountRepository struct { fakeRepository - created store.PaymentAccountCreation + created store.PaymentAccountCreation + updated store.PaymentAccountCreation + updatedID int64 } func (f *paymentAccountRepository) CreatePaymentAccount(_ context.Context, input store.PaymentAccountCreation) (*store.PaymentAccount, error) { @@ -35,6 +37,12 @@ func (f *paymentAccountRepository) CreatePaymentAccount(_ context.Context, input return &store.PaymentAccount{ID: 1, AccountType: input.AccountType}, nil } +func (f *paymentAccountRepository) UpdatePaymentAccount(_ context.Context, id int64, input store.PaymentAccountCreation) (*store.PaymentAccount, error) { + f.updatedID = id + f.updated = input + return &store.PaymentAccount{ID: id, AccountType: input.AccountType, BankName: input.BankName}, nil +} + func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) { return f.config, nil } @@ -50,6 +58,9 @@ func (f *fakeRepository) PaymentAccountDetails(context.Context, int64, int64) ([ func (f *fakeRepository) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) { return nil, errors.New("not implemented") } +func (f *fakeRepository) UpdatePaymentAccount(context.Context, int64, store.PaymentAccountCreation) (*store.PaymentAccount, error) { + return nil, errors.New("not implemented") +} func (f *fakeRepository) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) { return f.account, nil } @@ -155,7 +166,8 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) { repository.details = []store.PaymentAccount{{ ID: 7, BankName: "中国工商银行", HolderCipher: holder, IdentityNoCipher: identity, AccountNoCipher: account, DigitalRMBCodeCipher: digital, - WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png", VerifyStatus: 2, + WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png", + WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, VerifyStatus: 2, }} items, err := service.PaymentAccountDetails(context.Background(), 1, 2) if err != nil { @@ -163,11 +175,34 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) { } 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" { + items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" || + items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 { t.Fatalf("unexpected account details: %#v", items) } } +func TestUpdatePaymentAccountEncryptsEditedFields(t *testing.T) { + repository := &paymentAccountRepository{} + service := New(repository, &redis.Client{}, config.AuthConfig{ + FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters", + }) + result, err := service.UpdatePaymentAccount(context.Background(), 1, 2, PaymentAccountInput{ + ID: 7, BankName: "中国工商银行", HolderName: "李四", IdentityNo: "11010119900101123X", + AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, + }) + if err != nil { + t.Fatal(err) + } + if result.ID != 7 || repository.updatedID != 7 || repository.updated.TenantID != 1 || repository.updated.MemberID != 2 || + repository.updated.WechatQRAttachmentID != 8 || repository.updated.AlipayQRAttachmentID != 9 || + repository.updated.DigitalRMBCodeCipher != "" { + t.Fatalf("unexpected updated account: result=%#v data=%#v", result, repository.updated) + } + if holder, err := service.decrypt(repository.updated.HolderCipher); err != nil || holder != "李四" { + t.Fatalf("edited holder was not encrypted correctly: %q, %v", holder, err) + } +} + func TestPaymentAccountDetailsFallsBackToMasksWhenHistoricalKeyIsUnavailable(t *testing.T) { repository := &fakeRepository{details: []store.PaymentAccount{{ ID: 8, BankName: "中国银行", HolderCipher: "legacy-holder", HolderMask: "张*", diff --git a/internal/httpapi/finance.go b/internal/httpapi/finance.go index c42c7ea..923dad8 100644 --- a/internal/httpapi/finance.go +++ b/internal/httpapi/finance.go @@ -12,8 +12,8 @@ import ( ) func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodPost { - methodNotAllowed(w, r, http.MethodGet, http.MethodPost) + if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodPut { + methodNotAllowed(w, r, http.MethodGet, http.MethodPost, http.MethodPut) return } principal, ok := a.authenticateFinanceMember(w, r) @@ -33,7 +33,13 @@ func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) { if !decodeBody(w, r, &body) { return } - result, err := a.finance.CreatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body) + var result *finance.PaymentAccount + var err error + if r.Method == http.MethodPut { + result, err = a.finance.UpdatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body) + } else { + result, err = a.finance.CreatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body) + } if err != nil { writeFinanceError(w, r, err) return diff --git a/internal/store/withdrawals.go b/internal/store/withdrawals.go index b91124d..022bf51 100644 --- a/internal/store/withdrawals.go +++ b/internal/store/withdrawals.go @@ -251,6 +251,59 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr VerifyStatus: 2, IsDefault: isDefault}, nil } +func (s *Store) UpdatePaymentAccount(ctx context.Context, accountID int64, input PaymentAccountCreation) (*PaymentAccount, error) { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + var isDefault int + err = tx.GetContext(ctx, &isDefault, `SELECT is_default FROM hg_app_member_payment_account + WHERE id=? AND tenant_id=? AND member_id=? AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, + accountID, input.TenantID, input.MemberID) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrPaymentAccountInvalid + } + if err != nil { + return nil, err + } + var attachmentCount int + err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment + WHERE id IN (?,?) AND app_id='olons_server' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`, + input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID) + if err != nil { + return nil, err + } + if attachmentCount != 2 { + return nil, ErrAttachmentInvalid + } + _, err = tx.ExecContext(ctx, `UPDATE hg_app_member_payment_account SET + bank_name=?,holder_cipher=?,holder_mask=?,identity_no_cipher=?,identity_no_hash=?,identity_no_mask=?, + account_no_cipher=?,account_no_hash=?,account_no_mask=?,digital_rmb_code_cipher=?, + digital_rmb_code_hash=?,digital_rmb_code_mask=?,wechat_qr_attachment_id=?,alipay_qr_attachment_id=?, + verify_status=2,updated_by=?,updated_at=NOW() WHERE id=? AND tenant_id=? AND member_id=? AND deleted_at IS NULL`, + input.BankName, input.HolderCipher, input.HolderMask, input.IdentityNoCipher, input.IdentityNoHash, + input.IdentityNoMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask, + input.DigitalRMBCodeCipher, input.DigitalRMBCodeHash, input.DigitalRMBCodeMask, + input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID, + accountID, input.TenantID, input.MemberID) + if err != nil { + if isDuplicate(err) { + return nil, ErrPaymentAccountExists + } + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + return &PaymentAccount{ID: accountID, AccountType: input.AccountType, BankCode: input.BankCode, + BankName: input.BankName, HolderMask: input.HolderMask, IdentityNoMask: input.IdentityNoMask, + AccountNoMask: input.AccountNoMask, DigitalRMBCodeMask: input.DigitalRMBCodeMask, + WechatQRAttachmentID: input.WechatQRAttachmentID, + AlipayQRAttachmentID: input.AlipayQRAttachmentID, + VerifyStatus: 2, IsDefault: isDefault}, nil +} + func nullableString(value string) any { if value == "" { return nil diff --git a/web/src/mobile/styles.css b/web/src/mobile/styles.css index e4eacf0..af8c7ba 100644 --- a/web/src/mobile/styles.css +++ b/web/src/mobile/styles.css @@ -1839,36 +1839,10 @@ body { .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 { - display: grid; - gap: 6px; -} -.m-bound-account span { +.m-bound-account-hint { + margin: 16px 20px; color: var(--m-muted); -} -.m-bound-account strong { - overflow-wrap: anywhere; - font-weight: 500; -} -.m-bank-form-title { - padding: 12px 0 0; + font-size: 14px; } .m-profile-edit { text-align: center; diff --git a/web/src/mobile/views/FormView.vue b/web/src/mobile/views/FormView.vue index 36d47b1..9ca6a00 100644 --- a/web/src/mobile/views/FormView.vue +++ b/web/src/mobile/views/FormView.vue @@ -50,20 +50,9 @@ -
历史资料仅展示脱敏信息,重新绑定后可查看完整资料
-