Compare commits

...

2 Commits

Author SHA1 Message Date
1dd9a313e6 feat: 新增用户中心模块及短信登录功能
- 新增用户中心模块,包含估值记录、对公转账和开票管理功能
- 实现短信验证码登录功能,优化登录流程
- 新增首页和个人中心页面设计
- 更新API接口以支持新功能
- 调整环境变量配置,更新API基础路径
- 优化用户管理界面,增加ID查询和操作记录展示
- 重构开票记录页面,简化操作流程
- 添加菜单初始化SQL脚本
- 修复若干已知问题,优化用户体验
2025-11-20 20:54:51 +08:00
f536178428 feat: 新增交易记录管理功能与统一上传接口
feat(交易记录): 新增交易记录管理页面与API接口
feat(上传): 添加统一上传接口支持自动识别文件类型
feat(用户管理): 为用户模型添加备注字段并更新相关接口
feat(邮件): 实现SMTP邮件发送功能并添加测试脚本
feat(短信): 增强短信服务配置灵活性与日志记录

fix(发票): 修复发票列表时间筛选功能
fix(nginx): 调整上传大小限制与超时配置

docs: 添加多个功能模块的说明文档
docs(估值): 补充估值计算流程与API提交数据说明

chore: 更新依赖与Docker镜像版本
2025-11-20 20:53:09 +08:00
30 changed files with 1549 additions and 35 deletions

View File

@ -0,0 +1,98 @@
## 背景与确认
* 备注为“用户维度”,按指示在 `app_user` 增加备注字段并贯通列表与配额调整流程。
* 操作记录需补充时间字段以便前端展示。
* 交易管理列表需支持“提交时间区间、状态、手机号、公司名称、公司税号”搜索。
* 上传接口从两个合并为一个,统一入参与返回结构。
## 改动总览
* 模型:为 `AppUser` 增加 `notes: CharField(256, null=True)`
* 接口:
* `admin-App用户管理`:列表返回 `notes`;配额调整将 `remark` 同步写入 `AppUser.notes`;配额日志返回 `created_at`
* `交易管理`:对公转账列表增加“提交时间区间”筛选。
* `上传`:新增统一上传接口 `/api/v1/upload/upload`(或 `/api/v1/upload`),老接口内部复用统一实现保留兼容。
* 迁移:使用现有 Aerich 流程生成并升级迁移。
## 详细实施
### 1. App 用户备注(用户维度)
* 文件:`app/models/user.py`
* 在 `AppUser` 增加字段 `notes = fields.CharField(max_length=256, null=True, description="备注")`
* 文件:`app/api/v1/app_users/admin_manage.py`
* `/list`:返回 `notes = u.notes or ""`
* `/quota`:接收 `remark` 后,除写入 `AppUserQuotaLog`
### 2. 操作记录时间字段
* 文件:`app/schemas/app_user.py`
* `AppUserQuotaLogOut` 增加 `created_at: str`
* 文件:`app/api/v1/app_users/admin_manage.py`
* `/ {user_id}/quota-logs`:为每条日志填充 `created_at = r.created_at.isoformat()` 返回。
### 3. 交易管理列表搜索增强
* 文件:`app/controllers/invoice.py`
* `list_receipts(...)`:增加对 `PaymentReceipt.created_at` 的区间筛选:
* 支持 `created_at`(数组,毫秒时间戳)或 `submitted_start/submitted_end`ISO 或毫秒)。
* 文件:`app/api/v1/transactions/transactions.py`
* `list_receipts`:路由签名增加上述可选查询参数并传入控制器。
### 4. 上传接口统一
* 文件:`app/controllers/upload.py`
* 新增 `upload_any(file: UploadFile)`
* `image/*` 保存到 `static/images`;其他受支持类型保存到 `static/files`。
* 返回 `{ url, filename, content_type }`
* 文件:`app/api/v1/upload/upload.py`
* 新增 `/upload`(或 `/upload/upload` 按路由前缀安排):统一入口;
* 旧 `/image``/file` 内部调用 `upload_any` 保持兼容。
* 文件:`app/schemas/upload.py`
* 若需要,补充统一响应模型,或复用 `FileUploadResponse`(包含 `content_type`)。
## 迁移与兼容
* Aerich`init_db` 已集成迁移流程,生成迁移后执行 `upgrade`,自动创建 `app_user.notes` 列。
* 旧接口兼容:上传旧路径保留;交易列表与日志返回仅新增字段,不影响既有消费逻辑。
## 验证与测试
* 备注更新:调用 `/app-user-admin/quota``remark`,再查 `/app-user-admin/list` 验证 `notes`;查 `/app-user-admin/{id}/quota-logs` 验证 `created_at` 存在。
* 交易筛选:造两条不同日期的凭证,分别用时间区间查询命中与不命中。
* 上传统一:上传 PNG 与 PDF 验证保存路径与返回结构;旧接口路由到统一实现成功。
## 交付
* 提交代码与迁移;更新接口文档的路由说明与字段变化;前端无需大改即可使用新返回的 `notes` 与日志 `created_at`,交易列表按“凭证时间”筛选可用。

View File

