diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3ae44ee..a4fc851 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -56,7 +56,25 @@ "Bash(xargs grep -l \"Route.*orders\")", "Bash(xargs grep -l \"Route.*orders/:id\")", "Bash(netstat -ano)", - "Bash(findstr :3000)" + "Bash(findstr :3000)", + "Bash(curl -s http://localhost:3000/api/admin/finance/reports/overview)", + "Bash(curl -s http://localhost:5174)", + "Bash(awk '{print $2}')", + "Bash(cd /d/project/company/rent && grep -n \"handlePreviewWeekly\" apps/platform-admin/src/pages/finance/Settlements.tsx)", + "Read(//d/d/project/company/rent/apps/platform-admin/src/pages/finance/**)", + "Bash(taskkill /F /PID 5664)", + "Bash(taskkill //F //PID 5664)", + "Bash(grep -A 3 \"INSERT INTO \\\\`merchants\\\\`\" database/seeds/001_init_data.sql)", + "Bash(taskkill //F //PID 6944)", + "Bash(taskkill //F //PID 2968)", + "Bash(taskkill //F //PID 1964)", + "Bash(pnpm --filter @rent/server dev)", + "Bash(powershell -Command 'Get-NetTCPConnection -LocalPort 3000 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }')", + "Bash(sleep 5 && tail -50 /c/Users/admin/AppData/Local/Temp/1/claude/d--project-company-rent/949fb73a-e05f-4a74-a5a8-ad2ee982a5f4/tasks/b2lmg0egw.output)", + "Read(//c/Users/admin/AppData/Local/Temp/1/claude/d--project-company-rent/949fb73a-e05f-4a74-a5a8-ad2ee982a5f4/tasks/**)", + "Bash(powershell -Command 'Get-NetTCPConnection -LocalPort 3000 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | Where-Object { $_ -ne 0 } | ForEach-Object { Stop-Process -Id $_ -Force }')", + "Bash(npx tsc *)", + "Bash(curl -s http://localhost:3000/api/admin/finance/platform-accounts)" ] } } diff --git a/apps/merchant-admin/src/components/AccountCard.tsx b/apps/merchant-admin/src/components/AccountCard.tsx index e93871e..2cfac5a 100644 --- a/apps/merchant-admin/src/components/AccountCard.tsx +++ b/apps/merchant-admin/src/components/AccountCard.tsx @@ -15,7 +15,7 @@ export const AccountCard: React.FC = ({ account, loading = fal return ( - + = ({ account, loading = fal valueStyle={{ color: '#1890ff' }} /> - + = ({ account, loading = fal valueStyle={{ color: '#faad14' }} /> - + = ({ account, loading = fal valueStyle={{ color: '#52c41a' }} /> - - - + = ({ account, loading = fal prefix="¥" /> - + + + = { pending: { color: 'gold', text: '审核中' }, approved: { color: 'green', text: '营业中' }, @@ -43,6 +48,11 @@ const Settings: React.FC = () => { district: merchant?.district, address: merchant?.address, description: merchant?.description, + logo: merchant?.logo, + coverImage: merchant?.coverImage, + licenseNo: merchant?.licenseNo, + legalPerson: merchant?.legalPerson, + businessLicense: merchant?.businessLicense, }); setEditOpen(true); } @@ -67,6 +77,7 @@ const Settings: React.FC = () => { } const status = statusMap[merchant.status] || { color: 'default', text: merchant.status }; + const hotelImagesArray = merchant.hotelImages ? merchant.hotelImages.split(',').filter(Boolean) : []; return (
@@ -111,8 +122,22 @@ const Settings: React.FC = () => { {/* 基本信息 */} - + 基本信息} style={{ marginBottom: 24 }}> + + {merchant.logo ? ( + + ) : ( + 未上传 + )} + + + {merchant.coverImage ? ( + + ) : ( + 未上传 + )} + {merchant.phone} @@ -127,6 +152,115 @@ const Settings: React.FC = () => { + {/* 店铺照片 */} + {hotelImagesArray.length > 0 && ( + 店铺照片} style={{ marginBottom: 24 }}> + + + {hotelImagesArray.map((img: string, index: number) => ( + + ))} + + + + )} + + {/* 资质信息 */} + 资质信息} style={{ marginBottom: 24 }}> + + + {merchant.contractType === 'personal' ? '个人签约' : '公司/法人签约'} + + + {merchant.storeLicense ? ( + + ) : ( + 未上传 + )} + + {merchant.contractType === 'personal' ? ( + <> + + {merchant.idCardFront ? ( + + ) : ( + 未上传 + )} + + + {merchant.idCardBack ? ( + + ) : ( + 未上传 + )} + + + ) : ( + <> + + {merchant.businessLicense ? ( + + ) : ( + 未上传 + )} + + + {merchant.legalIdCardFront ? ( + + ) : ( + 未上传 + )} + + + {merchant.legalIdCardBack ? ( + + ) : ( + 未上传 + )} + + + )} + + + + {/* 银行信息 */} + 银行信息} style={{ marginBottom: 24 }}> + + + {merchant.accountType === 'company' ? '对公账户' : '对私账户'} + + {merchant.bankName || '-'} + {merchant.bankAccount || '-'} + {merchant.accountName || '-'} + {merchant.accountType === 'personal' && ( + {merchant.bankBranch || '-'} + )} + {merchant.accountType === 'company' && merchant.bankLicense && ( + + + + )} + {merchant.accountType === 'personal' && ( + <> + + {merchant.accountIdCardFront ? ( + + ) : ( + 未上传 + )} + + + {merchant.accountIdCardBack ? ( + + ) : ( + 未上传 + )} + + + )} + + + {/* 编辑弹窗 */} { onCancel={() => setEditOpen(false)} onOk={handleUpdate} confirmLoading={loading} - width={560} + width={720} + style={{ top: 20 }} > +
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-
- 提示:修改后店铺将重新进入审核状态 -
); diff --git a/apps/merchant-admin/src/pages/finance/Settlements.tsx b/apps/merchant-admin/src/pages/finance/Settlements.tsx index 4b9d073..6a56463 100644 --- a/apps/merchant-admin/src/pages/finance/Settlements.tsx +++ b/apps/merchant-admin/src/pages/finance/Settlements.tsx @@ -12,11 +12,10 @@ import { SettlementStatusTag } from '@/components/SettlementStatusTag'; interface SettlementItem { id: number; orderId: number; + orderNo: string; orderAmount: number; - platformFeeRate: number; - platformFee: number; + serviceFee: number; settlementAmount: number; - orderCompletedAt: string; } const Settlements: React.FC = () => { @@ -32,7 +31,7 @@ const Settlements: React.FC = () => { fetchFn: async (params) => { const res = await getMerchantSettlements(params); return { - list: res.data.items, + list: res.data.list || res.data.items || [], total: res.data.total, page: params.page || 1, pageSize: params.pageSize || 20, @@ -87,8 +86,8 @@ const Settlements: React.FC = () => { }, { title: '订单总额', - dataIndex: 'totalAmount', - key: 'totalAmount', + dataIndex: 'orderAmount', + key: 'orderAmount', width: 120, render: (amount: number) => formatMoney(amount), }, @@ -101,8 +100,8 @@ const Settlements: React.FC = () => { }, { title: '结算金额', - dataIndex: 'actualAmount', - key: 'actualAmount', + dataIndex: 'settlementAmount', + key: 'settlementAmount', width: 120, render: (amount: number) => ( {formatMoney(amount)} @@ -147,6 +146,12 @@ const Settlements: React.FC = () => { key: 'orderId', width: 100, }, + { + title: '订单号', + dataIndex: 'orderNo', + key: 'orderNo', + width: 180, + }, { title: '订单金额', dataIndex: 'orderAmount', @@ -154,17 +159,10 @@ const Settlements: React.FC = () => { width: 120, render: (amount: number) => formatMoney(amount), }, - { - title: '服务费率', - dataIndex: 'platformFeeRate', - key: 'platformFeeRate', - width: 100, - render: (rate: number) => `${(rate * 100).toFixed(1)}%`, - }, { title: '服务费', - dataIndex: 'platformFee', - key: 'platformFee', + dataIndex: 'serviceFee', + key: 'serviceFee', width: 120, render: (fee: number) => {formatMoney(fee)}, }, @@ -175,13 +173,6 @@ const Settlements: React.FC = () => { width: 120, render: (amount: number) => {formatMoney(amount)}, }, - { - title: '订单完成时间', - dataIndex: 'orderCompletedAt', - key: 'orderCompletedAt', - width: 180, - render: (date: string) => formatDateTime(date), - }, ]; return ( @@ -222,21 +213,21 @@ const Settlements: React.FC = () => { {detailModal.data.periodStart} ~ {detailModal.data.periodEnd} {detailModal.data.orderCount} 笔 - {formatMoney(detailModal.data.totalAmount)} + {formatMoney(detailModal.data.orderAmount)} {formatMoney(detailModal.data.serviceFee)} - {formatMoney(detailModal.data.actualAmount)} + {formatMoney(detailModal.data.settlementAmount)} {formatDateTime(detailModal.data.createdAt)} - {detailModal.data.paidAt && ( - {formatDateTime(detailModal.data.paidAt)} + {detailModal.data.settledAt && ( + {formatDateTime(detailModal.data.settledAt)} )} )} diff --git a/apps/merchant-admin/src/pages/finance/Wallet.tsx b/apps/merchant-admin/src/pages/finance/Wallet.tsx index f2138f0..affc173 100644 --- a/apps/merchant-admin/src/pages/finance/Wallet.tsx +++ b/apps/merchant-admin/src/pages/finance/Wallet.tsx @@ -1,7 +1,9 @@ import React, { useEffect, useState } from 'react'; -import { Card, Button, Table, message } from 'antd'; +import { Card, Button, Table, message, Modal, Form, InputNumber, Input, Space, Alert } from 'antd'; import { useNavigate } from 'react-router-dom'; -import { getMerchantAccount, getMerchantTransactions } from '@/api/finance'; +import { ExportOutlined } from '@ant-design/icons'; +import { getMerchantAccount, getMerchantTransactions, applyWithdrawal } from '@/api/finance'; +import { getMerchantInfo } from '@/api/auth'; import type { ColumnsType } from 'antd/es/table'; import type { Account, Transaction, TransactionType } from '@rent/shared-types'; import { TRANSACTION_TYPE_MAP } from '@rent/shared-types'; @@ -14,6 +16,9 @@ const Wallet: React.FC = () => { const navigate = useNavigate(); const [account, setAccount] = useState(null); const [accountLoading, setAccountLoading] = useState(false); + const [withdrawModalVisible, setWithdrawModalVisible] = useState(false); + const [merchantInfo, setMerchantInfo] = useState(null); + const [form] = Form.useForm(); const { data: transactions, @@ -52,6 +57,46 @@ const Wallet: React.FC = () => { } }; + const handleWithdraw = async () => { + if (!account || account.availableAmount <= 0) { + message.warning('可用余额不足,无法提现'); + return; + } + + // 获取商家信息,读取银行卡信息 + try { + const res = await getMerchantInfo(); + setMerchantInfo(res.data); + + // 自动填充银行卡信息 + form.setFieldsValue({ + bankAccount: res.data.bankAccount || '', + bankName: res.data.bankName || '', + }); + + setWithdrawModalVisible(true); + } catch (error) { + message.error('获取银行卡信息失败'); + } + }; + + const handleWithdrawSubmit = async (values: any) => { + try { + await applyWithdrawal({ + amount: values.amount, + bankAccount: values.bankAccount, + bankName: values.bankName, + accountName: merchantInfo?.accountName || values.accountName || '', + }); + message.success('提现申请已提交,请等待审核'); + setWithdrawModalVisible(false); + form.resetFields(); + fetchAccount(); + } catch (error: any) { + message.error(error.response?.data?.message || '提现申请失败'); + } + }; + const columns: ColumnsType = [ { title: '交易时间', @@ -97,6 +142,14 @@ const Wallet: React.FC = () => { {account && (
+
+ + + + +
)} @@ -124,6 +177,114 @@ const Wallet: React.FC = () => { }} />
+ + { + setWithdrawModalVisible(false); + form.resetFields(); + }} + footer={null} + width={500} + > +
+ +
+ {formatMoney(account?.availableAmount || 0)} +
+
+ 最低提现金额:100元 +
+
+ { + if (value && value < 100) { + return Promise.reject('提现金额不能小于100元'); + } + if (value && value > (account?.availableAmount || 0)) { + return Promise.reject('提现金额不能大于可用余额'); + } + if (value && value <= 0) { + return Promise.reject('提现金额必须大于0'); + } + return Promise.resolve(); + }, + }, + ]} + > + + + + {merchantInfo && merchantInfo.bankAccount ? ( + <> + + + + + + + +
+ 如需修改银行卡信息,请前往"店铺设置"页面 +
+ + ) : ( + <> + + + + + + + + + )} + + + + + + + + +
); }; diff --git a/apps/merchant-admin/src/pages/finance/Withdrawals.tsx b/apps/merchant-admin/src/pages/finance/Withdrawals.tsx index cba2164..98cd840 100644 --- a/apps/merchant-admin/src/pages/finance/Withdrawals.tsx +++ b/apps/merchant-admin/src/pages/finance/Withdrawals.tsx @@ -1,7 +1,6 @@ -import React, { useState } from 'react'; -import { Card, Table, Button, Space, Modal, Form, Input, InputNumber, message, Descriptions } from 'antd'; -import { PlusOutlined } from '@ant-design/icons'; -import { getMerchantWithdrawals, getWithdrawalDetail, applyWithdrawal, cancelWithdrawal } from '@/api/finance'; +import React from 'react'; +import { Card, Table, Button, Space, Modal, message, Descriptions } from 'antd'; +import { getMerchantWithdrawals, cancelWithdrawal } from '@/api/finance'; import type { ColumnsType } from 'antd/es/table'; import type { Withdrawal } from '@rent/shared-types'; import { formatMoney, formatDateTime } from '@rent/shared-utils'; @@ -10,9 +9,6 @@ import { useModal } from '@/hooks/useModal'; import { WithdrawalStatusTag } from '@/components/WithdrawalStatusTag'; const Withdrawals: React.FC = () => { - const [form] = Form.useForm(); - const [submitting, setSubmitting] = useState(false); - const { data: withdrawals, loading, @@ -36,24 +32,8 @@ const Withdrawals: React.FC = () => { initialParams: { pageSize: 20 }, }); - const applyModal = useModal(); const detailModal = useModal(); - const handleApply = async (values: any) => { - setSubmitting(true); - try { - await applyWithdrawal(values); - message.success('提现申请提交成功'); - applyModal.close(); - form.resetFields(); - refresh(); - } catch (error: any) { - message.error(error.response?.data?.message || '提现申请失败'); - } finally { - setSubmitting(false); - } - }; - const handleCancel = async (id: number) => { Modal.confirm({ title: '确认取消', @@ -153,13 +133,13 @@ const Withdrawals: React.FC = () => {

提现管理

- -
- -
- + + 如需申请提现,请前往"我的钱包"页面 +
+ } + > { /> - { - applyModal.close(); - form.resetFields(); - }} - footer={null} - width={500} - > -
- - - - - - - - - - - - - - - - - - - -
- { background: #f5f5f5; border-radius: 12rpx; font-size: 28rpx; + box-sizing: border-box; } .gender-group { diff --git a/apps/miniapp/src/pages/index/index.vue b/apps/miniapp/src/pages/index/index.vue index 0cc54b9..04d4b40 100644 --- a/apps/miniapp/src/pages/index/index.vue +++ b/apps/miniapp/src/pages/index/index.vue @@ -166,6 +166,7 @@ +``` + +#### 4. API封装 + +**文件**:`apps/miniapp/src/api/user/auth.ts` + +```typescript +// 注册 +export function register(data: { + phone: string; + code: string; + password?: string; + nickname?: string; + inviteCode?: string; +}) { + return post('/api/app/auth/register', data); +} + +// 微信授权登录 +export function loginByWechat( + code: string, + nickname?: string, + avatar?: string, + inviteCode?: string +) { + return post('/api/app/auth/login/wechat', { + code, + nickname, + avatar, + inviteCode, + }); +} +``` + +**文件**:`apps/miniapp/src/api/user/invite.ts` + +```typescript +// 获取邀请统计 +export function getInviteStats() { + return get('/api/app/invite/stats'); +} + +// 获取邀请列表 +export function getInviteList(params: { + page: number; + pageSize: number; +}) { + return get('/api/app/invite/list', params); +} +``` + +--- + +## 测试场景 + +### 功能测试 + +#### 1. 邀请码生成测试 +- [ ] 用户注册后自动生成邀请码 +- [ ] 邀请码格式正确(6位,0-9A-Z) +- [ ] 邀请码唯一性(不重复) +- [ ] 微信授权登录新用户生成邀请码 + +#### 2. 邀请关系绑定测试 +- [ ] 扫码进入小程序,邀请码正确存储 +- [ ] 注册时邀请码正确传递 +- [ ] 邀请关系正确创建 +- [ ] 邀请人统计正确更新(totalInvites +1) +- [ ] 防重复绑定(已被邀请的用户不能再次绑定) +- [ ] 防自我邀请(不能使用自己的邀请码) +- [ ] 无效邀请码处理(邀请码不存在) + +#### 3. 邀请统计测试 +- [ ] 邀请人数统计正确 +- [ ] 订单数统计正确 +- [ ] 返现金额计算正确 +- [ ] 可用余额更新正确 + +#### 4. 边界情况测试 +- [ ] 没有启用的邀请活动时的处理 +- [ ] 邀请码生成冲突时的重试机制 +- [ ] 并发注册时的邀请码唯一性 +- [ ] 老用户登录时不处理邀请码 + +--- + +## 注意事项 + +### 安全性 +1. **邀请码唯一性**:必须确保邀请码在数据库中唯一 +2. **防刷机制**:需要防止恶意刷邀请关系 +3. **数据一致性**:邀请统计数据需要与实际订单数据保持一致 + +### 性能优化 +1. **邀请码索引**:在 `invite_code` 字段上建立唯一索引 +2. **查询优化**:邀请列表查询需要分页和索引优化 +3. **缓存策略**:用户邀请统计可以使用 Redis 缓存 + +### 扩展性 +1. **多活动支持**:系统支持多个邀请活动同时进行 +2. **返现规则配置**:返现比例可在活动配置中灵活调整 +3. **邀请码格式**:可以根据需要调整邀请码长度和字符集 + +--- + +## 更新日志 + +| 日期 | 版本 | 说明 | +|------|------|------| +| 2026-05-13 | v1.0 | 初始版本,完成邀请码系统设计和实现 | + +--- + +**维护团队**:开发团队 +**最后更新**:2026-05-13 diff --git a/docs/features/settlement-and-finance-system.md b/docs/features/settlement-and-finance-system.md new file mode 100644 index 0000000..a12c262 --- /dev/null +++ b/docs/features/settlement-and-finance-system.md @@ -0,0 +1,789 @@ +# 结算与财务系统设计文档 + +> **版本**:v2.0 +> **最后更新**:2026-05-21 +> **状态**:✅ 已实现并优化 + +--- + +## 📋 目录 + +1. [系统概述](#系统概述) +2. [核心概念](#核心概念) +3. [资金流转流程](#资金流转流程) +4. [平台钱包设计](#平台钱包设计) +5. [结算周期](#结算周期) +6. [服务费计算](#服务费计算) +7. [数据库设计](#数据库设计) +8. [API接口](#api接口) +9. [技术实现](#技术实现) +10. [前端页面](#前端页面) +11. [扩展方案](#扩展方案) + +--- + +## 系统概述 + +结算与财务系统是平台的核心模块,负责处理订单支付、资金流转、商家结算、平台钱包和提现等业务。 + +### 核心功能 + +- ✅ 订单支付时记录平台账户收入 +- ✅ 自动周结算(每周一凌晨2点) +- ✅ 手动执行周结算(管理员触发) +- ✅ 结算预览功能 +- ✅ 防止重复结算 +- ✅ 服务费自动计算和扣除 +- ✅ 平台钱包管理 +- ✅ 商家账户余额管理 +- ✅ 提现申请和审核 +- ✅ 完整的交易流水记录 + +--- + +## 核心概念 + +### 平台账户 vs 平台钱包 + +#### 平台账户(Platform Account) +- **定位**:资金汇总账户,记录所有资金流转 +- **包含内容**: + - 用户支付的订单金额(全额) + - 待结算给商家的金额 + - 平台的收入(服务费、广告费等) + - 平台的支出(商家结算、邀请返现等) + +#### 平台钱包(Platform Wallet) +- **定位**:平台可支配的资金 +- **包含内容**: + - 服务费收入 + - 其他收入(广告费、会员费等) + - 扣除:邀请返现等支出 +- **计算公式**: + ``` + 钱包余额 = 账户总收入 - 账户总支出 + = (订单总额) - (商家结算 + 邀请返现) + = (商家应得 + 服务费) - 商家应得 - 邀请返现 + = 服务费 + 其他收入 - 邀请返现 + ``` + +### 三方账户体系 + +``` +系统账户体系: +├─ 平台账户(PlatformAccount) +│ └─ 主账户(全局唯一,资金中转) +├─ 商家账户(MerchantAccount) +│ └─ 每个商家一个账户 +└─ 用户账户(UserAccount) + └─ 每个用户一个账户(用于返现、提现) +``` + +### 可提现金额 + +| 角色 | 可提现金额计算 | 说明 | +|------|---------------|------| +| **商家** | `balance - frozen_balance` | 已结算金额(扣除服务费后) | +| **用户** | `balance - frozen_balance` | 邀请返现所得 | +| **平台** | `total_service_fee - frozen_balance` | 服务费收入(未来可扩展其他收入) | + +--- + +## 资金流转流程 + +### 完整流程图 + +``` +┌─────────────┐ +│ 用户支付 │ +└──────┬──────┘ + │ 订单金额(全额) + ↓ +┌─────────────────────┐ +│ 平台账户收入 │ +│ - 记录交易流水 │ +│ - 更新账户余额 │ +│ - 累加服务费 │ +└──────┬──────────────┘ + │ 等待订单完成 + ↓ +┌─────────────────────┐ +│ 商家确认离店 │ +│ - 订单状态: completed│ +│ - 记录离店时间 │ +└──────┬──────────────┘ + │ 等待周结算 + ↓ +┌─────────────────────┐ +│ 周结算(自动) │ +│ - 每周一凌晨2点 │ +│ - 统计上周完成订单 │ +│ - 计算结算金额 │ +└──────┬──────────────┘ + │ 结算金额 = 订单金额 - 服务费 + ↓ +┌─────────────────────┐ +│ 商家账户入账 │ +│ - 平台账户扣减 │ +│ - 商家账户增加 │ +│ - 复式记账 │ +└──────┬──────────────┘ + │ 商家可提现 + ↓ +┌─────────────────────┐ +│ 商家申请提现 │ +│ - 冻结余额 │ +│ - 管理员审核 │ +│ - 确认打款 │ +└─────────────────────┘ +``` + +### 详细流程说明 + +#### 1. 用户支付订单 + +``` +用户支付 1000元 +├─ 平台账户收入:+1000元 +│ ├─ balance: +1000 +│ ├─ total_income: +1000 +│ └─ total_service_fee: +50(5%) +└─ 商家账户:无变化(等待结算) +``` + +**代码位置**:[order.service.ts](../../apps/server/src/modules/app/order/order.service.ts) + +```typescript +await this.accountService.addPlatformBalance( + order.payAmount, // 订单金额(全额) + order.serviceFee, // 服务费(5%) + transactionNo, + 'order_payment', + order.id, + order.orderNo, + `订单 ${order.orderNo} 支付收入` +); +``` + +#### 2. 订单完成后周结算 + +``` +每周一凌晨2点自动结算 +├─ 计算商家应得:1000 × 95% = 950元 +├─ 平台账户支出:-950元 +│ ├─ balance: 1000 - 950 = 50 +│ └─ total_expense: +950 +├─ 商家账户收入:+950元 +│ ├─ balance: +950 +│ └─ total_income: +950 +└─ 平台保留服务费:50元 +``` + +**代码位置**:[settlement.service.ts](../../apps/server/src/modules/shared/finance/settlement.service.ts) + +#### 3. 邀请返现(可选) + +``` +用户邀请新用户注册 +├─ 平台账户支出:-10元 +│ ├─ balance: 50 - 10 = 40 +│ └─ total_expense: +10 +└─ 用户账户收入:+10元 + ├─ balance: +10 + └─ total_income: +10 +``` + +#### 4. 各方提现 + +``` +商家提现:500元 +├─ 商家账户:balance -500, frozen_balance +500 +└─ 审核通过后:frozen_balance -500, total_expense +500 + +用户提现:10元 +├─ 用户账户:balance -10, frozen_balance +10 +└─ 审核通过后:frozen_balance -10, total_expense +10 + +平台提现:30元 +├─ 平台账户:balance -30, frozen_balance +30 +└─ 审核通过后:frozen_balance -30, total_expense +30 +``` + +--- + +## 平台钱包设计 + +### 钱包余额计算 + +```typescript +// 钱包余额 = 账户总收入 - 账户总支出 +balance = total_income - total_expense + +// 展开计算 +balance = (订单总额) - (商家结算 + 邀请返现) + = (商家应得 + 服务费) - 商家应得 - 邀请返现 + = 服务费 - 邀请返现 +``` + +### 可提现金额计算 + +**当前实现:** +```typescript +// 可提现金额 = 累计服务费 - 冻结余额 +const withdrawableAmount = total_service_fee - frozen_balance; +``` + +**未来扩展:** +```typescript +// 方案1:使用钱包余额(推荐) +const withdrawableAmount = balance - frozen_balance; + +// 方案2:累加所有收入类型 +const withdrawableAmount = (total_service_fee + total_ad_revenue + ...) - frozen_balance; +``` + +### 前端展示 + +#### 核心指标卡片 + +``` +┌─────────────┬─────────────┬─────────────┬─────────────┐ +│ 钱包余额 │ 可提现金额 │ 冻结金额 │ 服务费收入 │ +│ 40.00元 │ 40.00元 │ 0.00元 │ 50.00元 │ +│ 平台可支配 │ 余额-冻结 │ 提现申请中 │ 累计服务费 │ +└─────────────┴─────────────┴─────────────┴─────────────┘ +``` + +#### 收入支出统计 + +``` +┌─────────────────┬─────────────────┬─────────────────┐ +│ 账户总收入 │ 账户总支出 │ 其他收入 │ +│ 1000.00元 │ 960.00元 │ 0.00元 │ +│ 所有订单支付 │ 结算+返现 │ 广告费、会员费 │ +└─────────────────┴─────────────────┴─────────────────┘ +``` + +--- + +## 结算周期 + +### 周结算机制 + +**执行时间**:每周一凌晨 2:00 +**结算周期**:上周一 00:00:00 ~ 上周日 23:59:59 +**结算对象**:状态为 `completed` 的订单 +**判断依据**:订单的 `checkout_at`(离店时间)字段 + +### 定时任务配置 + +**文件位置**:[settlement.schedule.ts](../../apps/server/src/schedule/settlement.schedule.ts) + +```typescript +@Cron('0 2 * * 1') // 每周一凌晨2点 +async handleWeeklySettlement() { + await this.settlementService.handleWeeklySettlement(); +} +``` + +### 结算逻辑 + +1. **查询上周已完成订单** + ```typescript + const orders = await this.orderRepo + .createQueryBuilder('o') + .where('o.status = :status', { status: 'completed' }) + .andWhere('o.checkout_at BETWEEN :start AND :end', { + start: `${lastWeekStart} 00:00:00`, + end: `${lastWeekEnd} 23:59:59` + }) + .getMany(); + ``` + +2. **按商家分组统计** + - 订单数量:`orderCount` + - 订单总额:`orderAmount` + - 服务费总额:`serviceFee` + - 结算金额:`settlementAmount = orderAmount - serviceFee` + +3. **生成结算单** + - 创建 `Settlement` 记录 + - 创建 `SettlementItem` 记录 + +4. **执行资金转账** + - 平台账户扣减 + - 商家账户增加 + - 复式记账 + +### 防止重复结算 + +```typescript +// 检查该周期是否已经结算过 +const existingSettlements = await this.settlementRepo.count({ + where: { + periodStart: lastWeekStart, + periodEnd: lastWeekEnd + } +}); + +if (existingSettlements > 0) { + throw new Error(`该周期 ${lastWeekStart} ~ ${lastWeekEnd} 已经结算过,无法重复结算`); +} +``` + +--- + +## 服务费计算 + +### 计算公式 + +```typescript +// 用户实付金额 +payAmount = totalAmount - couponDiscount + +// 服务费 +serviceFee = Math.round(payAmount * serviceFeeRate * 100) / 100 + +// 商家实收金额 +merchantIncome = payAmount - serviceFee +``` + +### 服务费率配置 + +**默认值**:5% (0.05) +**配置位置**:`platform_configs` 表 +**配置键**:`service_fee_rate` + +### 示例计算 + +``` +房费总额:1000元 +优惠券抵扣:100元 +服务费率:5% + +用户实付:1000 - 100 = 900元 +服务费:900 × 0.05 = 45元 +商家实收:900 - 45 = 855元 +平台收入:45元 +``` + +--- + +## 数据库设计 + +### 核心表结构 + +#### 1. platform_accounts(平台账户表) + +```sql +CREATE TABLE `platform_accounts` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT, + `account_name` VARCHAR(50) COMMENT '账户名称', + `balance` DECIMAL(12,2) DEFAULT 0 COMMENT '钱包余额', + `frozen_balance` DECIMAL(12,2) DEFAULT 0 COMMENT '冻结余额', + `total_income` DECIMAL(12,2) DEFAULT 0 COMMENT '账户总收入', + `total_expense` DECIMAL(12,2) DEFAULT 0 COMMENT '账户总支出', + `total_service_fee` DECIMAL(12,2) DEFAULT 0 COMMENT '累计服务费', + `version` INT UNSIGNED DEFAULT 0 COMMENT '乐观锁版本号', + `status` ENUM('active', 'frozen', 'closed') DEFAULT 'active', + PRIMARY KEY (`id`) +); +``` + +**字段说明:** + +| 字段 | 说明 | 当前用途 | 未来扩展 | +|------|------|----------|----------| +| `balance` | 钱包余额 | 服务费 - 邀请返现 | 所有收入 - 所有支出 | +| `frozen_balance` | 冻结余额 | 提现申请中的金额 | 同左 | +| `total_income` | 账户总收入 | 所有订单支付金额 | 订单 + 广告 + 会员等 | +| `total_expense` | 账户总支出 | 商家结算 + 邀请返现 | 同左 + 其他支出 | +| `total_service_fee` | 服务费收入 | 订单服务费累计 | 同左 | + +#### 2. merchant_accounts(商家账户表) + +```sql +CREATE TABLE `merchant_accounts` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT, + `merchant_id` BIGINT UNSIGNED, + `balance` DECIMAL(12,2) DEFAULT 0 COMMENT '可用余额', + `frozen_balance` DECIMAL(12,2) DEFAULT 0 COMMENT '冻结余额', + `debt_amount` DECIMAL(12,2) DEFAULT 0 COMMENT '欠款金额', + `total_income` DECIMAL(12,2) DEFAULT 0 COMMENT '累计收入', + `total_expense` DECIMAL(12,2) DEFAULT 0 COMMENT '累计支出', + `total_settlement` DECIMAL(12,2) DEFAULT 0 COMMENT '累计结算', + `total_withdraw` DECIMAL(12,2) DEFAULT 0 COMMENT '累计提现', + `pending_settlement` DECIMAL(12,2) DEFAULT 0 COMMENT '待结算', + `last_settlement_at` DATETIME COMMENT '最后结算时间', + `version` INT UNSIGNED DEFAULT 0, + `status` ENUM('active', 'frozen', 'closed') DEFAULT 'active', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_merchant_id` (`merchant_id`) +); +``` + +#### 3. settlements(结算单表) + +```sql +CREATE TABLE `settlements` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT, + `settlement_no` VARCHAR(32) UNIQUE, + `merchant_id` BIGINT UNSIGNED, + `period_start` DATE, + `period_end` DATE, + `order_count` INT UNSIGNED DEFAULT 0, + `order_amount` DECIMAL(12,2) DEFAULT 0, + `service_fee` DECIMAL(12,2) DEFAULT 0, + `settlement_amount` DECIMAL(12,2) DEFAULT 0, + `status` ENUM('pending','settled','failed'), + `settled_at` DATETIME, + PRIMARY KEY (`id`), + KEY `idx_merchant_id` (`merchant_id`), + KEY `idx_period` (`period_start`, `period_end`) +); +``` + +#### 4. platform_transactions(平台交易流水表) + +```sql +CREATE TABLE `platform_transactions` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT, + `transaction_no` VARCHAR(32) UNIQUE, + `account_id` BIGINT UNSIGNED, + `direction` ENUM('income','expense'), + `amount` DECIMAL(12,2), + `balance_before` DECIMAL(12,2), + `balance_after` DECIMAL(12,2), + `transaction_type` VARCHAR(50), + `business_type` VARCHAR(50), + `business_id` BIGINT UNSIGNED, + `business_no` VARCHAR(32), + `related_account_type` ENUM('merchant','user'), + `related_account_id` BIGINT UNSIGNED, + `remark` VARCHAR(255), + PRIMARY KEY (`id`), + KEY `idx_account_id` (`account_id`), + KEY `idx_business` (`business_type`, `business_id`) +); +``` + +--- + +## API接口 + +### 平台钱包接口 + +#### 1. 查询平台账户 + +**接口**:`GET /api/admin/finance/platform-accounts` +**权限**:平台管理员 + +**响应示例**: +```json +{ + "id": 1, + "account_name": "主账户", + "balance": 40.00, + "frozen_balance": 0.00, + "total_income": 1000.00, + "total_expense": 960.00, + "total_service_fee": 50.00, + "status": "active" +} +``` + +#### 2. 平台申请提现 + +**接口**:`POST /api/admin/finance/platform-withdrawals` +**权限**:平台管理员 + +**请求参数**: +```json +{ + "amount": 30.00, + "bankName": "中国工商银行", + "bankAccount": "6222021234567890123", + "accountName": "某某科技有限公司", + "remark": "提现备注" +} +``` + +### 结算管理接口 + +#### 3. 预览周结算 + +**接口**:`GET /api/admin/finance/settlements/preview-weekly` +**权限**:平台管理员 + +**响应示例**: +```json +{ + "periodStart": "2026-05-13", + "periodEnd": "2026-05-19", + "totalOrders": 45, + "totalAmount": 35000.00, + "totalServiceFee": 1750.00, + "totalSettlementAmount": 33250.00, + "merchants": [...] +} +``` + +#### 4. 执行周结算 + +**接口**:`POST /api/admin/finance/settlements/execute-weekly` +**权限**:平台管理员 + +--- + +## 技术实现 + +### 核心服务 + +#### 1. AccountService(账户服务) + +**文件位置**:[account.service.ts](../../apps/server/src/modules/shared/finance/account.service.ts) + +**核心方法**: + +```typescript +// 平台账户增加余额(订单支付) +async addPlatformBalance( + amount: number, + serviceFee: number, + transactionNo: string, + businessType: string, + businessId: number, + businessNo: string, + remark: string, +): Promise { + // 更新字段: + // - balance: +amount + // - total_income: +amount + // - total_service_fee: +serviceFee +} + +// 平台账户扣减余额(结算/返现) +async deductPlatformBalance( + amount: number, + transactionNo: string, + transactionType: string, + businessType: string, + businessId: number, + businessNo: string, + remark: string, +): Promise { + // 更新字段: + // - balance: -amount + // - total_expense: +amount +} +``` + +#### 2. WithdrawalService(提现服务) + +**文件位置**:[withdrawal.service.ts](../../apps/server/src/modules/shared/finance/withdrawal.service.ts) + +**平台提现逻辑**: + +```typescript +async createPlatformWithdrawal(dto: { + amount: number; + bankName: string; + bankAccount: string; + accountName: string; +}) { + const account = await this.accountService.getPlatformAccount(); + + // 计算可提现金额 + const availableAmount = Number(account.total_service_fee) - Number(account.frozen_balance); + + if (availableAmount < amount) { + throw new BadRequestException(`可提现金额不足,当前可提现:${availableAmount.toFixed(2)}元`); + } + + // 冻结余额 + account.balance -= amount; + account.frozen_balance += amount; + + // 创建提现记录 + // ... +} +``` + +### 并发控制 + +#### 悲观锁 + +```typescript +const account = await queryRunner.manager.findOne(PlatformAccount, { + where: { account_name: '主账户' }, + lock: { mode: 'pessimistic_write' } // 行级锁 +}); +``` + +#### 乐观锁 + +```typescript +await this.accountRepo.update( + { id: accountId, version: currentVersion }, + { balance: newBalance, version: currentVersion + 1 } +); +``` + +### 复式记账 + +每笔转账生成两条流水记录: +- 一条支出记录(平台账户) +- 一条收入记录(商家账户) + +两条流水的 `transaction_no` 相同,通过 `related_account_type` 和 `related_account_id` 关联。 + +--- + +## 前端页面 + +### 1. 平台钱包页面 + +**文件位置**:[PlatformWallet.tsx](../../apps/platform-admin/src/pages/finance/PlatformWallet.tsx) + +**功能特性**: +- 钱包余额、可提现金额、冻结金额、服务费收入展示 +- 账户总收入、总支出、其他收入统计 +- 钱包详情查看 +- 申请提现功能(集成在页面内) + +### 2. 平台交易记录 + +**文件位置**:[PlatformTransactions.tsx](../../apps/platform-admin/src/pages/finance/PlatformTransactions.tsx) + +**功能特性**: +- 交易流水查询(按流水号、方向、类型、时间筛选) +- 交易详情展示 +- 分页展示 + +### 3. 结算管理 + +**文件位置**:[Settlements.tsx](../../apps/platform-admin/src/pages/finance/Settlements.tsx) + +**功能特性**: +- 结算单列表查询 +- 结算单详情查看 +- 预览周结算数据 +- 手动执行周结算 + +### 4. 商家提现审核 + +**文件位置**:[Withdrawals.tsx](../../apps/platform-admin/src/pages/finance/Withdrawals.tsx) + +**功能特性**: +- 商家提现申请列表 +- 审核通过/拒绝 +- 确认打款 + +--- + +## 扩展方案 + +### 未来增加其他收入 + +#### 1. 数据库增加字段 + +```sql +ALTER TABLE platform_accounts +ADD COLUMN total_ad_revenue DECIMAL(12,2) DEFAULT 0 COMMENT '累计广告收入', +ADD COLUMN total_membership_fee DECIMAL(12,2) DEFAULT 0 COMMENT '累计会员费收入', +ADD COLUMN total_other_income DECIMAL(12,2) DEFAULT 0 COMMENT '累计其他收入'; +``` + +#### 2. 后端增加方法 + +```typescript +// 广告收入 +async addPlatformAdRevenue(amount: number, ...): Promise { + account.balance += amount; + account.total_income += amount; + account.total_ad_revenue += amount; +} + +// 会员费收入 +async addPlatformMembershipFee(amount: number, ...): Promise { + account.balance += amount; + account.total_income += amount; + account.total_membership_fee += amount; +} +``` + +#### 3. 前端增加展示 + +```typescript + + + +``` + +#### 4. 调整可提现计算 + +```typescript +// 方案1:继续使用各项收入累加 +const withdrawableAmount = ( + total_service_fee + + total_ad_revenue + + total_membership_fee +) - frozen_balance; + +// 方案2:直接使用钱包余额(推荐) +const withdrawableAmount = balance - frozen_balance; +``` + +**推荐方案2**,因为: +- `balance` 已经包含了所有收入和支出 +- 不需要每次增加新收入类型都修改代码 +- 更简洁、更易维护 + +--- + +## 数据一致性保证 + +### 1. 事务保证 + +所有涉及金额变动的操作都在事务中执行: +- 订单支付:订单状态更新 + 平台账户收入 +- 结算转账:平台账户扣减 + 商家账户增加 + 结算单生成 +- 提现:余额冻结/扣减 + 提现记录更新 + +### 2. 乐观锁 + +账户表使用 `version` 字段实现乐观锁,防止并发更新导致余额错误。 + +### 3. 复式记账 + +每笔转账生成两条流水记录,确保资金流向可追溯。 + +### 4. CHECK 约束 + +```sql +CONSTRAINT `chk_balance` CHECK (`balance` >= 0) +CONSTRAINT `chk_frozen_balance` CHECK (`frozen_balance` >= 0) +``` + +--- + +## 相关文档 + +- [项目需求文档](../requirements/项目需求文档.md) +- [数据库设计](../database/finance-database.md) +- [开发总结](../DEVELOPMENT_SUMMARY.md) + +--- + +**维护团队**:开发团队 +**最后更新**:2026-05-21