feat: 新增交易记录管理功能与统一上传接口

feat(交易记录): 新增交易记录管理页面与API接口
feat(上传): 添加统一上传接口支持自动识别文件类型
feat(用户管理): 为用户模型添加备注字段并更新相关接口
feat(邮件): 实现SMTP邮件发送功能并添加测试脚本
feat(短信): 增强短信服务配置灵活性与日志记录

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

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

chore: 更新依赖与Docker镜像版本
This commit is contained in:
邹方成 2025-11-20 20:53:09 +08:00
parent c905d2492b
commit f536178428
30 changed files with 1148 additions and 27 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,
"wechat": u.alias,
"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),
"user_type": None,
})
@ -52,6 +52,9 @@ async def update_quota(payload: AppUserQuotaUpdateSchema, operator=Depends(AuthC
)
if not user:
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="调整成功")
@ -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,
op_type=r.op_type,
remark=r.remark,
created_at=r.created_at.isoformat() if r.created_at else "",
) for r in rows
]
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:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(reason))
try:
code = store.generate_code()
store.set_code(payload.phone, code)
res = sms_client.send_code(payload.phone, code)
otp = store.generate_code()
store.set_code(payload.phone, otp)
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")
rid = res.get("RequestId") or res.get("MessageId")
if code == "OK":
@ -194,4 +197,4 @@ async def sms_login(payload: SMSLoginRequest) -> SMSLoginResponse:
return SMSLoginResponse(user=user_info, token=token_out)
class VerifyCodeRequest(BaseModel):
phone: str = Field(...)
code: str = Field(...)
code: str = Field(...)

View File

@ -24,6 +24,9 @@ async def list_receipts(
status: Optional[str] = Query(None),
ticket_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_size: int = Query(10, ge=1, le=100),
):
@ -40,6 +43,9 @@ async def list_receipts(
status=status,
ticket_type=ticket_type,
invoice_type=invoice_type,
created_at=created_at,
submitted_start=submitted_start,
submitted_end=submitted_end,
)
return SuccessExtra(
data=result["items"],
@ -101,4 +107,25 @@ async def send_email(data: SendEmailRequest, file: Optional[UploadFile] = File(N
else:
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="上传文件")
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"):
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()
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}",
filename=filename,
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.models.admin import Api, Menu, Role
from app.models.invoice import Invoice, PaymentReceipt
from app.schemas.menus import MenuType
from app.settings.config import settings
@ -237,6 +238,45 @@ async def init_menus():
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():
apis = await api_controller.model.exists()
@ -287,9 +327,123 @@ async def init_roles():
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():
await init_db()
await init_superuser()
await init_menus()
await init_apis()
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)
last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True)
remaining_quota = fields.IntField(default=0, description="剩余估值次数", index=True)
notes = fields.CharField(max_length=256, null=True, description="备注")
class Meta:
table = "app_user"

View File

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

View File

@ -28,10 +28,10 @@ class EmailClient:
msg.attach(part)
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()
else:
server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT)
server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30)
try:
if settings.SMTP_USERNAME and settings.SMTP_PASSWORD:
server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)

View File

@ -24,14 +24,24 @@ class SMSClient:
return
from alibabacloud_tea_openapi import models as open_api_models # 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:
raise RuntimeError("短信凭证未配置:请设置 ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET")
from alibabacloud_credentials.client import Client as CredentialClient # type: ignore
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:
raise RuntimeError("短信签名未配置:请设置 ALIYUN_SMS_SIGN_NAME")
config = open_api_models.Config(
access_key_id=settings.ALIBABA_CLOUD_ACCESS_KEY_ID,
access_key_secret=settings.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
)
if str(settings.ALIYUN_SMS_SIGN_NAME).upper().startswith("SMS_"):
raise RuntimeError("短信签名配置错误:签名不应为模板码")
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
self.client = DysmsClient(config)
@ -54,7 +64,8 @@ class SMSClient:
template_code=template_code,
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:
resp = self.client.send_sms(req)
body = resp.body.to_map() if hasattr(resp, "body") else {}
@ -74,7 +85,10 @@ class SMSClient:
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]:
"""发送报告通知短信
@ -85,7 +99,9 @@ class SMSClient:
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()

View File

@ -4,6 +4,8 @@ from datetime import date
from typing import Dict, Optional, Tuple
from app.settings import settings
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:
"""验证码与限流存储
@ -28,12 +30,14 @@ class VerificationStore:
self.failures: Dict[str, Dict[str, float]] = {}
def generate_code(self) -> str:
"""生成6位数字验证码
"""生成数字验证码
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:
"""设置验证码与过期时间

View File

@ -97,8 +97,14 @@ class Settings(BaseSettings):
ALIBABA_CLOUD_ACCESS_KEY_ID: typing.Optional[str] = "LTAI5tA8gcgM8Qc7K9qCtmXg"
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_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_PORT: typing.Optional[int] = None

View File

@ -1,6 +1,21 @@
server {
listen 80;
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 / {
root /opt/vue-fastapi-admin/web/dist;
index index.html index.htm;
@ -8,6 +23,8 @@ server {
}
location ^~ /api/ {
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_util==0.3.14
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'
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

@ -723,6 +723,9 @@ export default {
remindInvoice: (data = {}) => request.post('/invoice/remind', data),
refundInvoice: (data = {}) => request.post('/invoice/refund', data),
sendInvoice: (data = {}) => request.post('/invoice/send', data),
// transactions (对公转账记录)
getReceiptList: (params = {}) => request.get('/transactions/receipts', { params }),
getReceiptById: (params = {}) => request.get(`/transactions/receipts/${params.id}`),
// valuation (估值评估)
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
docker build -t zfc931912343/bindbox-game:v1.0 .
docker push zfc931912343/bindbox-game:v1.0
docker build -t zfc931912343/guzhi-fastapi-admin:v1.5 .
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