@ -0,0 +1,63 @@
## 接口定位
- 用户端提交路径:`POST /v1/app-valuations/`(处理函数在 `app/api/v1/app_valuations/app_valuations.py:234`
- 后台计算任务:`_perform_valuation_calculation``app/api/v1/app_valuations/app_valuations.py:40`
- 计算输入映射:
- B1 提取:`_extract_calculation_params_b1``app/api/v1/app_valuations/app_valuations.py:325`
- B2 提取:`_extract_calculation_params_b2``app/api/v1/app_valuations/app_valuations.py:430`
- B3 提取:`_extract_calculation_params_b3``app/api/v1/app_valuations/app_valuations.py:517`
- C 提取:`_extract_calculation_params_c``app/api/v1/app_valuations/app_valuations.py:532`
- 请求体模型:`UserValuationCreate` 基于 `ValuationAssessmentBase``app/schemas/valuation.py:7``app/schemas/valuation.py:153`
## 必填/推荐字段
- 基础:`asset_name``institution``industry`
- 财务B1`three_year_income``rd_investment``annual_revenue``funding_status``application_coverage``implementation_stage``platform_accounts(douyin.likes/comments/shares)``sales_volume``link_views`
- 文化B2`inheritor_level``offline_activities``cooperation_depth``historical_evidence`
- 风险B3`price_fluctuation``inheritor_age_count`
- 市场C`scarcity_level``circulation``market_activity_time`
- 质押率辅助:`heritage_asset_level`(用于动态质押率)
## 字段规范化
- 数组字段统一为数值数组:`three_year_income``price_fluctuation`
- 金额/计数按模型定义传字符串(系统内有安全转换):`annual_revenue``rd_investment``sales_volume``link_views``offline_activities`
- 平台账号字段示例:`{"douyin": {"account": "...", "likes": "...", "comments": "...", "shares": "..."}}`
- 覆盖范围枚举:`全球覆盖/全国覆盖/区域覆盖`(未提供时默认全国覆盖,参见 `app/api/v1/app_valuations/app_valuations.py:343-347`
## 提交载荷示例(基于“马王堆”数据)
```json
{
"asset_name": "马王堆",
"institution": "成都文化产权交易所",
"industry": "文化艺术业",
"annual_revenue": "10000",
"rd_investment": "6000",
"three_year_income": [8000, 9000, 9500],
"funding_status": "省级资助",
"inheritor_level": "省级传承人",
"inheritor_age_count": [200, 68, 20],
"heritage_asset_level": "纳入《国家文化数字化战略清单》",
"historical_evidence": {"artifacts": 58, "ancient_literature": 789, "inheritor_testimony": 100},
"implementation_stage": "成熟应用",
"application_coverage": "全国覆盖",
"cooperation_depth": "0",
"offline_activities": "20",
"platform_accounts": {
"douyin": {"account": "成都文交所", "likes": "500000", "comments": "89222", "shares": "97412"}
},
"sales_volume": "60000",
"link_views": "350000",
"circulation": "3",
"scarcity_level": "流通:总发行份数 >1000份或二级市场流通率 ≥ 5%",
"market_activity_time": "近一周",
"monthly_transaction_amount": "月交易额100万500万",
"price_fluctuation": [402, 445]
}
```
## 验证步骤(确认后执行)
- 发送上述 JSON 到 `POST /v1/app-valuations/`,观察返回 `status``id`
- 通过 `GET /v1/app-valuations/{id}` 查看写入的 `calculation_input``model_value_b/market_value_c/final_value_ab/dynamic_pledge_rate`
- 若需批量提交,按相同字段规范为每条记录生成载荷并调用接口(可提供脚本)
## 可能的扩展
- 无抖音 `views` 字段时已用 `link_views`,如需更精确的 C 值,可补充 `views`(参见 `app/api/v1/app_valuations/app_valuations.py:566-585`
- `application_coverage` 为非枚举值时将按默认 7 分处理(见 `app/api/v1/app_valuations/app_valuations.py:343-347`

View File

@ -0,0 +1,60 @@
## 目标
- 创建若干“交易记录”(对公转账付款凭证)测试数据,并确保后台管理可以正常查看、筛选与分页。
- 全流程可回滚,严格隔离开发与生产数据。
## 关键假设
- 交易记录以 `PaymentReceipt`(关联 `Invoice`)为核心:后端模型位于 `app/models/invoice.py`
- 后端已提供发票与凭证相关 API
- 发票:`GET/POST /api/v1/invoice/*``app/api/v1/invoice/invoice.py`
- 凭证:`POST /api/v1/invoice/{id}/receipt`、列表 `GET /api/v1/transactions/receipts``app/api/v1/transactions/transactions.py`)。
- 现有后台页已覆盖发票列表 `web/src/views/transaction/invoice/index.vue`,尚无“凭证专属列表”页面。
## 数据建模与来源
- `Invoice`:发票记录(开票状态、类型、抬头信息、公司税号、银行账户等)。
- `PaymentReceipt`:对公转账付款凭证(字段含 `invoice_id``url``note``verified``created_at`)。
- 为保证后台可见,需同时插入可查询的 `Invoice` 与其关联的 `PaymentReceipt`
## 实施路径
### 方案 A通过现有 API 批量生成(零代码改动,首选)
- 步骤:
- 调用 `POST /api/v1/invoice/create` 批量创建 510 条覆盖不同类型/状态的发票(含公司名、税号、银行账户等)。
- 对每条发票调用 `POST /api/v1/invoice/{id}/receipt` 上传/登记 12 条付款凭证(`file_url``url``note``verified`)。
- 可选:通过 `POST /api/v1/transactions/send-email` 生成邮件日志,验证通知链路。
- 优势:不改动代码,快速、低风险;与现有权限和审计链路一致。
### 方案 B后端种子脚本仅开发环境便于重复初始化
- 在 `app/core/init_app.py` 新增 `init_demo_transactions()`
- 仅在开发环境执行(如 `ENV=dev`);避免污染生产。
- 批量创建 `Invoice` 测试数据,再为每条 `Invoice` 创建若干 `PaymentReceipt`
- 为可识别性,在 `note` 中加入 `DEMO` 标签或新增布尔字段(若允许)。
- 将该方法纳入现有 `init_data()` 的“开发模式”分支;保留一键清理逻辑(删除 `DEMO` 标记数据)。
## 前端后台展示
- 新增后台页“交易记录”:`web/src/views/transaction/receipts/index.vue`Naive UI
- 数据源:`GET /api/v1/transactions/receipts`;支持分页与筛选(手机号、公司名、税号、状态、类型等)。
- 列:凭证时间、公司名称、税号、转账备注(`note`)、审核状态(`verified`)、关联发票 ID/类型,查看详情。
- 行为:查看详情(`GET /api/v1/transactions/receipts/{id}`),跳转到发票详情。
- 菜单与权限:
- 在后端 `init_menus()` 增加“交易记录”菜单,并为管理员角色在 `init_roles()` 授权;在 `init_apis()` 注册 `receipts` 相关 API。
- 备选轻改:在 `invoice/index.vue` 增加“凭证数/链接”列与详情入口,先实现可见性,后续再拆分独立列表页。
## 验证步骤
- 数据验证:
- 通过 API 查询:`GET /api/v1/invoice/list``GET /api/v1/transactions/receipts`,确认条数、筛选与分页正确。
- 随机抽样验证 `PaymentReceipt` 与对应 `Invoice` 关联完整、字段齐备。
- 前端验证:
- 后台页加载正常、列渲染与筛选可用;详情跳转与状态标签正确。
- 安全验证:
- 在生产环境禁用种子逻辑;标记测试数据,提供清理。
## 回滚与清理
- 提供清理脚本/接口:按 `DEMO` 标记或 ID 范围批量删除测试发票与凭证。
- 菜单与权限变更可回退至原状态(移除菜单、撤销授权)。
## 交付物
- A一组 API 请求示例(可直接运行)生成测试交易与凭证。
- B可选开发环境种子函数与清理脚本。
- C前端“交易记录”后台页或在发票页增加凭证列的最小改动
## 备注
- 编码时为新增函数与接口补充函数级注释(功能、参数、返回值说明),遵循现有代码风格与安全规范。

View File

@ -0,0 +1,25 @@
## 目标
- 提供一个可在本机直接运行的 Shell 脚本,测试 `POST /api/v1/transactions/send-email`,支持本地文件上传与远程文件 URL两种模式均可验证。
## 实现方式
- 新增 `scripts/send_email_test.sh`
- 参数:
- `-t <token>` 后台 token必填
- `-e <email>` 收件人邮箱(必填)
- `-s <subject>` 邮件主题(可选)
- `-b <body>` 邮件正文(必填)
- `-f <file_path>` 本地附件路径(可选)
- `-u <file_url>` 远程附件 URL可选`-f` 互斥)
- `-a <base_api>` 基础地址,默认 `http://127.0.0.1:9999/api/v1`
- 逻辑:
- 若提供 `-f`,使用 `curl -F` 构造 `multipart/form-data` 表单项:`email/subject/body/file`
- 否则以 JSON 发送:`email/subject/body`,可选 `file_url`
- 输出:直接打印服务端返回的 JSON
## 验证
- 无附件:`./scripts/send_email_test.sh -t <token> -e zfc9393@163.com -s 测试 -b 测试代码`
- 本地附件:`./scripts/send_email_test.sh -t <token> -e zfc9393@163.com -s 测试 -b 测试代码 -f ./demo.pdf`
- 远程附件:`./scripts/send_email_test.sh -t <token> -e zfc9393@163.com -s 测试 -b 测试代码 -u https://example.com/demo.pdf`
## 安全
- 不写入任何敏感信息到仓库;脚本仅通过命令行参数接收 token 与内容。

View File

@ -0,0 +1,101 @@
目标
* 在 `蜀锦估值计算流程核对.md` 中补充并修复:历史传承度 HI=18 的明确记录,以及由此引发的 B22、B2、模型估值 B、最终估值 A 的新数值与计算公式。保留现有内容同时在文末新增“修正HI=18 后的计算”分节,按“参数 | 步骤 | 计算结果 | 计算公式”的统一格式展示。
修改要点
* 新增分节:`## 修正:历史传承度 HI=18 后的计算`
* 插入子段落:
1. 历史传承度 HI参数/步骤/计算结果/计算公式)→ HI=18
2. 纹样基因 B22使用 SC=1.5, H=9, HI=18→ B22=810
3. 文化价值 B2B21=9.37804, B22=810→ B2=38.026824
4. 模型估值 BB1≈519.8, B2=38.026824, B3=0.92)→ B≈346.9 万元
5. 市场估值 C复核不变→ C=9452.0 万元
6. 最终估值 A使用新 B 与 C→ A≈3078.43 万元
* 每一段落严格附加“计算公式”行,确保审阅可落地。
输出格式与一致性
* 所有行使用中文标签与反引号包裹关键数值/公式。
* 不删除原有章节,仅在文末追加修正;与日志与现有参数保持一致。
执行后期望
* 文档清晰体现 HI 修正后的计算链路与结果,审阅时可直接对照参数与公式核验。
## 修正:历史传承度 HI=18 后的计算
**历史传承度 HI**
* 参数: `historical_evidence={"artifacts":2,"ancient_literature":5,"inheritor_testimony":5,"modern_research":6}`
* 步骤: 解析为对象→对各项做安全数值化→求和
* 计算结果: `HI=18`
* 计算公式: `HI = sum(safe_float(v) for v in historical_evidence)`
**纹样基因 B22文化 B2 子项)**
* 参数: `SC=1.5``H=9``HI=18`
* 步骤: 复杂度与熵线性合成×历史度×10
* 计算结果: `B22=810`
* 计算公式: `B22 = (SC×0.6 + H×0.4) × HI × 10``(1.5×0.6 + 9×0.4) × 18 × 10 = 4.5 × 18 × 10 = 810`
**文化价值 B2合成**
* 参数: `B21=9.37804``B22=810`
* 步骤: `B2 = B21×0.6 + (B22/10)×0.4`
* 计算结果: `B2=38.026824`
* 计算公式: `B2 = B21×0.6 + (B22/10)×0.4``9.37804×0.6 + (810/10)×0.4 = 5.626824 + 32.4 = 38.026824`
**模型估值 B**
* 参数: `B1≈519.8``B2=38.026824``B3=0.92`
* 步骤: 经济与文化加权后乘风险系数
* 计算结果: `B≈346.9` 万元
* 计算公式: `B = (B1×0.7 + B2×0.3) × B3``(519.8×0.7 + 38.026824×0.3) × 0.92 ≈ (363.86 + 11.408) × 0.92 ≈ 346.9`
**市场估值 C**
* 参数: `C1=2780``C2=2.0``C3=1.7``C4=1.0`
* 步骤: 乘法聚合 `C = C1 × C2 × C3 × C4`
* 计算结果: `C=9452.0` 万元
* 计算公式: `C = C1 × C2 × C3 × C4`
**C1 市场竞价平均交易价C 子项)**
* 参数: `bids=[3980,1580,2780]``weighted_average_price=None``expert_valuations=[]`
* 步骤: 平均价缺失→排序出价→取中位数
* 计算结果: `C1=2780`
* 计算公式: `C1 = median(bids)`;奇数 `n``sorted_bids[n//2]`;偶数 `n``(sorted_bids[n//2-1] + sorted_bids[n//2]) / 2`
**最终估值 A**
* 参数: `B≈346.9``C=9452.0`
* 步骤: 固定权重合成
* 计算结果: `A≈3078.43` 万元
* 计算公式: `A = 0.7×B + 0.3×C``0.7×346.9 + 0.3×9452 ≈ 242.83 + 2835.6 ≈ 3078.43`

View File

