SFT/mock-server/routes/wallet.js

542 lines
24 KiB
JavaScript

import express from 'express'
import {
walletAccounts,
balances,
boundCards,
enterpriseOpenApplications,
personalOpenApplications
} from '../db/seedWallet.js'
import { individualCustomers } from '../db/seedCustomer.js'
import { nextId } from '../utils/id.js'
import { paginate } from '../utils/pagination.js'
import { sendOk, sendFail } from '../utils/response.js'
import { requireAuth } from '../middleware/requireAuth.js'
import { verifySmsCode } from '../state.js'
const router = express.Router()
router.use(requireAuth)
function toWalletAccountVo(a) {
return {
id: a.id,
customerId: a.customerId,
accountNo: a.accountNo,
accountType: a.accountType,
accountName: a.accountName,
accountRelation: a.accountRelation,
customerNo: a.customerNo,
accountStatus: a.accountStatus,
mainAccountNo: a.mainAccountNo,
openDate: a.openDate
}
}
// ================= 账户列表 / 账户主页 =================
router.post('/wallet-account/page', (req, res) => {
const { accountName, accountNo, accountType, page, pageSize } = req.body || {}
let list = walletAccounts
if (accountName) list = list.filter((a) => a.accountName.includes(accountName))
if (accountNo) list = list.filter((a) => a.accountNo.includes(accountNo))
if (accountType) list = list.filter((a) => a.accountType === accountType)
const result = paginate(list, page, pageSize)
sendOk(res, { ...result, records: result.records.map(toWalletAccountVo) })
})
router.post('/wallet-account/detail', (req, res) => {
const { id } = req.body || {}
const account = walletAccounts.find((a) => a.id === id)
if (!account) return sendFail(res, '账户不存在', 404)
const siblings = walletAccounts.filter((a) => a.customerId === account.customerId && a.customerType === account.customerType)
const findByType = (type) => siblings.find((a) => a.accountType === type)
const main = findByType('A1')
const a2 = findByType('A2')
const a6 = findByType('A6')
const a7 = findByType('A7')
sendOk(res, {
mainAccountNo: main?.accountNo || '',
mainBalance: main ? balances[main.accountNo] || '0.00' : '0.00',
a2AccountNo: a2?.accountNo || '',
a2Balance: a2 ? balances[a2.accountNo] || '0.00' : '0.00',
a6AccountNo: a6?.accountNo || '',
a6Balance: a6 ? balances[a6.accountNo] || '0.00' : '0.00',
a7AccountNo: a7?.accountNo || '',
a7Balance: a7 ? balances[a7.accountNo] || '0.00' : '0.00'
})
})
router.post('/wallet-account/close', (req, res) => {
const { id } = req.body || {}
const account = walletAccounts.find((a) => a.id === id)
if (!account) return sendFail(res, '账户不存在', 404)
account.accountStatus = 'CLOSED'
sendOk(res, true, '销户成功')
})
router.post('/wallet-account/open-sub-account', (req, res) => {
const { accountNo } = req.body || {}
const main = walletAccounts.find((a) => a.accountNo === accountNo)
if (!main) return sendFail(res, '主账户不存在', 404)
const id = nextId('walletAccount')
const seq = nextId('walletAccountSeq')
const sub = {
id,
customerId: main.customerId,
customerType: main.customerType,
accountNo: `622${String(seq).padStart(13, '0')}`,
accountType: 'A7',
accountName: main.accountName,
accountRelation: '1',
customerNo: main.customerNo,
accountStatus: 'NORMAL',
mainAccountNo: main.accountNo,
openDate: new Date().toISOString().slice(0, 19).replace('T', ' ')
}
walletAccounts.push(sub)
balances[sub.accountNo] = '0.00'
sendOk(res, toWalletAccountVo(sub), '分户开立成功')
})
router.post('/wallet-account/update', (req, res) => {
const { accountNo, mobile, bankCardNumber, bankNo, bankName } = req.body || {}
const account = walletAccounts.find((a) => a.accountNo === accountNo)
if (!account) return sendFail(res, '账户不存在', 404)
const card = boundCards.find((c) => c.accountNo === accountNo)
if (card) {
if (mobile !== undefined) card.mobile = mobile
if (bankCardNumber !== undefined) card.primaryAccount = bankCardNumber
if (bankNo !== undefined) card.bankNo = bankNo
if (bankName !== undefined) card.bankName = bankName
}
sendOk(res, true, '账户信息变更成功')
})
router.post('/wallet-account/change-mobile', (req, res) => {
const { accountNo, newMobile } = req.body || {}
if (!accountNo || !newMobile) return sendFail(res, 'accountNo/newMobile不能为空', 400)
const card = boundCards.find((c) => c.accountNo === accountNo)
if (card) card.mobile = newMobile
sendOk(res, true, '手机号变更成功')
})
router.post('/wallet-account/sync-balance', (req, res) => {
const { id } = req.body || {}
const account = walletAccounts.find((a) => a.id === id)
if (!account) return sendFail(res, '账户不存在', 404)
sendOk(res, true, '余额同步成功')
})
// 交易明细:mock按查询区间生成一批确定性的示例流水,不做持久化存储
router.post('/wallet-account/transaction-detail', (req, res) => {
const { accountNo, channelNo, startDate, endDate, page, rows } = req.body || {}
if (!accountNo || !channelNo || !startDate || !endDate) {
return sendFail(res, 'accountNo/channelNo/startDate/endDate不能为空', 400)
}
const account = walletAccounts.find((a) => a.accountNo === accountNo)
const balance = account ? Number(balances[accountNo] || 0) : 0
const sampleTypes = ['收款', '付款', '提现', '保证金缴纳']
const detailList = Array.from({ length: 5 }).map((_, idx) => {
const amount = (1000 + idx * 500).toFixed(2)
const isIncome = idx % 2 === 0
return {
id: idx + 1,
accountDate: startDate.slice(0, 10),
tradeTime: startDate,
detailType: '01',
transType: sampleTypes[idx % sampleTypes.length],
transAmount: isIncome ? amount : `-${amount}`,
balance: (balance - idx * 100).toFixed(2),
oppAccountNo: `622${String(9000000000000 + idx).slice(0, 13)}`,
oppAccountName: `示例对方户${idx + 1}`,
remark: '业务往来款'
}
})
sendOk(res, {
accountNo,
accountName: account?.accountName || '',
curBalance: balance.toFixed(2),
availBalance: balance.toFixed(2),
withdrawBalance: balance.toFixed(2),
preBalance: '0.00',
detailList,
page: page ?? 0,
rows: rows ?? 10000
})
})
router.post('/wallet-account/withdraw', (req, res) => {
const { channelNo, withdrawRequestEo } = req.body || {}
if (!channelNo || !withdrawRequestEo) return sendFail(res, 'channelNo/withdrawRequestEo不能为空', 400)
const { accountNo, accountName, primaryAccount, amount, verifyCode, transSummary } = withdrawRequestEo
if (!verifyCode || !verifySmsCode('WALLET_WITHDRAW', boundCards.find((c) => c.accountNo === accountNo)?.mobile || '', verifyCode)) {
return sendFail(res, '验证码错误或已过期', 400)
}
const current = Number(balances[accountNo] || 0)
const amt = Number(amount)
if (!amt || amt <= 0) return sendFail(res, '提现金额不合法', 400)
if (amt > current) return sendFail(res, '账户余额不足', 400)
balances[accountNo] = (current - amt).toFixed(2)
sendOk(res, {
success: true,
recode: '0000',
recodeInfo: '提现成功',
accountNo,
accountName,
serialNo: `WD${Date.now()}`,
transAmount: amount
}, transSummary || '提现成功')
})
router.post('/wallet-account/margin-pay', (req, res) => {
const { accountNo, accountName, tradeAmount } = req.body || {}
const account = walletAccounts.find((a) => a.accountNo === accountNo)
if (!account) return sendFail(res, '主账户不存在', 404)
const marginAccount = walletAccounts.find((a) => a.customerId === account.customerId && a.customerType === account.customerType && a.accountType === 'A6')
if (!marginAccount) return sendFail(res, '该客户未开立保证金账户', 400)
const amt = Number(tradeAmount)
const mainBalance = Number(balances[accountNo] || 0)
if (!amt || amt <= 0) return sendFail(res, '缴纳金额不合法', 400)
if (amt > mainBalance) return sendFail(res, '主账户余额不足', 400)
balances[accountNo] = (mainBalance - amt).toFixed(2)
balances[marginAccount.accountNo] = (Number(balances[marginAccount.accountNo] || 0) + amt).toFixed(2)
sendOk(res, {
success: true,
recode: '0000',
recodeInfo: '保证金缴纳成功',
accountNoBzj: marginAccount.accountNo,
accountNameBzj: accountName || account.accountName
})
})
router.post('/wallet-account/margin-release', (req, res) => {
const { accountNo, accountName, tradeAmount } = req.body || {}
const account = walletAccounts.find((a) => a.accountNo === accountNo)
if (!account) return sendFail(res, '主账户不存在', 404)
const marginAccount = walletAccounts.find((a) => a.customerId === account.customerId && a.customerType === account.customerType && a.accountType === 'A6')
if (!marginAccount) return sendFail(res, '该客户未开立保证金账户', 400)
const amt = Number(tradeAmount)
const marginBalance = Number(balances[marginAccount.accountNo] || 0)
if (!amt || amt <= 0) return sendFail(res, '释放金额不合法', 400)
if (amt > marginBalance) return sendFail(res, '保证金账户余额不足', 400)
balances[marginAccount.accountNo] = (marginBalance - amt).toFixed(2)
balances[accountNo] = (Number(balances[accountNo] || 0) + amt).toFixed(2)
sendOk(res, {
success: true,
recode: '0000',
recodeInfo: '保证金释放成功',
accountNoBzj: marginAccount.accountNo,
accountNameBzj: accountName || account.accountName
})
})
router.post('/wallet-account/download-statement', (req, res) => {
const { accountNo, channelNo, startDate, endDate } = req.body || {}
if (!accountNo || !channelNo || !startDate || !endDate) {
return sendFail(res, 'accountNo/channelNo/startDate/endDate不能为空', 400)
}
sendOk(res, { fileData: Buffer.from(`对账单-${accountNo}-${startDate}~${endDate}`).toString('base64') })
})
router.post('/wallet-account/download-receipt', (req, res) => {
const { accountNo, channelNo, originalSerialNo } = req.body || {}
if (!accountNo || !channelNo || !originalSerialNo) {
return sendFail(res, 'accountNo/channelNo/originalSerialNo不能为空', 400)
}
sendOk(res, { fileData: Buffer.from(`回单-${accountNo}-${originalSerialNo}`).toString('base64') })
})
function toBindCardListVo(accountNo) {
const account = walletAccounts.find((a) => a.accountNo === accountNo)
const balance = account ? balances[accountNo] || '0.00' : '0.00'
const cards = boundCards.filter((c) => c.accountNo === accountNo)
return {
accountNo,
accountName: account?.accountName || '',
idType: 'ID_CARD',
idNo: '',
curBalance: balance,
availBalance: balance,
withdrawBalance: balance,
preBalance: '0.00',
detailList: cards.map((c) => ({ mobile: c.mobile, primaryAccount: c.primaryAccount }))
}
}
router.post('/wallet-account/bind-card-list', (req, res) => {
const { accountNo, channelNo } = req.body || {}
if (!accountNo || !channelNo) return sendFail(res, 'accountNo/channelNo不能为空', 400)
sendOk(res, toBindCardListVo(accountNo))
})
router.post('/wallet-account/bind-card', (req, res) => {
const { accountNo, idNo, mobile, bankCardNumber, bankNo, bankName, setDefault, verifyCode } = req.body || {}
if (!verifyCode || !verifySmsCode('WALLET_BIND_CARD', mobile, verifyCode)) {
return sendFail(res, '验证码错误或已过期', 400)
}
if (setDefault === 'true') {
boundCards.filter((c) => c.accountNo === accountNo).forEach((c) => (c.setDefault = false))
}
boundCards.push({ accountNo, idNo, mobile, primaryAccount: bankCardNumber, bankNo, bankName, setDefault: setDefault === 'true' })
sendOk(res, { success: true, message: '绑卡成功' })
})
router.post('/wallet-account/unbind-card', (req, res) => {
const { accountNo, bankCardNumber } = req.body || {}
const cardsOfAccount = boundCards.filter((c) => c.accountNo === accountNo)
if (cardsOfAccount.length <= 1) return sendOk(res, { success: false, message: '最后一张绑卡不可解绑' })
const index = boundCards.findIndex((c) => c.accountNo === accountNo && c.primaryAccount === bankCardNumber)
if (index === -1) return sendFail(res, '未找到该绑卡记录', 404)
boundCards.splice(index, 1)
sendOk(res, { success: true, message: '解绑成功' })
})
// 校验银行卡号是否有效/可用:真实后端方法签名为 @RequestParam(cardNo, channelNo),走查询字符串传参,
// 不是 JSON Body,故这里读 req.query 而不是 req.body(个人开户申请"银行卡号"字段失焦时触发)。
// 响应体字段对齐真实后端 QueryCardInfoVo:bankName/bankNo/cardType(0借记卡1准贷记卡2贷记卡)/payBankNo
router.post('/wallet-account/query-card-info', (req, res) => {
const { cardNo, channelNo } = req.query || {}
if (!cardNo || !channelNo) return sendFail(res, 'cardNo/channelNo不能为空', 400)
if (!/^(\d{16}|\d{19})$/.test(cardNo)) return sendFail(res, '银行卡号格式不正确', 400)
sendOk(res, { bankName: '中国工商银行', bankNo: '102100099996', cardType: '0', payBankNo: '102100000037' })
})
// ================= 个人开户 =================
router.post('/wallet-management/personal-opening/query-customer', (req, res) => {
const { organizationId, customerCode, customerName, certificateNumber, page, pageSize } = req.body || {}
let list = individualCustomers
if (organizationId) list = list.filter((c) => c.organizationId === organizationId)
if (customerCode) list = list.filter((c) => c.customerCode === customerCode)
if (customerName) list = list.filter((c) => c.customerName.includes(customerName))
if (certificateNumber) list = list.filter((c) => c.certificateNumber === certificateNumber)
const result = paginate(list, page, pageSize)
sendOk(res, {
...result,
records: result.records.map((c) => ({
id: c.id,
customerCode: c.customerCode,
customerName: c.customerName,
certificateType: c.certificateType,
certificateNumber: c.certificateNumber,
mobilePhone: c.mobilePhone,
organizationId: c.organizationId,
gender: c.gender,
birthDate: c.birthDate,
ethnicity: c.ethnicity,
certificateEffectiveDate: c.certificateEffectiveDate,
certificateExpiryDate: c.certificateExpiryDate,
issuingAuthority: c.issuingAuthority,
certificateAddress: c.certificateAddress,
occupation: c.occupation
}))
})
})
router.post('/wallet-management/personal-opening/create-draft', (req, res) => {
const body = req.body || {}
if (!body.customerId || !body.customerName) return sendFail(res, 'customerId/customerName不能为空', 400)
const applicationNo = `APPPO${String(nextId('personalOpenApplication')).padStart(8, '0')}`
personalOpenApplications.push({
applicationNo,
...body,
applicationStatus: 'DRAFT',
failReason: '',
mainAccountNo: '',
customerNo: '',
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
updatedAt: new Date().toISOString().slice(0, 19).replace('T', ' ')
})
sendOk(res, { applicationNo }, '草稿创建成功')
})
router.post('/wallet-management/personal-opening/query-progress', (req, res) => {
const { applicationNo } = req.body || {}
const application = personalOpenApplications.find((a) => a.applicationNo === applicationNo)
if (!application) return sendFail(res, '申请单不存在', 404)
// mock简化:草稿创建后立即模拟"已开立"结果,便于演示完整流程
if (application.applicationStatus === 'DRAFT') {
const id = nextId('walletAccount')
const seq = nextId('walletAccountSeq')
const newAccountNo = `622${String(seq).padStart(13, '0')}`
walletAccounts.push({
id,
customerId: application.customerId,
customerType: 'PERSONAL',
accountNo: newAccountNo,
accountType: 'A1',
accountName: application.customerName,
accountRelation: '0',
customerNo: `CN${String(application.customerId).padStart(8, '0')}`,
accountStatus: 'NORMAL',
mainAccountNo: newAccountNo,
openDate: new Date().toISOString().slice(0, 19).replace('T', ' ')
})
balances[newAccountNo] = '0.00'
application.applicationStatus = 'OPENED'
application.mainAccountNo = newAccountNo
application.customerNo = `CN${String(application.customerId).padStart(8, '0')}`
application.updatedAt = new Date().toISOString().slice(0, 19).replace('T', ' ')
}
sendOk(res, {
applicationNo: application.applicationNo,
applicationStatus: application.applicationStatus,
failReason: application.failReason,
mainAccountNo: application.mainAccountNo,
customerNo: application.customerNo,
createdAt: application.createdAt,
updatedAt: application.updatedAt
})
})
// list/detail/update:后端尚未提供,mock 按 enterprise-open-application 四件套的写法模拟实现,
// 用 applicationNo 定位记录(不假设有数字 id),详见《缺失的后端接口.md》Part 2 新增条目
function toPersonalOpenListVo(a) {
return {
applicationNo: a.applicationNo,
customerName: a.customerName,
certificateNumber: a.certificateNumber,
mobilePhone: a.mobilePhone,
// 列表页展示开户成功后的主账户账号(未开立成功前为空字符串),不展示银行卡号,
// 与企业开户申请列表 toEnterpriseOpenListVo 的字段取舍保持一致
mainAccountNo: a.mainAccountNo || '',
channelNo: a.channelNo,
applicationStatus: a.applicationStatus,
failReason: a.failReason,
createdAt: a.createdAt,
updatedAt: a.updatedAt
}
}
router.post('/wallet-management/personal-opening/list', (req, res) => {
const { applicationNo, customerNameLike, certificateNumber, applicationStatus, startDate, endDate, page, pageSize } =
req.body || {}
let list = personalOpenApplications
if (applicationNo) list = list.filter((a) => a.applicationNo === applicationNo)
if (customerNameLike) list = list.filter((a) => a.customerName.includes(customerNameLike))
if (certificateNumber) list = list.filter((a) => a.certificateNumber === certificateNumber)
if (applicationStatus) list = list.filter((a) => a.applicationStatus === applicationStatus)
if (startDate) list = list.filter((a) => a.createdAt >= startDate)
if (endDate) list = list.filter((a) => a.createdAt <= endDate)
const result = paginate(list, page, pageSize)
sendOk(res, { ...result, records: result.records.map(toPersonalOpenListVo) })
})
router.post('/wallet-management/personal-opening/detail', (req, res) => {
const { applicationNo } = req.body || {}
const application = personalOpenApplications.find((a) => a.applicationNo === applicationNo)
if (!application) return sendFail(res, '申请单不存在', 404)
sendOk(res, application)
})
router.post('/wallet-management/personal-opening/update', (req, res) => {
const { applicationNo, accountOpenApplicationFileBtoList, ...rest } = req.body || {}
const application = personalOpenApplications.find((a) => a.applicationNo === applicationNo)
if (!application) return sendFail(res, '申请单不存在', 404)
Object.assign(application, rest, {
// 前端 update 请求的证件照片字段名为 accountOpenApplicationFileBtoList(与 create-draft 的
// fileList 不同),此处归一存回 fileList,保证 detail 回显与本地演示一致
...(accountOpenApplicationFileBtoList ? { fileList: accountOpenApplicationFileBtoList } : {}),
updatedAt: new Date().toISOString().slice(0, 19).replace('T', ' ')
})
sendOk(res, true)
})
// ================= 企业开户申请 =================
function toEnterpriseOpenListVo(a) {
return {
applicationNo: a.applicationNo,
enterpriseName: a.enterpriseName,
businessLicense: a.businessLicense,
applicationStatus: a.applicationStatus,
channelNo: a.channelNo,
confirmTime: a.confirmTime,
mainAccountNo: a.mainAccountNo,
customerNo: a.customerNo,
failReason: a.failReason,
createdAt: a.createdAt,
updatedAt: a.updatedAt
}
}
router.post('/wallet-management/enterprise-open-application/list', (req, res) => {
const { applicationNo, enterpriseNameLike, applicationStatus, startDate, endDate, page, pageSize } = req.body || {}
let list = enterpriseOpenApplications
if (applicationNo) list = list.filter((a) => a.applicationNo === applicationNo)
if (enterpriseNameLike) list = list.filter((a) => a.enterpriseName.includes(enterpriseNameLike))
if (applicationStatus) list = list.filter((a) => a.applicationStatus === applicationStatus)
if (startDate) list = list.filter((a) => a.createdAt >= startDate)
if (endDate) list = list.filter((a) => a.createdAt <= endDate)
const result = paginate(list, page, pageSize)
sendOk(res, { ...result, records: result.records.map(toEnterpriseOpenListVo) })
})
router.post('/wallet-management/enterprise-open-application/create', (req, res) => {
const body = req.body || {}
if (!body.enterpriseName) return sendFail(res, '企业名称不能为空', 400)
const id = nextId('enterpriseOpenApplication')
const applicationNo = `APPEO${String(id).padStart(8, '0')}`
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
const application = {
id,
applicationNo,
applicationStatus: 'DRAFT',
failReason: '',
mainAccountNo: '',
customerNo: '',
confirmTime: '',
createdAt: now,
updatedAt: now,
fileList: body.enterpriseAccountOpenApplicationFileBtoList || [],
beneficiaryList: body.enterpriseAccountOpenApplicationBeneficiaryBtoList || [],
...body
}
enterpriseOpenApplications.push(application)
sendOk(res, { applicationNo }, '申请创建成功')
})
router.post('/wallet-management/enterprise-open-application/detail', (req, res) => {
const { applicationNo } = req.body || {}
const application = enterpriseOpenApplications.find((a) => a.applicationNo === applicationNo)
if (!application) return sendFail(res, '申请单不存在', 404)
sendOk(res, application)
})
// 已知契约缺口(见《缺失的后端接口.md》Part2-23):list/detail均不返回id,
// 前端按约定用applicationNo兜底传入id位置,mock按"数字id优先,匹配不到再按applicationNo兜底"两种方式兼容匹配
router.post('/wallet-management/enterprise-open-application/update', (req, res) => {
const { id, ...rest } = req.body || {}
const application = enterpriseOpenApplications.find((a) => a.id === id || a.applicationNo === id)
if (!application) return sendFail(res, '申请单不存在', 404)
Object.assign(application, rest, { updatedAt: new Date().toISOString().slice(0, 19).replace('T', ' ') })
sendOk(res, true)
})
// ================= 企业绑卡 =================
router.post('/wallet-management/enterprise-bank-card/list', (req, res) => {
const { accountNo, channelNo } = req.body || {}
if (!accountNo || !channelNo) return sendFail(res, 'accountNo/channelNo不能为空', 400)
sendOk(res, toBindCardListVo(accountNo))
})
router.post('/wallet-management/enterprise-bank-card/bind', (req, res) => {
const { accountNo, idNo, mobile, primaryAccount, bankNo, bankName, setDefault, verifyCode } = req.body || {}
if (!verifyCode || !verifySmsCode('WALLET_BIND_CARD', mobile, verifyCode)) {
return sendFail(res, '验证码错误或已过期', 400)
}
if (setDefault === 'true') {
boundCards.filter((c) => c.accountNo === accountNo).forEach((c) => (c.setDefault = false))
}
boundCards.push({ accountNo, idNo, mobile, primaryAccount, bankNo, bankName, setDefault: setDefault === 'true' })
sendOk(res, { success: true, message: '绑卡成功' })
})
router.post('/wallet-management/enterprise-bank-card/unbind', (req, res) => {
const { accountNo, primaryAccount } = req.body || {}
const cardsOfAccount = boundCards.filter((c) => c.accountNo === accountNo)
if (cardsOfAccount.length <= 1) return sendOk(res, { success: false, message: '最后一张绑卡不可解绑' })
const index = boundCards.findIndex((c) => c.accountNo === accountNo && c.primaryAccount === primaryAccount)
if (index === -1) return sendOk(res, { success: false, message: '未找到该绑卡记录' })
boundCards.splice(index, 1)
sendOk(res, { success: true, message: '解绑成功' })
})
export default router