Merge branch 'main' of https://git.1024tool.vip/zfc/guzhi
* 'main' of https://git.1024tool.vip/zfc/guzhi: up bug feat(发票): 支持多附件上传和邮件发送功能
@ -1,59 +0,0 @@
|
||||
## 输出目标
|
||||
- 以 `admin`(后台)与 `app`(用户端)两大类重组全部现有 `v1` API。
|
||||
- 统一每个接口的文档格式:路径、方法、版本、功能说明、公开/认证、(admin)权限要求、请求参数与格式、响应结构、错误代码。
|
||||
- 版本标注统一为 `v1`(前缀 `"/api/v1"`)。
|
||||
|
||||
## 分类规则
|
||||
- `admin`:在 `app/api/v1/__init__.py:33-38,45-51` 通过 `dependencies=[DependAuth, DependPermission]` 绑定的模块及其接口:`user/role/menu/api/dept/auditlog/valuations/invoice/transactions/third_party_api`,以及 `base`(后台登录与个人信息)。
|
||||
- `app`:面向终端用户的模块:`app-user`、`app-valuations`、`sms`(登录与验证码相关)、`upload`。
|
||||
- 管理功能但当前公开(未绑定后台依赖):`industry/index/policy/esg`,归入 `admin(公开)`,在文档中明确“公开接口”。
|
||||
|
||||
## 文档结构
|
||||
- 顶层两章:
|
||||
- `app(用户端)`:模块分组(用户认证与账户、用户资料与仪表盘、用户端估值、短信验证码、上传)
|
||||
- `admin(后台)`:模块分组(用户管理、角色管理、菜单管理、API 权限管理、部门管理、审计日志、估值评估、发票管理、交易管理、第三方内置接口、内容管理:行业/指数/政策/ESG、基础登录/个人信息)
|
||||
- 接口条目统一字段:
|
||||
- 路径:`/api/v1/<module>/<subpath>`
|
||||
- 方法:`GET/POST/PUT/DELETE`
|
||||
- 版本:`v1`
|
||||
- 功能说明:一句话摘要
|
||||
- 公开/认证:`公开` 或 `需认证`(`admin` 另标注“需权限校验”)
|
||||
- 权限要求(admin):是否受 `DependPermission` 控制(匹配 `(method, path)`)
|
||||
- 请求参数:Query/Path/Body(Body 引用 `pydantic` 模型名)
|
||||
- 响应结构:统一 `{code,msg,data}` 或分页 `{code,msg,data,total,page,page_size}`(引用响应模型)
|
||||
- 错误代码:200/400/401/403/404/422/500(依据全局异常与业务抛出)
|
||||
|
||||
## 信息来源与标注依据
|
||||
- 路由与版本:`app/api/v1/__init__.py:28-52`(前缀与模块挂载)。
|
||||
- 认证与权限:
|
||||
- `admin` 统一:`DependAuth`、`DependPermission`(`app/api/v1/__init__.py:33-38,45-51`;依赖定义于 `app/core/dependency.py`)。
|
||||
- `app` 认证:`Depends(get_current_app_user)`/`Depends(get_current_app_user_id)`(`app/utils/app_user_jwt.py:51,71-72`)。
|
||||
- 单接口特例:`sms` 的 `/send-report` 需后台认证(`app/api/v1/sms/sms.py:68`)。
|
||||
- 请求/响应模型:端点签名与 `response_model`(来源 `app/schemas/*`)。
|
||||
- 错误码与统一响应:`app/core/init_app.py:56-59`(注册),`app/core/exceptions.py:15,23,31`(处理器)。
|
||||
|
||||
## 示例格式(两条)
|
||||
- `app|POST /api/v1/sms/send-code`(v1)
|
||||
- 功能:发送登录验证码到手机号
|
||||
- 公开/认证:公开
|
||||
- 请求参数(Body):`SendCodeRequest`
|
||||
- 响应结构:`SendResponse`(`{code,msg,data}`)
|
||||
- 错误码:`400/422/500`
|
||||
- 代码参照:`app/api/v1/sms/sms.py:68`
|
||||
- `admin|GET /api/v1/user/list`(v1)
|
||||
- 功能:分页查询后台用户
|
||||
- 公开/认证:需认证;需权限校验
|
||||
- 请求参数(Query):分页与过滤
|
||||
- 响应结构:`SuccessExtra`(`{code,msg,data,total,page,page_size}`)
|
||||
- 错误码:`401/403/422/500`
|
||||
- 代码参照:`app/api/v1/__init__.py:33`
|
||||
|
||||
## 交付内容
|
||||
- 生成统一的 Markdown 文档(建议 `docs/api-v1.md`),按 `app` 与 `admin` 两章、功能模块分组列出全部接口,逐条填充统一字段。
|
||||
- 附“错误码说明”与“认证/权限机制”章节,提供关键代码路径引用,便于后续维护与审计。
|
||||
|
||||
## 验证与维护
|
||||
- 通过聚合路由与端点扫描确认无遗漏;如有新接口,按相同格式追加。
|
||||
- 校验每条接口的认证与权限标注是否与代码一致;抽样比对响应结构、错误码与异常处理一致性。
|
||||
|
||||
请确认上述按 `admin` 与 `app` 分类的计划;确认后我将开始生成完整文档并交付。
|
||||
@ -1,184 +0,0 @@
|
||||
## 现状速览
|
||||
|
||||
* 后端框架:FastAPI;ORM:Tortoise;权限:`DependAuth` + `DependPermission` 挂载在 admin 路由(`app/api/v1/__init__.py:33-37`)。
|
||||
|
||||
* 用户端估值评估路由:`/api/v1/app-valuations`(`app/api/v1/app_valuations/app_valuations.py`),控制器分层清晰(`app/controllers/user_valuation.py`、`app/controllers/valuation.py`)。
|
||||
|
||||
* 上传目前仅支持图片(`app/controllers/upload.py:12-52`,`app/api/v1/upload/upload.py:7-14`)。
|
||||
|
||||
* 发票与抬头能力完善(`app/api/v1/invoice/invoice.py`、`app/controllers/invoice.py`)。
|
||||
|
||||
* Web 管理端用户列表当前使用 Mock 数据展示“剩余体验次数”(`web/src/views/user-management/user-list/index.vue:115-122` 与 `web/src/api/index.js:279-352`)。
|
||||
|
||||
## 目标改造概览
|
||||
|
||||
* 交易管理新增“邮件发送”接口,支持正文与附件,完备校验与日志。
|
||||
|
||||
* 用户管理新增“剩余估值次数”字段与管理员调额能力,提供操作日志(前/后值、类型、备注)。
|
||||
|
||||
* 估值表新增“报告/证书”URL 字段与多格式上传能力,生成可下载链接。
|
||||
|
||||
* 估值表新增“统一社会信用代码/身份证号”与“业务/传承介绍”,前后端同步与校验。
|
||||
|
||||
* 全量权限控制、API 文档补充、数据库变更记录与单元测试覆盖。
|
||||
|
||||
## 数据库与模型变更
|
||||
|
||||
* ValuationAssessment(`app/models/valuation.py`)新增:
|
||||
|
||||
* `report_url: CharField(512)`、`certificate_url: CharField(512)` 用于管理员上传的报告/证书下载地址。
|
||||
|
||||
* `credit_code_or_id: CharField(64)` 用于统一社会信用代码或身份证号。
|
||||
|
||||
* `biz_intro: TextField` 业务/传承介绍。
|
||||
|
||||
* 用户配额与日志:
|
||||
|
||||
* AppUser(`app/models/user.py`)新增 `remaining_quota: IntField(default=0)`。
|
||||
|
||||
* 新增操作日志模型(建议放入 `app/models/invoice.py` 同模块管理,或新建 `app/models/transaction.py`):`AppUserQuotaLog` 字段:`app_user_id`、`operator_id`、`operator_name`、`before_count`、`after_count`、`op_type`(如:付费估值)、`remark`、`created_at`。
|
||||
|
||||
* 邮件发送日志模型:`EmailSendLog` 字段:`email`、`subject`、`body` 摘要(前 N 字符)、`file_name`/`file_url`、`status`(OK/FAIL)、`error`(可空)、`sent_at`。
|
||||
|
||||
* 迁移:使用 Aerich 生成并升级(保持兼容,所有新增字段均可空或有安全默认值)。
|
||||
|
||||
## 接口设计与权限控制
|
||||
|
||||
* 交易管理新增:`POST /api/v1/transactions/send-email`
|
||||
|
||||
* Body(multipart 或 JSON):
|
||||
|
||||
* `email`(必填,邮箱校验)
|
||||
|
||||
* `subject`(可选,默认“估值服务通知”)
|
||||
|
||||
* `body`(必填,文案内容,长度与危险字符校验)
|
||||
|
||||
* `file`(可选,`UploadFile`,或 `file_url` 字符串二选一)
|
||||
|
||||
* 行为:使用标准库 `smtplib` + `email` 组合发送;支持 TLS/SSL;发送后落库 `EmailSendLog`;返回发送状态与日志 ID。
|
||||
|
||||
* 权限:挂载于 `transactions_router`(已带 `DependAuth`、`DependPermission`,`app/api/v1/__init__.py:52`)。
|
||||
|
||||
* 用户管理新增:
|
||||
|
||||
* `GET /api/v1/user/list` 返回结构新增 `remaining_quota` 字段。
|
||||
|
||||
* `POST /api/v1/user/quota`(管理员)调整用户剩余估值次数:
|
||||
|
||||
* Body:`user_id`、`target_count` 或 `delta`、`op_type`、`remark`
|
||||
|
||||
* 行为:读取当前值,计算前后值,更新 `AppUser.remaining_quota`,记录 `AppUserQuotaLog`。
|
||||
|
||||
* `GET /api/v1/user/{id}/quota-logs` 返回日志列表(分页、类型筛选)。
|
||||
|
||||
* 发票抬头查看:
|
||||
|
||||
* 复用现有接口 `GET /api/v1/invoice/headers?app_user_id=...`(`app/api/v1/invoice/invoice.py:118-124`)。
|
||||
|
||||
* 估值评估新增字段对外:
|
||||
|
||||
* 管理端与用户端输出 Schema 同步包含 `report_url`、`certificate_url`、`credit_code_or_id`、`biz_intro`。
|
||||
|
||||
## 上传能力扩展
|
||||
|
||||
* 控制器:新增 `upload_file(file: UploadFile)` 支持 `pdf/docx/xlsx/zip` 等白名单;存储到 `app/static/files`;生成可下载链接 `settings.BASE_URL + /static/files/{name}`。
|
||||
|
||||
* 路由:`POST /api/v1/upload/file`(保留图片接口不变)。
|
||||
|
||||
* 校验:
|
||||
|
||||
* MIME 白名单与大小限制;文件名清洗与去重;异常返回 4xx/5xx。
|
||||
|
||||
## Schema 与后端校验
|
||||
|
||||
* 估值 Schema(`app/schemas/valuation.py`)新增并校验:
|
||||
|
||||
* `credit_code_or_id`:正则校验(统一社会信用代码/18位身份证格式二选一)。
|
||||
|
||||
* `report_url`、`certificate_url`:URL 格式校验。
|
||||
|
||||
* 用户配额:新增 `AppUserQuotaUpdateSchema` 与 `AppUserQuotaLogOutSchema`。
|
||||
|
||||
* 邮件发送:`SendEmailRequest` 支持两种附件输入;返回 `SendEmailResponse`。
|
||||
|
||||
<br />
|
||||
|
||||
## 发送逻辑实现要点
|
||||
|
||||
* 设置:在 `app/settings/config.py` 增加 SMTP 相关配置:`SMTP_HOST`、`SMTP_PORT`、`SMTP_USERNAME`、`SMTP_PASSWORD`、`SMTP_TLS`、`SMTP_FROM`(默认 None,走环境变量注入)。
|
||||
|
||||
* 发送器:`app/services/email_client.py`(或 `app/controllers/transactions.py` 内部封装),使用 `smtplib.SMTP_SSL`/`SMTP.starttls()`,构造 `MIMEText` 与 `MIMEBase`,附件从 `UploadFile` 或远程 `file_url` 下载后附加。
|
||||
|
||||
* 错误处理:
|
||||
|
||||
* 参数校验失败返回 422;SMTP 异常记录 `EmailSendLog.error` 并返回 500;长正文截断日志摘要防止超长存储。
|
||||
|
||||
* 日志:统一 `loguru` 记录关键事件(如 `transactions.email_send_start/ok/fail`)。
|
||||
|
||||
## 权限控制与 API 权限表
|
||||
|
||||
* 新增接口在 `Api` 表登记(路径+方法+标签),使用现有刷新接口 `POST /api/v1/api/refresh` 扫描路由自动入库。
|
||||
|
||||
* 路由均落于已挂载依赖的 admin 模块,App 端路由继续独立(`app/api/v1/__init__.py:28-33`)。
|
||||
|
||||
## API 文档与变更记录
|
||||
|
||||
* 为所有新增接口与控制器方法补充函数级注释(功能、参数、返回值),满足用户规范。
|
||||
|
||||
* 通过 FastAPI 自动生成的 OpenAPI 展示;补充接口示例与错误码说明(Docstring)。
|
||||
|
||||
* 数据库变更记录:在迁移文件中含新增字段/表说明;在说明文档中列出字段语义与默认值(本次提交提供变更概要)。
|
||||
|
||||
## 单元测试计划
|
||||
|
||||
* 邮件发送:
|
||||
|
||||
* 伪造 `FakeEmailClient`,覆盖成功/失败/附件两种输入;比照 `tests/api/v1/test_sms.py` 的 monkeypatch 风格。
|
||||
|
||||
* 用户配额:
|
||||
|
||||
* 调额接口:前后值正确、日志记录正确、权限检查(需登录管理员)。
|
||||
|
||||
* 估值字段:
|
||||
|
||||
* 创建/更新时包含新字段;URL 与正则校验失败用例;上传文件生成链接断言。
|
||||
|
||||
* 上传:
|
||||
|
||||
* 非法 MIME 与超限大小拒绝;合法文件成功返回 URL。
|
||||
|
||||
## 兼容性与回滚策略
|
||||
|
||||
* 所有新增字段均为可空或安全默认,旧数据不受影响。
|
||||
|
||||
* 新增接口均为新增路由,不改动原有行为;前端逐步切换数据源,保留 Mock 作为回退。
|
||||
|
||||
* 迁移脚本按标准生成;如需回滚 aerich 支持 `downgrade`。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 模型与 Schema 更新;生成 Aerich 迁移;本地升级并验证。
|
||||
2. 上传控制器扩展与新路由;估值控制器/输出字段同步。
|
||||
3. 交易管理发送接口(含 SMTP 封装、日志落库、异常处理)。
|
||||
4. 用户配额接口与日志模型/路由;admin 列表与详情改造。
|
||||
5. 权限入库与刷新;为接口添加函数级注释。
|
||||
6. 单元测试编写与通过;OpenAPI 检视;交付 API/DB 变更说明。
|
||||
|
||||
## 关键代码定位参考
|
||||
|
||||
* 路由注册:`app/api/v1/__init__.py:28-52`
|
||||
|
||||
* 用户端估值入口:`app/api/v1/app_valuations/app_valuations.py:233-318`
|
||||
|
||||
* 估值控制器:`app/controllers/valuation.py:73-100`、`app/controllers/user_valuation.py:21-42`
|
||||
|
||||
* 上传控制器:`app/controllers/upload.py:12-52`
|
||||
|
||||
* 发票抬头接口:`app/api/v1/invoice/invoice.py:118-144`
|
||||
|
||||
* 管理端用户列表(前端):`web/src/views/user-management/user-list/index.vue:69-166`
|
||||
|
||||
* Mock 数据与 API:`web/src/api/index.js:279-352, 433-479`
|
||||
|
||||
——请确认方案后,我将按上述步骤开始落地实现、编写迁移与测试。
|
||||
@ -1,71 +0,0 @@
|
||||
## 目标与范围
|
||||
- 针对“估值二期”需求(用户端、管理端)设计完整 API,去除 Webhook 回调。
|
||||
- 对齐现有约定:认证 `POST /api/v1/base/access_token`(app/api/v1/base/base.py:19-38)、`token` 请求头(web/src/utils/http/interceptors.js:11-14);响应 `Success/SuccessExtra/Fail`(app/schemas/base.py),成功码 `code===200`(web/src/utils/http/interceptors.js:23-33);估值域已有 `/api/v1/valuations`(app/api/v1/valuations/valuations.py:21-191)。
|
||||
|
||||
## 核心流程
|
||||
- 用户端:登录→评估提交→个人中心(汇款凭证、抬头选择、类型选择、发票列表/详情)→估值记录(下载证书/报告、分享、历史结果、剩余次数)。
|
||||
- 管理端:交易管理(查看/核验/邮件/开票/状态)→用户管理(信息/操作/修改/审核/投诉/短信文案/证书与报告)→审核列表(上传证书/下载/重传)。
|
||||
|
||||
## 实体与关系
|
||||
- AppUser ⇄ Valuation(1..n)、Invoice(1..n)、InvoiceHeader(n)
|
||||
- Valuation ⇄ ValuationCalculationStep(1..n) ⇄ Certificate/Report
|
||||
- Invoice ⇄ InvoiceHeader/PaymentReceipt/Transaction
|
||||
- Complaint、SMSMessage 与用户/估值/发票按需关联
|
||||
|
||||
## 端点设计(与前端映射保持一致)
|
||||
- 认证与用户
|
||||
- POST `/api/v1/base/access_token` 登录(app/api/v1/base/base.py:19-38)
|
||||
- GET `/api/v1/base/userinfo` 用户信息(app/api/v1/base/base.py:40-46)
|
||||
- GET `/api/v1/app-user/profile` 当前用户画像与剩余估值次数
|
||||
- GET `/api/v1/app-user/list`、GET `/api/v1/app-user/get`、POST `/api/v1/app-user/register|update`、DELETE `/api/v1/app-user/delete`(对齐 web/src/api/index.js:433-503)
|
||||
- 估值评估(沿用并扩展)
|
||||
- POST `/api/v1/valuations/`、GET `/api/v1/valuations/`、GET `/api/v1/valuations/{id}`(已存在)
|
||||
- GET `/api/v1/valuations/{id}/steps`(已存在,用于过程展示)
|
||||
- GET `/api/v1/valuations/{id}/certificate`(新增:证书下载)
|
||||
- GET `/api/v1/valuations/{id}/report`(新增:报告下载)
|
||||
- POST `/api/v1/valuations/{id}/share`(新增:生成分享链接/小程序码,异步)
|
||||
- POST `/api/v1/valuations/batch/delete`(已存在)
|
||||
- 发票与交易(保留现有路径)
|
||||
- GET `/api/v1/invoice/list`、GET `/api/v1/invoice/detail`、POST `/api/v1/invoice/create|update|send|remind|refund`、DELETE `/api/v1/invoice/delete`、POST `/api/v1/invoice/update-status`(对齐 web/src/api/index.js:504-725)
|
||||
- POST `/api/v1/invoice/{id}/receipt`(新增:上传付款凭证)
|
||||
- GET `/api/v1/invoice/headers`、GET `/api/v1/invoice/headers/{id}`、POST `/api/v1/invoice/headers`(新增:抬头管理)
|
||||
- POST `/api/v1/invoice/{id}/issue`(新增:开票,异步 Job)
|
||||
- 审核与证书
|
||||
- GET `/api/v1/review/valuations` 审核列表(新增)
|
||||
- POST `/api/v1/review/valuations/{id}/approve|reject`(复用估值审核,app/api/v1/valuations/valuations.py:167-183)
|
||||
- POST `/api/v1/review/valuations/{id}/certificate` 上传证书(新增)
|
||||
- PUT `/api/v1/review/valuations/{id}/report` 重传报告(新增)
|
||||
- 投诉与短信
|
||||
- GET `/api/v1/complaints`、GET `/api/v1/complaints/{id}`、PUT `/api/v1/complaints/{id}`(新增)
|
||||
- GET `/api/v1/sms/templates`、POST `/api/v1/sms/templates`、POST `/api/v1/sms/send`(新增)
|
||||
|
||||
## 请求/响应格式与认证
|
||||
- 统一 JSON;请求头 `token` 必填(除登录与公共资源)。
|
||||
- 成功:`{code:200,data:...,msg:"success"}`;失败:`{code:4xx/5xx,msg:"错误"}`。
|
||||
|
||||
## 字段与校验(示例)
|
||||
- ValuationCreate:`asset_name(1-64)`, `institution(1-128)`, `industry(1-64)`, `heritage_level?`, `inputs(object)`, `attachments?[url[]]`
|
||||
- InvoiceCreate:`ticket_type(electronic|paper)`, `invoice_type(special|normal)`, `phone`, `email`, `company_name`, `tax_number`, `register_address`, `register_phone`, `bank_name`, `bank_account`
|
||||
- PaymentReceipt:`url`, `uploaded_at`, `verified`
|
||||
- ShareRequest:`channel(miniprogram|link)`, `expire(<=604800)`
|
||||
- 规则:邮箱/手机号/税号格式;枚举校验;附件数量与大小限制。
|
||||
|
||||
## 错误码
|
||||
- 200 成功;400 参数错误;401 未认证(前端自动登出,web/src/utils/http/interceptors.js:45-53);403 无权限;404 不存在;409 冲突;422 校验失败;429 频率限制;500 内部错误。
|
||||
|
||||
## 批量与异步(无 Webhook)
|
||||
- 批量:发票批量开具、邮件批量发送(`POST /api/v1/invoice/batch/issue|send`)。
|
||||
- 异步 Job:开票/报告/分享生成返回 `job_id`;查询 `GET /api/v1/jobs/{id}`(`status: pending|running|success|failed`)。客户端采用轮询或前端提示重试。
|
||||
|
||||
## 性能目标
|
||||
- 登录/用户信息 P95 ≤ 100ms;列表分页 P95 ≤ 200ms(单页 ≤ 100 条)。
|
||||
- 异步任务完成 ≤ 5s;QPS(单实例):读 200+/s、写 50+/s。
|
||||
|
||||
## 实施建议
|
||||
1. 在 `app/api/v1/` 新增 invoices/reviews/complaints/sms 路由文件。
|
||||
2. 在 `controllers/` 实现控制器,复用 `ValuationController` 的计算步骤记录(app/controllers/valuation.py:24-53)。
|
||||
3. 在 `schemas/` 新增/扩展 Pydantic 模型,严格校验。
|
||||
4. 增加 Job 状态查询端点,统一返回结构;无 Webhook 的情况下采用客户端轮询。
|
||||
5. 前端按 `web/src/api/index.js` 对齐接入,复用错误处理与 401 登出。
|
||||
|
||||
——请确认该无 Webhook 版本的 API 方案,确认后我将开始后端路由/控制器/模型实现并提供前端对接示例。
|
||||
@ -1,184 +0,0 @@
|
||||
## 目标
|
||||
|
||||
* 完整设计并落实“估值计算步骤”API与落库机制,保证:
|
||||
|
||||
1. 用户提交估值后,所有中间计算步骤按规范写入数据库;
|
||||
2. 管理端在详情中查看完整步骤链条与中间结果;
|
||||
3. 统一数学公式、变量来源、步骤编号与展示结构。
|
||||
|
||||
## 现有能力与锚点
|
||||
|
||||
* 步骤模型:`ValuationCalculationStep`(app/models/valuation.py:88-107)
|
||||
|
||||
* 步骤写入:控制器提供创建/查询(app/controllers/valuation.py:24-53, 37-53)
|
||||
|
||||
* 管理端步骤查询:`GET /api/v1/valuations/{id}/steps`(app/api/v1/valuations/valuations.py:50-56)
|
||||
|
||||
* 已有示例记录:风险调整B3模块内已演示步骤写入(app/utils/calculation\_engine/risk\_adjustment\_b3/sub\_formulas/risk\_adjustment\_b3.py:195-237)
|
||||
|
||||
* 用户端计算入口:后台任务执行统一计算(app/api/v1/app\_valuations/app\_valuations.py:210-299)
|
||||
|
||||
## 公式总览与数学表达
|
||||
|
||||
1. 经济价值 B1(economic\_value\_b1)
|
||||
|
||||
* 基础价值 B11:依据财务与法律/创新、普及度
|
||||
|
||||
* 示例表达:`B11 = w_f * f(three_year_income) + w_i * innovation_ratio + w_p * popularity_score + w_l * infringement_factor + w_pat * patent_score`
|
||||
|
||||
* 流量因子 B12:`S = α * S1 + β * S2 + γ * S3`;其中 S1 搜索指数(百度/微信/微博),S2 行业均值,S3 社交传播(点赞/评论/分享)
|
||||
|
||||
* 政策乘数 B13:`P = p_impl * implementation_stage_score + p_fund * funding_support_score`
|
||||
|
||||
* 汇总:`B1 = B11 * (1 + θ * S) * (1 + λ * P)`
|
||||
|
||||
1. 文化价值 B2(cultural\_value\_b2)
|
||||
|
||||
* 活态传承 B21:`B21 = κ1 * inheritor_level_coefficient + κ2 * offline_sessions + κ3 * social_views`
|
||||
|
||||
* 纹样基因 B22:`B22 = μ1 * historical_inheritance + μ2 * structure_complexity + μ3 * normalized_entropy`
|
||||
|
||||
* 汇总:`B2 = B21 + B22`
|
||||
|
||||
1. 风险调整 B3(risk\_adjustment\_b3)
|
||||
|
||||
* 风险评分总和:`R = 0.3 * market_risk + 0.4 * legal_risk + 0.3 * inheritance_risk`
|
||||
|
||||
* 风险调整系数:`B3 = 0.8 + 0.4 * R`(app/utils/.../risk\_adjustment\_b3.py:33-45, 47-66)
|
||||
|
||||
1. 市场价值 C(market\_value\_c)
|
||||
|
||||
* 竞价 C1:`C1 = weighted_average_price(transaction_data, manual_bids, expert_valuations)`
|
||||
|
||||
* 热度系数 C2:`C2 = ψ1 * daily_browse_volume + ψ2 * collection_count`
|
||||
|
||||
* 稀缺性乘数 C3:`C3 = φ(circulation)`(限量>稀缺性高)
|
||||
|
||||
* 时效性衰减 C4:`C4 = decay(recent_market_activity)`
|
||||
|
||||
* 汇总:`C = C1 * (1 + C2) * C3 * C4`
|
||||
|
||||
1. 最终估值 AB(final\_value\_ab)
|
||||
|
||||
* 模型估值 B:`B = B1 + B2`;再叠加风险调整:`B_adj = B * B3`
|
||||
|
||||
* 市场估值:`C`
|
||||
|
||||
* 最终:`Final = f(B_adj, C)`(例如加权平均或规则合成)
|
||||
|
||||
## 变量定义与来源映射
|
||||
|
||||
* 用户输入(UserValuationCreate,app/schemas/valuation.py:144-147):
|
||||
|
||||
* `three_year_income`、`annual_revenue`、`rd_investment`、`application_coverage`、`offline_activities`、`platform_accounts`、`sales_volume`、`link_views`、`circulation`、`last_market_activity`、`price_fluctuation`、`funding_status`、`implementation_stage`、`patent_application_no`、`historical_evidence`、`pattern_images`、`inheritor_level`、`inheritor_age_count`
|
||||
|
||||
* 系统/API来源:
|
||||
|
||||
* 搜索指数S1、行业均值S2、社交传播S3(app/api/v1/app\_valuations/app\_valuations.py:328-347, 333-343)
|
||||
|
||||
* ESG分、行业系数、政策匹配度(app/api/v1/app\_valuations/app\_valuations.py:47-80)
|
||||
|
||||
* 侵权/专利校验(app/api/v1/app\_valuations/app\_valuations.py:81-118)
|
||||
|
||||
## 计算步骤落库设计
|
||||
|
||||
* 统一步骤结构(app/schemas/valuation.py:239-259):
|
||||
|
||||
* `step_order`:序号(含小数层级,如 1.11, 2.31)
|
||||
|
||||
* `step_name`:中文名称(如“基础价值B11计算”)
|
||||
|
||||
* `step_description`:公式与解释
|
||||
|
||||
* `input_params`:输入参数 JSON(含变量与其来源)
|
||||
|
||||
* `output_result`:中间结果(如每项得分,最终值)
|
||||
|
||||
* `status`:`in_progress|completed|failed`
|
||||
|
||||
* `error_message`:失败描述
|
||||
|
||||
* 步骤编号建议:
|
||||
|
||||
* 经济价值 B1:2.1x(B11=2.11,B12=2.12,B13=2.13,汇总B1=2.19)
|
||||
|
||||
* 文化价值 B2:2.2x(B21=2.21,B22=2.22,汇总B2=2.29)
|
||||
|
||||
* 风险调整 B3:2.3x(总评R=2.30,B3=2.31)
|
||||
|
||||
* 市场价值 C:3.1x(C1=3.11,C2=3.12,C3=3.13,C4=3.14,汇总C=3.19)
|
||||
|
||||
* 最终估值 AB:4.1x(B组合=4.11,B×B3=4.12,Final=4.19)
|
||||
|
||||
* 落库时机:统一在后台任务中分模块记录(app/api/v1/app\_valuations/app\_valuations.py:38-41, 142-171)
|
||||
|
||||
* 写入方式:通过控制器 `create_calculation_step`(app/controllers/valuation.py:24-36)
|
||||
|
||||
* 已有范例:风险调整B3模块先 `in_progress` 再 `completed`(app/utils/.../risk\_adjustment\_b3.py:195-237)
|
||||
|
||||
## 完整流程说明
|
||||
|
||||
1. 原始数据输入:`POST /api/v1/app-valuations/`(app/api/v1/app\_valuations/app\_valuations.py:210-299)
|
||||
2. 后台任务提取参数:B1/B2/B3/C(app/api/v1/app\_valuations/app\_valuations.py:302-567)
|
||||
3. 模块计算与步骤记录:按编号分别执行,逐步写入 `ValuationCalculationStep`
|
||||
4. 汇总合成:计算 `model_value_b`、`market_value_c`、`final_value_ab` 与 `dynamic_pledge_rate` 并存入 `ValuationAssessment`
|
||||
5. 管理端查看:
|
||||
|
||||
* 详情:`GET /api/v1/valuations/{id}`(返回序列化后的详情)
|
||||
|
||||
* 步骤:`GET /api/v1/valuations/{id}/steps`(返回序列化后的步骤数组)
|
||||
|
||||
## 示例计算过程(模拟数据)
|
||||
|
||||
* 输入(节选):
|
||||
|
||||
* `three_year_income=[400,450,500]`,`annual_revenue=500`,`rd_investment=50`(创新投入比=10%)
|
||||
|
||||
* `application_coverage=全国覆盖`(popularity\_score→由B11计算器给分)、`offline_activities=12`
|
||||
|
||||
* `platform_accounts.douyin.likes=1200`(S3参数),`price_fluctuation=[95,105]`(波动率)
|
||||
|
||||
* `inheritor_level=市级传承人`(转换为系数)、`inheritor_age_count=[45,60,75]`
|
||||
|
||||
* `historical_evidence={历史文献:3, 考古发现:2, 传承谱系:5}`
|
||||
|
||||
* 步骤样例:
|
||||
|
||||
* 2.11 基础价值B11:`input_params={three_year_income, innovation_ratio, popularity_score, infringement_score, patent_score}` → `output_result={B11: 123.45}`
|
||||
|
||||
* 2.12 流量因子B12:`input_params={S1,S2,S3}` → `output_result={S: 0.32}`
|
||||
|
||||
* 2.13 政策乘数B13:`input_params={implementation_stage,funding_support}` → `output_result={P: 0.15}`
|
||||
|
||||
* 2.19 B1汇总:`output_result={B1: 156.78}`
|
||||
|
||||
* 2.21 活态传承B21:`input_params={inheritor_level_coefficient,offline_sessions, social_views}` → `output_result={B21: 10.2}`
|
||||
|
||||
* 2.22 纹样基因B22:`input_params={historical_inheritance,structure_complexity,normalized_entropy}` → `output_result={B22: 8.9}`
|
||||
|
||||
* 2.30 风险总评R:`input_params={market_risk,legal_risk,inheritance_risk}` → `output_result={R: 0.42}`
|
||||
|
||||
* 2.31 风险调整B3:`output_result={B3: 0.97}`
|
||||
|
||||
* 3.11~~3.14 市场价值子项:分别写入 C1~~C4
|
||||
|
||||
* 3.19 市场价值C:`output_result={C: 118.0}`
|
||||
|
||||
* 4.11/4.12/4.19 最终汇总:`output_result={B: 175.88, B_adj: 170.6, Final: 122.0}`
|
||||
|
||||
## 后台展示规范
|
||||
|
||||
* 列表返回序列化后的 Pydantic 对象,避免 JSONResponse 序列化错误(已在管理端端点处理)
|
||||
|
||||
* 步骤展示:按照 `step_order` 升序,逐条显示 `step_name`、`step_description`、`input_params`、`output_result`、`status`;失败步骤显示 `error_message`
|
||||
|
||||
## 实施项
|
||||
|
||||
1. 将 B1、B2、C 模块对齐 B3 的“步骤写入”模式:每个子公式在计算前记录 `in_progress`,完成后记录 `completed` 并带结果;异常时标记 `failed`。
|
||||
2. 在 `FinalValueACalculator` 合成阶段补充步骤记录(B组合、B×B3、Final)。
|
||||
3. 确保管理端详情与步骤返回统一进行 JSON 序列化(管理端端点已按 `model_dump_json()` 修复)。
|
||||
|
||||
## 交付
|
||||
|
||||
* 我将按上述规范逐步在计算引擎各子模块与统一计算入口中补充“步骤写入”,并确保管理端端点返回可序列化的数据结构;完成后会提供一份面向管理员的“估值步骤查看”前后端对接说明(端点与字段)。
|
||||
|
||||
@ -1,98 +0,0 @@
|
||||
## 背景与确认
|
||||
|
||||
* 备注为“用户维度”,按指示在 `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`,交易列表按“凭证时间”筛选可用。
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
## 问题
|
||||
- 后台任务在提取B1参数时引用未定义函数(calculate_popularity_score、calculate_patent_score),导致计算中止,步骤未入库。
|
||||
|
||||
## 修复方案
|
||||
1) 移除未定义函数引用,在 `_extract_calculation_params_b1` 内实现本地计算:
|
||||
- 普及地域分:mapping {全球覆盖:10,全国覆盖:7,区域覆盖:4},默认7
|
||||
- 专利分:按剩余年限近似 {>10年:10,5-10年:7,<5年:3};用已有 `calculate_total_years(data_list)` 近似转换
|
||||
- 保留创新投入比与搜索指数、行业均值等现有逻辑
|
||||
- 增加logger输出:popularity_score、innovation_ratio、patent_score
|
||||
|
||||
2) 确保步骤写入链路:
|
||||
- 计算入口:先创建估值记录取 `valuation_id`;将 `valuation_id` 传入统一计算器;计算后用 `ValuationAssessmentUpdate` 更新记录
|
||||
- 模型B汇总:对经济B1、文化B2、风险B3的计算改为 `await` 并传 `valuation_id`
|
||||
- 市场C与风险B3保持原有按 `valuation_id` 写入
|
||||
|
||||
3) 日志增强:
|
||||
- 在步骤创建与更新时写入日志(calcstep.create/update/list)
|
||||
- 在估值初始化与更新时写入日志(valuation.init_created/updated)
|
||||
|
||||
## 验证
|
||||
- 重启服务、运行脚本;查看服务日志:应出现calcstep.create/update/list与valuation.updated;Admin脚本打印“后台估值计算步骤”。
|
||||
|
||||
——确认后我将按以上方案进行代码调整并回填日志。
|
||||
@ -1,63 +0,0 @@
|
||||
## 接口定位
|
||||
- 用户端提交路径:`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`)
|
||||
@ -1,60 +0,0 @@
|
||||
## 目标
|
||||
- 创建若干“交易记录”(对公转账付款凭证)测试数据,并确保后台管理可以正常查看、筛选与分页。
|
||||
- 全流程可回滚,严格隔离开发与生产数据。
|
||||
|
||||
## 关键假设
|
||||
- 交易记录以 `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)前端“交易记录”后台页(或在发票页增加凭证列的最小改动)。
|
||||
|
||||
## 备注
|
||||
- 编码时为新增函数与接口补充函数级注释(功能、参数、返回值说明),遵循现有代码风格与安全规范。
|
||||
@ -1,29 +0,0 @@
|
||||
## 目标
|
||||
- 汇总所有前后端需求与 API 到一个文档,其他分散文档删除,仅保留总览,便于统一查看与维护。
|
||||
|
||||
## 操作与产物
|
||||
- 新增单一文档:`docs/估值二期-需求与API总览.md`
|
||||
- 内容包含:
|
||||
1) 概述与约定(认证、响应包装、错误码、前缀)
|
||||
2) 数据实体(InvoiceHeader、PaymentReceipt、TransactionRecord、Valuation、AppUser、Complaint、SMSMessage)与字段定义
|
||||
3) 用户端流程与 API(登录/首页摘要、评估提交/校验/状态、估值记录下载与分享、个人中心-对公转账、发票抬头/类型、发票列表与详情、剩余次数、投诉与短信、批量与异步Job)
|
||||
4) 管理端流程与 API(交易管理记录:列表/详情/状态/邮件/开票/批量;审核列表;用户管理)
|
||||
5) 前端对接映射(`web/src/api/index.js` 现有与新增占位)
|
||||
6) 请求/响应示例与字段校验(重点:抬头管理、对公转账、交易管理记录)
|
||||
7) 性能与限制(分页、异步、频率)
|
||||
- 删除分散文档:
|
||||
- `web/docs/项目结构.md`、`web/docs/技术架构.md`、`web/docs/关键模块说明.md`、`web/docs/接口约定与预留.md`
|
||||
- `app/docs/项目结构.md`、`app/docs/技术架构.md`、`app/docs/关键模块说明.md`、`app/docs/接口约定与预留.md`
|
||||
|
||||
## 你提出的新增明确项(将详列在文档中)
|
||||
- 抬头管理字段(必填项标注):公司名称、公司税号、注册地址、注册电话、开户银行、银行账号、邮箱
|
||||
- 对公转账(用户端):上传图片(付款凭证)+ 选择发票抬头 + 开票类型(电子/纸质、专票/普票)
|
||||
- 交易管理记录(管理端):提交时间、付款凭证、手机号、微信号、公司名称、公司税号、注册地址、注册电话、开户银行、银行账号、接收邮箱、开票类型、状态
|
||||
- 状态枚举:pending/verified/issued/rejected 等
|
||||
|
||||
## 执行步骤
|
||||
1. 创建总览文档并写入上述结构内容与端点清单
|
||||
2. 迁移现有 app/web 文档中的有效信息到总览文档
|
||||
3. 删除分散文档,仅保留总览
|
||||
|
||||
——请确认上述方案,确认后我将生成唯一的总览文档并删除分散文档。
|
||||
@ -1,26 +0,0 @@
|
||||
## 问题
|
||||
- 计算步骤创建时报 Pydantic 校验错误:`step_order` 期望整型,但代码使用层级小数(如 2.1、2.11)。这不是计算公式问题,而是“类型不匹配”导致步骤未入库。
|
||||
|
||||
## 修复原则
|
||||
- 不改变任何计算公式或数值流程,仅调整“步骤顺序”的存储与校验类型,使其能接受层级小数。
|
||||
|
||||
## 具体改动
|
||||
1) 模型字段修改(不涉公式):
|
||||
- `app/models/valuation.py` 中 `ValuationCalculationStep.step_order: IntField → DecimalField(max_digits=8, decimal_places=3)`;保留 `ordering=["step_order"]`,确保排序正确。
|
||||
|
||||
2) Schema 修改(不涉公式):
|
||||
- `ValuationCalculationStepCreate.step_order: int → Decimal`,添加前置校验,支持 int/float/str 自动转换为 Decimal;`ValuationCalculationStepOut` 同步为 Decimal。
|
||||
- 列表与详情端点已使用 `model_dump_json()` 再 `json.loads()`,Decimal 会被正确序列化为 JSON 数字,无需改动。
|
||||
|
||||
3) 代码调用无需改(不涉公式):
|
||||
- 由于 Schema 接受 float 并转换为 Decimal,现有调用处传入 `2.1/2.11/...` 不需改。
|
||||
|
||||
4) 迁移与验证
|
||||
- 启动时执行 Aerich 迁移更新列类型(项目已有初始化流程)。
|
||||
- 跑脚本观测:`calcstep.create` 不再报错;`calcstep.list` 数量 > 0;后台“估值计算步骤”返回完整数组。
|
||||
|
||||
## 影响范围与安全性
|
||||
- 仅变更“步骤顺序”的字段类型与 Schema 校验,不触及任何计算逻辑或公式。
|
||||
- 排序按照 Decimal 正常工作,层级表达(2.11 < 2.2)保留。
|
||||
|
||||
——确认后,我将按以上方案修改模型与 Schema,并执行验证,保证不改变计算逻辑,仅解决类型不匹配问题。
|
||||
@ -1,17 +0,0 @@
|
||||
## 目标
|
||||
- 让每次用户估值的所有中间步骤写入 `valuation_calculation_steps` 并可关联该估值ID
|
||||
- 测试脚本打印详细步骤链,包括 step_order、step_name、step_description、input_params、output_result、status
|
||||
|
||||
## 代码改动
|
||||
1) 计算入口 `_perform_valuation_calculation`:
|
||||
- 先创建估值记录以拿到 `valuation_id`
|
||||
- 传 `valuation_id` 给 `FinalValueACalculator.calculate_complete_final_value_a`
|
||||
- 计算完成后用 `ValuationAssessmentUpdate` 将结果更新到该记录
|
||||
|
||||
2) 测试脚本:
|
||||
- 在 AdminClient 增加 `valuation_steps(id)` 方法
|
||||
- 打印步骤数组,包含名称、描述、输入与输出
|
||||
|
||||
## 验证
|
||||
- 运行 `python run.py`
|
||||
- 运行脚本并查看详细步骤输出
|
||||
@ -1,99 +0,0 @@
|
||||
## 目标与范围
|
||||
- 接入阿里云短信服务,封装发送客户端
|
||||
- 提供两类发送接口:验证码通知、报告生成通知,供 App 调用
|
||||
- 支持模板动态调用与验证码变量 `${code}` 的正确替换
|
||||
- 记录发送日志并融入现有审计体系
|
||||
- 实现同一手机号每分钟不超过 1 条的频率限制
|
||||
- 安全存储 AccessKey 等敏感信息(环境变量/配置)
|
||||
|
||||
## 技术选型
|
||||
- 后端框架:FastAPI(现有工程)
|
||||
- 短信 SDK:Alibaba Cloud SMS Python SDK(Tea/OpenAPI V2,`alibabacloud_dysmsapi20170525`)
|
||||
- 端点(中国站):`dysmsapi.aliyuncs.com`
|
||||
- 关键请求字段:`PhoneNumbers`、`SignName`、`TemplateCode`、`TemplateParam`
|
||||
- 日志:沿用 `app/log` 的 Loguru 与审计中间件
|
||||
- 频率限制:服务内共享的内存限流(后续可升级为 Redis)
|
||||
- 安全:通过环境变量注入凭证,Pydantic Settings 读取
|
||||
- 参考文档:
|
||||
- Alibaba Cloud SDK V2(Python)示例(SendSms):https://www.alibabacloud.com/help/en/sdk/developer-reference/v2-python-integrated-sdk
|
||||
- 短信服务 SendSms 接口(2017-05-25):https://help.aliyun.com/zh/sms/developer-reference/api-dysmsapi-2017-05-25-sendsms
|
||||
|
||||
## 代码改动
|
||||
- 新增:`app/services/sms_client.py`
|
||||
- 初始化 Dysms 客户端(读取 `ALIBABA_CLOUD_ACCESS_KEY_ID`、`ALIBABA_CLOUD_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`、`ALIYUN_SMS_ENDPOINT`)
|
||||
- 方法:`send_by_template(phone, template_code, template_param_json)`
|
||||
- 方法:`send_code(phone, code)`(模板:`SMS_498190229`)
|
||||
- 方法:`send_report(phone)`(模板:`SMS_498140213`)
|
||||
- 新增:`app/services/rate_limiter.py`
|
||||
- 类:`PhoneRateLimiter`,键为手机号,值为最近一次发送时间戳;判定 60s 内拒绝
|
||||
- 新增路由:`app/api/v1/sms/sms.py`
|
||||
- `POST /api/v1/sms/send-code`(无鉴权,用于登录场景)
|
||||
- `POST /api/v1/sms/send-report`(需要鉴权,防滥用)
|
||||
- 统一返回结构:`{status, message, request_id}`
|
||||
- 路由聚合:在 `app/api/v1/__init__.py` 注册 `sms_router(prefix="/sms", tags=["短信服务"])`
|
||||
- 配置:扩展 `app/settings/config.py`(Pydantic Settings)增加短信相关字段并从环境读入
|
||||
|
||||
## 接口设计
|
||||
- `POST /api/v1/sms/send-code`
|
||||
- 请求体:`{ "phone": "1390000****", "code": "123456" }`
|
||||
- 处理:限流校验 → 构造 `TemplateParam` 为 `{"code": "123456"}` → 调用 `SMS_498190229`
|
||||
- 成功:`{ "status": "OK", "message": "sent", "request_id": "..." }`
|
||||
- 失败:`{ "status": "ERROR", "message": "..." }`
|
||||
- `POST /api/v1/sms/send-report`
|
||||
- 请求体:`{ "phone": "1390000****" }`
|
||||
- 处理:鉴权(`DependAuth`)→ 限流校验 → 调用 `SMS_498140213`
|
||||
- 返回同上
|
||||
- 校验:手机号格式(支持无前缀或 `+86`),`code` 为 4–8 位数字(可按需约束)
|
||||
|
||||
## 模板与变量替换
|
||||
- 验证码模板:`SMS_498190229`
|
||||
- `TemplateParam`:`{"code": "<动态验证码>"}` 与 `${code}` 正确对应
|
||||
- 报告通知模板:`SMS_498140213`
|
||||
- 不含变量,可传空对象 `{}` 或不传 `TemplateParam`
|
||||
- 签名:`ALIYUN_SMS_SIGN_NAME` 读取为“成都文化产权交易所”且不在代码中硬编码
|
||||
|
||||
## 日志与审计
|
||||
- 路由层:审计中间件自动记录请求/响应(`module=短信服务`,`summary=验证码发送/报告通知发送`)
|
||||
- 服务层:`from app.log import logger`
|
||||
- 发送开始、Provider 请求入参(不含敏感信息)、返回码、`RequestId`、耗时、失败异常
|
||||
- 敏感信息不入日志:AccessKey、完整模板内容不打印
|
||||
|
||||
## 频率限制
|
||||
- 策略:同一手机号在 60 秒内全模板合并限 1 次(共享窗口)
|
||||
- 实现:进程内 `dict[phone]=last_ts`;进入路由先校验再发送;返回 429(或业务错误码)
|
||||
- 进阶:如需多实例一致性,后续接入 Redis,键:`sms:limit:{phone}` TTL=60s
|
||||
|
||||
## 安全与配置
|
||||
- 环境变量:
|
||||
- `ALIBABA_CLOUD_ACCESS_KEY_ID`
|
||||
- `ALIBABA_CLOUD_ACCESS_KEY_SECRET`
|
||||
- `ALIYUN_SMS_SIGN_NAME`
|
||||
- `ALIYUN_SMS_ENDPOINT`(默认 `dysmsapi.aliyuncs.com`)
|
||||
- Pydantic Settings 统一读取,避免硬编码,并在 `/docs` 与审计中隐藏敏感字段
|
||||
|
||||
## 依赖与安装
|
||||
- 在 `pyproject.toml` 添加:
|
||||
- `alibabacloud_dysmsapi20170525`
|
||||
- `alibabacloud_tea_openapi`
|
||||
- `alibabacloud_tea_util`
|
||||
- 与 `requirements.txt` 保持一致版本钉死策略;Python 3.11 兼容性验证
|
||||
|
||||
## 测试与验证
|
||||
- 单元测试:
|
||||
- Mock SDK 客户端,校验 `TemplateCode` 与 `TemplateParam` 的正确构造
|
||||
- 限流:同号 60s 内第二次返回限制错误
|
||||
- 集成测试:
|
||||
- 使用 `httpx.AsyncClient` 调用两个接口并断言响应结构
|
||||
- 在预设测试手机号上进行真实发送,观察到达与模板内容正确
|
||||
- 观测:
|
||||
- 查看应用日志与审计表(`AuditLog`)记录
|
||||
|
||||
## 风险与回滚
|
||||
- 进程内限流仅在单实例有效,多实例需 Redis(后续迭代)
|
||||
- SDK 版本冲突,采用独立最小版本并逐项验证;必要时锁版本
|
||||
- 若出现发送失败,保留错误码与 `RequestId`,按官方错误码表排查(见 SendSms 文档)
|
||||
|
||||
## 交付物
|
||||
- 新增短信客户端与路由模块
|
||||
- 两个可调用接口(验证码发送、报告通知发送)
|
||||
- 限流与日志落地,配置基于环境变量
|
||||
@ -1,41 +0,0 @@
|
||||
## 目标
|
||||
- 编写一个一次性可运行的接口测试脚本,按照总览文档顺序执行:
|
||||
1) App 用户注册 → 登录 → 用户相关接口
|
||||
2) 提交估值(用户端)并轮询结果(列表/详情)
|
||||
3) 管理端登录(admin)→ 查看估值数据(列表/详情)
|
||||
- 输出结构化结果与关键字段校验,便于快速人工检查。
|
||||
|
||||
## 脚本位置与运行
|
||||
- 路径:`scripts/api_smoke_test.py`
|
||||
- 运行:`python scripts/api_smoke_test.py --base http://127.0.0.1:9991/api/v1`
|
||||
- 基础:默认使用 `http://127.0.0.1:9991/api/v1`(根据 `run.py`),可通过参数覆盖。
|
||||
|
||||
## 步骤与端点
|
||||
1. App 用户注册与登录
|
||||
- POST `/app-user/register`(若手机号已存在则跳过)
|
||||
- POST `/app-user/login` → 获取 `access_token`
|
||||
- GET `/app-user/profile`、GET `/app-user/dashboard`、GET `/app-user/quota`
|
||||
|
||||
2. 用户端估值
|
||||
- POST `/app-valuations/`(使用总览文档示例数据,最小必要字段)
|
||||
- 轮询 GET `/app-valuations/`(分页)查看是否新增记录,捕获 `id`
|
||||
- GET `/app-valuations/{id}` 详情
|
||||
|
||||
3. 管理端数据查看
|
||||
- POST `/base/access_token`(admin/123456)获取后端 `token`
|
||||
- GET `/valuations` 列表(后台视角)
|
||||
- GET `/valuations/{id}` 详情(与用户端一致性对比)
|
||||
|
||||
## 输出与校验
|
||||
- 每步打印:请求路径、状态码、关键字段(如 `access_token`、`user_id`、`latest_valuation`、`final_value_ab`)
|
||||
- 断言:登录成功、列表包含新记录、详情字段存在。
|
||||
- 错误处理:捕获非 200 情况并打印 `code/msg`。
|
||||
|
||||
## 技术细节
|
||||
- 使用 `requests` 与 `Session` 维护 `token`;用户端与后台端各独立 `Session`。
|
||||
- 函数化:为每个步骤提供函数与函数级注释(描述、参数、返回值)。
|
||||
- 兼容:对注册接口“手机号已存在”返回情况做兼容(脚本继续执行)。
|
||||
|
||||
## 交付
|
||||
- 创建 `scripts/api_smoke_test.py` 并填充完整逻辑;默认数据内置,必要处留参数。
|
||||
- 如你需要,我可在脚本创建后直接运行,输出结果供你检查。
|
||||
@ -1,25 +0,0 @@
|
||||
## 目标
|
||||
- 提供一个可在本机直接运行的 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 与内容。
|
||||
@ -1,20 +0,0 @@
|
||||
## 目标
|
||||
- 将 `scripts/api_smoke_test.py` 的示例负载替换为你提供的完整参数,保持后端现有计算逻辑不变。
|
||||
|
||||
## 具体改动
|
||||
- 替换 `build_sample_payload()` 返回值为你提供的 JSON;字段逐项对齐:
|
||||
- `asset_name`、`institution`、`industry`、`annual_revenue`、`rd_investment`、`three_year_income`、`funding_status`、`sales_volume`、`link_views`、`circulation`、`last_market_activity`、`monthly_transaction`、`price_fluctuation`、`application_maturity`、`application_coverage`、`cooperation_depth`、`offline_activities`、`inheritor_level`、`inheritor_age_count`、`inheritor_certificates`、`heritage_level`、`historical_evidence`、`patent_certificates`、`pattern_images`、`patent_application_no`、`heritage_asset_level`、`inheritor_ages`、`implementation_stage`、`coverage_area`、`collaboration_type`、`scarcity_level`、`market_activity_time`、`monthly_transaction_amount`、`platform_accounts`
|
||||
- 保留 `platform_accounts`(douyin)作为后端期望的数据源;`online_accounts`(数组)不参与当前计算,保留或忽略均可;默认保留以便后续扩展。
|
||||
- `application_coverage`:后端当前使用该字段计算普及分;你的参数中同时有 `coverage_area`,将按优先 `application_coverage` 使用;若 `application_coverage` 为占位(如“0”),建议同步设置为“全球覆盖”(或我在脚本中用 `coverage_area` 回填)。
|
||||
- 其余数值以字符串提供,后端已通过 `safe_float` 做转换,无需脚本侧强制转数值。
|
||||
|
||||
## 兼容与注意
|
||||
- 不改计算逻辑;仅更新脚本负载以贴合后端字段期望。
|
||||
- 保持 `AdminClient` 输出“后台估值详情”和“后台估值计算步骤”打印,便于你核验。
|
||||
|
||||
## 验证
|
||||
- 启动后端并确保迁移已执行(`step_order` 已支持 Decimal)。
|
||||
- 运行脚本:`python scripts/api_smoke_test.py --base http://127.0.0.1:9991/api/v1 --phone 13800138001`
|
||||
- 观察输出:用户侧成功提交,后台列表/详情显示完整数据,步骤列表非空。
|
||||
|
||||
——确认后我将直接更新脚本并提交。
|
||||
@ -1,48 +0,0 @@
|
||||
## 目标
|
||||
|
||||
* 将“用户端”API补齐内容更新到现有文档,便于你审阅。
|
||||
|
||||
## 更新范围
|
||||
|
||||
* 修改 `app/docs/接口约定与预留.md`:新增“用户端 API”章节,覆盖登录/首页/评估/个人中心/估值记录/通知/批量与异步,与错误码和校验对齐。
|
||||
|
||||
* 修改 `web/docs/接口约定与预留.md`:新增“前端对接(用户端)”章节,列出与 `web/src/api/index.js` 的映射与新增端点占位,确保路径与请求方式一致。
|
||||
|
||||
## 文档结构变更
|
||||
|
||||
* `app/docs/接口约定与预留.md`
|
||||
|
||||
* 新增:
|
||||
|
||||
* “用户端 API 概览”
|
||||
|
||||
* “认证与首页”
|
||||
|
||||
* “评估提交与引导提示”
|
||||
|
||||
* “估值记录与分享”
|
||||
|
||||
* “个人中心:汇款凭证/发票抬头与类型/发票列表与详情”
|
||||
|
||||
* “剩余估值次数”
|
||||
|
||||
<br />
|
||||
|
||||
* `web/docs/接口约定与预留.md`
|
||||
|
||||
* 新增:
|
||||
|
||||
* “用户端对接路径”与现有 `invoice/*`、`valuation/*`、`app-user/*` 的映射
|
||||
|
||||
* 新增端点建议(如 `app-valuation/*`、`invoice/headers`、`invoice/{id}/receipt`、`jobs/{id}`)的前端占位说明
|
||||
|
||||
## 风格与格式
|
||||
|
||||
* 统一中文、RESTful端点风格,示例以JSON格式。
|
||||
|
||||
* 保持与现有文档用语一致(Success/Fail、token、code===200)。
|
||||
|
||||
## 交付
|
||||
|
||||
* 完成上述两处文档更新,不新增新文档文件;更新内容可直接在IDE中查看。
|
||||
|
||||
@ -1,101 +0,0 @@
|
||||
目标
|
||||
|
||||
* 在 `蜀锦估值计算流程核对.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`)
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
## 目标
|
||||
- 以简明清晰的中文,给出发票状态(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”原因说明,便于直接使用。
|
||||
@ -1,70 +0,0 @@
|
||||
## 目标
|
||||
- 提升 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` 或更高;若上层云负载均衡也有限制,需要同步放宽。
|
||||
- 后续可在应用层增加最大体积限制与提示,避免无界上传占用过多资源。
|
||||
@ -1,36 +0,0 @@
|
||||
## 目标
|
||||
- 配置并启用阿里企业邮箱 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。
|
||||
@ -1,103 +0,0 @@
|
||||
## 工作范围与目标
|
||||
- 范围:梳理 `web` 目录(排除 `web1`),形成结构与架构产物
|
||||
- 目标:
|
||||
- 生成完整项目结构文档(目录树、职责说明、关键路径)
|
||||
- 制作技术架构示意图(前端分层、运行时链路、对后端的调用关系)
|
||||
- 编写关键模块说明文档(路由、状态、鉴权、HTTP、页面域)
|
||||
- 预留接口说明,支撑后续开发接入
|
||||
|
||||
## 技术栈识别
|
||||
- 框架:Vue 3(`web/package.json:37`),构建:Vite(`web/package.json:43`,`web/vite.config.js:1`)
|
||||
- 路由:vue-router@4(`web/package.json:39`);守卫统一注册(`web/src/router/guard/index.js:5-9`)
|
||||
- 状态:Pinia(`web/package.json:26`,`web/src/store/index.js:1-5`)
|
||||
- UI 与样式:Naive UI(`web/package.json:25`),UnoCSS(`web/package.json:30`,`web/src/main.js:3`),全局样式(`web/src/styles/global.scss`)
|
||||
- 国际化:vue-i18n(`web/package.json:38`,`web/src/main.js:22`,`web/i18n/index.js`)
|
||||
- 网络:Axios(`web/package.json:20`),自建请求封装(`web/src/utils/http/index.js:4-18`)与拦截器(`web/src/utils/http/interceptors.js:23-33,35-59`)
|
||||
- 运行环境:`.env.*` 配置,开发代理到后端 `127.0.0.1:9999` 的 `/api/v1`(`web/.env.development:8`,`web/build/constant.js:19-22`,`web/vite.config.js:31-35`)
|
||||
|
||||
## 项目结构综述
|
||||
- 顶层关键目录:
|
||||
- `build/`:Vite 定制化(定义、插件、脚本、代理)(`web/build/*`)
|
||||
- `i18n/`:国际化资源与实例(`web/i18n/index.js`,`web/i18n/messages/*`)
|
||||
- `settings/`:主题与全局设置(`web/settings/theme.json`,`web/settings/index.js`)
|
||||
- `public/`:静态资源与加载占位(`web/public/resource/*`)
|
||||
- `src/`:业务主目录(见下)
|
||||
- `src/` 结构与职责:
|
||||
- 入口与应用:`main.js`(应用装配,挂载插件)(`web/src/main.js:14-23`),`App.vue`
|
||||
- 路由:`router/`(基本路由、动态路由、守卫、滚动)(`web/src/router/index.js:7-18,30-55`)
|
||||
- 状态:`store/`(Pinia 注册与模块聚合)(`web/src/store/index.js:1-5`,`web/src/store/modules/index.js:1-4`)
|
||||
- 组件:`components/`(通用、表格、查询栏、图标、页面容器)
|
||||
- 视图:`views/`(系统管理、估值评估、交易开票、登录、工作台等域)
|
||||
- 工具:`utils/`(鉴权、存储、HTTP、通用工具)(`web/src/utils/*`)
|
||||
- 指令:`directives/`(权限等自定义指令)
|
||||
- 可复用逻辑:`composables/`(如 `useCRUD`)
|
||||
- 样式:`styles/`(Reset、全局样式,UnoCSS 原子类)
|
||||
|
||||
## 核心模块与功能点
|
||||
- 鉴权与导航:
|
||||
- 登录白名单与重定向(`web/src/router/guard/auth-guard.js:3-16`)
|
||||
- 动态路由注入、用户与权限联动(`web/src/router/index.js:30-55`)
|
||||
- 状态管理:
|
||||
- 用户信息获取、登出流程(`web/src/store/modules/user/index.js:37-60`)
|
||||
- 标签、权限、应用模块聚合(`web/src/store/modules/index.js:1-4`)
|
||||
- 网络与错误处理:
|
||||
- `request` 实例与 `baseURL` 环境绑定(`web/src/utils/http/index.js:17-19`)
|
||||
- 成功码约定 `code === 200`、统一错误提示(`web/src/utils/http/interceptors.js:23-33`)
|
||||
- 401 处理与自动登出(`web/src/utils/http/interceptors.js:45-53`)
|
||||
- 业务域:
|
||||
- 系统管理(用户、角色、菜单、部门、审计日志)(`web/src/views/system/*`,API 汇总:`web/src/api/index.js:393-431`)
|
||||
- 客户端用户管理、开票记录、估值评估(`web/src/views/user-management/*`,`web/src/views/transaction/invoice/*`,`web/src/views/valuation/*`;对应 API:`web/src/api/index.js`)
|
||||
- UI 框架与布局:
|
||||
- 布局与头部/侧栏/标签页组件(`web/src/layout/*`)
|
||||
- Naive UI 组件与 UnoCSS 原子化样式协同
|
||||
|
||||
## 技术架构示意图
|
||||
```mermaid
|
||||
graph TD
|
||||
U[用户] --> A[Vue 应用]
|
||||
A --> R[Router]
|
||||
A --> S[Pinia]
|
||||
A --> V[视图与组件]
|
||||
V --> UI[Naive UI / UnoCSS]
|
||||
A --> I18N[vue-i18n]
|
||||
S --> H[HTTP 封装]
|
||||
R --> G[路由守卫]
|
||||
H --> X[Axios 拦截器]
|
||||
X --> B[(后端 API /api/v1)]
|
||||
subgraph 构建与运行
|
||||
Vite[Vite Dev/Build]
|
||||
end
|
||||
A --> Vite
|
||||
```
|
||||
|
||||
## 文档产出方案
|
||||
- 目录与位置:在 `web/docs/` 下生成 4 个文档,统一中文、层级清晰、可落地
|
||||
- `项目结构.md`:目录树 + 角色说明 + 入口与关键路径
|
||||
- `技术架构.md`:架构分层 + 运行链路 + Mermaid 图
|
||||
- `关键模块说明.md`:路由、状态、HTTP、业务域的职责与协作
|
||||
- `接口约定与预留.md`:环境、鉴权、响应约定、扩展接入指引
|
||||
- 文档格式约定:
|
||||
- 标题层级:H1 总览,H2 模块,H3 文件与职责;统一术语与中文阐述
|
||||
- 代码引用统一用内联反引号与文件定位(如 `web/src/router/index.js:30-55`)
|
||||
|
||||
## 接口预留说明(用于后续开发)
|
||||
- 基础约定:
|
||||
- `baseURL`:`VITE_BASE_API`(默认 `/api/v1`,`web/.env.development:8`)
|
||||
- 认证头:`token`(由拦截器自动注入,`web/src/utils/http/interceptors.js:11-14`)
|
||||
- 成功响应:`{ code: 200, data, msg }`(`web/src/utils/http/interceptors.js:23-33`)
|
||||
- 接入方式:在 `web/src/api/index.js` 中以函数方式声明对应业务端点,统一走 `request`
|
||||
- 错误处理:全局弹窗与 401 自动登出链路已就绪(`web/src/utils/http/interceptors.js:45-53`)
|
||||
|
||||
## 执行步骤
|
||||
1. 固化目录树与职责说明,输出《项目结构.md》
|
||||
2. 绘制 Mermaid 架构图并输出《技术架构.md》
|
||||
3. 编写《关键模块说明.md》,覆盖路由、状态、HTTP、页面域
|
||||
4. 编写《接口约定与预留.md》,包含新增接口接入模板与约束
|
||||
5. 交付文档后,等待新需求文档,启动开发
|
||||
|
||||
## 输出验收与规范
|
||||
- 文档格式:统一中文,标题层级一致,引用路径与行号定位
|
||||
- 风格一致:术语与代码片段与现有实现保持一致(如 `request`、`useUserStore`)
|
||||
- 可演进:接口文档预留扩展章节,支持后续模块按同规范接入
|
||||
|
||||
——请确认以上方案,确认后我将按该方案生成 4 个文档并提交供评审。
|
||||
@ -2,10 +2,11 @@ from fastapi import APIRouter, Query, Depends
|
||||
from typing import Optional
|
||||
|
||||
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse
|
||||
from app.schemas.invoice import InvoiceOut, InvoiceHeaderOut, InvoiceHeaderCreate, InvoiceHeaderUpdate
|
||||
from app.schemas.invoice import InvoiceOut, InvoiceHeaderOut, InvoiceHeaderCreate, InvoiceHeaderUpdate, PaymentReceiptCreate, AppCreateInvoiceWithReceipt, InvoiceCreate
|
||||
from app.controllers.invoice import invoice_controller
|
||||
from app.utils.app_user_jwt import get_current_app_user
|
||||
from app.models.user import AppUser
|
||||
from app.models.invoice import InvoiceHeader
|
||||
|
||||
app_invoices_router = APIRouter(tags=["app-发票管理"])
|
||||
|
||||
@ -75,3 +76,56 @@ async def delete_my_header(id: int, current_user: AppUser = Depends(get_current_
|
||||
return Success(data={"deleted": False}, msg="未找到")
|
||||
ok = await invoice_controller.delete_header(id)
|
||||
return Success(data={"deleted": ok}, msg="删除成功" if ok else "未找到")
|
||||
|
||||
|
||||
@app_invoices_router.post("/receipts/{id}", summary="上传我的付款凭证", response_model=BasicResponse[dict])
|
||||
async def upload_my_receipt(id: int, data: PaymentReceiptCreate, current_user: AppUser = Depends(get_current_app_user)):
|
||||
inv = await invoice_controller.model.filter(id=id, app_user_id=current_user.id).first()
|
||||
if not inv:
|
||||
return Success(data={}, msg="未找到")
|
||||
receipt = await invoice_controller.create_receipt(id, data)
|
||||
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||
return Success(data=detail, msg="上传成功")
|
||||
|
||||
|
||||
@app_invoices_router.post("/create-with-receipt", summary="创建我的发票并上传付款凭证", response_model=BasicResponse[dict])
|
||||
async def create_with_receipt(payload: AppCreateInvoiceWithReceipt, current_user: AppUser = Depends(get_current_app_user)):
|
||||
header = await InvoiceHeader.filter(id=payload.header_id, app_user_id=current_user.id).first()
|
||||
if not header:
|
||||
return Success(data={}, msg="抬头未找到")
|
||||
ticket_type = payload.ticket_type or "electronic"
|
||||
invoice_type = payload.invoice_type
|
||||
if not invoice_type:
|
||||
mapping = {"0": "normal", "1": "special"}
|
||||
invoice_type = mapping.get(str(payload.invoiceTypeIndex)) if payload.invoiceTypeIndex is not None else None
|
||||
if not invoice_type:
|
||||
invoice_type = "normal"
|
||||
inv_data = InvoiceCreate(
|
||||
ticket_type=ticket_type,
|
||||
invoice_type=invoice_type,
|
||||
phone=current_user.phone,
|
||||
email=header.email,
|
||||
company_name=header.company_name,
|
||||
tax_number=header.tax_number,
|
||||
register_address=header.register_address,
|
||||
register_phone=header.register_phone,
|
||||
bank_name=header.bank_name,
|
||||
bank_account=header.bank_account,
|
||||
app_user_id=current_user.id,
|
||||
header_id=header.id,
|
||||
wechat=getattr(current_user, "alias", None),
|
||||
)
|
||||
inv = await invoice_controller.create(inv_data)
|
||||
receipt = await invoice_controller.create_receipt(inv.id, PaymentReceiptCreate(url=payload.receipt_url, note=payload.note))
|
||||
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||
return Success(data=detail, msg="创建并上传成功")
|
||||
@app_invoices_router.get("/headers/list", summary="我的抬头列表(分页)", response_model=PageResponse[InvoiceHeaderOut])
|
||||
async def get_my_headers_paged(page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100), current_user: AppUser = Depends(get_current_app_user)):
|
||||
qs = invoice_controller.model_header.filter(app_user_id=current_user.id) if hasattr(invoice_controller, "model_header") else None
|
||||
# Fallback when controller没有暴露model_header
|
||||
from app.models.invoice import InvoiceHeader
|
||||
qs = InvoiceHeader.filter(app_user_id=current_user.id)
|
||||
total = await qs.count()
|
||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||
items = [InvoiceHeaderOut.model_validate(r) for r in rows]
|
||||
return SuccessExtra(data=[i.model_dump() for i in items], total=total, page=page, page_size=page_size, msg="获取成功")
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, Query, Depends, HTTPException
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse
|
||||
from app.schemas.app_user import AppUserQuotaUpdateSchema, AppUserQuotaLogOut, AppUserUpdateSchema
|
||||
@ -15,18 +16,27 @@ admin_app_users_router = APIRouter(dependencies=[DependAuth, DependPermission],
|
||||
async def list_app_users(
|
||||
phone: Optional[str] = Query(None),
|
||||
wechat: Optional[str] = Query(None),
|
||||
id: Optional[str] = Query(None),
|
||||
created_at: Optional[List[int]] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
):
|
||||
qs = AppUser.filter()
|
||||
if id is not None and id.strip().isdigit():
|
||||
qs = qs.filter(id=int(id.strip()))
|
||||
if phone:
|
||||
qs = qs.filter(phone__icontains=phone)
|
||||
if wechat:
|
||||
qs = qs.filter(alias__icontains=wechat)
|
||||
if created_at and len(created_at) == 2:
|
||||
start_dt = datetime.fromtimestamp(created_at[0] / 1000)
|
||||
end_dt = datetime.fromtimestamp(created_at[1] / 1000)
|
||||
qs = qs.filter(created_at__gte=start_dt, created_at__lte=end_dt)
|
||||
total = await qs.count()
|
||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||
items = []
|
||||
for u in rows:
|
||||
last_log = await AppUserQuotaLog.filter(app_user_id=u.id).order_by("-created_at").first()
|
||||
items.append({
|
||||
"id": u.id,
|
||||
"phone": u.phone,
|
||||
@ -34,7 +44,7 @@ async def list_app_users(
|
||||
"created_at": u.created_at.isoformat() if u.created_at else "",
|
||||
"notes": getattr(u, "notes", "") or "",
|
||||
"remaining_count": int(getattr(u, "remaining_quota", 0) or 0),
|
||||
"user_type": None,
|
||||
"user_type": getattr(last_log, "op_type", None),
|
||||
})
|
||||
return SuccessExtra(data=items, total=total, page=page, page_size=page_size, msg="获取成功")
|
||||
|
||||
@ -94,6 +104,7 @@ async def update_app_user(user_id: int, data: AppUserUpdateSchema):
|
||||
"company_contact": getattr(user, "company_contact", None),
|
||||
"company_phone": getattr(user, "company_phone", None),
|
||||
"company_email": getattr(user, "company_email", None),
|
||||
"notes": getattr(user, "notes", None),
|
||||
"is_active": user.is_active,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else "",
|
||||
"updated_at": user.updated_at.isoformat() if user.updated_at else "",
|
||||
|
||||
@ -10,6 +10,7 @@ from app.schemas.invoice import (
|
||||
InvoiceHeaderCreate,
|
||||
InvoiceHeaderUpdate,
|
||||
PaymentReceiptCreate,
|
||||
AppCreateInvoiceWithReceipt,
|
||||
InvoiceOut,
|
||||
InvoiceList,
|
||||
InvoiceHeaderOut,
|
||||
@ -19,6 +20,7 @@ from app.controllers.invoice import invoice_controller
|
||||
from app.utils.app_user_jwt import get_current_app_user
|
||||
from app.core.dependency import DependAuth, DependPermission
|
||||
from app.models.user import AppUser
|
||||
from app.models.invoice import InvoiceHeader
|
||||
|
||||
|
||||
invoice_router = APIRouter(tags=["发票管理"])
|
||||
@ -168,36 +170,3 @@ async def delete_invoice_header(id: int):
|
||||
async def update_invoice_header(id: int, data: InvoiceHeaderUpdate):
|
||||
header = await invoice_controller.update_header(id, data)
|
||||
return Success(data=header or {}, msg="更新成功" if header else "未找到")
|
||||
|
||||
|
||||
# 用户端:我的发票列表(使用App用户token)
|
||||
@invoice_router.get("/app-list", summary="我的发票列表", response_model=PageResponse[InvoiceOut])
|
||||
async def list_my_invoices(
|
||||
status: Optional[str] = Query(None),
|
||||
ticket_type: Optional[str] = Query(None),
|
||||
invoice_type: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
current_user: AppUser = Depends(get_current_app_user),
|
||||
):
|
||||
result = await invoice_controller.list(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
status=status,
|
||||
ticket_type=ticket_type,
|
||||
invoice_type=invoice_type,
|
||||
app_user_id=current_user.id,
|
||||
)
|
||||
return SuccessExtra(
|
||||
data=result.items,
|
||||
total=result.total,
|
||||
page=result.page,
|
||||
page_size=result.page_size,
|
||||
msg="获取成功",
|
||||
)
|
||||
|
||||
# 用户端:我的发票抬头(使用App用户token)
|
||||
@invoice_router.get("/app-headers", summary="我的发票抬头", response_model=BasicResponse[list[InvoiceHeaderOut]])
|
||||
async def get_my_invoice_headers(current_user: AppUser = Depends(get_current_app_user)):
|
||||
headers = await invoice_controller.get_headers(user_id=current_user.id)
|
||||
return Success(data=headers, msg="获取成功")
|
||||
|
||||
@ -4,6 +4,8 @@ from typing import Optional
|
||||
from app.schemas.base import Success, SuccessExtra, PageResponse, BasicResponse
|
||||
from app.schemas.invoice import PaymentReceiptOut
|
||||
from app.controllers.invoice import invoice_controller
|
||||
from app.models.invoice import PaymentReceipt
|
||||
from fastapi import Body
|
||||
from app.schemas.transactions import SendEmailRequest, SendEmailResponse
|
||||
from app.services.email_client import email_client
|
||||
from app.models.invoice import EmailSendLog
|
||||
@ -65,47 +67,60 @@ async def get_receipt_detail(id: int):
|
||||
return Success(data=data or {}, msg="获取成功" if data else "未找到")
|
||||
|
||||
|
||||
@transactions_router.post("/send-email", summary="发送邮件", response_model=BasicResponse[SendEmailResponse])
|
||||
async def send_email(data: SendEmailRequest, file: Optional[UploadFile] = File(None)):
|
||||
if not data.email or "@" not in data.email:
|
||||
raise HTTPException(status_code=422, detail="邮箱格式不正确")
|
||||
if not data.body:
|
||||
raise HTTPException(status_code=422, detail="文案内容不能为空")
|
||||
|
||||
file_bytes = None
|
||||
file_name = None
|
||||
if file is not None:
|
||||
file_bytes = await file.read()
|
||||
file_name = file.filename
|
||||
elif data.file_url:
|
||||
|
||||
@transactions_router.post("/send-email", summary="发送邮件", response_model=BasicResponse[SendEmailResponse])
|
||||
async def send_email(payload: SendEmailRequest = Body(...)):
|
||||
|
||||
attachments = []
|
||||
urls = []
|
||||
if payload.file_urls:
|
||||
urls.extend([u.strip().strip('`') for u in payload.file_urls if isinstance(u, str)])
|
||||
if urls:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(data.file_url)
|
||||
r.raise_for_status()
|
||||
file_bytes = r.content
|
||||
file_name = data.file_url.split("/")[-1]
|
||||
for u in urls:
|
||||
r = await client.get(u)
|
||||
r.raise_for_status()
|
||||
attachments.append((r.content, u.split("/")[-1]))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"附件下载失败: {e}")
|
||||
|
||||
logger.info("transactions.email_send_start email={} subject={}", data.email, data.subject or "")
|
||||
result = email_client.send(data.email, data.subject, data.body, file_bytes, file_name, getattr(file, "content_type", None))
|
||||
logger.info("transactions.email_send_start email={} subject={}", payload.email, payload.subject or "")
|
||||
try:
|
||||
result = email_client.send_many(payload.email, payload.subject, payload.body, attachments)
|
||||
except RuntimeError as e:
|
||||
result = {"status": "FAIL", "error": str(e)}
|
||||
except Exception as e:
|
||||
result = {"status": "FAIL", "error": str(e)}
|
||||
|
||||
body_summary = data.body[:500]
|
||||
body_summary = payload.body[:500]
|
||||
status = result.get("status")
|
||||
error = result.get("error")
|
||||
first_name = attachments[0][1] if attachments else None
|
||||
first_url = urls[0] if urls else None
|
||||
log = await EmailSendLog.create(
|
||||
email=data.email,
|
||||
subject=data.subject,
|
||||
email=payload.email,
|
||||
subject=payload.subject,
|
||||
body_summary=body_summary,
|
||||
file_name=file_name,
|
||||
file_url=data.file_url,
|
||||
file_name=first_name,
|
||||
file_url=first_url,
|
||||
status=status,
|
||||
error=error,
|
||||
)
|
||||
if status == "OK":
|
||||
logger.info("transactions.email_send_ok email={}", data.email)
|
||||
logger.info("transactions.email_send_ok email={}", payload.email)
|
||||
else:
|
||||
logger.error("transactions.email_send_fail email={} err={}", data.email, error)
|
||||
logger.error("transactions.email_send_fail email={} err={}", payload.email, error)
|
||||
|
||||
if status == "OK" and data.receipt_id:
|
||||
try:
|
||||
r = await PaymentReceipt.filter(id=data.receipt_id).first()
|
||||
if r:
|
||||
r.extra = (r.extra or {}) | data.model_dump()
|
||||
await r.save()
|
||||
except Exception as e:
|
||||
logger.error("transactions.email_extra_save_fail id={} err={}", data.receipt_id, str(e))
|
||||
|
||||
return Success(data={"status": status, "log_id": log.id, "error": error}, msg="发送成功" if status == "OK" else "发送失败")
|
||||
|
||||
@ -128,4 +143,4 @@ async def smtp_config_status():
|
||||
"tls": settings.SMTP_TLS,
|
||||
"configured": configured,
|
||||
}
|
||||
return Success(data=data, msg="OK")
|
||||
return Success(data=data, msg="OK")
|
||||
|
||||
@ -116,6 +116,9 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
||||
op_type=op_type,
|
||||
remark=remark,
|
||||
)
|
||||
# if remark is not None:
|
||||
# user.notes = remark
|
||||
# await user.save()
|
||||
return user
|
||||
|
||||
async def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||
|
||||
@ -33,7 +33,14 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
||||
返回:
|
||||
InvoiceHeaderOut: 抬头输出对象
|
||||
"""
|
||||
header = await InvoiceHeader.create(app_user_id=user_id, **data.model_dump())
|
||||
payload = data.model_dump()
|
||||
for k in ["register_address", "register_phone", "bank_name", "bank_account"]:
|
||||
if payload.get(k) is None:
|
||||
payload[k] = ""
|
||||
if payload.get("is_default"):
|
||||
if user_id is not None:
|
||||
await InvoiceHeader.filter(app_user_id=user_id).update(is_default=False)
|
||||
header = await InvoiceHeader.create(app_user_id=user_id, **payload)
|
||||
return InvoiceHeaderOut.model_validate(header)
|
||||
|
||||
async def get_headers(self, user_id: Optional[int] = None) -> List[InvoiceHeaderOut]:
|
||||
@ -74,6 +81,9 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
||||
return None
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if update_data:
|
||||
if update_data.get("is_default"):
|
||||
if header.app_user_id is not None:
|
||||
await InvoiceHeader.filter(app_user_id=header.app_user_id).exclude(id=header.id).update(is_default=False)
|
||||
await header.update_from_dict(update_data).save()
|
||||
return InvoiceHeaderOut.model_validate(header)
|
||||
|
||||
@ -178,6 +188,7 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
||||
note=receipt.note,
|
||||
verified=receipt.verified,
|
||||
created_at=receipt.created_at.isoformat() if receipt.created_at else "",
|
||||
extra=receipt.extra,
|
||||
)
|
||||
|
||||
async def list_receipts(self, page: int = 1, page_size: int = 10, **filters) -> dict:
|
||||
@ -254,6 +265,7 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
||||
"invoice_id": getattr(inv, "id", None),
|
||||
"submitted_at": r.created_at.isoformat() if r.created_at else "",
|
||||
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
|
||||
"extra": r.extra,
|
||||
"receipts": [
|
||||
{
|
||||
"id": r.id,
|
||||
@ -295,6 +307,7 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
||||
"invoice_id": getattr(inv, "id", None),
|
||||
"submitted_at": r.created_at.isoformat() if r.created_at else "",
|
||||
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
|
||||
"extra": r.extra,
|
||||
"receipts": [
|
||||
{
|
||||
"id": r.id,
|
||||
|
||||
@ -190,9 +190,9 @@ class ValuationController:
|
||||
a_s_dt = _parse_time(getattr(query, 'audited_start', None))
|
||||
a_e_dt = _parse_time(getattr(query, 'audited_end', None))
|
||||
if a_s_dt:
|
||||
queryset = queryset.filter(audited_at__isnull=False, audited_at__gte=a_s_dt)
|
||||
queryset = queryset.filter(updated_at__gte=a_s_dt)
|
||||
if a_e_dt:
|
||||
queryset = queryset.filter(audited_at__isnull=False, audited_at__lte=a_e_dt)
|
||||
queryset = queryset.filter(updated_at__lte=a_e_dt)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ class InvoiceHeader(BaseModel, TimestampMixin):
|
||||
bank_name = fields.CharField(max_length=128, description="开户银行")
|
||||
bank_account = fields.CharField(max_length=64, description="银行账号")
|
||||
email = fields.CharField(max_length=128, description="接收邮箱")
|
||||
is_default = fields.BooleanField(default=False, description="是否默认抬头", index=True)
|
||||
|
||||
class Meta:
|
||||
table = "invoice_header"
|
||||
@ -29,7 +30,7 @@ class Invoice(BaseModel, TimestampMixin):
|
||||
register_phone = fields.CharField(max_length=32, description="注册电话")
|
||||
bank_name = fields.CharField(max_length=128, description="开户银行")
|
||||
bank_account = fields.CharField(max_length=64, description="银行账号")
|
||||
status = fields.CharField(max_length=16, description="状态: pending|invoiced|rejected|refunded", index=True)
|
||||
status = fields.CharField(max_length=16, description="状态: pending|invoiced|rejected|refunded", index=True, default="pending")
|
||||
app_user_id = fields.IntField(null=True, description="App用户ID", index=True)
|
||||
header = fields.ForeignKeyField("models.InvoiceHeader", related_name="invoices", null=True, description="抬头关联")
|
||||
wechat = fields.CharField(max_length=64, null=True, description="微信号", index=True)
|
||||
@ -44,6 +45,7 @@ class PaymentReceipt(BaseModel, TimestampMixin):
|
||||
url = fields.CharField(max_length=512, description="付款凭证图片地址")
|
||||
note = fields.CharField(max_length=256, null=True, description="备注")
|
||||
verified = fields.BooleanField(default=False, description="是否已核验")
|
||||
extra = fields.JSONField(null=True, description="额外信息:邮件发送相关")
|
||||
|
||||
class Meta:
|
||||
table = "payment_receipt"
|
||||
@ -61,4 +63,4 @@ class EmailSendLog(BaseModel, TimestampMixin):
|
||||
|
||||
class Meta:
|
||||
table = "email_send_log"
|
||||
table_description = "邮件发送日志"
|
||||
table_description = "邮件发送日志"
|
||||
|
||||
@ -62,6 +62,7 @@ class AppUserUpdateSchema(BaseModel):
|
||||
company_contact: Optional[str] = Field(None, description="公司联系人")
|
||||
company_phone: Optional[str] = Field(None, description="公司电话")
|
||||
company_email: Optional[str] = Field(None, description="公司邮箱")
|
||||
notes: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class AppUserChangePasswordSchema(BaseModel):
|
||||
@ -113,4 +114,4 @@ class AppUserRegisterOut(BaseModel):
|
||||
class TokenValidateOut(BaseModel):
|
||||
"""Token 校验结果"""
|
||||
user_id: int = Field(..., description="用户ID")
|
||||
phone: str = Field(..., description="手机号")
|
||||
phone: str = Field(..., description="手机号")
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from pydantic import BaseModel, Field, EmailStr, field_validator, model_validator
|
||||
|
||||
|
||||
class InvoiceHeaderCreate(BaseModel):
|
||||
company_name: str = Field(..., min_length=1, max_length=128)
|
||||
tax_number: str = Field(..., min_length=1, max_length=32)
|
||||
register_address: str = Field(..., min_length=1, max_length=256)
|
||||
register_phone: str = Field(..., min_length=1, max_length=32)
|
||||
bank_name: str = Field(..., min_length=1, max_length=128)
|
||||
bank_account: str = Field(..., min_length=1, max_length=64)
|
||||
register_address: Optional[str] = Field(None, min_length=1, max_length=256)
|
||||
register_phone: Optional[str] = Field(None, min_length=1, max_length=32)
|
||||
bank_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
bank_account: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
email: EmailStr
|
||||
is_default: Optional[bool] = False
|
||||
|
||||
|
||||
class InvoiceHeaderOut(BaseModel):
|
||||
@ -24,16 +25,18 @@ class InvoiceHeaderOut(BaseModel):
|
||||
email: EmailStr
|
||||
class Config:
|
||||
from_attributes = True
|
||||
is_default: Optional[bool] = False
|
||||
|
||||
|
||||
class InvoiceHeaderUpdate(BaseModel):
|
||||
company_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
tax_number: Optional[str] = Field(None, min_length=1, max_length=32)
|
||||
register_address: Optional[str] = Field(None, min_length=1, max_length=256)
|
||||
register_phone: Optional[str] = Field(None, min_length=1, max_length=32)
|
||||
bank_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
bank_account: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
register_address: Optional[str] = Field(None, max_length=256)
|
||||
register_phone: Optional[str] = Field(None, max_length=32)
|
||||
bank_name: Optional[str] = Field(None, max_length=128)
|
||||
bank_account: Optional[str] = Field(None, max_length=64)
|
||||
email: Optional[EmailStr] = None
|
||||
is_default: Optional[bool] = None
|
||||
|
||||
|
||||
class InvoiceCreate(BaseModel):
|
||||
@ -105,6 +108,7 @@ class UpdateType(BaseModel):
|
||||
class PaymentReceiptCreate(BaseModel):
|
||||
url: str = Field(..., min_length=1, max_length=512)
|
||||
note: Optional[str] = Field(None, max_length=256)
|
||||
extra: Optional[dict] = None
|
||||
|
||||
|
||||
class PaymentReceiptOut(BaseModel):
|
||||
@ -113,3 +117,41 @@ class PaymentReceiptOut(BaseModel):
|
||||
note: Optional[str]
|
||||
verified: bool
|
||||
created_at: str
|
||||
extra: Optional[dict] = None
|
||||
|
||||
|
||||
class AppCreateInvoiceWithReceipt(BaseModel):
|
||||
header_id: int
|
||||
ticket_type: Optional[str] = Field(None, pattern=r"^(electronic|paper)$")
|
||||
invoice_type: Optional[str] = Field(None, pattern=r"^(special|normal)$")
|
||||
# 兼容前端索引字段:"0"→normal,"1"→special
|
||||
invoiceTypeIndex: Optional[str] = None
|
||||
receipt_url: str = Field(..., min_length=1, max_length=512)
|
||||
note: Optional[str] = Field(None, max_length=256)
|
||||
|
||||
@field_validator('ticket_type', mode='before')
|
||||
@classmethod
|
||||
def _default_ticket_type(cls, v):
|
||||
return v or 'electronic'
|
||||
|
||||
@field_validator('receipt_url', mode='before')
|
||||
@classmethod
|
||||
def _clean_receipt_url(cls, v):
|
||||
if isinstance(v, list) and v:
|
||||
v = v[0]
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if s.startswith('`') and s.endswith('`'):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _coerce_invoice_type(self):
|
||||
if not self.invoice_type and self.invoiceTypeIndex is not None:
|
||||
mapping = {'0': 'normal', '1': 'special'}
|
||||
self.invoice_type = mapping.get(str(self.invoiceTypeIndex))
|
||||
# 若仍为空,默认 normal
|
||||
if not self.invoice_type:
|
||||
self.invoice_type = 'normal'
|
||||
return self
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Union
|
||||
|
||||
|
||||
class SendEmailRequest(BaseModel):
|
||||
receipt_id: Optional[int] = Field(None, description="付款凭证ID")
|
||||
email: str = Field(..., description="邮箱地址")
|
||||
subject: Optional[str] = Field(None, description="邮件主题")
|
||||
body: str = Field(..., description="文案内容")
|
||||
file_url: Optional[str] = Field(None, description="附件URL")
|
||||
file_urls: Optional[List[str]] = Field(None, description="附件URL列表")
|
||||
|
||||
|
||||
class SendEmailBody(BaseModel):
|
||||
data: SendEmailRequest
|
||||
|
||||
|
||||
class SendEmailResponse(BaseModel):
|
||||
@ -22,4 +27,8 @@ class EmailSendLogOut(BaseModel):
|
||||
body_summary: Optional[str]
|
||||
file_name: Optional[str]
|
||||
file_url: Optional[str]
|
||||
status: str
|
||||
status: str
|
||||
|
||||
|
||||
class SendEmailBody(BaseModel):
|
||||
data: SendEmailRequest
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Any, Dict, Union
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
@ -149,12 +149,15 @@ class ValuationAssessmentOut(ValuationAssessmentBase):
|
||||
id: int = Field(..., description="主键ID")
|
||||
user_id: int = Field(..., description="用户ID")
|
||||
user_phone: Optional[str] = Field(None, description="用户手机号")
|
||||
report_url: Optional[str] = Field(None, description="评估报告URL")
|
||||
certificate_url: Optional[str] = Field(None, description="证书URL")
|
||||
report_url: List[str] = Field(default_factory=list, description="评估报告URL列表")
|
||||
certificate_url: List[str] = Field(default_factory=list, description="证书URL列表")
|
||||
report_download_urls: List[str] = Field(default_factory=list, description="评估报告下载地址列表")
|
||||
certificate_download_urls: List[str] = Field(default_factory=list, description="证书下载地址列表")
|
||||
status: str = Field(..., description="评估状态")
|
||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
updated_at: datetime = Field(..., description="更新时间")
|
||||
audited_at: Optional[datetime] = Field(None, description="审核时间")
|
||||
is_active: bool = Field(..., description="是否激活")
|
||||
|
||||
class Config:
|
||||
@ -165,6 +168,29 @@ class ValuationAssessmentOut(ValuationAssessmentBase):
|
||||
# 确保所有字段都被序列化,包括None值
|
||||
exclude_none = False
|
||||
|
||||
@field_validator('report_url', 'certificate_url', mode='before')
|
||||
@classmethod
|
||||
def _to_list(cls, v):
|
||||
def clean(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith('`') and s.endswith('`'):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
|
||||
if isinstance(v, str):
|
||||
s = clean(v)
|
||||
return [s] if s else []
|
||||
return []
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _fill_downloads(self):
|
||||
self.report_download_urls = list(self.report_url or [])
|
||||
self.certificate_download_urls = list(self.certificate_url or [])
|
||||
return self
|
||||
|
||||
|
||||
# 用户端专用模式
|
||||
class UserValuationCreate(ValuationAssessmentBase):
|
||||
@ -176,8 +202,10 @@ class UserValuationOut(ValuationAssessmentBase):
|
||||
"""用户端估值评估输出模型"""
|
||||
id: int = Field(..., description="主键ID")
|
||||
user_id: Optional[int] = Field(None, description="用户ID")
|
||||
report_url: Optional[str] = Field(None, description="评估报告URL")
|
||||
certificate_url: Optional[str] = Field(None, description="证书URL")
|
||||
report_url: List[str] = Field(default_factory=list, description="评估报告URL列表")
|
||||
certificate_url: List[str] = Field(default_factory=list, description="证书URL列表")
|
||||
report_download_urls: List[str] = Field(default_factory=list, description="评估报告下载地址列表")
|
||||
certificate_download_urls: List[str] = Field(default_factory=list, description="证书下载地址列表")
|
||||
status: str = Field(..., description="评估状态")
|
||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
@ -191,12 +219,37 @@ class UserValuationOut(ValuationAssessmentBase):
|
||||
}
|
||||
exclude_none = False
|
||||
|
||||
@field_validator('report_url', 'certificate_url', mode='before')
|
||||
@classmethod
|
||||
def _to_list(cls, v):
|
||||
def clean(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith('`') and s.endswith('`'):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
|
||||
if isinstance(v, str):
|
||||
s = clean(v)
|
||||
return [s] if s else []
|
||||
return []
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _fill_downloads(self):
|
||||
self.report_download_urls = list(self.report_url or [])
|
||||
self.certificate_download_urls = list(self.certificate_url or [])
|
||||
return self
|
||||
|
||||
|
||||
class UserValuationDetail(ValuationAssessmentBase):
|
||||
"""用户端详细估值评估模型"""
|
||||
id: int = Field(..., description="主键ID")
|
||||
report_url: Optional[str] = Field(None, description="评估报告URL")
|
||||
certificate_url: Optional[str] = Field(None, description="证书URL")
|
||||
report_url: List[str] = Field(default_factory=list, description="评估报告URL列表")
|
||||
certificate_url: List[str] = Field(default_factory=list, description="证书URL列表")
|
||||
report_download_urls: List[str] = Field(default_factory=list, description="评估报告下载地址列表")
|
||||
certificate_download_urls: List[str] = Field(default_factory=list, description="证书下载地址列表")
|
||||
status: str = Field(..., description="评估状态")
|
||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
@ -208,6 +261,29 @@ class UserValuationDetail(ValuationAssessmentBase):
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
|
||||
@field_validator('report_url', 'certificate_url', mode='before')
|
||||
@classmethod
|
||||
def _to_list(cls, v):
|
||||
def clean(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith('`') and s.endswith('`'):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
|
||||
if isinstance(v, str):
|
||||
s = clean(v)
|
||||
return [s] if s else []
|
||||
return []
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _fill_downloads(self):
|
||||
self.report_download_urls = list(self.report_url or [])
|
||||
self.certificate_download_urls = list(self.certificate_url or [])
|
||||
return self
|
||||
|
||||
|
||||
class UserValuationList(BaseModel):
|
||||
"""用户端估值评估列表模型"""
|
||||
|
||||
@ -3,7 +3,7 @@ from email.mime.base import MIMEBase
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email import encoders
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Tuple
|
||||
import httpx
|
||||
|
||||
from app.settings.config import settings
|
||||
@ -27,6 +27,14 @@ class EmailClient:
|
||||
part.add_header("Content-Disposition", f"attachment; filename=\"{file_name}\"")
|
||||
msg.attach(part)
|
||||
|
||||
if hasattr(self, "_extra_attachments") and isinstance(self._extra_attachments, list):
|
||||
for fb, fn in self._extra_attachments:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(fb)
|
||||
encoders.encode_base64(part)
|
||||
part.add_header("Content-Disposition", f"attachment; filename=\"{fn}\"")
|
||||
msg.attach(part)
|
||||
|
||||
if settings.SMTP_TLS:
|
||||
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30)
|
||||
server.starttls()
|
||||
@ -45,5 +53,12 @@ class EmailClient:
|
||||
pass
|
||||
return {"status": "FAIL", "error": str(e)}
|
||||
|
||||
def send_many(self, to_email: str, subject: Optional[str], body: str, attachments: Optional[List[Tuple[bytes, str]]] = None) -> dict:
|
||||
self._extra_attachments = attachments or []
|
||||
try:
|
||||
return self.send(to_email, subject, body, None, None, None)
|
||||
finally:
|
||||
self._extra_attachments = []
|
||||
|
||||
email_client = EmailClient()
|
||||
|
||||
email_client = EmailClient()
|
||||
|
||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
BIN
app/static/images/7-icon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
@ -165,11 +165,12 @@ class FinalValueACalculator:
|
||||
logger.info("final_value_a.calculating_model_value_b 开始计算模型估值B")
|
||||
model_start_time = time.time()
|
||||
|
||||
model_result = await self.model_value_calculator.calculate_complete_model_value_b(
|
||||
model_result_raw = await self.model_value_calculator.calculate_complete_model_value_b(
|
||||
valuation_id,
|
||||
input_data['model_data']
|
||||
)
|
||||
model_value_b = model_result if isinstance(model_result, (int, float)) else model_result.get('model_value_b')
|
||||
model_result = model_result_raw if isinstance(model_result_raw, dict) else {"model_value_b": model_result_raw}
|
||||
model_value_b = model_result.get('model_value_b')
|
||||
model_duration = time.time() - model_start_time
|
||||
|
||||
logger.info("final_value_a.model_value_b_calculated 模型估值B计算完成: 模型估值B={}万元 耗时={}ms 返回字段={}",
|
||||
@ -194,17 +195,18 @@ class FinalValueACalculator:
|
||||
logger.info("final_value_a.calculating_market_value_c 开始计算市场估值C")
|
||||
market_start_time = time.time()
|
||||
|
||||
market_result = await self.market_value_calculator.calculate_complete_market_value_c(
|
||||
market_result_raw = await self.market_value_calculator.calculate_complete_market_value_c(
|
||||
valuation_id,
|
||||
input_data['market_data']
|
||||
)
|
||||
market_value_c = market_result if isinstance(market_result, (int, float)) else market_result.get('market_value_c')
|
||||
market_result = market_result_raw if isinstance(market_result_raw, dict) else {"market_value_c": market_result_raw}
|
||||
market_value_c = market_result.get('market_value_c')
|
||||
market_duration = time.time() - market_start_time
|
||||
|
||||
logger.info("final_value_a.market_value_c_calculated 市场估值C计算完成: 市场估值C={}万元 耗时={}ms 返回字段={}",
|
||||
logger.info("final_value_a.market_value_c_calculated 市场估值C计算完成: 市场估值C={}万元 耗时={}ms 请求字段={}",
|
||||
market_value_c,
|
||||
int(market_duration * 1000),
|
||||
list(market_result.keys()))
|
||||
input_data['market_data'])
|
||||
|
||||
await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
|
||||
@ -51,7 +51,7 @@ class ModelValueBCalculator:
|
||||
|
||||
return model_value
|
||||
|
||||
async def calculate_complete_model_value_b(self, valuation_id: int, input_data: Dict) -> float:
|
||||
async def calculate_complete_model_value_b(self, valuation_id: int, input_data: Dict) -> Dict[str, float]:
|
||||
"""
|
||||
计算完整的模型估值B,并记录详细的计算步骤。
|
||||
|
||||
@ -69,7 +69,7 @@ class ModelValueBCalculator:
|
||||
}
|
||||
|
||||
Returns:
|
||||
float: 计算得出的模型估值B。
|
||||
Dict[str, float]: 包含中间结果和最终模型估值B的字典。
|
||||
|
||||
Raises:
|
||||
Exception: 在计算过程中遇到的任何异常都会被捕获、记录,然后重新抛出。
|
||||
@ -87,8 +87,21 @@ class ModelValueBCalculator:
|
||||
)
|
||||
step_order += 1
|
||||
|
||||
current_stage = "初始化模型估值B参数"
|
||||
try:
|
||||
if not isinstance(input_data, dict):
|
||||
raise TypeError(f"model_data必须为字典,当前类型为{type(input_data).__name__}")
|
||||
|
||||
required_sections = ("economic_data", "cultural_data", "risky_data")
|
||||
missing_sections = [
|
||||
section for section in required_sections
|
||||
if not isinstance(input_data.get(section), dict)
|
||||
]
|
||||
if missing_sections:
|
||||
raise ValueError(f"model_data缺少必要字段: {', '.join(missing_sections)}")
|
||||
|
||||
# 计算经济价值B1(传入估值ID并等待异步完成)
|
||||
current_stage = "经济价值B1计算"
|
||||
economic_value_b1 = await self.economic_value_calculator.calculate_complete_economic_value_b1(
|
||||
valuation_id,
|
||||
input_data['economic_data']
|
||||
@ -107,6 +120,7 @@ class ModelValueBCalculator:
|
||||
step_order += 1
|
||||
|
||||
# 计算文化价值B2(传入估值ID并等待异步完成)
|
||||
current_stage = "文化价值B2计算"
|
||||
cultural_value_b2 = await self.cultural_value_calculator.calculate_complete_cultural_value_b2(
|
||||
valuation_id,
|
||||
input_data['cultural_data']
|
||||
@ -125,6 +139,7 @@ class ModelValueBCalculator:
|
||||
step_order += 1
|
||||
|
||||
# 计算风险调整系数B3(传入估值ID并等待异步完成)
|
||||
current_stage = "风险调整系数B3计算"
|
||||
risk_value_b3 = await self.risk_adjustment_calculator.calculate_complete_risky_value_b3(
|
||||
valuation_id,
|
||||
input_data['risky_data']
|
||||
@ -143,6 +158,7 @@ class ModelValueBCalculator:
|
||||
step_order += 1
|
||||
|
||||
# 计算模型估值B
|
||||
current_stage = "模型估值B汇总"
|
||||
model_value_b = self.calculate_model_value_b(
|
||||
economic_value_b1,
|
||||
cultural_value_b2,
|
||||
@ -159,7 +175,12 @@ class ModelValueBCalculator:
|
||||
status="completed"
|
||||
)
|
||||
)
|
||||
return model_value_b
|
||||
return {
|
||||
"economic_value_b1": economic_value_b1,
|
||||
"cultural_value_b2": cultural_value_b2,
|
||||
"risk_value_b3": risk_value_b3,
|
||||
"model_value_b": model_value_b,
|
||||
}
|
||||
except Exception as e:
|
||||
await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
@ -168,7 +189,7 @@ class ModelValueBCalculator:
|
||||
step_name="计算失败",
|
||||
step_description="计算过程中发生错误。",
|
||||
status="failed",
|
||||
error_message=str(e)
|
||||
error_message=f"{current_stage}失败: {e}"
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
@ -101,7 +101,7 @@ class MarketValueCCalculator:
|
||||
market_value = (market_bidding_c1 * heat_coefficient_c2 *
|
||||
scarcity_multiplier_c3 * temporal_decay_c4)
|
||||
|
||||
return market_value
|
||||
return market_value / 10000.0
|
||||
|
||||
async def calculate_complete_market_value_c(self, valuation_id: int, input_data: Dict) -> float:
|
||||
"""
|
||||
|
||||
@ -21,6 +21,15 @@ server {
|
||||
index index.html index.htm;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
# PC 前端(/pc/ 前缀)
|
||||
location = /pc {
|
||||
return 302 /pc/;
|
||||
}
|
||||
location ^~ /pc/ {
|
||||
alias /opt/vue-fastapi-admin/web1/dist/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location ^~ /api/ {
|
||||
proxy_pass http://127.0.0.1:9999;
|
||||
proxy_set_header Host $host;
|
||||
|
||||