@ -0,0 +1,23 @@
## 目标
- 以简明清晰的中文给出发票状态pending、invoiced、rejected、refunded的标准释义。
- 重点补充“rejected已拒绝”的常见触发原因与处理建议便于运营与审核同口径使用。
## 来源与现状
- 状态字段来源:`app/models/invoice.py:32``pending|invoiced|rejected|refunded`)。
- 后台页面状态标签:`web/src/views/transaction/invoice/index.vue:82-91`
## 输出内容
- 提供四个状态的含义与可执行动作:
- pending未开票等待处理可进行开票或退款需核验资料。
- invoiced已开票发票已生成可查看重发邮件等不可再提交同笔开票。
- rejected已拒绝审核未通过说明常见拒绝原因与后续处理方式。
- refunded已退款已取消并完成退款如需再次开票需走新流程。
- “rejected”典型原因分类与处理建议
- 企业信息不一致/缺失;税号无效或格式不符;银行账户异常;发票类型不合规;重复或异常申请;付款凭证无法核验等。
- 建议:更正信息后重新提交;提供有效凭证;必要时走退款流程。
## 可选改进(待确认)
- 在后台页面增加“拒绝原因”提示(若未来增加原因字段),或在详情中补充统一口径的原因文案与建议。
## 交付方式
- 立即以文本形式给出状态释义与“rejected”原因说明便于直接使用。

View File

@ -0,0 +1,70 @@
## 目标
- 提升 Nginx 代理下的上传体积与超时解决大文件上传被限制的问题413/超时)。
## 现状
- 当前配置文件:`deploy/web.conf` 使用 `location ^~ /api/` 反向代理到后端 `http://127.0.0.1:9999`,未设置上传大小和超时。
## 修改内容
- 在 `server {}` 块中增加统一的体积限制。
- 在 `location ^~ /api/` 中增加长超时与关闭请求缓冲(提升大文件上传稳定性)。
## 具体改动(覆盖式替换关键片段)
- 在 `server_name localhost;` 后增加:
```
client_max_body_size 50m;
```
- 在 `location ^~ /api/ { ... }` 内追加:
```
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
```
## 变更后的完整结构示例
```
server {
listen 80;
server_name localhost;
client_max_body_size 50m;
location = /docs {
proxy_pass http://127.0.0.1:9999/docs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location = /redoc {
proxy_pass http://127.0.0.1:9999/redoc;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location = /openapi.json {
proxy_pass http://127.0.0.1:9999/openapi.json;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
root /opt/vue-fastapi-admin/web/dist;
index index.html index.htm;
try_files $uri /index.html;
}
location ^~ /api/ {
proxy_pass http://127.0.0.1:9999;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
}
```
## 发布与验证
- 检查配置:`nginx -t`
- 重新加载:`nginx -s reload`
- 测试上传:使用 3040MB 文件调用 `POST /api/v1/upload/file`,确认不再出现 413 或上传超时;同时测试 `POST /api/v1/upload/image` 大图。
## 备注
- 如需更大体积可将 `client_max_body_size` 调整为 `100m` 或更高;若上层云负载均衡也有限制,需要同步放宽。
- 后续可在应用层增加最大体积限制与提示,避免无界上传占用过多资源。

View File

@ -0,0 +1,36 @@
## 目标
- 配置并启用阿里企业邮箱 SMTP完成服务端发送邮件功能验证含附件/远程文件)。
## 所需配置
- 配置项位置:`app/settings/config.py:103-109``SMTP_HOST/SMTP_PORT/SMTP_USERNAME/SMTP_PASSWORD/SMTP_TLS/SMTP_FROM`
- 建议取值(不要写入仓库,使用环境变量注入):
- `SMTP_HOST=smtp.qiye.aliyun.com`
- `SMTP_PORT=465`SSL 直连)
- `SMTP_TLS=false`465 对应 SSL如用 587 则改为 `SMTP_TLS=true``SMTP_PORT=587`
- `SMTP_FROM=value@cdcee.net`
- `SMTP_USERNAME=value@cdcee.net`
- `SMTP_PASSWORD=<授权码>`(你提供的授权码)
- 安全要求:不将授权码写入代码或仓库;仅通过环境变量或部署系统秘密管理器设置。
## 实现检查
- 发送实现:`app/services/email_client.py:12-49`
- 当 `SMTP_TLS=false` 时使用 `SMTP_SSL(host, 465)`;为 `true` 时使用 `SMTP(host, 587).starttls()`
- 发送成功返回 `{status: 'OK'}`,失败返回 `{status: 'FAIL', error}`
- 接口:`POST /api/v1/transactions/send-email``app/api/v1/transactions/transactions.py:62-104`
- 支持直接上传附件(`file`)或通过 `file_url` 拉取远程文件,记录日志到 `email_send_log`
## 验证步骤
1. 获取后台 token`POST /api/v1/base/access_token`admin 账号)
2. 发送纯文本邮件(无附件):
- `POST /api/v1/transactions/send-email`JSON`{email, subject, body}`,携带 `token`
3. 发送带附件file_url
- JSON`{email, subject, body, file_url: 'https://...'}`;或使用 `multipart/form-data` 上传 `file`
4. 结果期望:返回 `{"status":"OK","log_id":...}`;失败时查看错误内容并修正配置。
## 可选增强
- 为 `email_client.send` 增加更明确的错误分类与超时提示(保留现有结构)。
- 提供健康检查端点:尝试建立 SMTP 连接并返回诊断信息(仅管理员角色可访问)。
## 回滚与安全
- 变更仅在环境变量层面,可随时回滚;不改代码,不提交授权码。
- 若切换至 587需同时改为 `SMTP_TLS=true` 并确保上游网络允许 STARTTLS。

29
aaa.json Normal file
View File

@ -0,0 +1,29 @@
2025-11-17 18:13:15.766 | INFO | app.api.v1.app_valuations.app_valuations:calculate_valuation:284 - valuation.task_queued user_id=30 asset_name= industry=
2025-11-17 18:13:15.768 | INFO | app.api.v1.app_valuations.app_valuations:_perform_valuation_calculation:44 - valuation.calc_start user_id=30 asset_name= industry=
2025-11-17 18:13:15 - INFO - 14.145.4.28:0 - "POST /api/v1/app-valuations/ HTTP/1.0" 200 OK
2025-11-17 18:13:16,048 - INFO - API: dajiala.web_search
2025-11-17 18:13:16,049 - ERROR - API: {'code': 20001, 'msg': '', 'data': ''}
2025-11-17 18:13:16,049 - WARNING -
2025-11-17 18:13:16.049 | INFO | app.api.v1.app_valuations.app_valuations:_extract_calculation_params_b1:336 - '' 30: 0.0
2025-11-17 18:13:16,051 - INFO - S2: S2=2200.0
{'orderNo': '202511171813162420295', 'rc': '0001', 'msg': ','}
2025-11-17 18:13:16,827 - INFO - API: chinaz.judgement
{'orderNo': '202511171813169260297', 'rc': '0002', 'msg': ','}
2025-11-17 18:13:17,428 - INFO - API: chinaz.patent
2025-11-17 18:13:17.428 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:80 - final_value_a.calculation_start input_data_keys=['model_data', 'market_data'] model_data_keys=['economic_data', 'cultural_data', 'risky_data'] market_data_keys=['weighted_average_price', 'manual_bids', 'expert_valuations', 'daily_browse_volume', 'collection_count', 'issuance_level', 'recent_market_activity']
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:89 - final_value_a.economic_data B1: =[169.0, 169.0, 169.0] =3.0 =7.0 =0.0 =18.93491124260355 ESG=5.0 使=0.0 =-0.5
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:101 - final_value_a.cultural_data B2: =0.7 =0.3 线=50.0 =67000.0 =0 =0 =1.5 =9 =0.0
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:114 - final_value_a.risky_data B3: =3980.0 =1580.0 =0.0 =[0, 0, 2]
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:122 - final_value_a.market_data C: =None =[3980.0, 1580.0, 2780.0] =[] =296000.0 =67000 = 100 =
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:132 - final_value_a.calculating_model_value_b B
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:142 - final_value_a.model_value_b_calculated B: B=336.37180882339413 =0ms =['economic_value_b1', 'cultural_value_b2', 'risk_value_b3', 'model_value_b']
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:152 - final_value_a.calculating_market_value_c C
:
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:162 - final_value_a.market_value_c_calculated C: C=9452.0 =0ms =['market_bidding_c1', 'heat_coefficient_c2', 'scarcity_multiplier_c3', 'temporal_decay_c4', 'market_value_c']
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:172 - final_value_a.calculating_final_value_a A: B=336.37180882339413 C=9452.0
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_final_value_a:50 - final_value_a.calculate_final_value_a A: B=336.37180882339413 C=9452.0
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_final_value_a:57 - final_value_a.weighted_values : B=235.46026617637588(0.7) C=2835.6(0.3)
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_final_value_a:62 - final_value_a.final_calculation A: B=336.37180882339413 C=9452.0 =235.46026617637588 =2835.6 AB=3071.060266176376
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:183 - final_value_a.calculation_completed A: AB=3071.060266176376 B=336.37180882339413 C=9452.0 =1ms =0ms =0ms
2025-11-17 18:13:17.430 | INFO | app.api.v1.app_valuations.app_valuations:_perform_valuation_calculation:160 - valuation.calc_done user_id=30 duration_ms=1662 model_value_b=336.37180882339413 market_value_c=9452.0 final_value_ab=3071.060266176376
Traceback (most recent call last):

View File

@ -32,7 +32,7 @@ async def list_app_users(
"phone": u.phone, "phone": u.phone,
"wechat": u.alias, "wechat": u.alias,
"created_at": u.created_at.isoformat() if u.created_at else "", "created_at": u.created_at.isoformat() if u.created_at else "",
"notes": "", "notes": getattr(u, "notes", "") or "",
"remaining_count": int(getattr(u, "remaining_quota", 0) or 0), "remaining_count": int(getattr(u, "remaining_quota", 0) or 0),
"user_type": None, "user_type": None,
}) })
@ -52,6 +52,9 @@ async def update_quota(payload: AppUserQuotaUpdateSchema, operator=Depends(AuthC
) )
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
if payload.remark is not None:
user.notes = payload.remark
await user.save()
return Success(data={"user_id": user.id, "remaining_quota": user.remaining_quota}, msg="调整成功") return Success(data={"user_id": user.id, "remaining_quota": user.remaining_quota}, msg="调整成功")
@ -70,6 +73,7 @@ async def quota_logs(user_id: int, page: int = Query(1, ge=1), page_size: int =
after_count=r.after_count, after_count=r.after_count,
op_type=r.op_type, op_type=r.op_type,
remark=r.remark, remark=r.remark,
created_at=r.created_at.isoformat() if r.created_at else "",
) for r in rows ) for r in rows
] ]
data_items = [m.model_dump() for m in models] data_items = [m.model_dump() for m in models]

