From f536178428cdb793c648da004336e7fd91a977eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=96=B9=E6=88=90?= Date: Thu, 20 Nov 2025 20:53:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BA=A4=E6=98=93?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E4=B8=8E?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=8A=E4=BC=A0=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(交易记录): 新增交易记录管理页面与API接口 feat(上传): 添加统一上传接口支持自动识别文件类型 feat(用户管理): 为用户模型添加备注字段并更新相关接口 feat(邮件): 实现SMTP邮件发送功能并添加测试脚本 feat(短信): 增强短信服务配置灵活性与日志记录 fix(发票): 修复发票列表时间筛选功能 fix(nginx): 调整上传大小限制与超时配置 docs: 添加多个功能模块的说明文档 docs(估值): 补充估值计算流程与API提交数据说明 chore: 更新依赖与Docker镜像版本 --- ...复后台管理接口与统一上传接口的改造方案.md | 98 ++++++++++ .trae/documents/准备估值测算 API 提交数据.md | 63 +++++++ ...始化交易记录数据并在后台可见的实施方案.md | 60 +++++++ .../新增发送邮件测试脚本(Shell).md | 25 +++ .trae/documents/蜀锦估值计算流程核对.md | 101 +++++++++++ .../说明发票状态含义与拒绝原因说明.md | 23 +++ .../调整 Nginx 上传大小与超时以支持大文件.md | 70 ++++++++ .../配置阿里企业邮箱SMTP并验证发送.md | 36 ++++ aaa.json | 29 +++ app/api/v1/app_users/admin_manage.py | 6 +- app/api/v1/sms/sms.py | 11 +- app/api/v1/transactions/transactions.py | 29 ++- app/api/v1/upload/upload.py | 6 +- app/controllers/invoice.py | 36 ++++ app/controllers/upload.py | 14 +- app/core/init_app.py | 154 ++++++++++++++++ app/models/user.py | 1 + app/schemas/app_user.py | 1 + app/services/email_client.py | 4 +- app/services/sms_client.py | 34 +++- app/services/sms_store.py | 10 +- app/settings/config.py | 8 +- deploy/web.conf | 17 ++ requirements.txt | 1 + run.py | 2 +- scripts/send_email_test.py | 103 +++++++++++ scripts/send_email_test.sh | 54 ++++++ web/src/api/index.js | 3 + web/src/views/transaction/receipts/index.vue | 167 ++++++++++++++++++ 估值字段.txt | 9 +- 30 files changed, 1148 insertions(+), 27 deletions(-) create mode 100644 .trae/documents/修复后台管理接口与统一上传接口的改造方案.md create mode 100644 .trae/documents/准备估值测算 API 提交数据.md create mode 100644 .trae/documents/初始化交易记录数据并在后台可见的实施方案.md create mode 100644 .trae/documents/新增发送邮件测试脚本(Shell).md create mode 100644 .trae/documents/蜀锦估值计算流程核对.md create mode 100644 .trae/documents/说明发票状态含义与拒绝原因说明.md create mode 100644 .trae/documents/调整 Nginx 上传大小与超时以支持大文件.md create mode 100644 .trae/documents/配置阿里企业邮箱SMTP并验证发送.md create mode 100644 aaa.json create mode 100644 scripts/send_email_test.py create mode 100644 scripts/send_email_test.sh create mode 100644 web/src/views/transaction/receipts/index.vue diff --git a/.trae/documents/修复后台管理接口与统一上传接口的改造方案.md b/.trae/documents/修复后台管理接口与统一上传接口的改造方案.md new file mode 100644 index 0000000..a777095 --- /dev/null +++ b/.trae/documents/修复后台管理接口与统一上传接口的改造方案.md @@ -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`,交易列表按“凭证时间”筛选可用。 + diff --git a/.trae/documents/准备估值测算 API 提交数据.md b/.trae/documents/准备估值测算 API 提交数据.md new file mode 100644 index 0000000..c4fe8d4 --- /dev/null +++ b/.trae/documents/准备估值测算 API 提交数据.md @@ -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`) \ No newline at end of file diff --git a/.trae/documents/初始化交易记录数据并在后台可见的实施方案.md b/.trae/documents/初始化交易记录数据并在后台可见的实施方案.md new file mode 100644 index 0000000..c42c3df --- /dev/null +++ b/.trae/documents/初始化交易记录数据并在后台可见的实施方案.md @@ -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` 批量创建 5–10 条覆盖不同类型/状态的发票(含公司名、税号、银行账户等)。 + - 对每条发票调用 `POST /api/v1/invoice/{id}/receipt` 上传/登记 1–2 条付款凭证(`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)前端“交易记录”后台页(或在发票页增加凭证列的最小改动)。 + +## 备注 +- 编码时为新增函数与接口补充函数级注释(功能、参数、返回值说明),遵循现有代码风格与安全规范。 \ No newline at end of file diff --git a/.trae/documents/新增发送邮件测试脚本(Shell).md b/.trae/documents/新增发送邮件测试脚本(Shell).md new file mode 100644 index 0000000..d6fbcfe --- /dev/null +++ b/.trae/documents/新增发送邮件测试脚本(Shell).md @@ -0,0 +1,25 @@ +## 目标 +- 提供一个可在本机直接运行的 Shell 脚本,测试 `POST /api/v1/transactions/send-email`,支持本地文件上传与远程文件 URL,两种模式均可验证。 + +## 实现方式 +- 新增 `scripts/send_email_test.sh`: + - 参数: + - `-t ` 后台 token(必填) + - `-e ` 收件人邮箱(必填) + - `-s ` 邮件主题(可选) + - `-b ` 邮件正文(必填) + - `-f ` 本地附件路径(可选) + - `-u ` 远程附件 URL(可选,与 `-f` 互斥) + - `-a ` 基础地址,默认 `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 -e zfc9393@163.com -s 测试 -b 测试代码` +- 本地附件:`./scripts/send_email_test.sh -t -e zfc9393@163.com -s 测试 -b 测试代码 -f ./demo.pdf` +- 远程附件:`./scripts/send_email_test.sh -t -e zfc9393@163.com -s 测试 -b 测试代码 -u https://example.com/demo.pdf` + +## 安全 +- 不写入任何敏感信息到仓库;脚本仅通过命令行参数接收 token 与内容。 \ No newline at end of file diff --git a/.trae/documents/蜀锦估值计算流程核对.md b/.trae/documents/蜀锦估值计算流程核对.md new file mode 100644 index 0000000..7918d24 --- /dev/null +++ b/.trae/documents/蜀锦估值计算流程核对.md @@ -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. 文化价值 B2(B21=9.37804, B22=810)→ B2=38.026824 + 4. 模型估值 B(B1≈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`) + diff --git a/.trae/documents/说明发票状态含义与拒绝原因说明.md b/.trae/documents/说明发票状态含义与拒绝原因说明.md new file mode 100644 index 0000000..66e7975 --- /dev/null +++ b/.trae/documents/说明发票状态含义与拒绝原因说明.md @@ -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”原因说明,便于直接使用。 \ No newline at end of file diff --git a/.trae/documents/调整 Nginx 上传大小与超时以支持大文件.md b/.trae/documents/调整 Nginx 上传大小与超时以支持大文件.md new file mode 100644 index 0000000..bc17ce4 --- /dev/null +++ b/.trae/documents/调整 Nginx 上传大小与超时以支持大文件.md @@ -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` +- 测试上传:使用 30–40MB 文件调用 `POST /api/v1/upload/file`,确认不再出现 413 或上传超时;同时测试 `POST /api/v1/upload/image` 大图。 + +## 备注 +- 如需更大体积可将 `client_max_body_size` 调整为 `100m` 或更高;若上层云负载均衡也有限制,需要同步放宽。 +- 后续可在应用层增加最大体积限制与提示,避免无界上传占用过多资源。 \ No newline at end of file diff --git a/.trae/documents/配置阿里企业邮箱SMTP并验证发送.md b/.trae/documents/配置阿里企业邮箱SMTP并验证发送.md new file mode 100644 index 0000000..2b190b6 --- /dev/null +++ b/.trae/documents/配置阿里企业邮箱SMTP并验证发送.md @@ -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。 \ No newline at end of file diff --git a/aaa.json b/aaa.json new file mode 100644 index 0000000..9af7f17 --- /dev/null +++ b/aaa.json @@ -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): \ No newline at end of file diff --git a/app/api/v1/app_users/admin_manage.py b/app/api/v1/app_users/admin_manage.py index 3d9688c..967db15 100644 --- a/app/api/v1/app_users/admin_manage.py +++ b/app/api/v1/app_users/admin_manage.py @@ -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] diff --git a/app/api/v1/sms/sms.py b/app/api/v1/sms/sms.py index f4a9267..5614ece 100644 --- a/app/api/v1/sms/sms.py +++ b/app/api/v1/sms/sms.py @@ -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(...) \ No newline at end of file + code: str = Field(...) diff --git a/app/api/v1/transactions/transactions.py b/app/api/v1/transactions/transactions.py index 0659617..fc698bb 100644 --- a/app/api/v1/transactions/transactions.py +++ b/app/api/v1/transactions/transactions.py @@ -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 "发送失败") \ No newline at end of file + 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") \ No newline at end of file diff --git a/app/api/v1/upload/upload.py b/app/api/v1/upload/upload.py index 449c119..b1db2f6 100644 --- a/app/api/v1/upload/upload.py +++ b/app/api/v1/upload/upload.py @@ -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) \ No newline at end of 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) \ No newline at end of file diff --git a/app/controllers/invoice.py b/app/controllers/invoice.py index fd8fd49..a57ff99 100644 --- a/app/controllers/invoice.py +++ b/app/controllers/invoice.py @@ -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) diff --git a/app/controllers/upload.py b/app/controllers/upload.py index 3182702..772f9c0 100644 --- a/app/controllers/upload.py +++ b/app/controllers/upload.py @@ -87,4 +87,16 @@ class UploadController: url=f"{settings.BASE_URL}/static/files/{filename}", filename=filename, content_type=file.content_type, - ) \ No newline at end of file + ) + + @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) \ No newline at end of file diff --git a/app/core/init_app.py b/app/core/init_app.py index 89bb57a..2073904 100644 --- a/app/core/init_app.py +++ b/app/core/init_app.py @@ -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() diff --git a/app/models/user.py b/app/models/user.py index b3f91cd..0fd6202 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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" diff --git a/app/schemas/app_user.py b/app/schemas/app_user.py index ac6417c..6f37749 100644 --- a/app/schemas/app_user.py +++ b/app/schemas/app_user.py @@ -100,6 +100,7 @@ class AppUserQuotaLogOut(BaseModel): after_count: int op_type: str remark: Optional[str] = None + created_at: str class AppUserRegisterOut(BaseModel): diff --git a/app/services/email_client.py b/app/services/email_client.py index 65beea4..685d1b2 100644 --- a/app/services/email_client.py +++ b/app/services/email_client.py @@ -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) diff --git a/app/services/sms_client.py b/app/services/sms_client.py index 5b42490..6093a60 100644 --- a/app/services/sms_client.py +++ b/app/services/sms_client.py @@ -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() \ No newline at end of file diff --git a/app/services/sms_store.py b/app/services/sms_store.py index 2524697..b015dc8 100644 --- a/app/services/sms_store.py +++ b/app/services/sms_store.py @@ -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: """设置验证码与过期时间 diff --git a/app/settings/config.py b/app/settings/config.py index da405c7..a54b0c8 100644 --- a/app/settings/config.py +++ b/app/settings/config.py @@ -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 diff --git a/deploy/web.conf b/deploy/web.conf index e5aa7a1..280bff6 100644 --- a/deploy/web.conf +++ b/deploy/web.conf @@ -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; } } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5957f71..698bada 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/run.py b/run.py index 4364ae4..37be141 100644 --- a/run.py +++ b/run.py @@ -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) diff --git a/scripts/send_email_test.py b/scripts/send_email_test.py new file mode 100644 index 0000000..5210b8d --- /dev/null +++ b/scripts/send_email_test.py @@ -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]] - 包含host、port、from、username、password、tls等键的配置 + """ + 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) \ No newline at end of file diff --git a/scripts/send_email_test.sh b/scripts/send_email_test.sh new file mode 100644 index 0000000..6cee8ea --- /dev/null +++ b/scripts/send_email_test.sh @@ -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 -e -b [-s ] [-f | -u ] [-a ]" + 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 \ No newline at end of file diff --git a/web/src/api/index.js b/web/src/api/index.js index 461a67a..20cfdcf 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -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 = {}) => { // 模拟分页和搜索 diff --git a/web/src/views/transaction/receipts/index.vue b/web/src/views/transaction/receipts/index.vue new file mode 100644 index 0000000..40e9a55 --- /dev/null +++ b/web/src/views/transaction/receipts/index.vue @@ -0,0 +1,167 @@ + + + \ No newline at end of file diff --git a/估值字段.txt b/估值字段.txt index 997b46c..d515e8c 100644 --- a/估值字段.txt +++ b/估值字段.txt @@ -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 \ No newline at end of file + 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 \ No newline at end of file