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