View File

@ -58,9 +58,12 @@ async def send_code(payload: SendCodeRequest) -> SendResponse:
if not ok: if not ok:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(reason)) raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(reason))
try: try:
code = store.generate_code() otp = store.generate_code()
store.set_code(payload.phone, code) store.set_code(payload.phone, otp)
res = sms_client.send_code(payload.phone, code) from app.settings import settings
if settings.SMS_DEBUG_LOG_CODE:
logger.info("sms.code generated phone={} code={}", payload.phone, otp)
res = sms_client.send_code(payload.phone, otp)
code = res.get("Code") or res.get("ResponseCode") code = res.get("Code") or res.get("ResponseCode")
rid = res.get("RequestId") or res.get("MessageId") rid = res.get("RequestId") or res.get("MessageId")
if code == "OK": if code == "OK":
@ -194,4 +197,4 @@ async def sms_login(payload: SMSLoginRequest) -> SMSLoginResponse:
return SMSLoginResponse(user=user_info, token=token_out) return SMSLoginResponse(user=user_info, token=token_out)
class VerifyCodeRequest(BaseModel): class VerifyCodeRequest(BaseModel):
phone: str = Field(...) phone: str = Field(...)
code: str = Field(...) code: str = Field(...)

View File

@ -24,6 +24,9 @@ async def list_receipts(
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
ticket_type: Optional[str] = Query(None), ticket_type: Optional[str] = Query(None),
invoice_type: Optional[str] = Query(None), invoice_type: Optional[str] = Query(None),
created_at: Optional[list[int]] = Query(None),
submitted_start: Optional[str] = Query(None),
submitted_end: Optional[str] = Query(None),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100), page_size: int = Query(10, ge=1, le=100),
): ):
@ -40,6 +43,9 @@ async def list_receipts(
status=status, status=status,
ticket_type=ticket_type, ticket_type=ticket_type,
invoice_type=invoice_type, invoice_type=invoice_type,
created_at=created_at,
submitted_start=submitted_start,
submitted_end=submitted_end,
) )
return SuccessExtra( return SuccessExtra(
data=result["items"], data=result["items"],
@ -101,4 +107,25 @@ async def send_email(data: SendEmailRequest, file: Optional[UploadFile] = File(N
else: else:
logger.error("transactions.email_send_fail email={} err={}", data.email, error) logger.error("transactions.email_send_fail email={} err={}", data.email, error)
return Success(data={"status": status, "log_id": log.id, "error": error}, msg="发送成功" if status == "OK" else "发送失败") return Success(data={"status": status, "log_id": log.id, "error": error}, msg="发送成功" if status == "OK" else "发送失败")
@transactions_router.get("/smtp-config", summary="SMTP配置状态", response_model=BasicResponse[dict])
async def smtp_config_status():
configured = all([
settings.SMTP_HOST,
settings.SMTP_PORT,
settings.SMTP_FROM,
settings.SMTP_USERNAME,
settings.SMTP_PASSWORD,
])
data = {
"host": bool(settings.SMTP_HOST),
"port": bool(settings.SMTP_PORT),
"from": bool(settings.SMTP_FROM),
"username": bool(settings.SMTP_USERNAME),
"password": bool(settings.SMTP_PASSWORD),
"tls": settings.SMTP_TLS,
"configured": configured,
}
return Success(data=data, msg="OK")

View File

@ -15,4 +15,8 @@ async def upload_image(file: UploadFile = File(...)) -> ImageUploadResponse:
@router.post("/file", response_model=FileUploadResponse, summary="上传文件") @router.post("/file", response_model=FileUploadResponse, summary="上传文件")
async def upload_file(file: UploadFile = File(...)) -> FileUploadResponse: async def upload_file(file: UploadFile = File(...)) -> FileUploadResponse:
return await UploadController.upload_file(file) return await UploadController.upload_file(file)
@router.post("/upload", response_model=FileUploadResponse, summary="统一上传接口")
async def upload(file: UploadFile = File(...)) -> FileUploadResponse:
return await UploadController.upload_any(file)

View File

@ -188,6 +188,42 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
if filters.get("invoice_type"): if filters.get("invoice_type"):
qs = qs.filter(invoice__invoice_type=filters["invoice_type"]) qs = qs.filter(invoice__invoice_type=filters["invoice_type"])
# 时间区间筛选(凭证提交时间)
created_range = filters.get("created_at")
submitted_start = filters.get("submitted_start")
submitted_end = filters.get("submitted_end")
if created_range and isinstance(created_range, (list, tuple)) and len(created_range) == 2:
try:
# 前端可能传毫秒时间戳
start_ms = int(created_range[0])
end_ms = int(created_range[1])
from datetime import datetime
start_dt = datetime.fromtimestamp(start_ms / 1000)
end_dt = datetime.fromtimestamp(end_ms / 1000)
qs = qs.filter(created_at__gte=start_dt, created_at__lte=end_dt)
except Exception:
pass
else:
from datetime import datetime
def parse_time(v):
try:
iv = int(v)
return datetime.fromtimestamp(iv / 1000)
except Exception:
try:
# ISO 字符串
return datetime.fromisoformat(v)
except Exception:
return None
if submitted_start:
s_dt = parse_time(submitted_start)
if s_dt:
qs = qs.filter(created_at__gte=s_dt)
if submitted_end:
e_dt = parse_time(submitted_end)
if e_dt:
qs = qs.filter(created_at__lte=e_dt)
total = await qs.count() total = await qs.count()
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size) rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)

View File

@ -87,4 +87,16 @@ class UploadController:
url=f"{settings.BASE_URL}/static/files/{filename}", url=f"{settings.BASE_URL}/static/files/{filename}",
filename=filename, filename=filename,
content_type=file.content_type, content_type=file.content_type,
) )
@staticmethod
async def upload_any(file: UploadFile) -> FileUploadResponse:
"""
统一上传入口自动识别图片与非图片类型
返回统一结构url, filename, content_type
"""
if file.content_type and file.content_type.startswith("image/"):
img = await UploadController.upload_image(file)
return FileUploadResponse(url=img.url, filename=img.filename, content_type=file.content_type or "image")
# 非图片类型复用原文件上传校验
return await UploadController.upload_file(file)

View File

@ -23,6 +23,7 @@ from app.core.exceptions import (
) )
from app.log import logger from app.log import logger
from app.models.admin import Api, Menu, Role from app.models.admin import Api, Menu, Role
from app.models.invoice import Invoice, PaymentReceipt
from app.schemas.menus import MenuType from app.schemas.menus import MenuType
from app.settings.config import settings from app.settings.config import settings
@ -237,6 +238,45 @@ async def init_menus():
redirect="", redirect="",
) )
# 创建交易管理菜单
transaction_menu = await Menu.create(
menu_type=MenuType.CATALOG,
name="交易管理",
path="/transaction",
order=3,
parent_id=0,
icon="carbon:wallet",
is_hidden=False,
component="Layout",
keepalive=False,
redirect="/transaction/invoice",
)
transaction_children = [
Menu(
menu_type=MenuType.MENU,
name="发票管理",
path="invoice",
order=1,
parent_id=transaction_menu.id,
icon="mdi:file-document-outline",
is_hidden=False,
component="/transaction/invoice",
keepalive=False,
),
Menu(
menu_type=MenuType.MENU,
name="交易记录",
path="receipts",
order=2,
parent_id=transaction_menu.id,
icon="mdi:receipt-text-outline",
is_hidden=False,
component="/transaction/receipts",
keepalive=False,
),
]
await Menu.bulk_create(transaction_children)
async def init_apis(): async def init_apis():
apis = await api_controller.model.exists() apis = await api_controller.model.exists()
@ -287,9 +327,123 @@ async def init_roles():
await user_role.apis.add(*basic_apis) await user_role.apis.add(*basic_apis)
async def init_demo_transactions():
"""
创建开发环境演示用的发票与交易记录付款凭证数据
功能:
- 在无现有付款凭证数据时批量生成若干 `Invoice` 与关联的 `PaymentReceipt`
- 仅在调试模式下执行避免污染生产环境
参数:
返回: `None`异步执行插入操作
"""
if not settings.DEBUG:
return
has_receipt = await PaymentReceipt.exists()
if has_receipt:
return
demo_invoices = []
demo_payloads = [
{
"ticket_type": "electronic",
"invoice_type": "normal",
"phone": "13800000001",
"email": "demo1@example.com",
"company_name": "演示科技有限公司",
"tax_number": "91310000MA1DEMO01",
"register_address": "上海市浦东新区演示路 100 号",
"register_phone": "021-88880001",
"bank_name": "招商银行上海分行",
"bank_account": "6214830000000001",
"status": "pending",
"wechat": "demo_wechat_01",
},
{
"ticket_type": "paper",
"invoice_type": "special",
"phone": "13800000002",
"email": "demo2@example.com",
"company_name": "示例信息技术股份有限公司",
"tax_number": "91310000MA1DEMO02",
"register_address": "北京市海淀区知春路 66 号",
"register_phone": "010-66660002",
"bank_name": "中国银行北京分行",
"bank_account": "6216610000000002",
"status": "invoiced",
"wechat": "demo_wechat_02",
},
{
"ticket_type": "electronic",
"invoice_type": "special",
"phone": "13800000003",
"email": "demo3@example.com",
"company_name": "华夏制造有限公司",
"tax_number": "91310000MA1DEMO03",
"register_address": "广州市天河区高新大道 8 号",
"register_phone": "020-77770003",
"bank_name": "建设银行广州分行",
"bank_account": "6227000000000003",
"status": "rejected",
"wechat": "demo_wechat_03",
},
{
"ticket_type": "paper",
"invoice_type": "normal",
"phone": "13800000004",
"email": "demo4@example.com",
"company_name": "泰岳网络科技有限公司",
"tax_number": "91310000MA1DEMO04",
"register_address": "杭州市滨江区科技大道 1 号",
"register_phone": "0571-55550004",
"bank_name": "农业银行杭州分行",
"bank_account": "6228480000000004",
"status": "refunded",
"wechat": "demo_wechat_04",
},
{
"ticket_type": "electronic",
"invoice_type": "normal",
"phone": "13800000005",
"email": "demo5@example.com",
"company_name": "星云数据有限公司",
"tax_number": "91310000MA1DEMO05",
"register_address": "成都市高新区软件园 9 号楼",
"register_phone": "028-33330005",
"bank_name": "工商银行成都分行",
"bank_account": "6222020000000005",
"status": "pending",
"wechat": "demo_wechat_05",
},
]
for payload in demo_payloads:
inv = await Invoice.create(**payload)
demo_invoices.append(inv)
for idx, inv in enumerate(demo_invoices, start=1):
await PaymentReceipt.create(
invoice=inv,
url=f"https://example.com/demo-receipt-{idx}-a.png",
note="DEMO 凭证 A",
verified=(inv.status == "invoiced"),
)
if idx % 2 == 0:
await PaymentReceipt.create(
invoice=inv,
url=f"https://example.com/demo-receipt-{idx}-b.png",
note="DEMO 凭证 B",
verified=False,
)
async def init_data(): async def init_data():
await init_db() await init_db()
await init_superuser() await init_superuser()
await init_menus() await init_menus()
await init_apis() await init_apis()
await init_roles() await init_roles()
await init_demo_transactions()

View File

@ -20,6 +20,7 @@ class AppUser(BaseModel, TimestampMixin):
is_active = fields.BooleanField(default=True, description="是否激活", index=True) is_active = fields.BooleanField(default=True, description="是否激活", index=True)
last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True) last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True)
remaining_quota = fields.IntField(default=0, description="剩余估值次数", index=True) remaining_quota = fields.IntField(default=0, description="剩余估值次数", index=True)
notes = fields.CharField(max_length=256, null=True, description="备注")
class Meta: class Meta:
table = "app_user" table = "app_user"

View File

@ -100,6 +100,7 @@ class AppUserQuotaLogOut(BaseModel):
after_count: int after_count: int
op_type: str op_type: str
remark: Optional[str] = None remark: Optional[str] = None
created_at: str
class AppUserRegisterOut(BaseModel): class AppUserRegisterOut(BaseModel):

View File

@ -28,10 +28,10 @@ class EmailClient:
msg.attach(part) msg.attach(part)
if settings.SMTP_TLS: if settings.SMTP_TLS:
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30)
server.starttls() server.starttls()
else: else:
server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30)
try: try:
if settings.SMTP_USERNAME and settings.SMTP_PASSWORD: if settings.SMTP_USERNAME and settings.SMTP_PASSWORD:
server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD) server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)

View File

@ -24,14 +24,24 @@ class SMSClient:
return return
from alibabacloud_tea_openapi import models as open_api_models # type: ignore from alibabacloud_tea_openapi import models as open_api_models # type: ignore
from alibabacloud_dysmsapi20170525.client import Client as DysmsClient # type: ignore from alibabacloud_dysmsapi20170525.client import Client as DysmsClient # type: ignore
if not settings.ALIBABA_CLOUD_ACCESS_KEY_ID or not settings.ALIBABA_CLOUD_ACCESS_KEY_SECRET: from alibabacloud_credentials.client import Client as CredentialClient # type: ignore
raise RuntimeError("短信凭证未配置:请设置 ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET") use_chain = bool(settings.ALIYUN_USE_DEFAULT_CREDENTIALS) or (not settings.ALIBABA_CLOUD_ACCESS_KEY_ID or not settings.ALIBABA_CLOUD_ACCESS_KEY_SECRET)
if not use_chain and (not settings.ALIBABA_CLOUD_ACCESS_KEY_ID or not settings.ALIBABA_CLOUD_ACCESS_KEY_SECRET):
raise RuntimeError("短信凭证未配置:请设置 ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET 或启用默认凭据链")
if not settings.ALIYUN_SMS_SIGN_NAME: if not settings.ALIYUN_SMS_SIGN_NAME:
raise RuntimeError("短信签名未配置:请设置 ALIYUN_SMS_SIGN_NAME") raise RuntimeError("短信签名未配置:请设置 ALIYUN_SMS_SIGN_NAME")
config = open_api_models.Config( if str(settings.ALIYUN_SMS_SIGN_NAME).upper().startswith("SMS_"):
access_key_id=settings.ALIBABA_CLOUD_ACCESS_KEY_ID, raise RuntimeError("短信签名配置错误:签名不应为模板码")
access_key_secret=settings.ALIBABA_CLOUD_ACCESS_KEY_SECRET, if settings.ALIYUN_SMS_TEMPLATE_CODE_VERIFY and settings.ALIYUN_SMS_TEMPLATE_CODE_REPORT and settings.ALIYUN_SMS_TEMPLATE_CODE_VERIFY == settings.ALIYUN_SMS_TEMPLATE_CODE_REPORT:
) raise RuntimeError("短信模板配置错误:验证码模板与报告模板重复")
if use_chain:
credential = CredentialClient()
config = open_api_models.Config(credential=credential)
else:
config = open_api_models.Config(
access_key_id=settings.ALIBABA_CLOUD_ACCESS_KEY_ID,
access_key_secret=settings.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
)
config.endpoint = settings.ALIYUN_SMS_ENDPOINT config.endpoint = settings.ALIYUN_SMS_ENDPOINT
self.client = DysmsClient(config) self.client = DysmsClient(config)
@ -54,7 +64,8 @@ class SMSClient:
template_code=template_code, template_code=template_code,
template_param=json.dumps(template_param or {}), template_param=json.dumps(template_param or {}),
) )
logger.info("sms.send start phone={} template={}", phone, template_code)
logger.info("sms.send start phone={} sign={} template={}", phone, settings.ALIYUN_SMS_SIGN_NAME, template_code)
try: try:
resp = self.client.send_sms(req) resp = self.client.send_sms(req)
body = resp.body.to_map() if hasattr(resp, "body") else {} body = resp.body.to_map() if hasattr(resp, "body") else {}
@ -74,7 +85,10 @@ class SMSClient:
Returns: Returns:
返回体映射字典 返回体映射字典
""" """
return self.send_by_template(phone, "SMS_498190229", {"code": code}) key = settings.ALIYUN_SMS_TEMPLATE_PARAM_CODE_KEY or "code"
template = settings.ALIYUN_SMS_TEMPLATE_CODE_VERIFY or "SMS_498190229"
logger.info("sms.send_code using key={} template={} phone={}", key, template, phone)
return self.send_by_template(phone, template, {key: code})
def send_report(self, phone: str) -> Dict[str, Any]: def send_report(self, phone: str) -> Dict[str, Any]:
"""发送报告通知短信 """发送报告通知短信
@ -85,7 +99,9 @@ class SMSClient:
Returns: Returns:
返回体映射字典 返回体映射字典
""" """
return self.send_by_template(phone, "SMS_498140213", {}) template = settings.ALIYUN_SMS_TEMPLATE_CODE_REPORT or "SMS_498140213"
logger.info("sms.send_report using template={} phone={}", template, phone)
return self.send_by_template(phone, template, {})
sms_client = SMSClient() sms_client = SMSClient()

View File

@ -4,6 +4,8 @@ from datetime import date
from typing import Dict, Optional, Tuple from typing import Dict, Optional, Tuple
from app.settings import settings
class VerificationStore: class VerificationStore:
def __init__(self, code_ttl_seconds: int = 300, minute_window: int = 60, daily_limit: int = 10, max_failures: int = 5, lock_seconds: int = 3600) -> None: def __init__(self, code_ttl_seconds: int = 300, minute_window: int = 60, daily_limit: int = 10, max_failures: int = 5, lock_seconds: int = 3600) -> None:
"""验证码与限流存储 """验证码与限流存储
@ -28,12 +30,14 @@ class VerificationStore:
self.failures: Dict[str, Dict[str, float]] = {} self.failures: Dict[str, Dict[str, float]] = {}
def generate_code(self) -> str: def generate_code(self) -> str:
"""生成6位数字验证码 """生成数字验证码
Returns: Returns:
六位数字字符串 指定位数的数字字符串
""" """
return f"{random.randint(0, 999999):06d}" digits = int(getattr(settings, "SMS_CODE_DIGITS", 6) or 6)
max_val = (10 ** digits) - 1
return f"{random.randint(0, max_val):0{digits}d}"
def set_code(self, phone: str, code: str) -> None: def set_code(self, phone: str, code: str) -> None:
"""设置验证码与过期时间 """设置验证码与过期时间

View File

@ -97,8 +97,14 @@ class Settings(BaseSettings):
ALIBABA_CLOUD_ACCESS_KEY_ID: typing.Optional[str] = "LTAI5tA8gcgM8Qc7K9qCtmXg" ALIBABA_CLOUD_ACCESS_KEY_ID: typing.Optional[str] = "LTAI5tA8gcgM8Qc7K9qCtmXg"
ALIBABA_CLOUD_ACCESS_KEY_SECRET: typing.Optional[str] = "eWZIWi6xILGtmPSGyJEAhILX5fQx0h" ALIBABA_CLOUD_ACCESS_KEY_SECRET: typing.Optional[str] = "eWZIWi6xILGtmPSGyJEAhILX5fQx0h"
ALIYUN_SMS_SIGN_NAME: typing.Optional[str] = "SMS_498140213" ALIYUN_SMS_SIGN_NAME: typing.Optional[str] = "成都文化产权交易所"
ALIYUN_SMS_ENDPOINT: str = "dysmsapi.aliyuncs.com" ALIYUN_SMS_ENDPOINT: str = "dysmsapi.aliyuncs.com"
ALIYUN_SMS_TEMPLATE_CODE_VERIFY: typing.Optional[str] = "SMS_498140213"
ALIYUN_SMS_TEMPLATE_CODE_REPORT: typing.Optional[str] = "SMS_49190229"
SMS_CODE_DIGITS: int = 6
SMS_DEBUG_LOG_CODE: bool = True
ALIYUN_USE_DEFAULT_CREDENTIALS: bool = False
ALIYUN_SMS_TEMPLATE_PARAM_CODE_KEY: typing.Optional[str] = "code"
SMTP_HOST: typing.Optional[str] = None SMTP_HOST: typing.Optional[str] = None
SMTP_PORT: typing.Optional[int] = None SMTP_PORT: typing.Optional[int] = None

View File

@ -1,6 +1,21 @@
server { server {
listen 80; listen 80;
server_name localhost; server_name localhost;
location = /docs {
proxy_pass http://127.0.0.1:9999/docs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location = /redoc {
proxy_pass http://127.0.0.1:9999/redoc;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location = /openapi.json {
proxy_pass http://127.0.0.1:9999/openapi.json;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / { location / {
root /opt/vue-fastapi-admin/web/dist; root /opt/vue-fastapi-admin/web/dist;
index index.html index.htm; index index.html index.htm;
@ -8,6 +23,8 @@ server {
} }
location ^~ /api/ { location ^~ /api/ {
proxy_pass http://127.0.0.1:9999; proxy_pass http://127.0.0.1:9999;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
} }
} }

View File

@ -68,3 +68,4 @@ alibabacloud_dysmsapi20170525==4.1.2
alibabacloud_tea_openapi==0.4.1 alibabacloud_tea_openapi==0.4.1
alibabacloud_tea_util==0.3.14 alibabacloud_tea_util==0.3.14
pytest==8.3.3 pytest==8.3.3
aiomysql

2
run.py
View File

@ -10,5 +10,5 @@ if __name__ == "__main__":
] = '%(asctime)s - %(levelname)s - %(client_addr)s - "%(request_line)s" %(status_code)s' ] = '%(asctime)s - %(levelname)s - %(client_addr)s - "%(request_line)s" %(status_code)s'
LOGGING_CONFIG["formatters"]["access"]["datefmt"] = "%Y-%m-%d %H:%M:%S" LOGGING_CONFIG["formatters"]["access"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
uvicorn.run("app:app", host="0.0.0.0", port=9991, reload=True, log_config=LOGGING_CONFIG) uvicorn.run("app:app", host="0.0.0.0", port=9999, reload=True, log_config=LOGGING_CONFIG)

103
scripts/send_email_test.py Normal file
View File

@ -0,0 +1,103 @@
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Dict, Optional
def _parse_bool(value: Optional[str]) -> bool:
"""
功能: 将环境变量中的布尔字符串解析为布尔值
参数: value (Optional[str]) - 环境变量字符串值, "true", "1", "yes"
返回: bool - 解析后的布尔值
"""
if value is None:
return False
return str(value).strip().lower() in {"1", "true", "yes", "y"}
def get_smtp_config() -> Dict[str, Optional[str]]:
"""
功能: 从环境变量读取SMTP配置并返回配置字典
参数:
返回: Dict[str, Optional[str]] - 包含hostportfromusernamepasswordtls等键的配置
"""
host = os.environ.get("SMTP_HOST", "smtp.qiye.aliyun.com")
port_str = os.environ.get("SMTP_PORT", "465")
from_addr = os.environ.get("SMTP_FROM","value@cdcee.net")
username = os.environ.get("SMTP_USERNAME","value@cdcee.net")
password = os.environ.get("SMTP_PASSWORD","PPXbILdGlRCn2VOx")
tls = _parse_bool(os.environ.get("SMTP_TLS"))
port = None
if port_str:
try:
port = int(port_str)
except Exception:
port = None
return {
"host": host,
"port": port,
"from": from_addr,
"username": username,
"password": password,
"tls": tls,
}
def send_test_email(to_email: str, subject: Optional[str], body: str) -> Dict[str, str]:
"""
功能: 使用SMTP配置发送测试邮件到指定邮箱
参数: to_email (str) - 收件人邮箱; subject (Optional[str]) - 邮件主题; body (str) - 邮件正文内容
返回: Dict[str, str] - 发送结果字典, 包含status("OK"/"FAIL")与error(失败信息)
"""
cfg = get_smtp_config()
if not cfg["host"] or not cfg["port"] or not cfg["from"]:
return {"status": "FAIL", "error": "SMTP 未配置: 需设置 SMTP_HOST/SMTP_PORT/SMTP_FROM"}
msg = MIMEMultipart()
msg["From"] = cfg["from"]
msg["To"] = to_email
msg["Subject"] = subject or "估值服务通知"
msg.attach(MIMEText(body, "plain", "utf-8"))
server = None
try:
if cfg["tls"]:
server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=30)
server.starttls()
else:
server = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=30)
if cfg["username"] and cfg["password"]:
server.login(cfg["username"], cfg["password"])
server.sendmail(cfg["from"], [to_email], msg.as_string())
server.quit()
return {"status": "OK"}
except Exception as e:
try:
if server:
server.quit()
except Exception:
pass
return {"status": "FAIL", "error": str(e)}
if __name__ == "__main__":
to = "zfc9393@163.com"
subject = "测试邮件"
body = "这是一封测试邮件用于验证SMTP配置。"
cfg = get_smtp_config()
print({
"host": cfg["host"],
"port": cfg["port"],
"from": cfg["from"],
"username_set": bool(cfg["username"]),
"password_set": bool(cfg["password"]),
"tls": cfg["tls"],
})
result = send_test_email(to, subject, body)
print(result)

View File

@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
TOKEN=""
EMAIL=""
SUBJECT=""
BODY=""
FILE_PATH=""
FILE_URL=""
BASE_API="http://127.0.0.1:9999/api/v1"
while getopts ":t:e:s:b:f:u:a:" opt; do
case "$opt" in
t) TOKEN="$OPTARG" ;;
e) EMAIL="$OPTARG" ;;
s) SUBJECT="$OPTARG" ;;
b) BODY="$OPTARG" ;;
f) FILE_PATH="$OPTARG" ;;
u) FILE_URL="$OPTARG" ;;
a) BASE_API="$OPTARG" ;;
*) echo "Invalid option: -$OPTARG"; exit 1 ;;
esac
done
if [[ -z "$TOKEN" || -z "$EMAIL" || -z "$BODY" ]]; then
echo "Usage: $0 -t <token> -e <email> -b <body> [-s <subject>] [-f <file_path> | -u <file_url>] [-a <base_api>]"
exit 1
fi
URL="$BASE_API/transactions/send-email"
if [[ -n "$FILE_PATH" ]]; then
if [[ ! -f "$FILE_PATH" ]]; then
echo "File not found: $FILE_PATH"
exit 1
fi
curl -s -X POST "$URL" \
-H "accept: application/json" \
-H "token: $TOKEN" \
-F "email=$EMAIL" \
-F "subject=$SUBJECT" \
-F "body=$BODY" \
-F "file=@$FILE_PATH" | jq -r '.' 2>/dev/null || true
else
PAYLOAD="{\"email\":\"$EMAIL\",\"subject\":\"$SUBJECT\",\"body\":\"$BODY\"}"
if [[ -n "$FILE_URL" ]]; then
PAYLOAD="{\"email\":\"$EMAIL\",\"subject\":\"$SUBJECT\",\"body\":\"$BODY\",\"file_url\":\"$FILE_URL\"}"
fi
curl -s -X POST "$URL" \
-H "accept: application/json" \
-H "token: $TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" | jq -r '.' 2>/dev/null || true
fi

View File

@ -276,6 +276,120 @@ const mockValuationDetails = valuationRecords.map((record) => ({
...record, ...record,
})) }))
const mockAppUsers = [
{
id: 11111111,
phone: '15021982682',
wechat: 'f1498480844',
created_at: '2024-01-15T10:30:00Z',
notes: '测试用户1',
remaining_count: 1,
user_type: '体验用户',
},
{
id: 11111112,
phone: '13800138002',
wechat: 'wx_limming2024',
created_at: '2024-02-20T14:20:00Z',
notes: '付费用户',
remaining_count: 5,
user_type: '付费用户',
},
{
id: 11111113,
phone: '13800138003',
wechat: null,
created_at: '2024-03-10T08:45:00Z',
notes: null,
remaining_count: 0,
user_type: '体验用户',
},
{
id: 11111114,
phone: '13800138004',
wechat: 'chenjun_vip',
created_at: '2024-04-05T11:30:00Z',
notes: 'VIP用户',
remaining_count: 10,
user_type: 'VIP',
},
{
id: 11111115,
phone: '13800138005',
wechat: 'liuxia888',
created_at: '2024-05-12T16:15:00Z',
notes: '体验用户',
remaining_count: 3,
user_type: '体验用户',
},
{
id: 11111116,
phone: '13800138006',
wechat: null,
created_at: '2024-06-18T09:00:00Z',
notes: '新注册用户',
remaining_count: 2,
user_type: '体验用户',
},
{
id: 11111117,
phone: '13800138007',
wechat: 'zhaolei2024',
created_at: '2024-07-22T12:45:00Z',
notes: null,
remaining_count: 0,
user_type: '体验用户',
},
{
id: 11111118,
phone: '13800138008',
wechat: 'sunmei_user',
created_at: '2024-08-30T15:20:00Z',
notes: '活跃用户',
remaining_count: 7,
user_type: 'VIP',
},
]
const defaultInvoiceHeaders = [
{
company_name: '成都文创科技有限公司',
tax_number: '91510100MA7XYZ1234',
register_address: '四川省成都市高新区天府三街666号',
register_phone: '028-66666666',
bank_name: '招商银行成都分行',
bank_account: '6225 6666 8888 0000',
email: 'finance@scwenchuang.com',
},
{
company_name: '天府文化发展有限公司',
tax_number: '91510100678912345K',
register_address: '四川省成都市武侯区科华北路88号',
register_phone: '028-12345678',
bank_name: '中国工商银行成都分行',
bank_account: '6212 8888 0000 9999',
email: 'invoice@tfculture.com',
},
]
const defaultOperationLogs = [
{
time: '2025-10-31 18:30:30',
operator: 'admin',
records: ['剩余估值次数0 -> 1', '类型:付费估值', '备注:新用户'],
},
{
time: '2025-10-31 18:30:30',
operator: 'admin',
records: ['剩余估值次数2 -> 1', '类型:付费估值', '备注:退款'],
},
{
time: '2025-10-31 18:30:30',
operator: 'admin',
records: ['用户备注111111111111 -> 22222222222222222222'],
},
]
export default { export default {
login: (data) => request.post('/base/access_token', data, { noNeedToken: true }), login: (data) => request.post('/base/access_token', data, { noNeedToken: true }),
getUserInfo: () => request.get('/base/userinfo'), getUserInfo: () => request.get('/base/userinfo'),
@ -316,17 +430,299 @@ export default {
// auditlog // auditlog
getAuditLogList: (params = {}) => request.get('/auditlog/list', { params }), getAuditLogList: (params = {}) => request.get('/auditlog/list', { params }),
// app users (客户端用户管理) - 使用现有的后端接口 // app users (客户端用户管理) - 使用现有的后端接口
getAppUserList: (params = {}) => request.get('/app-user-admin/list', { params }), getAppUserList: (params = {}) => {
updateAppUserQuota: (data = {}) => request.post('/app-user-admin/quota', data), // 模拟分页和搜索
getAppUserQuotaLogs: ({ user_id, ...params } = {}) => let filteredUsers = [...mockAppUsers]
request.get(`/app-user-admin/${user_id}/quota-logs`, { params }),
// 手机号搜索
if (params.phone) {
filteredUsers = filteredUsers.filter(user =>
user.phone.includes(params.phone)
)
}
// 微信号搜索
if (params.wechat) {
filteredUsers = filteredUsers.filter(user =>
user.wechat && user.wechat.includes(params.wechat)
)
}
// 注册时间筛选(日期范围)
if (params.created_at && Array.isArray(params.created_at) && params.created_at.length === 2) {
const [startTime, endTime] = params.created_at
filteredUsers = filteredUsers.filter(user => {
if (!user.created_at) return false
const userTime = new Date(user.created_at).getTime()
return userTime >= startTime && userTime <= endTime
})
}
// 分页处理
const page = Number(params.page) || 1
const pageSize = Number(params.page_size) || 10
const startIndex = (page - 1) * pageSize
const endIndex = startIndex + pageSize
const paginatedUsers = filteredUsers.slice(startIndex, endIndex)
// 返回 Promise 模拟异步请求
return new Promise((resolve) => {
setTimeout(() => {
resolve({
data: paginatedUsers,
total: filteredUsers.length,
page,
page_size: pageSize,
})
}, 300)
})
},
getAppUserById: (params = {}) =>
new Promise((resolve) => {
const id = Number(params.id)
const user = mockAppUsers.find((item) => item.id === id) || {}
setTimeout(() => {
resolve({
baseInfo: {
id: user.id,
phone: user.phone,
wechat: user.wechat,
register_time: user.created_at,
notes: user.notes,
remaining_count: user.remaining_count,
user_type: user.user_type || '体验用户',
},
invoiceHeaders: defaultInvoiceHeaders,
operationLogs: defaultOperationLogs,
})
}, 300)
}),
createAppUser: (data = {}) => request.post('/app-user/register', data), createAppUser: (data = {}) => request.post('/app-user/register', data),
updateAppUser: (data = {}) => request.post('/app-user/update', data), updateAppUser: (data = {}) => request.post('/app-user/update', data),
deleteAppUser: (params = {}) => request.delete('/app-user/delete', { params }), deleteAppUser: (params = {}) => request.delete('/app-user/delete', { params }),
// invoice (交易管理-对公转账记录) // invoice (开票记录)
getInvoiceList: (params = {}) => request.get('/transactions/receipts', { params }), getInvoiceList: (params = {}) => {
getInvoiceById: (params = {}) => request.get(`/transactions/receipts/${params.id}`, { params }), // Mock 数据
sendInvoice: (data = {}) => request.post('/transactions/send-email', data), const mockInvoices = [
{
id: 1001,
created_at: '2024-11-10T09:30:00Z',
ticket_type: 'electronic',
phone: '13800138001',
email: 'zhangsan@company1.com',
company_name: '北京科技有限公司',
tax_number: '91110000123456789A',
register_address: '北京市朝阳区科技园区A座1001室',
register_phone: '010-12345678',
bank_name: '中国工商银行北京分行',
bank_account: '6222021234567890123',
invoice_type: 'special',
status: 'pending'
},
{
id: 1002,
created_at: '2024-11-09T14:20:00Z',
ticket_type: 'paper',
phone: '13800138002',
email: 'lisi@company2.com',
company_name: '上海贸易股份有限公司',
tax_number: '91310000987654321B',
register_address: '上海市浦东新区金融街B座2002室',
register_phone: '021-87654321',
bank_name: '中国建设银行上海分行',
bank_account: '6217001234567890124',
invoice_type: 'normal',
status: 'invoiced'
},
{
id: 1003,
created_at: '2024-11-08T16:45:00Z',
ticket_type: 'electronic',
phone: '13800138003',
email: 'wangwu@company3.com',
company_name: '深圳创新科技有限公司',
tax_number: '91440300456789012C',
register_address: '深圳市南山区高新技术园C座3003室',
register_phone: '0755-23456789',
bank_name: '招商银行深圳分行',
bank_account: '6214851234567890125',
invoice_type: 'special',
status: 'rejected'
},
{
id: 1004,
created_at: '2024-11-07T11:15:00Z',
ticket_type: 'paper',
phone: '13800138004',
email: 'zhaoliu@company4.com',
company_name: '广州制造业集团有限公司',
tax_number: '91440100789012345D',
register_address: '广州市天河区商务中心D座4004室',
register_phone: '020-34567890',
bank_name: '中国银行广州分行',
bank_account: '6013821234567890126',
invoice_type: 'normal',
status: 'pending'
},
{
id: 1005,
created_at: '2024-11-06T08:30:00Z',
ticket_type: 'electronic',
phone: '13800138005',
email: 'sunqi@company5.com',
company_name: '杭州互联网科技有限公司',
tax_number: '91330100012345678E',
register_address: '杭州市西湖区互联网小镇E座5005室',
register_phone: '0571-45678901',
bank_name: '浙商银行杭州分行',
bank_account: '6228481234567890127',
invoice_type: 'special',
status: 'invoiced'
},
{
id: 1006,
created_at: '2024-11-05T13:20:00Z',
ticket_type: 'paper',
phone: '13800138006',
email: 'zhouba@company6.com',
company_name: '成都软件开发有限公司',
tax_number: '91510100345678901F',
register_address: '成都市高新区软件园F座6006室',
register_phone: '028-56789012',
bank_name: '中国农业银行成都分行',
bank_account: '6230521234567890128',
invoice_type: 'normal',
status: 'pending'
},
{
id: 1007,
created_at: '2024-11-04T15:45:00Z',
ticket_type: 'electronic',
phone: '13800138007',
email: 'wujiu@company7.com',
company_name: '武汉新能源科技有限公司',
tax_number: '91420100678901234G',
register_address: '武汉市江汉区新能源产业园G座7007室',
register_phone: '027-67890123',
bank_name: '交通银行武汉分行',
bank_account: '6222601234567890129',
invoice_type: 'special',
status: 'invoiced'
},
{
id: 1008,
created_at: '2024-11-03T10:10:00Z',
ticket_type: 'paper',
phone: '13800138008',
email: 'zhengshi@company8.com',
company_name: '西安电子商务有限公司',
tax_number: '91610100901234567H',
register_address: '西安市雁塔区电商产业园H座8008室',
register_phone: '029-78901234',
bank_name: '中信银行西安分行',
bank_account: '6217711234567890130',
invoice_type: 'normal',
status: 'refunded'
},
{
id: 1009,
created_at: '2024-11-02T14:30:00Z',
ticket_type: 'electronic',
phone: '13800138009',
email: 'wangwu@company9.com',
company_name: '天津物流科技有限公司',
tax_number: '91120000234567890I',
register_address: '天津市滨海新区物流园I座9009室',
register_phone: '022-89012345',
bank_name: '民生银行天津分行',
bank_account: '6226221234567890131',
invoice_type: 'special',
status: 'refunded'
},
{
id: 1010,
created_at: '2024-11-01T11:45:00Z',
ticket_type: 'paper',
phone: '13800138010',
email: 'liuliu@company10.com',
company_name: '重庆智能制造有限公司',
tax_number: '91500000567890123J',
register_address: '重庆市渝北区智能制造园J座1010室',
register_phone: '023-90123456',
bank_name: '华夏银行重庆分行',
bank_account: '6228881234567890132',
invoice_type: 'normal',
status: 'rejected'
}
]
// 模拟分页和搜索
let filteredInvoices = [...mockInvoices]
// 手机号搜索
if (params.phone) {
filteredInvoices = filteredInvoices.filter(invoice =>
invoice.phone.includes(params.phone)
)
}
// 公司名称搜索
if (params.company_name) {
filteredInvoices = filteredInvoices.filter(invoice =>
invoice.company_name.includes(params.company_name)
)
}
// 公司税号搜索
if (params.tax_number) {
filteredInvoices = filteredInvoices.filter(invoice =>
invoice.tax_number.includes(params.tax_number)
)
}
// 状态筛选
if (params.status) {
filteredInvoices = filteredInvoices.filter(invoice =>
invoice.status === params.status
)
}
// 提交时间筛选
if (params.created_at && Array.isArray(params.created_at) && params.created_at.length === 2) {
const [startDate, endDate] = params.created_at
filteredInvoices = filteredInvoices.filter(invoice => {
const invoiceDate = new Date(invoice.created_at)
return invoiceDate >= new Date(startDate) && invoiceDate <= new Date(endDate)
})
}
// 分页处理
const page = params.page || 1
const pageSize = params.page_size || 10
const startIndex = (page - 1) * pageSize
const endIndex = startIndex + pageSize
const paginatedInvoices = filteredInvoices.slice(startIndex, endIndex)
// 返回 Promise 模拟异步请求
return new Promise((resolve) => {
setTimeout(() => {
resolve({
data: paginatedInvoices,
total: filteredInvoices.length,
page: page,
page_size: pageSize
})
}, 300) // 模拟网络延迟
})
},
getInvoiceById: (params = {}) => request.get('/invoice/detail', { params }),
createInvoice: (data = {}) => request.post('/invoice/create', data),
updateInvoice: (data = {}) => request.post('/invoice/update', data),
deleteInvoice: (params = {}) => request.delete('/invoice/delete', { params }),
updateInvoiceStatus: (data = {}) => request.post('/invoice/update-status', data),
remindInvoice: (data = {}) => request.post('/invoice/remind', data),
refundInvoice: (data = {}) => request.post('/invoice/refund', data),
sendInvoice: (data = {}) => request.post('/invoice/send', data),
// valuation (估值评估) // valuation (估值评估)
getValuationList: (params = {}) => { getValuationList: (params = {}) => {
// 模拟分页和搜索 // 模拟分页和搜索

View File

@ -0,0 +1,167 @@
<script setup>
import { h, onMounted, ref } from 'vue'
import { NTag, NButton, NInput, NSelect, NDatePicker } from 'naive-ui'
import CommonPage from '@/components/page/CommonPage.vue'
import QueryBarItem from '@/components/query-bar/QueryBarItem.vue'
import CrudTable from '@/components/table/CrudTable.vue'
import { formatDate } from '@/utils'
import api from '@/api'
defineOptions({ name: '交易记录' })
const $table = ref(null)
const queryItems = ref({})
const statusOptions = [
{ label: '全部', value: '' },
{ label: '未开票', value: 'pending' },
{ label: '已开票', value: 'invoiced' },
{ label: '已退款', value: 'refunded' },
{ label: '已拒绝', value: 'rejected' },
]
const invoiceTypeOptions = [
{ label: '增值税普通发票', value: 'normal' },
{ label: '增值税专用发票', value: 'special' },
]
const ticketTypeOptions = [
{ label: '纸质发票', value: 'paper' },
{ label: '电子发票', value: 'electronic' },
]
onMounted(() => {
$table.value?.handleSearch()
})
const renderStatus = (status) => {
const statusMap = {
pending: { type: 'warning', text: '未开票' },
invoiced: { type: 'success', text: '已开票' },
refunded: { type: 'info', text: '已退款' },
rejected: { type: 'error', text: '已拒绝' },
}
const config = statusMap[status] || { type: 'default', text: '未知' }
return h(NTag, { type: config.type }, { default: () => config.text })
}
const renderInvoiceType = (type) => {
const typeMap = { normal: '增值税普通发票', special: '增值税专用发票' }
return typeMap[type] || type
}
const renderTicketType = (type) => {
const typeMap = { paper: '纸质发票', electronic: '电子发票' }
return typeMap[type] || type
}
const columns = [
{ title: 'ID', key: 'id', width: 60, align: 'center', render(row) { return row?.receipt?.id || '' } },
{ title: '凭证时间', key: 'submitted_at', width: 120, align: 'center', render(row) { return formatDate(row.submitted_at) } },
{ title: '公司名称', key: 'company_name', width: 160, align: 'center', ellipsis: { tooltip: true } },
{ title: '公司税号', key: 'tax_number', width: 160, align: 'center', ellipsis: { tooltip: true } },
{ title: '手机号', key: 'phone', width: 120, align: 'center' },
{ title: '微信号', key: 'wechat', width: 120, align: 'center' },
{ title: '开票类型', key: 'invoice_type', width: 120, align: 'center', render(row) { return renderInvoiceType(row.invoice_type) } },
{ title: '供票类型', key: 'ticket_type', width: 100, align: 'center', render(row) { return renderTicketType(row.ticket_type) } },
{ title: '备注', key: 'note', width: 140, align: 'center', render(row) { return row?.receipt?.note || '' } },
{ title: '核验', key: 'verified', width: 80, align: 'center', render(row) { return h(NTag, { type: row?.receipt?.verified ? 'success' : 'warning' }, { default: () => (row?.receipt?.verified ? '已核验' : '待核验') }) } },
{ title: '凭证', key: 'url', width: 100, align: 'center', render(row) { return h(NButton, { text: true, type: 'info', onClick: () => window.open(row?.receipt?.url, '_blank') }, { default: () => '打开' }) } },
]
</script>
<template>
<CommonPage show-footer title="交易记录">
<CrudTable
ref="$table"
v-model:query-items="queryItems"
:columns="columns"
:get-data="api.getReceiptList"
>
<template #queryBar>
<QueryBarItem label="凭证时间" :label-width="80">
<NDatePicker
v-model:value="queryItems.created_at"
type="daterange"
clearable
placeholder="请选择凭证时间"
style="width: 280px"
@update:value="$table?.handleSearch()"
/>
</QueryBarItem>
<QueryBarItem label="手机号" :label-width="80">
<NInput
v-model:value="queryItems.phone"
clearable
type="text"
placeholder="请输入手机号"
style="width: 200px"
@keypress.enter="$table?.handleSearch()"
/>
</QueryBarItem>
<QueryBarItem label="微信号" :label-width="80">
<NInput
v-model:value="queryItems.wechat"
clearable
type="text"
placeholder="请输入微信号"
style="width: 200px"
@keypress.enter="$table?.handleSearch()"
/>
</QueryBarItem>
<QueryBarItem label="公司名称" :label-width="80">
<NInput
v-model:value="queryItems.company_name"
clearable
type="text"
placeholder="请输入公司名称"
style="width: 200px"
@keypress.enter="$table?.handleSearch()"
/>
</QueryBarItem>
<QueryBarItem label="公司税号" :label-width="80">
<NInput
v-model:value="queryItems.tax_number"
clearable
type="text"
placeholder="请输入公司税号"
style="width: 200px"
@keypress.enter="$table?.handleSearch()"
/>
</QueryBarItem>
<QueryBarItem label="状态" :label-width="80">
<NSelect
v-model:value="queryItems.status"
:options="statusOptions"
placeholder="请选择状态"
clearable
style="width: 200px"
@update:value="$table?.handleSearch()"
/>
</QueryBarItem>
<QueryBarItem label="开票类型" :label-width="80">
<NSelect
v-model:value="queryItems.invoice_type"
:options="invoiceTypeOptions"
placeholder="请选择开票类型"
clearable
style="width: 200px"
@update:value="$table?.handleSearch()"
/>
</QueryBarItem>
<QueryBarItem label="供票类型" :label-width="80">
<NSelect
v-model:value="queryItems.ticket_type"
:options="ticketTypeOptions"
placeholder="请选择供票类型"
clearable
style="width: 200px"
@update:value="$table?.handleSearch()"
/>
</QueryBarItem>
</template>
</CrudTable>
</CommonPage>
</template>

View File

@ -37,8 +37,8 @@
export DOCKER_DEFAULT_PLATFORM=linux/amd64 export DOCKER_DEFAULT_PLATFORM=linux/amd64
docker build -t zfc931912343/bindbox-game:v1.0 . docker build -t zfc931912343/guzhi-fastapi-admin:v1.5 .
docker push zfc931912343/bindbox-game:v1.0 docker push zfc931912343/guzhi-fastapi-admin:v1.5
# 运行容器 # 运行容器
@ -68,4 +68,7 @@ docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.4 &&
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.4 && docker rm -f guzhi && docker run -itd --name=guzhi -p 8080:80 -v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped --memory=2g --cpus=1.0 -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.4 docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.4 && docker rm -f guzhi && docker run -itd --name=guzhi -p 8080:80 -v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped --memory=2g --cpus=1.0 -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.4
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.5 && docker rm -f guzhi_dev && docker run -itd --name=guzhi_dev -p 9990:80 -v ~/guzhi-data-dev/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.5