Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45aae516b2 | ||
|
|
4110dca428 | ||
| f17c1678c8 | |||
| 1157704d4b | |||
|
|
17b56a1c19 | ||
|
|
6718b51fb9 | ||
|
|
58f16be457 | ||
|
|
90c0f85972 | ||
|
|
7819c60ace | ||
|
|
1d73f6ed54 | ||
|
|
6b5967a4bb | ||
|
|
8926e047d4 | ||
| f1c1db580c | |||
| b10c357a56 | |||
|
|
cd8170ac02 | ||
| 01ed8fb25b | |||
|
|
253ed14c87 | ||
|
|
17d275e18c | ||
|
|
20d8f155c6 | ||
|
|
5093cf8146 | ||
|
|
e7e31213da | ||
|
|
4e2296300b | ||
| 9c219cda83 | |||
|
|
0b2824c3b0 | ||
|
|
81653a257d | ||
|
|
1c493965d0 | ||
|
|
7ded549269 | ||
|
|
f2068c7b16 | ||
|
|
d40e8c2e0d | ||
|
|
a33a80063d | ||
| 0021c94024 | |||
|
|
803106ecf2 | ||
|
|
9d08b3d2cb | ||
|
|
6f99611a0c | ||
| 7df2bde70b | |||
| 97b872aa9b | |||
|
|
df35a1a5bf | ||
|
|
cd4d00b013 | ||
|
|
633f733f1c | ||
|
|
64334ba4e3 | ||
| 99f411b31a | |||
|
|
1b64f7c1fd | ||
|
|
cf19b56b6d | ||
|
|
05f9c02517 | ||
| 5ca0152c55 | |||
| c7e191f096 | |||
|
|
cdc4253a10 | ||
|
|
dc218e3d0e | ||
| 4cac09cf57 | |||
|
|
fd07c81d4b | ||
|
|
7612792e08 | ||
|
|
823230cb2d | ||
| a187d0e8fa | |||
|
|
d98330d5ce | ||
| e99febec4e | |||
|
|
782b433821 | ||
| 3c03eaf353 | |||
| 831560592f | |||
| b0c80c500f | |||
| 3328439241 | |||
|
|
f6a84442d1 | ||
| c15f3e9925 | |||
| 27b6276cdc | |||
|
|
5059e57f19 | ||
|
|
f4714f6ca6 | ||
| d347f1b4c9 | |||
| df7ff7af23 | |||
| 9359666805 | |||
|
|
e990b0eb68 | ||
|
|
7c59d3385a | ||
|
|
5332324b10 | ||
|
|
4b945339d0 | ||
|
|
8972ead5b9 | ||
|
|
2b917878ee | ||
| b1f02e6b7a | |||
| d4b2c801f4 | |||
| 552c02516a | |||
|
|
0694ec28fb | ||
|
|
9657901339 | ||
| 01cdcec0b4 | |||
| de8c4e9cab | |||
|
|
f6243a66a0 | ||
|
|
10ed15d983 | ||
| 19ec7ca25a | |||
|
|
695769076a | ||
|
|
db31e25c61 | ||
| c19b0167e4 | |||
|
|
28a5607dad | ||
|
|
3db6b38e2e | ||
| 62d9fb8516 | |||
| efe2ec6416 | |||
|
|
6432325387 | ||
|
|
9e821e1bd2 | ||
| e803102263 | |||
| c690a95cab | |||
|
|
728ff095cb | ||
| 17a7da123d | |||
|
|
d9c6150ae1 | ||
|
|
def0d75840 | ||
|
|
948ac409df | ||
|
|
1d71f5b8bf | ||
|
|
1a4880ef4a |
@ -1 +1,3 @@
|
|||||||
web/node_modules
|
web/node_modules
|
||||||
|
web1/node_modules
|
||||||
|
migrations
|
||||||
@ -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 个文档并提交供评审。
|
|
||||||
225
DEPLOYMENT.md
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
# 非遗资产估值系统 - 部署文档
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
非遗资产估值系统是一个基于 Vue.js + FastAPI 的全栈应用,用于非物质文化遗产资产的价值评估。
|
||||||
|
|
||||||
|
- **前端**: Vue.js + Vite + pnpm
|
||||||
|
- **后端**: Python 3.11 + FastAPI + Tortoise ORM
|
||||||
|
- **数据库**: MySQL
|
||||||
|
- **容器化**: Docker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
youshu-guzhi/
|
||||||
|
├── app/ # 后端 FastAPI 应用
|
||||||
|
│ ├── api/ # API 路由
|
||||||
|
│ ├── controllers/ # 业务控制器
|
||||||
|
│ ├── models/ # 数据库模型
|
||||||
|
│ ├── schemas/ # Pydantic 数据模型
|
||||||
|
│ ├── settings/ # 配置文件
|
||||||
|
│ └── utils/ # 工具函数和计算引擎
|
||||||
|
├── web/ # 前端 Vue.js 应用
|
||||||
|
├── deploy/ # 部署相关文件
|
||||||
|
│ ├── entrypoint.sh # 容器启动脚本
|
||||||
|
│ └── web.conf # Nginx 配置
|
||||||
|
├── Dockerfile # Docker 构建文件
|
||||||
|
├── requirements.txt # Python 依赖
|
||||||
|
└── run.py # 应用启动入口
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
### 数据库配置
|
||||||
|
|
||||||
|
#### 使用 Docker 部署 MySQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建数据目录
|
||||||
|
mkdir -p ~/mysql-data
|
||||||
|
|
||||||
|
# 启动 MySQL 容器
|
||||||
|
docker run -d \
|
||||||
|
--name mysql-valuation \
|
||||||
|
-p 3306:3306 \
|
||||||
|
-e MYSQL_ROOT_PASSWORD=your_password \
|
||||||
|
-e MYSQL_DATABASE=valuation_service \
|
||||||
|
-v ~/mysql-data:/var/lib/mysql \
|
||||||
|
--restart=unless-stopped \
|
||||||
|
mysql:8.0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 应用配置
|
||||||
|
|
||||||
|
配置文件位置: `app/settings/config.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
TORTOISE_ORM = {
|
||||||
|
"connections": {
|
||||||
|
"mysql": {
|
||||||
|
"engine": "tortoise.backends.mysql",
|
||||||
|
"credentials": {
|
||||||
|
"host": "your_mysql_host", # 数据库主机地址
|
||||||
|
"port": 3306, # 数据库端口
|
||||||
|
"user": "root", # 数据库用户名
|
||||||
|
"password": "your_password", # 数据库密码
|
||||||
|
"database": "valuation_service", # 数据库名称
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第三方服务配置
|
||||||
|
|
||||||
|
| 服务 | 配置项 | 说明 |
|
||||||
|
|-----|-------|------|
|
||||||
|
| 阿里云短信 | `ALIBABA_CLOUD_ACCESS_KEY_ID/SECRET` | 短信验证码发送 |
|
||||||
|
| 阿里云邮件 | `SMTP_*` | 邮件发送 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Python 依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 安装前端依赖
|
||||||
|
cd web
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动后端 (端口 9999)
|
||||||
|
python run.py
|
||||||
|
|
||||||
|
# 启动前端开发服务器 (另一个终端)
|
||||||
|
cd web
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
### 1. 构建镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 设置平台 (M1/M2 Mac 需要)
|
||||||
|
export DOCKER_DEFAULT_PLATFORM=linux/amd64
|
||||||
|
|
||||||
|
# 构建镜像
|
||||||
|
docker build -t zfc931912343/guzhi-fastapi-admin:v3.9 .
|
||||||
|
|
||||||
|
# 推送到 Docker Hub
|
||||||
|
docker push zfc931912343/guzhi-fastapi-admin:v3.9
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 部署到服务器
|
||||||
|
|
||||||
|
#### 生产环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建数据目录
|
||||||
|
mkdir -p ~/guzhi-data/static/images
|
||||||
|
|
||||||
|
# 拉取并运行
|
||||||
|
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
|
||||||
|
&& docker rm -f guzhi_pro \
|
||||||
|
&& docker run -itd \
|
||||||
|
--name=guzhi_pro \
|
||||||
|
-p 8080:9999 \
|
||||||
|
-v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images \
|
||||||
|
--restart=unless-stopped \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 开发/测试环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
|
||||||
|
&& docker rm -f guzhi_dev \
|
||||||
|
&& docker run -itd \
|
||||||
|
--name=guzhi_dev \
|
||||||
|
-p 9990:9999 \
|
||||||
|
-v ~/guzhi-data/static:/opt/vue-fastapi-admin/app/static \
|
||||||
|
--restart=unless-stopped \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 端口说明
|
||||||
|
|
||||||
|
| 环境 | 容器名 | 主机端口 | 容器端口 |
|
||||||
|
|-----|-------|---------|---------|
|
||||||
|
| 生产 | guzhi_pro | 8080 | 9999 |
|
||||||
|
| 开发 | guzhi_dev | 9990 | 9999 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据持久化
|
||||||
|
|
||||||
|
容器挂载的数据目录:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/guzhi-data/static/images -> /opt/vue-fastapi-admin/app/static/images
|
||||||
|
```
|
||||||
|
|
||||||
|
用于存储用户上传的图片文件(如非遗纹样图片、证书图片等)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用运维命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看容器日志
|
||||||
|
docker logs -f guzhi_pro
|
||||||
|
|
||||||
|
# 进入容器
|
||||||
|
docker exec -it guzhi_pro bash
|
||||||
|
|
||||||
|
# 重启容器
|
||||||
|
docker restart guzhi_pro
|
||||||
|
|
||||||
|
# 查看容器状态
|
||||||
|
docker ps | grep guzhi
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 接口说明
|
||||||
|
|
||||||
|
| 模块 | 路径前缀 | 说明 |
|
||||||
|
|-----|---------|------|
|
||||||
|
| 用户端估值 | `/api/v1/app-valuations/` | 用户提交估值请求 |
|
||||||
|
| 管理端估值 | `/api/v1/valuations/` | 管理后台查看/审核 |
|
||||||
|
| 计算报告 | `/api/v1/valuations/{id}/report` | 获取计算过程报告 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|-----|------|------|
|
||||||
|
| v3.9 | 2025-12-18 | 修复风险调整系数B3显示问题,添加计算过程详情 |
|
||||||
|
| v3.8 | 2025-12-18 | 修复历史传承度HI权重计算 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 联系信息
|
||||||
|
|
||||||
|
如有问题,请联系项目负责人。
|
||||||
10
Dockerfile
@ -1,8 +1,14 @@
|
|||||||
FROM node:18.12.0-alpine3.16 AS web
|
FROM node:18-alpine AS web
|
||||||
|
|
||||||
WORKDIR /opt/vue-fastapi-admin
|
WORKDIR /opt/vue-fastapi-admin
|
||||||
COPY /web ./web
|
COPY /web ./web
|
||||||
RUN npm install -g pnpm && cd /opt/vue-fastapi-admin/web && pnpm install --registry=https://registry.npmmirror.com && pnpm run build
|
|
||||||
|
# 安装pnpm并设置配置
|
||||||
|
RUN npm install -g pnpm && \
|
||||||
|
cd /opt/vue-fastapi-admin/web && \
|
||||||
|
pnpm config set registry https://registry.npmmirror.com && \
|
||||||
|
pnpm install && \
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.11-slim-bullseye
|
FROM python:3.11-slim-bullseye
|
||||||
|
|||||||
29
aaa.json
@ -1,29 +0,0 @@
|
|||||||
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):
|
|
||||||
@ -22,6 +22,7 @@ from .users import users_router
|
|||||||
from .valuations import router as valuations_router
|
from .valuations import router as valuations_router
|
||||||
from .invoice.invoice import invoice_router
|
from .invoice.invoice import invoice_router
|
||||||
from .transactions.transactions import transactions_router
|
from .transactions.transactions import transactions_router
|
||||||
|
from .app_invoices.app_invoices import app_invoices_router
|
||||||
from .sms.sms import router as sms_router
|
from .sms.sms import router as sms_router
|
||||||
|
|
||||||
v1_router = APIRouter()
|
v1_router = APIRouter()
|
||||||
@ -50,6 +51,7 @@ v1_router.include_router(
|
|||||||
tags=["admin-内置接口"],
|
tags=["admin-内置接口"],
|
||||||
)
|
)
|
||||||
v1_router.include_router(valuations_router, prefix="/valuations", dependencies=[DependAuth, DependPermission], tags=["admin-估值评估"])
|
v1_router.include_router(valuations_router, prefix="/valuations", dependencies=[DependAuth, DependPermission], tags=["admin-估值评估"])
|
||||||
v1_router.include_router(invoice_router, prefix="/invoice", dependencies=[DependAuth, DependPermission], tags=["admin-发票管理"])
|
v1_router.include_router(invoice_router, prefix="/invoice", tags=["admin-发票管理"])
|
||||||
v1_router.include_router(transactions_router, prefix="/transactions", dependencies=[DependAuth, DependPermission], tags=["admin-交易管理"])
|
v1_router.include_router(transactions_router, prefix="/transactions", dependencies=[DependAuth, DependPermission], tags=["admin-交易管理"])
|
||||||
v1_router.include_router(sms_router, prefix="/sms", tags=["app-短信服务"])
|
v1_router.include_router(sms_router, prefix="/sms", tags=["app-短信服务"])
|
||||||
|
v1_router.include_router(app_invoices_router, prefix="/app-invoices", tags=["app-发票管理"])
|
||||||
|
|||||||
153
app/api/v1/app_invoices/app_invoices.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
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, 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-发票管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@app_invoices_router.get("/list", summary="我的发票列表", response_model=PageResponse[InvoiceOut])
|
||||||
|
async def get_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_invoices_router.get("/headers", summary="我的发票抬头", response_model=BasicResponse[list[InvoiceHeaderOut]])
|
||||||
|
async def get_my_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="获取成功")
|
||||||
|
|
||||||
|
@app_invoices_router.get("/headers/{id}", summary="我的发票抬头详情", response_model=BasicResponse[InvoiceHeaderOut])
|
||||||
|
async def get_my_header_by_id(id: int, current_user: AppUser = Depends(get_current_app_user)):
|
||||||
|
header = await invoice_controller.get_header_by_id(id)
|
||||||
|
if not header or getattr(header, "id", None) is None:
|
||||||
|
return Success(data={}, msg="未找到")
|
||||||
|
# 仅允许访问属于自己的抬头
|
||||||
|
if getattr(header, "app_user_id", None) not in (current_user.id, None):
|
||||||
|
return Success(data={}, msg="未找到")
|
||||||
|
return Success(data=header, msg="获取成功")
|
||||||
|
|
||||||
|
@app_invoices_router.post("/headers", summary="新增我的发票抬头", response_model=BasicResponse[InvoiceHeaderOut])
|
||||||
|
async def create_my_header(data: InvoiceHeaderCreate, current_user: AppUser = Depends(get_current_app_user)):
|
||||||
|
header = await invoice_controller.create_header(user_id=current_user.id, data=data)
|
||||||
|
return Success(data=header, msg="创建成功")
|
||||||
|
|
||||||
|
@app_invoices_router.put("/headers/{id}", summary="更新我的发票抬头", response_model=BasicResponse[InvoiceHeaderOut])
|
||||||
|
async def update_my_header(id: int, data: InvoiceHeaderUpdate, current_user: AppUser = Depends(get_current_app_user)):
|
||||||
|
existing = await invoice_controller.get_header_by_id(id)
|
||||||
|
if not existing or getattr(existing, "id", None) is None:
|
||||||
|
return Success(data={}, msg="未找到")
|
||||||
|
if getattr(existing, "app_user_id", None) != current_user.id:
|
||||||
|
return Success(data={}, msg="未找到")
|
||||||
|
header = await invoice_controller.update_header(id, data)
|
||||||
|
return Success(data=header or {}, msg="更新成功" if header else "未找到")
|
||||||
|
|
||||||
|
@app_invoices_router.delete("/headers/{id}", summary="删除我的发票抬头", response_model=BasicResponse[dict])
|
||||||
|
async def delete_my_header(id: int, current_user: AppUser = Depends(get_current_app_user)):
|
||||||
|
existing = await invoice_controller.get_header_by_id(id)
|
||||||
|
if not existing or getattr(existing, "id", None) is None:
|
||||||
|
return Success(data={"deleted": False}, msg="未找到")
|
||||||
|
if getattr(existing, "app_user_id", None) != current_user.id:
|
||||||
|
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)
|
||||||
|
if payload.receipt_urls:
|
||||||
|
urls = payload.receipt_urls
|
||||||
|
main_url = urls[0] if isinstance(urls, list) and urls else None
|
||||||
|
receipt = await invoice_controller.create_receipt(
|
||||||
|
inv.id,
|
||||||
|
PaymentReceiptCreate(url=main_url, note=payload.note, extra=urls)
|
||||||
|
)
|
||||||
|
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||||
|
return Success(data={"invoice_id": inv.id, "receipts": [detail] if detail else []}, msg="创建并上传成功")
|
||||||
|
if isinstance(payload.receipt_url, list) and payload.receipt_url:
|
||||||
|
urls = payload.receipt_url
|
||||||
|
main_url = urls[0]
|
||||||
|
receipt = await invoice_controller.create_receipt(
|
||||||
|
inv.id,
|
||||||
|
PaymentReceiptCreate(url=main_url, note=payload.note, extra=urls)
|
||||||
|
)
|
||||||
|
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||||
|
return Success(data={"invoice_id": inv.id, "receipts": [detail] if detail else []}, msg="创建并上传成功")
|
||||||
|
if payload.receipt_url:
|
||||||
|
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="创建并上传成功")
|
||||||
|
else:
|
||||||
|
out = await invoice_controller.get_out(inv.id)
|
||||||
|
return Success(data=out.model_dump() if out else {}, 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,8 +1,9 @@
|
|||||||
from fastapi import APIRouter, Query, Depends, HTTPException
|
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.base import Success, SuccessExtra, BasicResponse, PageResponse
|
||||||
from app.schemas.app_user import AppUserQuotaUpdateSchema, AppUserQuotaLogOut
|
from app.schemas.app_user import AppUserQuotaUpdateSchema, AppUserQuotaLogOut, AppUserUpdateSchema
|
||||||
from app.controllers.app_user import app_user_controller
|
from app.controllers.app_user import app_user_controller
|
||||||
from app.models.user import AppUser, AppUserQuotaLog
|
from app.models.user import AppUser, AppUserQuotaLog
|
||||||
from app.core.dependency import DependAuth, DependPermission, AuthControl
|
from app.core.dependency import DependAuth, DependPermission, AuthControl
|
||||||
@ -15,18 +16,53 @@ admin_app_users_router = APIRouter(dependencies=[DependAuth, DependPermission],
|
|||||||
async def list_app_users(
|
async def list_app_users(
|
||||||
phone: Optional[str] = Query(None),
|
phone: Optional[str] = Query(None),
|
||||||
wechat: Optional[str] = Query(None),
|
wechat: Optional[str] = Query(None),
|
||||||
|
include_deleted: Optional[bool] = Query(False),
|
||||||
|
id: Optional[str] = Query(None),
|
||||||
|
created_start: Optional[str] = Query(None),
|
||||||
|
created_end: Optional[str] = Query(None),
|
||||||
|
created_at: Optional[List[int]] = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(10, ge=1, le=100),
|
page_size: int = Query(10, ge=1, le=100),
|
||||||
):
|
):
|
||||||
qs = AppUser.filter()
|
qs = AppUser.filter()
|
||||||
|
if not include_deleted:
|
||||||
|
qs = qs.filter(is_deleted=False)
|
||||||
|
if id is not None and id.strip().isdigit():
|
||||||
|
qs = qs.filter(id=int(id.strip()))
|
||||||
if phone:
|
if phone:
|
||||||
qs = qs.filter(phone__icontains=phone)
|
qs = qs.filter(phone__icontains=phone)
|
||||||
if wechat:
|
if wechat:
|
||||||
qs = qs.filter(alias__icontains=wechat)
|
qs = qs.filter(alias__icontains=wechat)
|
||||||
|
if created_start or created_end:
|
||||||
|
def _parse_dt(s: Optional[str]):
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
s = s.replace('+', ' ').strip()
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
ms = float(s)
|
||||||
|
return datetime.fromtimestamp(ms / 1000)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
start_dt = _parse_dt(created_start)
|
||||||
|
end_dt = _parse_dt(created_end)
|
||||||
|
if start_dt and end_dt:
|
||||||
|
qs = qs.filter(created_at__gte=start_dt, created_at__lte=end_dt)
|
||||||
|
elif start_dt:
|
||||||
|
qs = qs.filter(created_at__gte=start_dt)
|
||||||
|
elif end_dt:
|
||||||
|
qs = qs.filter(created_at__lte=end_dt)
|
||||||
|
elif 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()
|
total = await qs.count()
|
||||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||||
items = []
|
items = []
|
||||||
for u in rows:
|
for u in rows:
|
||||||
|
last_log = await AppUserQuotaLog.filter(app_user_id=u.id).order_by("-created_at").first()
|
||||||
items.append({
|
items.append({
|
||||||
"id": u.id,
|
"id": u.id,
|
||||||
"phone": u.phone,
|
"phone": u.phone,
|
||||||
@ -34,7 +70,7 @@ async def list_app_users(
|
|||||||
"created_at": u.created_at.isoformat() if u.created_at else "",
|
"created_at": u.created_at.isoformat() if u.created_at else "",
|
||||||
"notes": getattr(u, "notes", "") or "",
|
"notes": getattr(u, "notes", "") or "",
|
||||||
"remaining_count": int(getattr(u, "remaining_quota", 0) or 0),
|
"remaining_count": int(getattr(u, "remaining_quota", 0) or 0),
|
||||||
"user_type": None,
|
"user_type": getattr(last_log, "op_type", None),
|
||||||
})
|
})
|
||||||
return SuccessExtra(data=items, total=total, page=page, page_size=page_size, msg="获取成功")
|
return SuccessExtra(data=items, total=total, page=page, page_size=page_size, msg="获取成功")
|
||||||
|
|
||||||
@ -52,9 +88,9 @@ async def update_quota(payload: AppUserQuotaUpdateSchema, operator=Depends(AuthC
|
|||||||
)
|
)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
if payload.remark is not None:
|
# if payload.remark is not None:
|
||||||
user.notes = payload.remark
|
# user.notes = payload.remark
|
||||||
await user.save()
|
# await user.save()
|
||||||
return Success(data={"user_id": user.id, "remaining_quota": user.remaining_quota}, msg="调整成功")
|
return Success(data={"user_id": user.id, "remaining_quota": user.remaining_quota}, msg="调整成功")
|
||||||
|
|
||||||
|
|
||||||
@ -78,3 +114,33 @@ async def quota_logs(user_id: int, page: int = Query(1, ge=1), page_size: int =
|
|||||||
]
|
]
|
||||||
data_items = [m.model_dump() for m in models]
|
data_items = [m.model_dump() for m in models]
|
||||||
return SuccessExtra(data=data_items, total=total, page=page, page_size=page_size, msg="获取成功")
|
return SuccessExtra(data=data_items, total=total, page=page, page_size=page_size, msg="获取成功")
|
||||||
|
|
||||||
|
|
||||||
|
@admin_app_users_router.put("/{user_id}", summary="更新App用户信息", response_model=BasicResponse[dict])
|
||||||
|
async def update_app_user(user_id: int, data: AppUserUpdateSchema):
|
||||||
|
user = await app_user_controller.update_user_info(user_id, data)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
return Success(data={
|
||||||
|
"id": user.id,
|
||||||
|
"phone": user.phone,
|
||||||
|
"wechat": getattr(user, "alias", None),
|
||||||
|
"company_name": getattr(user, "company_name", None),
|
||||||
|
"company_address": getattr(user, "company_address", None),
|
||||||
|
"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 "",
|
||||||
|
"remaining_quota": int(getattr(user, "remaining_quota", 0) or 0),
|
||||||
|
}, msg="更新成功")
|
||||||
|
|
||||||
|
|
||||||
|
@admin_app_users_router.delete("/{user_id}", summary="注销App用户", response_model=BasicResponse[dict])
|
||||||
|
async def admin_delete_app_user(user_id: int):
|
||||||
|
ok = await app_user_controller.delete_user_account(user_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
return Success(data={"user_id": user_id}, msg="账号已注销")
|
||||||
|
|||||||
@ -11,20 +11,29 @@ from app.schemas.app_user import (
|
|||||||
AppUserQuotaOut,
|
AppUserQuotaOut,
|
||||||
)
|
)
|
||||||
from app.schemas.app_user import AppUserRegisterOut, TokenValidateOut
|
from app.schemas.app_user import AppUserRegisterOut, TokenValidateOut
|
||||||
from app.schemas.base import BasicResponse, MessageOut
|
from app.schemas.base import BasicResponse, MessageOut, Success
|
||||||
from app.utils.app_user_jwt import (
|
from app.utils.app_user_jwt import (
|
||||||
create_app_user_access_token,
|
create_app_user_access_token,
|
||||||
get_current_app_user,
|
get_current_app_user,
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES
|
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
|
verify_app_user_token
|
||||||
)
|
)
|
||||||
from app.models.user import AppUser
|
from app.models.user import AppUser
|
||||||
from app.controllers.user_valuation import user_valuation_controller
|
from app.controllers.user_valuation import user_valuation_controller
|
||||||
from app.controllers.invoice import invoice_controller
|
from app.controllers.invoice import invoice_controller
|
||||||
|
from app.core.token_blacklist import add_to_blacklist
|
||||||
|
from fastapi import Header
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
import time
|
||||||
|
from app.models.valuation import ValuationAssessment
|
||||||
|
from app.services.sms_store import store
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=BasicResponse[AppUserRegisterOut], summary="用户注册")
|
@router.post("/register", response_model=BasicResponse[dict], summary="用户注册")
|
||||||
async def register(
|
async def register(
|
||||||
register_data: AppUserRegisterSchema
|
register_data: AppUserRegisterSchema
|
||||||
):
|
):
|
||||||
@ -34,20 +43,16 @@ async def register(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
user = await app_user_controller.register(register_data)
|
user = await app_user_controller.register(register_data)
|
||||||
return {
|
return Success(data={
|
||||||
"code": 200,
|
|
||||||
"msg": "注册成功",
|
|
||||||
"data": {
|
|
||||||
"user_id": user.id,
|
"user_id": user.id,
|
||||||
"phone": user.phone,
|
"phone": user.phone,
|
||||||
"default_password": register_data.phone[-6:]
|
"default_password": register_data.phone[-6:]
|
||||||
}
|
})
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=200, detail=str(e))
|
raise HTTPException(status_code=200, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=AppUserJWTOut, summary="用户登录")
|
@router.post("/login", response_model=BasicResponse[dict], summary="用户登录")
|
||||||
async def login(
|
async def login(
|
||||||
login_data: AppUserLoginSchema
|
login_data: AppUserLoginSchema
|
||||||
):
|
):
|
||||||
@ -67,30 +72,90 @@ async def login(
|
|||||||
# 生成访问令牌
|
# 生成访问令牌
|
||||||
access_token = create_app_user_access_token(user.id, user.phone)
|
access_token = create_app_user_access_token(user.id, user.phone)
|
||||||
|
|
||||||
return AppUserJWTOut(
|
return Success(data={
|
||||||
access_token=access_token,
|
"access_token": access_token,
|
||||||
token_type="bearer",
|
"token_type": "bearer",
|
||||||
expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||||
)
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout", summary="用户登出", response_model=BasicResponse[MessageOut])
|
@router.post("/logout", summary="用户登出", response_model=BasicResponse[dict])
|
||||||
async def logout(current_user: AppUser = Depends(get_current_app_user)):
|
async def logout(current_user: AppUser = Depends(get_current_app_user)):
|
||||||
"""
|
"""
|
||||||
用户登出(客户端需要删除本地token)
|
用户登出(客户端需要删除本地token)
|
||||||
"""
|
"""
|
||||||
return {"code": 200, "msg": "OK", "data": {"message": "登出成功"}}
|
return Success(data={"message": "登出成功"})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile", response_model=AppUserInfoOut, summary="获取用户信息")
|
class DeleteAccountRequest(BaseModel):
|
||||||
|
code: Optional[str] = Field(None, description="短信验证码或绕过码")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/account", summary="注销用户信息", response_model=BasicResponse[dict])
|
||||||
|
async def delete_account(current_user: AppUser = Depends(get_current_app_user), token: str = Header(None), payload: Optional[DeleteAccountRequest] = None):
|
||||||
|
if payload and payload.code:
|
||||||
|
if settings.SMS_BYPASS_CODE and payload.code == settings.SMS_BYPASS_CODE:
|
||||||
|
store.mark_verified(current_user.phone)
|
||||||
|
else:
|
||||||
|
ok, reason = store.can_verify(current_user.phone)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=423, detail=str(reason))
|
||||||
|
record = store.get_code(current_user.phone)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=400, detail="验证码已过期")
|
||||||
|
code_stored, expires_at = record
|
||||||
|
if time.time() > expires_at:
|
||||||
|
store.clear_code(current_user.phone)
|
||||||
|
raise HTTPException(status_code=400, detail="验证码已过期")
|
||||||
|
if payload.code != code_stored:
|
||||||
|
count, locked = store.record_verify_failure(current_user.phone)
|
||||||
|
if locked:
|
||||||
|
raise HTTPException(status_code=423, detail="尝试次数过多,已锁定")
|
||||||
|
raise HTTPException(status_code=401, detail="验证码错误")
|
||||||
|
store.clear_code(current_user.phone)
|
||||||
|
store.reset_failures(current_user.phone)
|
||||||
|
store.mark_verified(current_user.phone)
|
||||||
|
else:
|
||||||
|
if not store.is_recently_verified(current_user.phone):
|
||||||
|
raise HTTPException(status_code=403, detail="请先完成手机号验证码验证")
|
||||||
|
remaining_quota = int(getattr(current_user, "remaining_quota", 0) or 0)
|
||||||
|
if remaining_quota > 0:
|
||||||
|
raise HTTPException(status_code=400, detail="当前剩余估值次数大于0,无法注销账号")
|
||||||
|
ok = await app_user_controller.delete_user_account(current_user.id)
|
||||||
|
if token:
|
||||||
|
payload = verify_app_user_token(token)
|
||||||
|
exp = getattr(payload, "exp", None) if payload else None
|
||||||
|
await add_to_blacklist(token, current_user.id, exp)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
return Success(data={"message": "账号已注销"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile", response_model=BasicResponse[dict], summary="获取用户信息")
|
||||||
async def get_profile(current_user: AppUser = Depends(get_current_app_user)):
|
async def get_profile(current_user: AppUser = Depends(get_current_app_user)):
|
||||||
"""
|
"""
|
||||||
获取当前用户信息
|
获取当前用户信息
|
||||||
"""
|
"""
|
||||||
return current_user
|
user_info = AppUserInfoOut(
|
||||||
|
id=current_user.id,
|
||||||
|
phone=current_user.phone,
|
||||||
|
nickname=getattr(current_user, "alias", None),
|
||||||
|
avatar=None,
|
||||||
|
company_name=current_user.company_name,
|
||||||
|
company_address=current_user.company_address,
|
||||||
|
company_contact=current_user.company_contact,
|
||||||
|
company_phone=current_user.company_phone,
|
||||||
|
company_email=current_user.company_email,
|
||||||
|
is_active=current_user.is_active,
|
||||||
|
last_login=current_user.last_login,
|
||||||
|
created_at=current_user.created_at,
|
||||||
|
updated_at=current_user.updated_at,
|
||||||
|
remaining_quota=current_user.remaining_quota,
|
||||||
|
)
|
||||||
|
return Success(data=user_info.model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard", response_model=AppUserDashboardOut, summary="用户首页摘要")
|
@router.get("/dashboard", response_model=BasicResponse[dict], summary="用户首页摘要")
|
||||||
async def get_dashboard(current_user: AppUser = Depends(get_current_app_user)):
|
async def get_dashboard(current_user: AppUser = Depends(get_current_app_user)):
|
||||||
"""
|
"""
|
||||||
用户首页摘要
|
用户首页摘要
|
||||||
@ -115,12 +180,12 @@ async def get_dashboard(current_user: AppUser = Depends(get_current_app_user)):
|
|||||||
pending_invoices = await invoice_controller.count_pending_for_user(current_user.id)
|
pending_invoices = await invoice_controller.count_pending_for_user(current_user.id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pending_invoices = 0
|
pending_invoices = 0
|
||||||
# 剩余估值次数(占位,可从用户扩展字段或配额表获取)
|
# 剩余估值次数
|
||||||
remaining_quota = 0
|
remaining_quota = current_user.remaining_quota
|
||||||
return AppUserDashboardOut(remaining_quota=remaining_quota, latest_valuation=latest_out, pending_invoices=pending_invoices)
|
return Success(data={"remaining_quota": remaining_quota, "latest_valuation": latest_out, "pending_invoices": pending_invoices})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/quota", response_model=AppUserQuotaOut, summary="剩余估值次数")
|
@router.get("/quota", response_model=BasicResponse[dict], summary="剩余估值次数")
|
||||||
async def get_quota(current_user: AppUser = Depends(get_current_app_user)):
|
async def get_quota(current_user: AppUser = Depends(get_current_app_user)):
|
||||||
"""
|
"""
|
||||||
剩余估值次数查询
|
剩余估值次数查询
|
||||||
@ -128,12 +193,11 @@ async def get_quota(current_user: AppUser = Depends(get_current_app_user)):
|
|||||||
- 当前实现返回默认 0 次与用户类型占位
|
- 当前实现返回默认 0 次与用户类型占位
|
||||||
- 若后续接入配额系统,可从数据库中读取真实值
|
- 若后续接入配额系统,可从数据库中读取真实值
|
||||||
"""
|
"""
|
||||||
remaining_count = 0
|
remaining_count = current_user.remaining_quota
|
||||||
user_type = "体验用户"
|
return Success(data={"remaining_count": remaining_count})
|
||||||
return AppUserQuotaOut(remaining_count=remaining_count, user_type=user_type)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/profile", response_model=AppUserInfoOut, summary="更新用户信息")
|
@router.put("/profile", response_model=BasicResponse[dict], summary="更新用户信息")
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
update_data: AppUserUpdateSchema,
|
update_data: AppUserUpdateSchema,
|
||||||
current_user: AppUser = Depends(get_current_app_user)
|
current_user: AppUser = Depends(get_current_app_user)
|
||||||
@ -145,10 +209,26 @@ async def update_profile(
|
|||||||
if not updated_user:
|
if not updated_user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
return updated_user
|
user_info = AppUserInfoOut(
|
||||||
|
id=updated_user.id,
|
||||||
|
phone=updated_user.phone,
|
||||||
|
nickname=getattr(updated_user, "alias", None),
|
||||||
|
avatar=None,
|
||||||
|
company_name=updated_user.company_name,
|
||||||
|
company_address=updated_user.company_address,
|
||||||
|
company_contact=updated_user.company_contact,
|
||||||
|
company_phone=updated_user.company_phone,
|
||||||
|
company_email=updated_user.company_email,
|
||||||
|
is_active=updated_user.is_active,
|
||||||
|
last_login=updated_user.last_login,
|
||||||
|
created_at=updated_user.created_at,
|
||||||
|
updated_at=updated_user.updated_at,
|
||||||
|
remaining_quota=updated_user.remaining_quota,
|
||||||
|
)
|
||||||
|
return Success(data=user_info.model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/change-password", summary="修改密码", response_model=BasicResponse[MessageOut])
|
@router.post("/change-password", summary="修改密码", response_model=BasicResponse[dict])
|
||||||
async def change_password(
|
async def change_password(
|
||||||
password_data: AppUserChangePasswordSchema,
|
password_data: AppUserChangePasswordSchema,
|
||||||
current_user: AppUser = Depends(get_current_app_user)
|
current_user: AppUser = Depends(get_current_app_user)
|
||||||
@ -165,19 +245,12 @@ async def change_password(
|
|||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=400, detail="原密码错误")
|
raise HTTPException(status_code=400, detail="原密码错误")
|
||||||
|
|
||||||
return {"code": 200, "msg": "OK", "data": {"message": "密码修改成功"}}
|
return Success(data={"message": "密码修改成功"})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/validate-token", summary="验证token", response_model=BasicResponse[TokenValidateOut])
|
@router.get("/validate-token", summary="验证token", response_model=BasicResponse[dict])
|
||||||
async def validate_token(current_user: AppUser = Depends(get_current_app_user)):
|
async def validate_token(current_user: AppUser = Depends(get_current_app_user)):
|
||||||
"""
|
"""
|
||||||
验证token是否有效
|
验证token是否有效
|
||||||
"""
|
"""
|
||||||
return {
|
return Success(data={"user_id": current_user.id, "phone": current_user.phone})
|
||||||
"code": 200,
|
|
||||||
"msg": "token有效",
|
|
||||||
"data": {
|
|
||||||
"user_id": current_user.id,
|
|
||||||
"phone": current_user.phone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ from app.schemas.valuation import (
|
|||||||
UserValuationOut,
|
UserValuationOut,
|
||||||
UserValuationDetail
|
UserValuationDetail
|
||||||
)
|
)
|
||||||
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse
|
from app.schemas.base import Success, BasicResponse
|
||||||
from app.utils.app_user_jwt import get_current_app_user_id, get_current_app_user
|
from app.utils.app_user_jwt import get_current_app_user_id, get_current_app_user
|
||||||
from app.utils.calculation_engine import FinalValueACalculator
|
from app.utils.calculation_engine import FinalValueACalculator
|
||||||
# from app.utils.calculation_engine.cultural_value_b2.sub_formulas.living_heritage_b21 import cross_border_depth_dict
|
# from app.utils.calculation_engine.cultural_value_b2.sub_formulas.living_heritage_b21 import cross_border_depth_dict
|
||||||
@ -37,13 +37,13 @@ from app.utils.wechat_index_calculator import wechat_index_calculator
|
|||||||
app_valuations_router = APIRouter(tags=["用户端估值评估"])
|
app_valuations_router = APIRouter(tags=["用户端估值评估"])
|
||||||
|
|
||||||
|
|
||||||
async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate):
|
async def _perform_valuation_calculation(user_id: int, valuation_id: int, data: UserValuationCreate):
|
||||||
"""
|
"""
|
||||||
后台任务:执行估值计算
|
后台任务:执行估值计算
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
start_ts = time.monotonic()
|
start_ts = time.monotonic()
|
||||||
logger.info("valuation.calc_start user_id={} asset_name={} industry={}", user_id,
|
logger.info("valuation.calc_start user_id={} valuation_id={} asset_name={} industry={}", user_id, valuation_id,
|
||||||
getattr(data, 'asset_name', None), getattr(data, 'industry', None))
|
getattr(data, 'asset_name', None), getattr(data, 'industry', None))
|
||||||
|
|
||||||
# 根据行业查询 ESG 基准分(优先用行业名称匹配,如用的是行业代码就把 name 改成 code)
|
# 根据行业查询 ESG 基准分(优先用行业名称匹配,如用的是行业代码就把 name 改成 code)
|
||||||
@ -71,8 +71,12 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
|||||||
logger.warning("valuation.policy_fetch_timeout industry={} err={}", data.industry, repr(e))
|
logger.warning("valuation.policy_fetch_timeout industry={} err={}", data.industry, repr(e))
|
||||||
policy_match_score = getattr(policy_obj, 'score', 0.0) or 0.0
|
policy_match_score = getattr(policy_obj, 'score', 0.0) or 0.0
|
||||||
|
|
||||||
|
|
||||||
# 提取 经济价值B1 计算参数
|
# 提取 经济价值B1 计算参数
|
||||||
input_data_by_b1 = await _extract_calculation_params_b1(data)
|
input_data_by_b1 = await _extract_calculation_params_b1(
|
||||||
|
data, esg_score=esg_score, industry_coefficient=fix_num_score, policy_match_score=policy_match_score
|
||||||
|
)
|
||||||
|
|
||||||
# ESG关联价值 ESG分 (0-10分)
|
# ESG关联价值 ESG分 (0-10分)
|
||||||
input_data_by_b1["esg_score"] = esg_score
|
input_data_by_b1["esg_score"] = esg_score
|
||||||
# 行业修正系数I
|
# 行业修正系数I
|
||||||
@ -80,25 +84,53 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
|||||||
# 政策匹配度
|
# 政策匹配度
|
||||||
input_data_by_b1["policy_match_score"] = policy_match_score
|
input_data_by_b1["policy_match_score"] = policy_match_score
|
||||||
|
|
||||||
# 侵权分 默认 6
|
# 法律风险/侵权记录:通过司法API查询诉讼状态
|
||||||
|
# 评分规则:无诉讼(10分), 已解决诉讼(7分), 未解决诉讼(0分)
|
||||||
|
lawsuit_status_text = "无诉讼" # 默认无诉讼
|
||||||
|
judicial_api_response = {} # 保存API原始返回用于日志
|
||||||
try:
|
try:
|
||||||
judicial_data = universal_api.query_judicial_data(data.institution)
|
judicial_data = universal_api.query_judicial_data(data.institution)
|
||||||
_data = judicial_data["data"].get("target", None) # 诉讼标的
|
_data = judicial_data.get("data", {})
|
||||||
if _data:
|
judicial_api_response = _data # 保存原始返回
|
||||||
|
target = _data.get("target", None) # 诉讼标的
|
||||||
|
total = _data.get("total", 0) # 诉讼总数
|
||||||
|
|
||||||
|
if target or total > 0:
|
||||||
|
# 有诉讼记录,检查是否已解决
|
||||||
|
settled = _data.get("settled", False)
|
||||||
|
if settled:
|
||||||
|
lawsuit_status_text = "已解决诉讼"
|
||||||
|
infringement_score = 7.0
|
||||||
|
else:
|
||||||
|
lawsuit_status_text = "未解决诉讼"
|
||||||
infringement_score = 0.0
|
infringement_score = 0.0
|
||||||
else:
|
else:
|
||||||
|
lawsuit_status_text = "无诉讼"
|
||||||
infringement_score = 10.0
|
infringement_score = 10.0
|
||||||
except:
|
|
||||||
infringement_score = 0.0
|
|
||||||
input_data_by_b1["infringement_score"] = infringement_score
|
|
||||||
|
|
||||||
# 获取专利信息 TODO 参数
|
logger.info(f"法律风险查询结果: 机构={data.institution} 诉讼状态={lawsuit_status_text} 评分={infringement_score}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"法律风险查询失败: {e}")
|
||||||
|
lawsuit_status_text = "查询失败"
|
||||||
|
infringement_score = 0.0
|
||||||
|
judicial_api_response = {"error": str(e)}
|
||||||
|
|
||||||
|
input_data_by_b1["infringement_score"] = infringement_score
|
||||||
|
# 保存诉讼状态文本,用于前端展示
|
||||||
|
lawsuit_status_for_display = lawsuit_status_text
|
||||||
|
|
||||||
|
# 获取专利信息
|
||||||
|
patent_api_response = {} # 保存API原始返回用于日志
|
||||||
|
patent_matched_count = 0
|
||||||
|
patent_years_total = 0
|
||||||
try:
|
try:
|
||||||
patent_data = universal_api.query_patent_info(data.industry)
|
patent_data = universal_api.query_patent_info(data.industry)
|
||||||
|
patent_api_response = patent_data # 保存原始返回
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("valuation.patent_api_error err={}", repr(e))
|
logger.warning("valuation.patent_api_error err={}", repr(e))
|
||||||
input_data_by_b1["patent_count"] = 0.0
|
input_data_by_b1["patent_count"] = 0.0
|
||||||
input_data_by_b1["patent_score"] = 0.0
|
input_data_by_b1["patent_score"] = 0.0
|
||||||
|
patent_api_response = {"error": str(e)}
|
||||||
|
|
||||||
patent_dict = patent_data if isinstance(patent_data, dict) else {}
|
patent_dict = patent_data if isinstance(patent_data, dict) else {}
|
||||||
inner_data = patent_dict.get("data", {}) if isinstance(patent_dict.get("data", {}), dict) else {}
|
inner_data = patent_dict.get("data", {}) if isinstance(patent_dict.get("data", {}), dict) else {}
|
||||||
@ -109,16 +141,17 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
|||||||
# 查询匹配申请号的记录集合
|
# 查询匹配申请号的记录集合
|
||||||
matched = [item for item in data_list if
|
matched = [item for item in data_list if
|
||||||
isinstance(item, dict) and item.get("SQH") == getattr(data, 'patent_application_no', None)]
|
isinstance(item, dict) and item.get("SQH") == getattr(data, 'patent_application_no', None)]
|
||||||
|
patent_matched_count = len(matched)
|
||||||
if matched:
|
if matched:
|
||||||
patent_count_score = min(len(matched) * 2.5, 10.0)
|
patent_count_score = min(len(matched) * 2.5, 10.0)
|
||||||
input_data_by_b1["patent_count"] = float(patent_count_score)
|
input_data_by_b1["patent_count"] = float(patent_count_score)
|
||||||
else:
|
else:
|
||||||
input_data_by_b1["patent_count"] = 0.0
|
input_data_by_b1["patent_count"] = 0.0
|
||||||
|
|
||||||
years_total = calculate_total_years(data_list)
|
patent_years_total = calculate_total_years(data_list)
|
||||||
if years_total > 10:
|
if patent_years_total > 10:
|
||||||
patent_score = 10.0
|
patent_score = 10.0
|
||||||
elif years_total >= 5:
|
elif patent_years_total >= 5:
|
||||||
patent_score = 7.0
|
patent_score = 7.0
|
||||||
else:
|
else:
|
||||||
patent_score = 3.0
|
patent_score = 3.0
|
||||||
@ -147,23 +180,212 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
|||||||
"market_data": input_data_by_c,
|
"market_data": input_data_by_c,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
calculator = FinalValueACalculator()
|
calculator = FinalValueACalculator()
|
||||||
# 先创建估值记录以获取ID,方便步骤落库关联
|
|
||||||
initial_detail = await user_valuation_controller.create_valuation(
|
# 步骤1:立即更新计算输入参数(不管后续是否成功)
|
||||||
user_id=user_id,
|
try:
|
||||||
data=data,
|
await valuation_controller.update_calc(
|
||||||
calculation_result=None,
|
valuation_id,
|
||||||
calculation_input=None,
|
ValuationAssessmentUpdate(
|
||||||
drp_result=None,
|
calculation_input=input_data,
|
||||||
status='pending'
|
|
||||||
)
|
)
|
||||||
valuation_id = initial_detail.id
|
)
|
||||||
logger.info("valuation.init_created user_id={} valuation_id={}", user_id, valuation_id)
|
logger.info("valuation.input_updated valuation_id={}", valuation_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("valuation.failed_to_update_input valuation_id={} err={}", valuation_id, repr(e))
|
||||||
|
|
||||||
|
# 步骤1.5:更新内置API计算字段
|
||||||
|
try:
|
||||||
|
# 准备内置API计算字段的值
|
||||||
|
api_calc_fields = {}
|
||||||
|
|
||||||
|
# ESG关联价值
|
||||||
|
api_calc_fields["esg_value"] = str(esg_score) if esg_score is not None else None
|
||||||
|
|
||||||
|
# 政策匹配度
|
||||||
|
api_calc_fields["policy_matching"] = str(policy_match_score) if policy_match_score is not None else None
|
||||||
|
|
||||||
|
# 侵权记录/法律风险 - 使用实际查询到的诉讼状态
|
||||||
|
api_calc_fields["infringement_record"] = lawsuit_status_for_display
|
||||||
|
api_calc_fields["legal_risk"] = lawsuit_status_for_display
|
||||||
|
|
||||||
|
# 专利使用量
|
||||||
|
patent_count_value = input_data_by_b1.get("patent_count", 0.0)
|
||||||
|
api_calc_fields["patent_count"] = str(patent_count_value) if patent_count_value is not None else None
|
||||||
|
|
||||||
|
# 结构复杂度(纹样基因熵值B22)
|
||||||
|
structure_complexity_value = input_data_by_b2.get("structure_complexity", 1.5)
|
||||||
|
api_calc_fields["pattern_complexity"] = str(structure_complexity_value) if structure_complexity_value is not None else None
|
||||||
|
|
||||||
|
# 归一化信息熵H
|
||||||
|
normalized_entropy_value = input_data_by_b2.get("normalized_entropy", 9)
|
||||||
|
api_calc_fields["normalized_entropy"] = str(normalized_entropy_value) if normalized_entropy_value is not None else None
|
||||||
|
|
||||||
|
# 线上课程点击量(暂时没有计算逻辑,设为None或默认值)
|
||||||
|
# api_calc_fields["online_course_views"] = None
|
||||||
|
|
||||||
|
# 基础质押率和流量修正系数(暂时没有计算逻辑,设为None或默认值)
|
||||||
|
# api_calc_fields["base_pledge_rate"] = None
|
||||||
|
# api_calc_fields["flow_correction"] = None
|
||||||
|
|
||||||
|
if api_calc_fields:
|
||||||
|
await valuation_controller.update_calc(
|
||||||
|
valuation_id,
|
||||||
|
ValuationAssessmentUpdate(**api_calc_fields)
|
||||||
|
)
|
||||||
|
logger.info("valuation.api_calc_fields_updated valuation_id={} fields={}", valuation_id, list(api_calc_fields.keys()))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("valuation.failed_to_update_api_calc_fields valuation_id={} err={}", valuation_id, repr(e))
|
||||||
|
|
||||||
|
# 步骤1.6:记录所有API查询结果和参数映射(便于检查参数匹配)
|
||||||
|
try:
|
||||||
|
# 1. ESG评分查询记录
|
||||||
|
await valuation_controller.log_formula_step(
|
||||||
|
valuation_id, "API_ESG_QUERY",
|
||||||
|
status="completed",
|
||||||
|
input_params={"industry": data.industry},
|
||||||
|
output_result={"esg_score": esg_score, "source": "ESG表"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 行业系数查询记录
|
||||||
|
await valuation_controller.log_formula_step(
|
||||||
|
valuation_id, "API_INDUSTRY_QUERY",
|
||||||
|
status="completed",
|
||||||
|
input_params={"industry": data.industry},
|
||||||
|
output_result={"industry_coefficient": fix_num_score, "source": "Industry表"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 政策匹配度查询记录
|
||||||
|
await valuation_controller.log_formula_step(
|
||||||
|
valuation_id, "API_POLICY_QUERY",
|
||||||
|
status="completed",
|
||||||
|
input_params={"industry": data.industry},
|
||||||
|
output_result={"policy_match_score": policy_match_score, "source": "Policy表"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 司法诉讼查询记录
|
||||||
|
await valuation_controller.log_formula_step(
|
||||||
|
valuation_id, "API_JUDICIAL_QUERY",
|
||||||
|
status="completed",
|
||||||
|
input_params={"institution": data.institution},
|
||||||
|
output_result={
|
||||||
|
"api_response": judicial_api_response, # API原始返回
|
||||||
|
"lawsuit_status": lawsuit_status_for_display,
|
||||||
|
"infringement_score": infringement_score,
|
||||||
|
"calculation": f"诉讼标的={judicial_api_response.get('target', '无')}, 诉讼总数={judicial_api_response.get('total', 0)} → {lawsuit_status_for_display} → {infringement_score}分",
|
||||||
|
"score_rule": "无诉讼:10分, 已解决:7分, 未解决:0分"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. 专利信息查询记录
|
||||||
|
await valuation_controller.log_formula_step(
|
||||||
|
valuation_id, "API_PATENT_QUERY",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"industry": data.industry,
|
||||||
|
"patent_application_no": data.patent_application_no
|
||||||
|
},
|
||||||
|
output_result={
|
||||||
|
"api_data_count": len(patent_api_response.get("data", {}).get("dataList", []) if isinstance(patent_api_response.get("data"), dict) else []),
|
||||||
|
"matched_count": patent_matched_count,
|
||||||
|
"years_total": patent_years_total,
|
||||||
|
"patent_count": input_data_by_b1.get("patent_count", 0),
|
||||||
|
"patent_score": input_data_by_b1.get("patent_score", 0),
|
||||||
|
"calculation": f"匹配专利数={patent_matched_count} → 专利数分={input_data_by_b1.get('patent_count', 0)}, 剩余年限合计={patent_years_total}年 → 专利分={patent_score}",
|
||||||
|
"score_rule": "剩余年限>10年:10分, 5-10年:7分, <5年:3分"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. 微信指数查询记录
|
||||||
|
await valuation_controller.log_formula_step(
|
||||||
|
valuation_id, "API_WECHAT_INDEX",
|
||||||
|
status="completed",
|
||||||
|
input_params={"asset_name": data.asset_name},
|
||||||
|
output_result={
|
||||||
|
"search_index_s1": input_data_by_b1.get("search_index_s1", 0),
|
||||||
|
"formula": "S1 = 微信指数 / 10"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7. 跨界合作深度映射记录
|
||||||
|
await valuation_controller.log_formula_step(
|
||||||
|
valuation_id, "MAPPING_CROSS_BORDER_DEPTH",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"user_input": getattr(data, 'cooperation_depth', None),
|
||||||
|
"mapping": {"0":"无(0分)", "1":"品牌联名(3分)", "2":"科技载体(5分)", "3":"国家外交礼品(10分)"}
|
||||||
|
},
|
||||||
|
output_result={"cross_border_depth": input_data_by_b2.get("cross_border_depth", 0)}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 8. 传承人等级映射记录
|
||||||
|
await valuation_controller.log_formula_step(
|
||||||
|
valuation_id, "MAPPING_INHERITOR_LEVEL",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"user_input": data.inheritor_level,
|
||||||
|
"mapping": {"国家级传承人":"10分", "省级传承人":"7分", "市级传承人及以下":"4分"}
|
||||||
|
},
|
||||||
|
output_result={"inheritor_level_coefficient": input_data_by_b2.get("inheritor_level_coefficient", 0)}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 9. 历史传承度HI计算记录
|
||||||
|
await valuation_controller.log_formula_step(
|
||||||
|
valuation_id, "CALC_HISTORICAL_INHERITANCE",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"historical_evidence": data.historical_evidence,
|
||||||
|
"weights": {"出土实物":1.0, "古代文献":0.8, "传承人佐证":0.6, "现代研究":0.4}
|
||||||
|
},
|
||||||
|
output_result={
|
||||||
|
"historical_inheritance": input_data_by_b2.get("historical_inheritance", 0),
|
||||||
|
"formula": "HI = 出土实物×1.0 + 古代文献×0.8 + 传承人佐证×0.6 + 现代研究×0.4"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 11. 市场风险价格波动记录
|
||||||
|
await valuation_controller.log_formula_step(
|
||||||
|
valuation_id, "CALC_MARKET_RISK",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"price_fluctuation": data.price_fluctuation,
|
||||||
|
"highest_price": input_data_by_b3.get("highest_price", 0),
|
||||||
|
"lowest_price": input_data_by_b3.get("lowest_price", 0)
|
||||||
|
},
|
||||||
|
output_result={
|
||||||
|
"volatility_rule": "波动率≤5%:10分, 5-15%:5分, >15%:0分"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("valuation.param_mapping_logged valuation_id={}", valuation_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("valuation.failed_to_log_param_mapping valuation_id={} err={}", valuation_id, repr(e))
|
||||||
|
|
||||||
# 计算最终估值A(统一计算),传入估值ID以关联步骤落库
|
# 计算最终估值A(统一计算),传入估值ID以关联步骤落库
|
||||||
calculation_result = await calculator.calculate_complete_final_value_a(valuation_id, input_data)
|
calculation_result = await calculator.calculate_complete_final_value_a(valuation_id, input_data)
|
||||||
|
|
||||||
|
# 步骤2:更新计算结果字段(模型估值B、市场估值C、最终估值AB、完整计算结果)
|
||||||
|
try:
|
||||||
|
await valuation_controller.update_calc(
|
||||||
|
valuation_id,
|
||||||
|
ValuationAssessmentUpdate(
|
||||||
|
model_value_b=calculation_result.get('model_value_b'),
|
||||||
|
market_value_c=calculation_result.get('market_value_c'),
|
||||||
|
final_value_ab=calculation_result.get('final_value_ab'),
|
||||||
|
calculation_result=calculation_result,
|
||||||
|
status='pending',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"valuation.result_updated valuation_id={} model_b={} market_c={} final_ab={}",
|
||||||
|
valuation_id,
|
||||||
|
calculation_result.get('model_value_b'),
|
||||||
|
calculation_result.get('market_value_c'),
|
||||||
|
calculation_result.get('final_value_ab'),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("valuation.failed_to_update_result valuation_id={} err={}", valuation_id, repr(e))
|
||||||
|
|
||||||
# 计算动态质押
|
# 计算动态质押
|
||||||
drp_c = DynamicPledgeRateCalculator()
|
drp_c = DynamicPledgeRateCalculator()
|
||||||
'''
|
'''
|
||||||
@ -172,7 +394,45 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
|||||||
'''
|
'''
|
||||||
# 解析月交易额字符串为数值
|
# 解析月交易额字符串为数值
|
||||||
monthly_amount = drp_c.parse_monthly_transaction_amount(data.monthly_transaction_amount or "")
|
monthly_amount = drp_c.parse_monthly_transaction_amount(data.monthly_transaction_amount or "")
|
||||||
|
drp_start_ts = time.monotonic()
|
||||||
drp_result = drp_c.calculate_dynamic_pledge_rate(monthly_amount, data.heritage_asset_level)
|
drp_result = drp_c.calculate_dynamic_pledge_rate(monthly_amount, data.heritage_asset_level)
|
||||||
|
drp_duration_ms = int((time.monotonic() - drp_start_ts) * 1000)
|
||||||
|
|
||||||
|
# 记录动态质押率计算步骤
|
||||||
|
await valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"DYNAMIC_PLEDGE_RATE",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"monthly_transaction_amount": data.monthly_transaction_amount,
|
||||||
|
"monthly_amount": monthly_amount,
|
||||||
|
"heritage_asset_level": data.heritage_asset_level,
|
||||||
|
},
|
||||||
|
output_result={
|
||||||
|
"dynamic_pledge_rate": drp_result,
|
||||||
|
"duration_ms": drp_duration_ms,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info("valuation.drp_calculated valuation_id={} drp={} duration_ms={}", valuation_id, drp_result, drp_duration_ms)
|
||||||
|
|
||||||
|
# 步骤3:更新动态质押率及相关字段
|
||||||
|
try:
|
||||||
|
# 从动态质押率计算器中获取基础质押率和流量修正系数
|
||||||
|
base_pledge_rate_value = "0.5" # 固定值:基础质押率 = 0.5
|
||||||
|
flow_correction_value = "0.3" # 固定值:流量修正系数 = 0.3
|
||||||
|
|
||||||
|
await valuation_controller.update_calc(
|
||||||
|
valuation_id,
|
||||||
|
ValuationAssessmentUpdate(
|
||||||
|
dynamic_pledge_rate=drp_result,
|
||||||
|
base_pledge_rate=base_pledge_rate_value,
|
||||||
|
flow_correction=flow_correction_value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("valuation.drp_updated valuation_id={} drp={} base_rate={} flow_correction={}",
|
||||||
|
valuation_id, drp_result, base_pledge_rate_value, flow_correction_value)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("valuation.failed_to_update_drp valuation_id={} err={}", valuation_id, repr(e))
|
||||||
|
|
||||||
# 结构化日志:关键分值
|
# 结构化日志:关键分值
|
||||||
try:
|
try:
|
||||||
@ -188,33 +448,15 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 更新估值评估记录(写入计算结果与输入摘要)
|
# 步骤4:计算完成,保持状态为 pending,等待后台审核
|
||||||
update_data = ValuationAssessmentUpdate(
|
try:
|
||||||
model_value_b=calculation_result.get('model_value_b'),
|
result = await valuation_controller.get_by_id(valuation_id)
|
||||||
market_value_c=calculation_result.get('market_value_c'),
|
logger.info("valuation.calc_finished valuation_id={} status=pending", valuation_id)
|
||||||
final_value_ab=calculation_result.get('final_value_ab'),
|
except Exception as e:
|
||||||
dynamic_pledge_rate=drp_result,
|
logger.warning("valuation.failed_to_fetch_after_calc valuation_id={} err={}", valuation_id, repr(e))
|
||||||
calculation_result=calculation_result,
|
result = None
|
||||||
calculation_input={
|
|
||||||
'model_data': {
|
|
||||||
'economic_data': list(input_data.get('model_data', {}).get('economic_data', {}).keys()),
|
|
||||||
'cultural_data': list(input_data.get('model_data', {}).get('cultural_data', {}).keys()),
|
|
||||||
'risky_data': list(input_data.get('model_data', {}).get('risky_data', {}).keys()),
|
|
||||||
},
|
|
||||||
'market_data': list(input_data.get('market_data', {}).keys()),
|
|
||||||
},
|
|
||||||
status='success'
|
|
||||||
)
|
|
||||||
result = await valuation_controller.update(valuation_id, update_data)
|
|
||||||
logger.info(
|
|
||||||
"valuation.updated valuation_id={} model_b={} market_c={} final_ab={}",
|
|
||||||
valuation_id,
|
|
||||||
calculation_result.get('model_value_b'),
|
|
||||||
calculation_result.get('market_value_c'),
|
|
||||||
calculation_result.get('final_value_ab'),
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("valuation.background_calc_success user_id={} valuation_id={}", user_id, result.id)
|
logger.info("valuation.background_calc_success user_id={} valuation_id={}", user_id, valuation_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
@ -224,8 +466,57 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
|||||||
# 计算失败时更新记录为失败状态
|
# 计算失败时更新记录为失败状态
|
||||||
try:
|
try:
|
||||||
if 'valuation_id' in locals():
|
if 'valuation_id' in locals():
|
||||||
|
# 准备失败时需要更新的字段
|
||||||
|
fail_update_fields = {"status": "rejected"}
|
||||||
|
|
||||||
|
# 如果 input_data 已经准备好,确保 calculation_input 被更新(即使计算失败)
|
||||||
|
if 'input_data' in locals():
|
||||||
|
fail_update_fields["calculation_input"] = input_data
|
||||||
|
|
||||||
|
# 如果内置API计算字段已经准备好,也尝试更新(即使计算失败)
|
||||||
|
# 这些字段在步骤1.5中计算,如果步骤1.5执行了,这些变量应该已经存在
|
||||||
|
api_calc_fields = {}
|
||||||
|
if 'esg_score' in locals():
|
||||||
|
api_calc_fields["esg_value"] = str(esg_score) if esg_score is not None else None
|
||||||
|
if 'policy_match_score' in locals():
|
||||||
|
api_calc_fields["policy_matching"] = str(policy_match_score) if policy_match_score is not None else None
|
||||||
|
if 'lawsuit_status_for_display' in locals():
|
||||||
|
api_calc_fields["infringement_record"] = lawsuit_status_for_display
|
||||||
|
api_calc_fields["legal_risk"] = lawsuit_status_for_display
|
||||||
|
elif 'infringement_score' in locals():
|
||||||
|
# 兼容旧逻辑
|
||||||
|
infringement_record_value = "无诉讼" if infringement_score == 10.0 else ("已解决诉讼" if infringement_score == 7.0 else "未解决诉讼")
|
||||||
|
api_calc_fields["infringement_record"] = infringement_record_value
|
||||||
|
api_calc_fields["legal_risk"] = infringement_record_value
|
||||||
|
if 'input_data_by_b1' in locals():
|
||||||
|
patent_count_value = input_data_by_b1.get("patent_count", 0.0)
|
||||||
|
api_calc_fields["patent_count"] = str(patent_count_value) if patent_count_value is not None else None
|
||||||
|
if 'input_data_by_b2' in locals():
|
||||||
|
structure_complexity_value = input_data_by_b2.get("structure_complexity", 1.5)
|
||||||
|
api_calc_fields["pattern_complexity"] = str(structure_complexity_value) if structure_complexity_value is not None else None
|
||||||
|
normalized_entropy_value = input_data_by_b2.get("normalized_entropy", 9)
|
||||||
|
api_calc_fields["normalized_entropy"] = str(normalized_entropy_value) if normalized_entropy_value is not None else None
|
||||||
|
|
||||||
|
# 合并所有需要更新的字段
|
||||||
|
fail_update_fields.update(api_calc_fields)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await valuation_controller.update_calc(
|
||||||
|
valuation_id,
|
||||||
|
ValuationAssessmentUpdate(**fail_update_fields)
|
||||||
|
)
|
||||||
|
logger.info("valuation.failed_but_fields_saved valuation_id={} fields={}", valuation_id, list(fail_update_fields.keys()))
|
||||||
|
except Exception as input_err:
|
||||||
|
logger.warning("valuation.failed_to_save_fields_on_error valuation_id={} err={}", valuation_id, repr(input_err))
|
||||||
|
# 如果保存失败,至少更新状态
|
||||||
|
try:
|
||||||
fail_update = ValuationAssessmentUpdate(status='rejected')
|
fail_update = ValuationAssessmentUpdate(status='rejected')
|
||||||
await valuation_controller.update(valuation_id, fail_update)
|
await valuation_controller.update_calc(valuation_id, fail_update)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# 如果 valuation_id 都不存在,说明在创建记录时就失败了,无法更新
|
||||||
|
logger.warning("valuation.failed_before_creation user_id={}", user_id)
|
||||||
except Exception as create_error:
|
except Exception as create_error:
|
||||||
logger.error("valuation.failed_to_update_record user_id={} err={}", user_id, repr(create_error))
|
logger.error("valuation.failed_to_update_record user_id={} err={}", user_id, repr(create_error))
|
||||||
|
|
||||||
@ -301,20 +592,52 @@ async def calculate_valuation(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 添加后台任务
|
from app.models.user import AppUser, AppUserQuotaLog
|
||||||
background_tasks.add_task(_perform_valuation_calculation, user_id, data)
|
user = await AppUser.filter(id=user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
if (user.remaining_quota or 0) < 1:
|
||||||
|
raise HTTPException(status_code=400, detail="估值次数不足")
|
||||||
|
before = user.remaining_quota or 0
|
||||||
|
user.remaining_quota = before - 1
|
||||||
|
await user.save()
|
||||||
|
try:
|
||||||
|
await AppUserQuotaLog.create(
|
||||||
|
app_user_id=user_id,
|
||||||
|
operator_id=user_id,
|
||||||
|
operator_name=user.alias or user.username or user.phone or "",
|
||||||
|
before_count=before,
|
||||||
|
after_count=before - 1,
|
||||||
|
remark="发起估值"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
logger.info("valuation.task_queued user_id={} asset_name={} industry={}",
|
# 先创建估值记录以获取ID,方便用户查询
|
||||||
user_id, getattr(data, 'asset_name', None), getattr(data, 'industry', None))
|
initial_detail = await user_valuation_controller.create_valuation(
|
||||||
|
user_id=user_id,
|
||||||
|
data=data,
|
||||||
|
calculation_result=None,
|
||||||
|
calculation_input=None,
|
||||||
|
drp_result=None,
|
||||||
|
status='pending'
|
||||||
|
)
|
||||||
|
valuation_id = initial_detail.id
|
||||||
|
|
||||||
|
background_tasks.add_task(_perform_valuation_calculation, user_id, valuation_id, data)
|
||||||
|
|
||||||
|
logger.info("valuation.task_queued user_id={} valuation_id={} asset_name={} industry={}",
|
||||||
|
user_id, valuation_id, getattr(data, 'asset_name', None), getattr(data, 'industry', None))
|
||||||
|
|
||||||
return Success(
|
return Success(
|
||||||
data={
|
data={
|
||||||
"task_status": "queued",
|
"task_status": "queued",
|
||||||
"message": "估值计算任务已提交,正在后台处理中",
|
"message": "估值计算任务已提交,正在后台处理中",
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"asset_name": getattr(data, 'asset_name', None)
|
"asset_name": getattr(data, 'asset_name', None),
|
||||||
},
|
"valuation_id": valuation_id,
|
||||||
msg="估值计算任务已启动"
|
"order_no": str(valuation_id)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -322,7 +645,12 @@ async def calculate_valuation(
|
|||||||
raise HTTPException(status_code=500, detail=f"任务提交失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"任务提交失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str, Any]:
|
async def _extract_calculation_params_b1(
|
||||||
|
data: UserValuationCreate,
|
||||||
|
esg_score: float = 0.0,
|
||||||
|
industry_coefficient: float = 0.0,
|
||||||
|
policy_match_score: float = 0.0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
从用户提交的数据中提取计算所需的参数
|
从用户提交的数据中提取计算所需的参数
|
||||||
|
|
||||||
@ -355,8 +683,7 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
|
|||||||
innovation_ratio = 0.0
|
innovation_ratio = 0.0
|
||||||
|
|
||||||
# 流量因子B12相关参数
|
# 流量因子B12相关参数
|
||||||
# 近30天搜索指数S1 - 从社交媒体数据计算 TODO 需要使用第三方API
|
# 近30天搜索指数S1 - 使用微信指数除以10计算
|
||||||
baidu_index = 1
|
|
||||||
|
|
||||||
# 获取微信指数并计算近30天平均值
|
# 获取微信指数并计算近30天平均值
|
||||||
try:
|
try:
|
||||||
@ -365,10 +692,9 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
|
|||||||
logger.info(f"资产 '{data.asset_name}' 的微信指数近30天平均值: {wechat_index}")
|
logger.info(f"资产 '{data.asset_name}' 的微信指数近30天平均值: {wechat_index}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取微信指数失败: {e}")
|
logger.error(f"获取微信指数失败: {e}")
|
||||||
wechat_index = 1
|
wechat_index = 10 # 失败时默认值,使得 S1 = 1
|
||||||
|
|
||||||
weibo_index = 1
|
search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10
|
||||||
search_index_s1 = calculate_search_index_s1(baidu_index, wechat_index, weibo_index) # 默认值,实际应从API获取
|
|
||||||
|
|
||||||
# 行业均值S2 - 从数据库查询行业数据计算
|
# 行业均值S2 - 从数据库查询行业数据计算
|
||||||
from app.utils.industry_calculator import calculate_industry_average_s2
|
from app.utils.industry_calculator import calculate_industry_average_s2
|
||||||
@ -415,6 +741,7 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
|
|||||||
'likes': safe_float(info["likes"]),
|
'likes': safe_float(info["likes"]),
|
||||||
'comments': safe_float(info["comments"]),
|
'comments': safe_float(info["comments"]),
|
||||||
'shares': safe_float(info["shares"]),
|
'shares': safe_float(info["shares"]),
|
||||||
|
'views': safe_float(info.get("views", 0)),
|
||||||
# followers 非当前计算用键,先移除避免干扰
|
# followers 非当前计算用键,先移除避免干扰
|
||||||
|
|
||||||
# click_count 与 view_count 目前未参与计算,先移除
|
# click_count 与 view_count 目前未参与计算,先移除
|
||||||
@ -422,7 +749,10 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
|
|||||||
'link_views': safe_float(data.link_views),
|
'link_views': safe_float(data.link_views),
|
||||||
# 政策乘数B13相关参数
|
# 政策乘数B13相关参数
|
||||||
'implementation_stage': implementation_stage,
|
'implementation_stage': implementation_stage,
|
||||||
'funding_support': funding_support
|
'funding_support': funding_support,
|
||||||
|
'esg_score': safe_float(esg_score),
|
||||||
|
'industry_coefficient': safe_float(industry_coefficient),
|
||||||
|
'policy_match_score': safe_float(policy_match_score),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -456,10 +786,18 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
|
|||||||
kuaishou_views = safe_float(rs.get("kuaishou", None).get("likes", 0)) if rs.get("kuaishou", None) else 0
|
kuaishou_views = safe_float(rs.get("kuaishou", None).get("likes", 0)) if rs.get("kuaishou", None) else 0
|
||||||
bilibili_views = safe_float(rs.get("bilibili", None).get("likes", 0)) if rs.get("bilibili", None) else 0
|
bilibili_views = safe_float(rs.get("bilibili", None).get("likes", 0)) if rs.get("bilibili", None) else 0
|
||||||
|
|
||||||
# 跨界合作深度:将枚举映射为项目数;若为数值字符串则直接取数值
|
# 跨界合作深度:将枚举映射为分值
|
||||||
|
# 前端传入的是数字字符串 ("0", "1", "2", "3"),后端也支持中文标签
|
||||||
try:
|
try:
|
||||||
val = getattr(data, 'cooperation_depth', None)
|
val = getattr(data, 'cooperation_depth', None)
|
||||||
mapping = {
|
mapping = {
|
||||||
|
# 前端传入的数字字符串
|
||||||
|
"0": 0.0, # 无
|
||||||
|
"1": 3.0, # 品牌联名
|
||||||
|
"2": 5.0, # 科技载体
|
||||||
|
"3": 10.0, # 国家外交礼品
|
||||||
|
# 兼容中文标签(以防其他入口传入)
|
||||||
|
"无": 0.0,
|
||||||
"品牌联名": 3.0,
|
"品牌联名": 3.0,
|
||||||
"科技载体": 5.0,
|
"科技载体": 5.0,
|
||||||
"国家外交礼品": 10.0,
|
"国家外交礼品": 10.0,
|
||||||
@ -474,14 +812,28 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
|
|||||||
# 纹样基因值B22相关参数
|
# 纹样基因值B22相关参数
|
||||||
|
|
||||||
# 以下三项需由后续模型/服务计算;此处提供默认可计算占位
|
# 以下三项需由后续模型/服务计算;此处提供默认可计算占位
|
||||||
#
|
|
||||||
# 历史传承度HI(用户填写)
|
# 历史传承度HI(用户填写)
|
||||||
|
# HI = 证据数量 × 对应权重后加总
|
||||||
|
# 权重分配:出土实物(1.0) + 古代文献(0.8) + 传承人佐证(0.6) + 现代研究(0.4)
|
||||||
|
# 示例: (2*1 + 5*0.8 + 5*0.6 + 6*0.4) = 11.4
|
||||||
historical_inheritance = 0.0
|
historical_inheritance = 0.0
|
||||||
try:
|
try:
|
||||||
|
evidence_weights = {
|
||||||
|
"artifacts": 1.0, # 出土实物
|
||||||
|
"ancient_literature": 0.8, # 古代文献
|
||||||
|
"inheritor_testimony": 0.6, # 传承人佐证
|
||||||
|
"modern_research": 0.4, # 现代研究
|
||||||
|
}
|
||||||
if isinstance(data.historical_evidence, dict):
|
if isinstance(data.historical_evidence, dict):
|
||||||
historical_inheritance = sum([safe_float(v) for v in data.historical_evidence.values()])
|
for key, weight in evidence_weights.items():
|
||||||
|
count = safe_float(data.historical_evidence.get(key, 0))
|
||||||
|
historical_inheritance += count * weight
|
||||||
elif isinstance(data.historical_evidence, (list, tuple)):
|
elif isinstance(data.historical_evidence, (list, tuple)):
|
||||||
historical_inheritance = sum([safe_float(i) for i in data.historical_evidence])
|
# 列表顺序:[出土实物, 古代文献, 传承人佐证, 现代研究]
|
||||||
|
weights = [1.0, 0.8, 0.6, 0.4]
|
||||||
|
for i, weight in enumerate(weights):
|
||||||
|
if i < len(data.historical_evidence):
|
||||||
|
historical_inheritance += safe_float(data.historical_evidence[i]) * weight
|
||||||
except Exception:
|
except Exception:
|
||||||
historical_inheritance = 0.0
|
historical_inheritance = 0.0
|
||||||
structure_complexity = 1.5 # 默认值 纹样基因熵值B22(系统计算)
|
structure_complexity = 1.5 # 默认值 纹样基因熵值B22(系统计算)
|
||||||
@ -513,17 +865,36 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# 获取 文化价值B2 相关参数
|
# 获取 风险调整系数B3 相关参数
|
||||||
async def _extract_calculation_params_b3(data: UserValuationCreate) -> Dict[str, Any]:
|
async def _extract_calculation_params_b3(data: UserValuationCreate) -> Dict[str, Any]:
|
||||||
# 过去30天最高价格 过去30天最低价格 TODO 需要根据字样进行切分获取最高价和最低价 转换成 float 类型
|
# 过去30天最高价格 过去30天最低价格
|
||||||
price_fluctuation = [float(i) for i in data.price_fluctuation]
|
price_fluctuation = [float(i) for i in data.price_fluctuation]
|
||||||
highest_price, lowest_price = max(price_fluctuation), min(price_fluctuation)
|
highest_price, lowest_price = max(price_fluctuation), min(price_fluctuation)
|
||||||
# lawsuit_status = "无诉讼" # 诉讼状态 TODO (API获取)
|
|
||||||
inheritor_ages = data.inheritor_age_count # [45, 60, 75] # 传承人年龄列表
|
# 传承风险:根据各年龄段传承人数量计算
|
||||||
|
# 前端传入: inheritor_age_count = [≤50岁人数, 50-70岁人数, ≥70岁人数]
|
||||||
|
# 评分规则: ≤50岁(10分), 50-70岁(5分), >70岁(0分),取有传承人的最高分
|
||||||
|
inheritor_age_count = data.inheritor_age_count or [0, 0, 0]
|
||||||
|
|
||||||
|
# 根据年龄段人数生成虚拟年龄列表(用于风险计算)
|
||||||
|
# 如果有≤50岁的传承人,添加一个45岁的代表
|
||||||
|
# 如果有50-70岁的传承人,添加一个60岁的代表
|
||||||
|
# 如果有>70岁的传承人,添加一个75岁的代表
|
||||||
|
inheritor_ages = []
|
||||||
|
if len(inheritor_age_count) > 0 and safe_float(inheritor_age_count[0]) > 0:
|
||||||
|
inheritor_ages.append(45) # ≤50岁代表 → 10分
|
||||||
|
if len(inheritor_age_count) > 1 and safe_float(inheritor_age_count[1]) > 0:
|
||||||
|
inheritor_ages.append(60) # 50-70岁代表 → 5分
|
||||||
|
if len(inheritor_age_count) > 2 and safe_float(inheritor_age_count[2]) > 0:
|
||||||
|
inheritor_ages.append(75) # >70岁代表 → 0分
|
||||||
|
|
||||||
|
# 如果没有任何传承人,默认给一个高风险年龄
|
||||||
|
if not inheritor_ages:
|
||||||
|
inheritor_ages = [75] # 默认高风险
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"highest_price": highest_price,
|
"highest_price": highest_price,
|
||||||
"lowest_price": lowest_price,
|
"lowest_price": lowest_price,
|
||||||
|
|
||||||
"inheritor_ages": inheritor_ages,
|
"inheritor_ages": inheritor_ages,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -618,13 +989,14 @@ async def _extract_calculation_params_c(data: UserValuationCreate) -> Dict[str,
|
|||||||
"expert_valuations": expert_valuations, # 专家估值列表 (系统配置)
|
"expert_valuations": expert_valuations, # 专家估值列表 (系统配置)
|
||||||
# 计算热度系数C2
|
# 计算热度系数C2
|
||||||
"daily_browse_volume": daily_browse_volume, # 近7日日均浏览量 (API获取)
|
"daily_browse_volume": daily_browse_volume, # 近7日日均浏览量 (API获取)
|
||||||
|
"platform_views": daily_browse_volume, # 从 platform_accounts/views 或 link_views 获取的浏览量
|
||||||
"collection_count": collection_count, # 收藏数
|
"collection_count": collection_count, # 收藏数
|
||||||
"issuance_level": circulation, # 默认 限量发行 计算稀缺性乘数C3
|
"issuance_level": circulation, # 默认 限量发行 计算稀缺性乘数C3
|
||||||
"recent_market_activity": recent_market_activity, # 默认 '近一月' 计算市场估值C
|
"recent_market_activity": recent_market_activity, # 默认 '近一月' 计算市场估值C
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app_valuations_router.get("/", summary="获取我的估值评估列表", response_model=PageResponse[UserValuationOut])
|
@app_valuations_router.get("/", summary="获取我的估值评估列表", response_model=BasicResponse[dict])
|
||||||
async def get_my_valuations(
|
async def get_my_valuations(
|
||||||
query: UserValuationQuery = Depends(),
|
query: UserValuationQuery = Depends(),
|
||||||
current_user: AppUser = Depends(get_current_app_user)
|
current_user: AppUser = Depends(get_current_app_user)
|
||||||
@ -641,13 +1013,14 @@ async def get_my_valuations(
|
|||||||
# 使用model_dump_json()来正确序列化datetime,然后解析为dict列表
|
# 使用model_dump_json()来正确序列化datetime,然后解析为dict列表
|
||||||
import json
|
import json
|
||||||
serialized_items = [json.loads(item.model_dump_json()) for item in result.items]
|
serialized_items = [json.loads(item.model_dump_json()) for item in result.items]
|
||||||
return SuccessExtra(
|
return Success(
|
||||||
data=serialized_items,
|
data={
|
||||||
total=result.total,
|
"items": serialized_items,
|
||||||
page=result.page,
|
"total": result.total,
|
||||||
page_size=result.size,
|
"page": result.page,
|
||||||
pages=result.pages,
|
"page_size": result.size,
|
||||||
msg="获取估值评估列表成功"
|
"pages": result.pages,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -656,7 +1029,7 @@ async def get_my_valuations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app_valuations_router.get("/{valuation_id}", summary="获取估值评估详情", response_model=BasicResponse[UserValuationDetail])
|
@app_valuations_router.get("/{valuation_id}", summary="获取估值评估详情", response_model=BasicResponse[dict])
|
||||||
async def get_valuation_detail(
|
async def get_valuation_detail(
|
||||||
valuation_id: int,
|
valuation_id: int,
|
||||||
current_user: AppUser = Depends(get_current_app_user)
|
current_user: AppUser = Depends(get_current_app_user)
|
||||||
@ -679,7 +1052,7 @@ async def get_valuation_detail(
|
|||||||
# 使用model_dump_json()来正确序列化datetime,然后解析为dict
|
# 使用model_dump_json()来正确序列化datetime,然后解析为dict
|
||||||
import json
|
import json
|
||||||
result_dict = json.loads(result.model_dump_json())
|
result_dict = json.loads(result.model_dump_json())
|
||||||
return Success(data=result_dict, msg="获取估值评估详情成功")
|
return Success(data=result_dict)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -700,7 +1073,7 @@ async def get_my_valuation_statistics(
|
|||||||
result = await user_valuation_controller.get_user_valuation_statistics(
|
result = await user_valuation_controller.get_user_valuation_statistics(
|
||||||
user_id=current_user.id
|
user_id=current_user.id
|
||||||
)
|
)
|
||||||
return Success(data=result, msg="获取统计信息成功")
|
return Success(data=result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
@ -728,7 +1101,7 @@ async def delete_valuation(
|
|||||||
detail="估值评估记录不存在或已被删除"
|
detail="估值评估记录不存在或已被删除"
|
||||||
)
|
)
|
||||||
|
|
||||||
return Success(data={"deleted": True}, msg="删除估值评估成功")
|
return Success(data={"deleted": True})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -276,11 +276,9 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
|
|||||||
|
|
||||||
|
|
||||||
# 流量因子B12相关参数
|
# 流量因子B12相关参数
|
||||||
# 近30天搜索指数S1 - 从社交媒体数据计算 TODO 需要使用第三方API
|
# 近30天搜索指数S1 - 使用微信指数除以10计算
|
||||||
baidu_index = 0.0
|
wechat_index = wechat_index_calculator.process_wechat_index_response(universal_api.wx_index(data.asset_name)) # 通过资产信息获取微信指数
|
||||||
wechat_index = wechat_index_calculator.process_wechat_index_response(universal_api.wx_index(data.asset_name)) # 通过资产信息获取微信指数 TODO 这里返回的没确认指数参数,有可能返回的图示是指数信息
|
search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10
|
||||||
weibo_index = 0.0
|
|
||||||
search_index_s1 = calculate_search_index_s1(baidu_index,wechat_index,weibo_index) # 默认值,实际应从API获取
|
|
||||||
# 行业均值S2 TODO 系统内置 未找到相关内容
|
# 行业均值S2 TODO 系统内置 未找到相关内容
|
||||||
industry_average_s2 = 0.0
|
industry_average_s2 = 0.0
|
||||||
# 社交媒体传播度S3 - TODO 需要使用第三方API,click_count view_count 未找到对应参数
|
# 社交媒体传播度S3 - TODO 需要使用第三方API,click_count view_count 未找到对应参数
|
||||||
@ -344,8 +342,22 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
|
|||||||
douyin_views = 0
|
douyin_views = 0
|
||||||
kuaishou_views= 0
|
kuaishou_views= 0
|
||||||
bilibili_views= 0
|
bilibili_views= 0
|
||||||
# 跨界合作深度 品牌联名0.3,科技载体0.5,国家外交礼品1.0
|
# 跨界合作深度:将枚举映射为分值
|
||||||
cross_border_depth = float(data.cooperation_depth)
|
# 前端传入的是数字字符串 ("0", "1", "2", "3"),后端也支持中文标签
|
||||||
|
depth_mapping = {
|
||||||
|
# 前端传入的数字字符串
|
||||||
|
"0": 0.0, # 无
|
||||||
|
"1": 3.0, # 品牌联名
|
||||||
|
"2": 5.0, # 科技载体
|
||||||
|
"3": 10.0, # 国家外交礼品
|
||||||
|
# 兼容中文标签(以防其他入口传入)
|
||||||
|
"无": 0.0,
|
||||||
|
"品牌联名": 3.0,
|
||||||
|
"科技载体": 5.0,
|
||||||
|
"国家外交礼品": 10.0,
|
||||||
|
}
|
||||||
|
depth_val = str(data.cooperation_depth) if data.cooperation_depth else "0"
|
||||||
|
cross_border_depth = depth_mapping.get(depth_val, 0.0)
|
||||||
|
|
||||||
# 纹样基因值B22相关参数
|
# 纹样基因值B22相关参数
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query, Depends, Header, HTTPException
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse, MessageOut
|
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse, MessageOut
|
||||||
@ -8,19 +8,25 @@ from app.schemas.invoice import (
|
|||||||
UpdateStatus,
|
UpdateStatus,
|
||||||
UpdateType,
|
UpdateType,
|
||||||
InvoiceHeaderCreate,
|
InvoiceHeaderCreate,
|
||||||
|
InvoiceHeaderUpdate,
|
||||||
PaymentReceiptCreate,
|
PaymentReceiptCreate,
|
||||||
|
AppCreateInvoiceWithReceipt,
|
||||||
InvoiceOut,
|
InvoiceOut,
|
||||||
InvoiceList,
|
InvoiceList,
|
||||||
InvoiceHeaderOut,
|
InvoiceHeaderOut,
|
||||||
PaymentReceiptOut,
|
PaymentReceiptOut,
|
||||||
)
|
)
|
||||||
from app.controllers.invoice import invoice_controller
|
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=["发票管理"])
|
invoice_router = APIRouter(tags=["发票管理"])
|
||||||
|
|
||||||
|
|
||||||
@invoice_router.get("/list", summary="获取发票列表", response_model=PageResponse[InvoiceOut])
|
@invoice_router.get("/list", summary="获取发票列表", response_model=PageResponse[InvoiceOut], dependencies=[DependAuth, DependPermission])
|
||||||
async def list_invoices(
|
async def list_invoices(
|
||||||
phone: Optional[str] = Query(None),
|
phone: Optional[str] = Query(None),
|
||||||
company_name: Optional[str] = Query(None),
|
company_name: Optional[str] = Query(None),
|
||||||
@ -28,6 +34,7 @@ async def list_invoices(
|
|||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
ticket_type: Optional[str] = Query(None),
|
ticket_type: Optional[str] = Query(None),
|
||||||
invoice_type: Optional[str] = Query(None),
|
invoice_type: Optional[str] = Query(None),
|
||||||
|
user_id: Optional[int] = Query(None, description="按App用户ID过滤"),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(10, ge=1, le=100),
|
page_size: int = Query(10, ge=1, le=100),
|
||||||
):
|
):
|
||||||
@ -45,13 +52,14 @@ async def list_invoices(
|
|||||||
status=status,
|
status=status,
|
||||||
ticket_type=ticket_type,
|
ticket_type=ticket_type,
|
||||||
invoice_type=invoice_type,
|
invoice_type=invoice_type,
|
||||||
|
app_user_id=user_id,
|
||||||
)
|
)
|
||||||
return SuccessExtra(
|
return SuccessExtra(
|
||||||
data=result.items, total=result.total, page=result.page, page_size=result.page_size, msg="获取成功"
|
data=result.items, total=result.total, page=result.page, page_size=result.page_size, msg="获取成功"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@invoice_router.get("/detail", summary="发票详情", response_model=BasicResponse[InvoiceOut])
|
@invoice_router.get("/detail", summary="发票详情", response_model=BasicResponse[InvoiceOut], dependencies=[DependAuth, DependPermission])
|
||||||
async def invoice_detail(id: int = Query(...)):
|
async def invoice_detail(id: int = Query(...)):
|
||||||
"""
|
"""
|
||||||
根据ID获取发票详情
|
根据ID获取发票详情
|
||||||
@ -62,7 +70,7 @@ async def invoice_detail(id: int = Query(...)):
|
|||||||
return Success(data=out, msg="获取成功")
|
return Success(data=out, msg="获取成功")
|
||||||
|
|
||||||
|
|
||||||
@invoice_router.post("/create", summary="创建发票", response_model=BasicResponse[InvoiceOut])
|
@invoice_router.post("/create", summary="创建发票", response_model=BasicResponse[InvoiceOut], dependencies=[DependAuth, DependPermission])
|
||||||
async def create_invoice(data: InvoiceCreate):
|
async def create_invoice(data: InvoiceCreate):
|
||||||
"""
|
"""
|
||||||
创建发票记录
|
创建发票记录
|
||||||
@ -72,7 +80,7 @@ async def create_invoice(data: InvoiceCreate):
|
|||||||
return Success(data=out, msg="创建成功")
|
return Success(data=out, msg="创建成功")
|
||||||
|
|
||||||
|
|
||||||
@invoice_router.post("/update", summary="更新发票", response_model=BasicResponse[InvoiceOut])
|
@invoice_router.post("/update", summary="更新发票", response_model=BasicResponse[InvoiceOut], dependencies=[DependAuth, DependPermission])
|
||||||
async def update_invoice(data: InvoiceUpdate, id: int = Query(...)):
|
async def update_invoice(data: InvoiceUpdate, id: int = Query(...)):
|
||||||
"""
|
"""
|
||||||
更新发票记录
|
更新发票记录
|
||||||
@ -82,7 +90,7 @@ async def update_invoice(data: InvoiceUpdate, id: int = Query(...)):
|
|||||||
return Success(data=out or {}, msg="更新成功" if updated else "未找到")
|
return Success(data=out or {}, msg="更新成功" if updated else "未找到")
|
||||||
|
|
||||||
|
|
||||||
@invoice_router.delete("/delete", summary="删除发票", response_model=BasicResponse[MessageOut])
|
@invoice_router.delete("/delete", summary="删除发票", response_model=BasicResponse[MessageOut], dependencies=[DependAuth, DependPermission])
|
||||||
async def delete_invoice(id: int = Query(...)):
|
async def delete_invoice(id: int = Query(...)):
|
||||||
"""
|
"""
|
||||||
删除发票记录
|
删除发票记录
|
||||||
@ -95,7 +103,7 @@ async def delete_invoice(id: int = Query(...)):
|
|||||||
return Success(data={"deleted": ok}, msg="删除成功" if ok else "未找到")
|
return Success(data={"deleted": ok}, msg="删除成功" if ok else "未找到")
|
||||||
|
|
||||||
|
|
||||||
@invoice_router.post("/update-status", summary="更新发票状态", response_model=BasicResponse[InvoiceOut])
|
@invoice_router.post("/update-status", summary="更新发票状态", response_model=BasicResponse[InvoiceOut], dependencies=[DependAuth, DependPermission])
|
||||||
async def update_invoice_status(data: UpdateStatus):
|
async def update_invoice_status(data: UpdateStatus):
|
||||||
"""
|
"""
|
||||||
更新发票状态(pending|invoiced|rejected|refunded)
|
更新发票状态(pending|invoiced|rejected|refunded)
|
||||||
@ -106,25 +114,37 @@ async def update_invoice_status(data: UpdateStatus):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@invoice_router.post("/{id}/receipt", summary="上传付款凭证", response_model=BasicResponse[PaymentReceiptOut])
|
@invoice_router.post("/{id}/receipt", summary="上传付款凭证", response_model=BasicResponse[dict], dependencies=[DependAuth, DependPermission])
|
||||||
async def upload_payment_receipt(id: int, data: PaymentReceiptCreate):
|
async def upload_payment_receipt(id: int, data: PaymentReceiptCreate):
|
||||||
"""
|
"""
|
||||||
上传对公转账付款凭证
|
上传对公转账付款凭证
|
||||||
"""
|
"""
|
||||||
receipt = await invoice_controller.create_receipt(id, data)
|
receipt = await invoice_controller.create_receipt(id, data)
|
||||||
return Success(data=receipt, msg="上传成功")
|
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||||
|
return Success(data=detail, msg="上传成功")
|
||||||
|
|
||||||
|
|
||||||
@invoice_router.get("/headers", summary="发票抬头列表", response_model=BasicResponse[list[InvoiceHeaderOut]])
|
@invoice_router.get("/headers", summary="发票抬头列表", response_model=PageResponse[InvoiceHeaderOut], dependencies=[DependAuth, DependPermission])
|
||||||
async def get_invoice_headers(app_user_id: Optional[int] = Query(None)):
|
async def get_invoice_headers(
|
||||||
|
app_user_id: Optional[int] = Query(None, description="按App用户ID过滤"),
|
||||||
|
user_id: Optional[int] = Query(None, description="按App用户ID过滤(兼容参数)"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(10, ge=1, le=100),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
获取发票抬头列表,可按 AppUser 过滤
|
管理端抬头列表(管理员token):支持按 App 用户过滤与分页。
|
||||||
"""
|
"""
|
||||||
headers = await invoice_controller.get_headers(user_id=app_user_id)
|
uid = app_user_id if app_user_id is not None else user_id
|
||||||
return Success(data=headers, msg="获取成功")
|
qs = InvoiceHeader.all()
|
||||||
|
if uid is not None:
|
||||||
|
qs = qs.filter(app_user_id=uid)
|
||||||
|
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="获取成功")
|
||||||
|
|
||||||
|
|
||||||
@invoice_router.get("/headers/{id}", summary="发票抬头详情", response_model=BasicResponse[InvoiceHeaderOut])
|
@invoice_router.get("/headers/{id}", summary="发票抬头详情", response_model=BasicResponse[InvoiceHeaderOut], dependencies=[DependAuth, DependPermission])
|
||||||
async def get_invoice_header_by_id(id: int):
|
async def get_invoice_header_by_id(id: int):
|
||||||
"""
|
"""
|
||||||
获取发票抬头详情
|
获取发票抬头详情
|
||||||
@ -133,7 +153,7 @@ async def get_invoice_header_by_id(id: int):
|
|||||||
return Success(data=header or {}, msg="获取成功" if header else "未找到")
|
return Success(data=header or {}, msg="获取成功" if header else "未找到")
|
||||||
|
|
||||||
|
|
||||||
@invoice_router.post("/headers", summary="新增发票抬头", response_model=BasicResponse[InvoiceHeaderOut])
|
@invoice_router.post("/headers", summary="新增发票抬头", response_model=BasicResponse[InvoiceHeaderOut], dependencies=[DependAuth, DependPermission])
|
||||||
async def create_invoice_header(data: InvoiceHeaderCreate, app_user_id: Optional[int] = Query(None)):
|
async def create_invoice_header(data: InvoiceHeaderCreate, app_user_id: Optional[int] = Query(None)):
|
||||||
"""
|
"""
|
||||||
新增发票抬头
|
新增发票抬头
|
||||||
@ -142,7 +162,7 @@ async def create_invoice_header(data: InvoiceHeaderCreate, app_user_id: Optional
|
|||||||
return Success(data=header, msg="创建成功")
|
return Success(data=header, msg="创建成功")
|
||||||
|
|
||||||
|
|
||||||
@invoice_router.put("/{id}/type", summary="更新发票类型", response_model=BasicResponse[InvoiceOut])
|
@invoice_router.put("/{id}/type", summary="更新发票类型", response_model=BasicResponse[InvoiceOut], dependencies=[DependAuth, DependPermission])
|
||||||
async def update_invoice_type(id: int, data: UpdateType):
|
async def update_invoice_type(id: int, data: UpdateType):
|
||||||
"""
|
"""
|
||||||
更新发票的电子/纸质与专票/普票类型
|
更新发票的电子/纸质与专票/普票类型
|
||||||
@ -151,4 +171,13 @@ async def update_invoice_type(id: int, data: UpdateType):
|
|||||||
return Success(data=out or {}, msg="更新成功" if out else "未找到")
|
return Success(data=out or {}, msg="更新成功" if out else "未找到")
|
||||||
|
|
||||||
|
|
||||||
# 对公转账记录接口在 transactions 路由中统一暴露
|
@invoice_router.delete("/headers/{id}", summary="删除发票抬头", response_model=BasicResponse[MessageOut], dependencies=[DependAuth, DependPermission])
|
||||||
|
async def delete_invoice_header(id: int):
|
||||||
|
ok = await invoice_controller.delete_header(id)
|
||||||
|
return Success(msg="删除成功" if ok else "未找到")
|
||||||
|
|
||||||
|
|
||||||
|
@invoice_router.put("/headers/{id}", summary="更新发票抬头", response_model=BasicResponse[InvoiceHeaderOut], dependencies=[DependAuth, DependPermission])
|
||||||
|
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 "未找到")
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from app.services.sms_store import store
|
|||||||
from app.core.dependency import DependAuth
|
from app.core.dependency import DependAuth
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas.app_user import AppUserInfoOut, AppUserJWTOut
|
from app.schemas.app_user import AppUserInfoOut, AppUserJWTOut
|
||||||
|
from app.schemas.base import BasicResponse, Success
|
||||||
|
|
||||||
|
|
||||||
class SendCodeRequest(BaseModel):
|
class SendCodeRequest(BaseModel):
|
||||||
@ -44,8 +45,8 @@ rate_limiter = PhoneRateLimiter(60)
|
|||||||
router = APIRouter(tags=["短信服务"])
|
router = APIRouter(tags=["短信服务"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send-code", response_model=SendResponse, summary="验证码发送")
|
@router.post("/send-code", response_model=BasicResponse[dict], summary="验证码发送")
|
||||||
async def send_code(payload: SendCodeRequest) -> SendResponse:
|
async def send_code(payload: SendCodeRequest) -> BasicResponse[dict]:
|
||||||
"""发送验证码短信
|
"""发送验证码短信
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -68,7 +69,13 @@ async def send_code(payload: SendCodeRequest) -> SendResponse:
|
|||||||
rid = res.get("RequestId") or res.get("MessageId")
|
rid = res.get("RequestId") or res.get("MessageId")
|
||||||
if code == "OK":
|
if code == "OK":
|
||||||
logger.info("sms.send_code success phone={} request_id={}", payload.phone, rid)
|
logger.info("sms.send_code success phone={} request_id={}", payload.phone, rid)
|
||||||
return SendResponse(status="OK", message="sent", request_id=str(rid) if rid else None)
|
return Success(
|
||||||
|
data={
|
||||||
|
"status": "OK",
|
||||||
|
"message": "sent",
|
||||||
|
"request_id": str(rid) if rid else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
msg = res.get("Message") or res.get("ResponseDescription") or "error"
|
msg = res.get("Message") or res.get("ResponseDescription") or "error"
|
||||||
logger.warning("sms.send_code fail phone={} code={} msg={}", payload.phone, code, msg)
|
logger.warning("sms.send_code fail phone={} code={} msg={}", payload.phone, code, msg)
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(msg))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(msg))
|
||||||
@ -79,8 +86,8 @@ async def send_code(payload: SendCodeRequest) -> SendResponse:
|
|||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="短信服务异常")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="短信服务异常")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send-report", response_model=SendResponse, summary="报告通知发送", dependencies=[DependAuth])
|
@router.post("/send-report", response_model=BasicResponse[dict], summary="报告通知发送", dependencies=[DependAuth])
|
||||||
async def send_report(payload: SendReportRequest) -> SendResponse:
|
async def send_report(payload: SendReportRequest) -> BasicResponse[dict]:
|
||||||
"""发送报告通知短信
|
"""发送报告通知短信
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -98,7 +105,13 @@ async def send_report(payload: SendReportRequest) -> SendResponse:
|
|||||||
rid = res.get("RequestId") or res.get("MessageId")
|
rid = res.get("RequestId") or res.get("MessageId")
|
||||||
if code == "OK":
|
if code == "OK":
|
||||||
logger.info("sms.send_report success phone={} request_id={}", payload.phone, rid)
|
logger.info("sms.send_report success phone={} request_id={}", payload.phone, rid)
|
||||||
return SendResponse(status="OK", message="sent", request_id=str(rid) if rid else None)
|
return Success(
|
||||||
|
data={
|
||||||
|
"status": "OK",
|
||||||
|
"message": "sent",
|
||||||
|
"request_id": str(rid) if rid else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
msg = res.get("Message") or res.get("ResponseDescription") or "error"
|
msg = res.get("Message") or res.get("ResponseDescription") or "error"
|
||||||
logger.warning("sms.send_report fail phone={} code={} msg={}", payload.phone, code, msg)
|
logger.warning("sms.send_report fail phone={} code={} msg={}", payload.phone, code, msg)
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(msg))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(msg))
|
||||||
@ -109,8 +122,8 @@ async def send_report(payload: SendReportRequest) -> SendResponse:
|
|||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="短信服务异常")
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="短信服务异常")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-code", summary="验证码验证", response_model=VerifyResponse)
|
@router.post("/verify-code", summary="验证码验证", response_model=BasicResponse[dict])
|
||||||
async def verify_code(payload: VerifyCodeRequest) -> VerifyResponse:
|
async def verify_code(payload: VerifyCodeRequest) -> BasicResponse[dict]:
|
||||||
"""验证验证码
|
"""验证验证码
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -119,6 +132,11 @@ async def verify_code(payload: VerifyCodeRequest) -> VerifyResponse:
|
|||||||
Returns:
|
Returns:
|
||||||
验证结果字典
|
验证结果字典
|
||||||
"""
|
"""
|
||||||
|
from app.settings import settings
|
||||||
|
if settings.SMS_BYPASS_CODE and payload.code == settings.SMS_BYPASS_CODE:
|
||||||
|
logger.info("sms.verify_code bypass phone={}", payload.phone)
|
||||||
|
store.mark_verified(payload.phone)
|
||||||
|
return Success(data={"status": "OK", "message": "verified"})
|
||||||
ok, reason = store.can_verify(payload.phone)
|
ok, reason = store.can_verify(payload.phone)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=str(reason))
|
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=str(reason))
|
||||||
@ -137,7 +155,8 @@ async def verify_code(payload: VerifyCodeRequest) -> VerifyResponse:
|
|||||||
store.clear_code(payload.phone)
|
store.clear_code(payload.phone)
|
||||||
store.reset_failures(payload.phone)
|
store.reset_failures(payload.phone)
|
||||||
logger.info("sms.verify_code success phone={}", payload.phone)
|
logger.info("sms.verify_code success phone={}", payload.phone)
|
||||||
return VerifyResponse(status="OK", message="verified")
|
store.mark_verified(payload.phone)
|
||||||
|
return Success(data={"status": "OK", "message": "verified"})
|
||||||
|
|
||||||
|
|
||||||
class SMSLoginRequest(BaseModel):
|
class SMSLoginRequest(BaseModel):
|
||||||
@ -146,8 +165,11 @@ class SMSLoginRequest(BaseModel):
|
|||||||
device_id: Optional[str] = Field(None)
|
device_id: Optional[str] = Field(None)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", summary="短信验证码登录", response_model=SMSLoginResponse)
|
@router.post("/login", summary="短信验证码登录", response_model=BasicResponse[dict])
|
||||||
async def sms_login(payload: SMSLoginRequest) -> SMSLoginResponse:
|
async def sms_login(payload: SMSLoginRequest) -> BasicResponse[dict]:
|
||||||
|
from app.settings import settings
|
||||||
|
bypass = settings.SMS_BYPASS_CODE and payload.verification_code == settings.SMS_BYPASS_CODE
|
||||||
|
if not bypass:
|
||||||
ok, reason = store.can_verify(payload.phone_number)
|
ok, reason = store.can_verify(payload.phone_number)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=str(reason))
|
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=str(reason))
|
||||||
@ -174,6 +196,7 @@ async def sms_login(payload: SMSLoginRequest) -> SMSLoginResponse:
|
|||||||
await app_user_controller.update_last_login(user.id)
|
await app_user_controller.update_last_login(user.id)
|
||||||
|
|
||||||
access_token = create_app_user_access_token(user_id=user.id, phone=user.phone)
|
access_token = create_app_user_access_token(user_id=user.id, phone=user.phone)
|
||||||
|
if not bypass:
|
||||||
store.clear_code(payload.phone_number)
|
store.clear_code(payload.phone_number)
|
||||||
store.reset_failures(payload.phone_number)
|
store.reset_failures(payload.phone_number)
|
||||||
logger.info("sms.login success phone={}", payload.phone_number)
|
logger.info("sms.login success phone={}", payload.phone_number)
|
||||||
@ -181,8 +204,8 @@ async def sms_login(payload: SMSLoginRequest) -> SMSLoginResponse:
|
|||||||
user_info = AppUserInfoOut(
|
user_info = AppUserInfoOut(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
phone=user.phone,
|
phone=user.phone,
|
||||||
nickname=user.nickname,
|
nickname=getattr(user, "alias", None),
|
||||||
avatar=user.avatar,
|
avatar=None,
|
||||||
company_name=user.company_name,
|
company_name=user.company_name,
|
||||||
company_address=user.company_address,
|
company_address=user.company_address,
|
||||||
company_contact=user.company_contact,
|
company_contact=user.company_contact,
|
||||||
@ -192,9 +215,10 @@ async def sms_login(payload: SMSLoginRequest) -> SMSLoginResponse:
|
|||||||
last_login=user.last_login,
|
last_login=user.last_login,
|
||||||
created_at=user.created_at,
|
created_at=user.created_at,
|
||||||
updated_at=user.updated_at,
|
updated_at=user.updated_at,
|
||||||
|
remaining_quota=user.remaining_quota,
|
||||||
)
|
)
|
||||||
token_out = AppUserJWTOut(access_token=access_token, expires_in=ACCESS_TOKEN_EXPIRE_MINUTES)
|
token_out = AppUserJWTOut(access_token=access_token, expires_in=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
return SMSLoginResponse(user=user_info, token=token_out)
|
return Success(data={"user": user_info.model_dump(), "token": token_out.model_dump()})
|
||||||
class VerifyCodeRequest(BaseModel):
|
class VerifyCodeRequest(BaseModel):
|
||||||
phone: str = Field(...)
|
phone: str = Field(...)
|
||||||
code: str = Field(...)
|
code: str = Field(...)
|
||||||
|
|||||||
@ -4,6 +4,8 @@ from typing import Optional
|
|||||||
from app.schemas.base import Success, SuccessExtra, PageResponse, BasicResponse
|
from app.schemas.base import Success, SuccessExtra, PageResponse, BasicResponse
|
||||||
from app.schemas.invoice import PaymentReceiptOut
|
from app.schemas.invoice import PaymentReceiptOut
|
||||||
from app.controllers.invoice import invoice_controller
|
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.schemas.transactions import SendEmailRequest, SendEmailResponse
|
||||||
from app.services.email_client import email_client
|
from app.services.email_client import email_client
|
||||||
from app.models.invoice import EmailSendLog
|
from app.models.invoice import EmailSendLog
|
||||||
@ -65,47 +67,87 @@ async def get_receipt_detail(id: int):
|
|||||||
return Success(data=data or {}, msg="获取成功" if data else "未找到")
|
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
|
@transactions_router.post("/send-email", summary="发送邮件", response_model=BasicResponse[SendEmailResponse])
|
||||||
if file is not None:
|
async def send_email(payload: SendEmailRequest = Body(...)):
|
||||||
file_bytes = await file.read()
|
|
||||||
file_name = file.filename
|
attachments = []
|
||||||
elif data.file_url:
|
urls = []
|
||||||
|
try:
|
||||||
|
domain = payload.email.split("@")[-1]
|
||||||
|
import dns.resolver
|
||||||
|
try:
|
||||||
|
dns.resolver.resolve(domain, "MX")
|
||||||
|
except Exception:
|
||||||
|
dns.resolver.resolve(domain, "A")
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="收件方地址域名不可用或未正确解析")
|
||||||
|
if payload.file_urls:
|
||||||
|
urls.extend([u.strip().strip('`') for u in payload.file_urls if isinstance(u, str)])
|
||||||
|
if payload.file_url:
|
||||||
|
if isinstance(payload.file_url, str):
|
||||||
|
urls.append(payload.file_url.strip().strip('`'))
|
||||||
|
elif isinstance(payload.file_url, list):
|
||||||
|
urls.extend([u.strip().strip('`') for u in payload.file_url if isinstance(u, str)])
|
||||||
|
if urls:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
r = await client.get(data.file_url)
|
for u in urls:
|
||||||
|
r = await client.get(u)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
file_bytes = r.content
|
attachments.append((r.content, u.split("/")[-1]))
|
||||||
file_name = data.file_url.split("/")[-1]
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"附件下载失败: {e}")
|
raise HTTPException(status_code=400, detail=f"附件下载失败: {e}")
|
||||||
|
|
||||||
logger.info("transactions.email_send_start email={} subject={}", data.email, data.subject or "")
|
logger.info("transactions.email_send_start email={} subject={}", payload.email, payload.subject or "")
|
||||||
result = email_client.send(data.email, data.subject, data.body, file_bytes, file_name, getattr(file, "content_type", None))
|
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
|
||||||
status = result.get("status")
|
status = result.get("status")
|
||||||
error = result.get("error")
|
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(
|
log = await EmailSendLog.create(
|
||||||
email=data.email,
|
email=payload.email,
|
||||||
subject=data.subject,
|
subject=payload.subject,
|
||||||
body_summary=body_summary,
|
body_summary=body_summary,
|
||||||
file_name=file_name,
|
file_name=first_name,
|
||||||
file_url=data.file_url,
|
file_url=first_url,
|
||||||
status=status,
|
status=status,
|
||||||
error=error,
|
error=error,
|
||||||
)
|
)
|
||||||
if status == "OK":
|
if status == "OK":
|
||||||
logger.info("transactions.email_send_ok email={}", data.email)
|
logger.info("transactions.email_send_ok email={}", payload.email)
|
||||||
else:
|
else:
|
||||||
logger.error("transactions.email_send_fail email={} err={}", data.email, error)
|
logger.error("transactions.email_send_fail email={} err={}", payload.email, error)
|
||||||
|
|
||||||
|
if payload.receipt_id:
|
||||||
|
try:
|
||||||
|
r = await PaymentReceipt.filter(id=payload.receipt_id).first()
|
||||||
|
if r:
|
||||||
|
try:
|
||||||
|
inv = await r.invoice
|
||||||
|
if inv:
|
||||||
|
s = str(payload.status or "").lower()
|
||||||
|
if s in {"invoiced", "success"}:
|
||||||
|
target = "invoiced"
|
||||||
|
elif s in {"refunded", "rejected", "pending"}:
|
||||||
|
target = s
|
||||||
|
else:
|
||||||
|
target = "invoiced"
|
||||||
|
inv.status = target
|
||||||
|
await inv.save()
|
||||||
|
logger.info("transactions.invoice_status_updated receipt_id={} invoice_id={} status={}", payload.receipt_id, inv.id, target)
|
||||||
|
except Exception as e2:
|
||||||
|
logger.warning("transactions.invoice_status_update_fail receipt_id={} err={}", payload.receipt_id, str(e2))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("transactions.email_extra_save_fail id={} err={}", payload.receipt_id, str(e))
|
||||||
|
|
||||||
return Success(data={"status": status, "log_id": log.id, "error": error}, msg="发送成功" if status == "OK" else "发送失败")
|
return Success(data={"status": status, "log_id": log.id, "error": error}, msg="发送成功" if status == "OK" else "发送失败")
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,11 @@
|
|||||||
from fastapi import APIRouter, UploadFile, File
|
from fastapi import APIRouter, UploadFile, File
|
||||||
from app.controllers.upload import UploadController
|
from app.controllers.upload import UploadController
|
||||||
from app.schemas.upload import ImageUploadResponse, FileUploadResponse
|
from app.schemas.upload import ImageUploadResponse, FileUploadResponse
|
||||||
|
from app.schemas.base import BasicResponse, Success
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.post("/image", response_model=ImageUploadResponse, summary="上传图片")
|
@router.post("/file", response_model=BasicResponse[dict], summary="统一上传接口")
|
||||||
async def upload_image(file: UploadFile = File(...)) -> ImageUploadResponse:
|
async def upload(file: UploadFile = File(...)) -> BasicResponse[dict]:
|
||||||
"""
|
res = await UploadController.upload_any(file)
|
||||||
上传图片接口
|
return Success(data={"url": res.url, "filename": res.filename, "content_type": res.content_type})
|
||||||
:param file: 图片文件
|
|
||||||
:return: 图片URL和文件名
|
|
||||||
"""
|
|
||||||
return await UploadController.upload_image(file)
|
|
||||||
|
|
||||||
@router.post("/file", response_model=FileUploadResponse, summary="上传文件")
|
|
||||||
async def upload_file(file: UploadFile = File(...)) -> FileUploadResponse:
|
|
||||||
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)
|
|
||||||
|
|||||||
@ -60,10 +60,37 @@ async def get_valuation_steps(valuation_id: int):
|
|||||||
return Success(data=steps_out, msg="获取计算步骤成功")
|
return Success(data=steps_out, msg="获取计算步骤成功")
|
||||||
|
|
||||||
|
|
||||||
|
@valuations_router.get("/{valuation_id}/report", summary="获取估值计算报告(Markdown格式)")
|
||||||
|
async def get_valuation_report(valuation_id: int):
|
||||||
|
"""
|
||||||
|
根据估值ID生成计算过程的 Markdown 报告
|
||||||
|
|
||||||
|
返回格式化的 Markdown 文档,包含:
|
||||||
|
- 估值基本信息
|
||||||
|
- 计算结果摘要
|
||||||
|
- 详细计算过程(按公式层级组织)
|
||||||
|
- 每个公式的输入参数、输出结果、状态等信息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
markdown = await valuation_controller.get_calculation_report_markdown(valuation_id)
|
||||||
|
from fastapi import Response
|
||||||
|
return Response(
|
||||||
|
content=markdown,
|
||||||
|
media_type="text/markdown; charset=utf-8",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="valuation_report_{valuation_id}.md"'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"生成报告失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@valuations_router.put("/{valuation_id}", summary="更新估值评估", response_model=BasicResponse[ValuationAssessmentOut])
|
@valuations_router.put("/{valuation_id}", summary="更新估值评估", response_model=BasicResponse[ValuationAssessmentOut])
|
||||||
async def update_valuation(valuation_id: int, data: ValuationAssessmentUpdate):
|
async def update_valuation(valuation_id: int, data: ValuationAssessmentUpdate):
|
||||||
"""更新估值评估记录"""
|
"""更新估值评估记录"""
|
||||||
result = await valuation_controller.update(valuation_id, data)
|
result = await valuation_controller.update1(valuation_id, data)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="估值评估记录不存在")
|
raise HTTPException(status_code=404, detail="估值评估记录不存在")
|
||||||
import json
|
import json
|
||||||
@ -87,8 +114,14 @@ async def get_valuations(
|
|||||||
heritage_level: Optional[str] = Query(None, description="非遗等级"),
|
heritage_level: Optional[str] = Query(None, description="非遗等级"),
|
||||||
status: Optional[str] = Query(None, description="评估状态"),
|
status: Optional[str] = Query(None, description="评估状态"),
|
||||||
is_active: Optional[bool] = Query(None, description="是否激活"),
|
is_active: Optional[bool] = Query(None, description="是否激活"),
|
||||||
|
phone: Optional[str] = Query(None, description="手机号模糊查询"),
|
||||||
|
submitted_start: Optional[str] = Query(None, description="提交时间开始(毫秒或ISO)"),
|
||||||
|
submitted_end: Optional[str] = Query(None, description="提交时间结束(毫秒或ISO)"),
|
||||||
|
audited_start: Optional[str] = Query(None, description="审核时间开始(证书修改时间,毫秒或ISO)"),
|
||||||
|
audited_end: Optional[str] = Query(None, description="审核时间结束(证书修改时间,毫秒或ISO)"),
|
||||||
page: int = Query(1, ge=1, description="页码"),
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
size: int = Query(10, ge=1, le=100, description="每页数量")
|
size: int = Query(10, ge=1, le=100, description="每页数量"),
|
||||||
|
page_size: Optional[int] = Query(None, alias="page_size", ge=1, le=100, description="每页数量")
|
||||||
):
|
):
|
||||||
"""获取估值评估列表,支持筛选和分页"""
|
"""获取估值评估列表,支持筛选和分页"""
|
||||||
query = ValuationAssessmentQuery(
|
query = ValuationAssessmentQuery(
|
||||||
@ -98,8 +131,13 @@ async def get_valuations(
|
|||||||
heritage_level=heritage_level,
|
heritage_level=heritage_level,
|
||||||
status=status,
|
status=status,
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
|
phone=phone,
|
||||||
|
submitted_start=submitted_start,
|
||||||
|
submitted_end=submitted_end,
|
||||||
|
audited_start=audited_start,
|
||||||
|
audited_end=audited_end,
|
||||||
page=page,
|
page=page,
|
||||||
size=size
|
size=page_size if page_size is not None else size
|
||||||
)
|
)
|
||||||
result = await valuation_controller.get_list(query)
|
result = await valuation_controller.get_list(query)
|
||||||
import json
|
import json
|
||||||
@ -118,10 +156,11 @@ async def get_valuations(
|
|||||||
async def search_valuations(
|
async def search_valuations(
|
||||||
keyword: str = Query(..., description="搜索关键词"),
|
keyword: str = Query(..., description="搜索关键词"),
|
||||||
page: int = Query(1, ge=1, description="页码"),
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
size: int = Query(10, ge=1, le=100, description="每页数量")
|
size: int = Query(10, ge=1, le=100, description="每页数量"),
|
||||||
|
page_size: Optional[int] = Query(None, alias="page_size", ge=1, le=100, description="每页数量")
|
||||||
):
|
):
|
||||||
"""根据关键词搜索估值评估记录"""
|
"""根据关键词搜索估值评估记录"""
|
||||||
result = await valuation_controller.search(keyword, page, size)
|
result = await valuation_controller.search(keyword, page, page_size if page_size is not None else size)
|
||||||
import json
|
import json
|
||||||
items = [json.loads(item.model_dump_json()) for item in result.items]
|
items = [json.loads(item.model_dump_json()) for item in result.items]
|
||||||
return SuccessExtra(
|
return SuccessExtra(
|
||||||
|
|||||||
@ -21,6 +21,15 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
|||||||
# 检查手机号是否已存在
|
# 检查手机号是否已存在
|
||||||
existing_user = await self.model.filter(phone=register_data.phone).first()
|
existing_user = await self.model.filter(phone=register_data.phone).first()
|
||||||
if existing_user:
|
if existing_user:
|
||||||
|
if getattr(existing_user, "is_deleted", False):
|
||||||
|
default_password = register_data.phone[-6:]
|
||||||
|
hashed_password = get_password_hash(default_password)
|
||||||
|
existing_user.is_deleted = False
|
||||||
|
existing_user.deleted_at = None
|
||||||
|
existing_user.is_active = True
|
||||||
|
existing_user.password = hashed_password
|
||||||
|
await existing_user.save()
|
||||||
|
return existing_user
|
||||||
raise HTTPException(status_code=400, detail="手机号已存在")
|
raise HTTPException(status_code=400, detail="手机号已存在")
|
||||||
|
|
||||||
# 生成默认密码:手机号后六位
|
# 生成默认密码:手机号后六位
|
||||||
@ -42,7 +51,7 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
|||||||
用户认证
|
用户认证
|
||||||
"""
|
"""
|
||||||
user = await self.model.filter(
|
user = await self.model.filter(
|
||||||
phone=login_data.phone, is_active=True
|
phone=login_data.phone, is_active=True, is_deleted=False
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
@ -57,13 +66,13 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
|||||||
"""
|
"""
|
||||||
根据ID获取用户
|
根据ID获取用户
|
||||||
"""
|
"""
|
||||||
return await self.model.filter(id=user_id, is_active=True).first()
|
return await self.model.filter(id=user_id, is_active=True, is_deleted=False).first()
|
||||||
|
|
||||||
async def get_user_by_phone(self, phone: str) -> Optional[AppUser]:
|
async def get_user_by_phone(self, phone: str) -> Optional[AppUser]:
|
||||||
"""
|
"""
|
||||||
根据手机号获取用户
|
根据手机号获取用户
|
||||||
"""
|
"""
|
||||||
return await self.model.filter(phone=phone, is_active=True).first()
|
return await self.model.filter(phone=phone, is_active=True, is_deleted=False).first()
|
||||||
|
|
||||||
async def update_last_login(self, user_id: int) -> bool:
|
async def update_last_login(self, user_id: int) -> bool:
|
||||||
"""
|
"""
|
||||||
@ -86,6 +95,9 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
|||||||
|
|
||||||
# 更新字段
|
# 更新字段
|
||||||
update_dict = update_data.model_dump(exclude_unset=True)
|
update_dict = update_data.model_dump(exclude_unset=True)
|
||||||
|
if "nickname" in update_dict:
|
||||||
|
update_dict["alias"] = update_dict.pop("nickname")
|
||||||
|
update_dict.pop("avatar", None)
|
||||||
for field, value in update_dict.items():
|
for field, value in update_dict.items():
|
||||||
setattr(user, field, value)
|
setattr(user, field, value)
|
||||||
|
|
||||||
@ -113,6 +125,9 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
|||||||
op_type=op_type,
|
op_type=op_type,
|
||||||
remark=remark,
|
remark=remark,
|
||||||
)
|
)
|
||||||
|
# if remark is not None:
|
||||||
|
# user.notes = remark
|
||||||
|
# await user.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
async def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
async def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||||
@ -143,6 +158,27 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def delete_user_account(self, user_id: int) -> bool:
|
||||||
|
user = await self.model.filter(id=user_id).first()
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
user.is_active = False
|
||||||
|
user.is_deleted = True
|
||||||
|
user.deleted_at = datetime.now()
|
||||||
|
masked = f"deleted_{user.id}"
|
||||||
|
user.username = None
|
||||||
|
user.alias = None
|
||||||
|
user.email = None
|
||||||
|
user.password = ""
|
||||||
|
user.company_name = None
|
||||||
|
user.company_address = None
|
||||||
|
user.company_contact = None
|
||||||
|
user.company_phone = None
|
||||||
|
user.company_email = None
|
||||||
|
user.phone = masked
|
||||||
|
await user.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
# 创建控制器实例
|
# 创建控制器实例
|
||||||
app_user_controller = AppUserController()
|
app_user_controller = AppUserController()
|
||||||
@ -9,6 +9,7 @@ from app.schemas.invoice import (
|
|||||||
InvoiceOut,
|
InvoiceOut,
|
||||||
InvoiceList,
|
InvoiceList,
|
||||||
InvoiceHeaderCreate,
|
InvoiceHeaderCreate,
|
||||||
|
InvoiceHeaderUpdate,
|
||||||
InvoiceHeaderOut,
|
InvoiceHeaderOut,
|
||||||
UpdateStatus,
|
UpdateStatus,
|
||||||
UpdateType,
|
UpdateType,
|
||||||
@ -32,7 +33,14 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
|||||||
返回:
|
返回:
|
||||||
InvoiceHeaderOut: 抬头输出对象
|
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)
|
return InvoiceHeaderOut.model_validate(header)
|
||||||
|
|
||||||
async def get_headers(self, user_id: Optional[int] = None) -> List[InvoiceHeaderOut]:
|
async def get_headers(self, user_id: Optional[int] = None) -> List[InvoiceHeaderOut]:
|
||||||
@ -60,6 +68,36 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
|||||||
header = await InvoiceHeader.filter(id=id_).first()
|
header = await InvoiceHeader.filter(id=id_).first()
|
||||||
return InvoiceHeaderOut.model_validate(header) if header else None
|
return InvoiceHeaderOut.model_validate(header) if header else None
|
||||||
|
|
||||||
|
async def delete_header(self, id_: int) -> bool:
|
||||||
|
header = await InvoiceHeader.filter(id=id_).first()
|
||||||
|
if not header:
|
||||||
|
return False
|
||||||
|
await header.delete()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def update_header(self, id_: int, data: InvoiceHeaderUpdate) -> Optional[InvoiceHeaderOut]:
|
||||||
|
header = await InvoiceHeader.filter(id=id_).first()
|
||||||
|
if not header:
|
||||||
|
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()
|
||||||
|
# 同步引用该抬头的发票基本信息
|
||||||
|
sync_fields = {
|
||||||
|
"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,
|
||||||
|
"email": header.email,
|
||||||
|
}
|
||||||
|
await Invoice.filter(header_id=header.id).update(**sync_fields)
|
||||||
|
return InvoiceHeaderOut.model_validate(header)
|
||||||
|
|
||||||
async def list(self, page: int = 1, page_size: int = 10, **filters) -> InvoiceList:
|
async def list(self, page: int = 1, page_size: int = 10, **filters) -> InvoiceList:
|
||||||
"""
|
"""
|
||||||
获取发票列表(支持筛选与分页)
|
获取发票列表(支持筛选与分页)
|
||||||
@ -83,6 +121,8 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
|||||||
qs = qs.filter(ticket_type=filters["ticket_type"])
|
qs = qs.filter(ticket_type=filters["ticket_type"])
|
||||||
if filters.get("invoice_type"):
|
if filters.get("invoice_type"):
|
||||||
qs = qs.filter(invoice_type=filters["invoice_type"])
|
qs = qs.filter(invoice_type=filters["invoice_type"])
|
||||||
|
if filters.get("app_user_id"):
|
||||||
|
qs = qs.filter(app_user_id=filters["app_user_id"])
|
||||||
|
|
||||||
total = await qs.count()
|
total = await qs.count()
|
||||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||||
@ -159,6 +199,7 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
|||||||
note=receipt.note,
|
note=receipt.note,
|
||||||
verified=receipt.verified,
|
verified=receipt.verified,
|
||||||
created_at=receipt.created_at.isoformat() if receipt.created_at else "",
|
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:
|
async def list_receipts(self, page: int = 1, page_size: int = 10, **filters) -> dict:
|
||||||
@ -230,14 +271,23 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
|||||||
items = []
|
items = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
inv = await r.invoice
|
inv = await r.invoice
|
||||||
|
urls = []
|
||||||
|
if isinstance(r.extra, list):
|
||||||
|
urls = [str(u) for u in r.extra if u]
|
||||||
|
elif isinstance(r.extra, dict):
|
||||||
|
v = r.extra.get("urls")
|
||||||
|
if isinstance(v, list):
|
||||||
|
urls = [str(u) for u in v if u]
|
||||||
|
if not urls:
|
||||||
|
urls = [r.url] if r.url else []
|
||||||
|
receipts = [{"id": r.id, "url": u, "note": r.note, "verified": r.verified} for u in urls]
|
||||||
items.append({
|
items.append({
|
||||||
"submitted_at": r.created_at.isoformat() if r.created_at else "",
|
|
||||||
"receipt": {
|
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
"url": r.url,
|
"invoice_id": getattr(inv, "id", None),
|
||||||
"note": r.note,
|
"submitted_at": r.created_at.isoformat() if r.created_at else "",
|
||||||
"verified": r.verified,
|
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
|
||||||
},
|
"extra": r.extra,
|
||||||
|
"receipts": receipts,
|
||||||
"phone": inv.phone,
|
"phone": inv.phone,
|
||||||
"wechat": inv.wechat,
|
"wechat": inv.wechat,
|
||||||
"company_name": inv.company_name,
|
"company_name": inv.company_name,
|
||||||
@ -266,14 +316,23 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
|||||||
if not r:
|
if not r:
|
||||||
return None
|
return None
|
||||||
inv = await r.invoice
|
inv = await r.invoice
|
||||||
|
urls = []
|
||||||
|
if isinstance(r.extra, list):
|
||||||
|
urls = [str(u) for u in r.extra if u]
|
||||||
|
elif isinstance(r.extra, dict):
|
||||||
|
v = r.extra.get("urls")
|
||||||
|
if isinstance(v, list):
|
||||||
|
urls = [str(u) for u in v if u]
|
||||||
|
if not urls:
|
||||||
|
urls = [r.url] if r.url else []
|
||||||
|
receipts = [{"id": r.id, "url": u, "note": r.note, "verified": r.verified} for u in urls]
|
||||||
return {
|
return {
|
||||||
"submitted_at": r.created_at.isoformat() if r.created_at else "",
|
|
||||||
"receipt": {
|
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
"url": r.url,
|
"invoice_id": getattr(inv, "id", None),
|
||||||
"note": r.note,
|
"submitted_at": r.created_at.isoformat() if r.created_at else "",
|
||||||
"verified": r.verified,
|
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
|
||||||
},
|
"extra": r.extra,
|
||||||
|
"receipts": receipts,
|
||||||
"phone": inv.phone,
|
"phone": inv.phone,
|
||||||
"wechat": inv.wechat,
|
"wechat": inv.wechat,
|
||||||
"company_name": inv.company_name,
|
"company_name": inv.company_name,
|
||||||
|
|||||||
@ -15,8 +15,9 @@ class UploadController:
|
|||||||
:param file: 上传的图片文件
|
:param file: 上传的图片文件
|
||||||
:return: 图片URL和文件名
|
:return: 图片URL和文件名
|
||||||
"""
|
"""
|
||||||
# 检查文件类型
|
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||||
if not file.content_type.startswith('image/'):
|
image_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
|
||||||
|
if not (file.content_type.startswith('image/') or ext in image_exts):
|
||||||
raise ValueError("只支持上传图片文件")
|
raise ValueError("只支持上传图片文件")
|
||||||
|
|
||||||
# 获取项目根目录
|
# 获取项目根目录
|
||||||
@ -61,8 +62,32 @@ class UploadController:
|
|||||||
"application/vnd.ms-excel",
|
"application/vnd.ms-excel",
|
||||||
"application/zip",
|
"application/zip",
|
||||||
"application/x-zip-compressed",
|
"application/x-zip-compressed",
|
||||||
|
"application/octet-stream",
|
||||||
|
"text/plain",
|
||||||
|
"text/csv",
|
||||||
|
"application/json",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"application/x-rar-compressed",
|
||||||
|
"application/x-7z-compressed",
|
||||||
}
|
}
|
||||||
if file.content_type not in allowed:
|
allowed_exts = {
|
||||||
|
".pdf",
|
||||||
|
".doc",
|
||||||
|
".docx",
|
||||||
|
".xls",
|
||||||
|
".xlsx",
|
||||||
|
".zip",
|
||||||
|
".rar",
|
||||||
|
".7z",
|
||||||
|
".txt",
|
||||||
|
".csv",
|
||||||
|
".ppt",
|
||||||
|
".pptx",
|
||||||
|
".json",
|
||||||
|
}
|
||||||
|
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||||
|
if (file.content_type not in allowed) and (ext not in allowed_exts):
|
||||||
raise ValueError("不支持的文件类型")
|
raise ValueError("不支持的文件类型")
|
||||||
|
|
||||||
base_dir = Path(__file__).resolve().parent.parent
|
base_dir = Path(__file__).resolve().parent.parent
|
||||||
@ -95,7 +120,9 @@ class UploadController:
|
|||||||
统一上传入口,自动识别图片与非图片类型。
|
统一上传入口,自动识别图片与非图片类型。
|
||||||
返回统一结构:url, filename, content_type
|
返回统一结构:url, filename, content_type
|
||||||
"""
|
"""
|
||||||
if file.content_type and file.content_type.startswith("image/"):
|
image_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
|
||||||
|
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||||
|
if (file.content_type and file.content_type.startswith("image/")) or (ext in image_exts):
|
||||||
img = await UploadController.upload_image(file)
|
img = await UploadController.upload_image(file)
|
||||||
return FileUploadResponse(url=img.url, filename=img.filename, content_type=file.content_type or "image")
|
return FileUploadResponse(url=img.url, filename=img.filename, content_type=file.content_type or "image")
|
||||||
# 非图片类型复用原文件上传校验
|
# 非图片类型复用原文件上传校验
|
||||||
|
|||||||
@ -114,7 +114,73 @@ class UserValuationController:
|
|||||||
|
|
||||||
async def _to_user_out(self, valuation: ValuationAssessment) -> UserValuationOut:
|
async def _to_user_out(self, valuation: ValuationAssessment) -> UserValuationOut:
|
||||||
"""转换为用户端输出模型"""
|
"""转换为用户端输出模型"""
|
||||||
return UserValuationOut.model_validate(valuation)
|
return UserValuationOut(
|
||||||
|
id=valuation.id,
|
||||||
|
asset_name=valuation.asset_name,
|
||||||
|
institution=valuation.institution,
|
||||||
|
industry=valuation.industry,
|
||||||
|
annual_revenue=valuation.annual_revenue,
|
||||||
|
rd_investment=valuation.rd_investment,
|
||||||
|
three_year_income=valuation.three_year_income,
|
||||||
|
funding_status=valuation.funding_status,
|
||||||
|
inheritor_level=valuation.inheritor_level,
|
||||||
|
inheritor_ages=valuation.inheritor_ages,
|
||||||
|
inheritor_age_count=valuation.inheritor_age_count,
|
||||||
|
inheritor_certificates=valuation.inheritor_certificates,
|
||||||
|
heritage_level=getattr(valuation, "heritage_level", None),
|
||||||
|
heritage_asset_level=valuation.heritage_asset_level,
|
||||||
|
patent_application_no=valuation.patent_application_no,
|
||||||
|
patent_remaining_years=valuation.patent_remaining_years,
|
||||||
|
historical_evidence=valuation.historical_evidence,
|
||||||
|
patent_certificates=valuation.patent_certificates,
|
||||||
|
pattern_images=valuation.pattern_images,
|
||||||
|
report_url=valuation.report_url,
|
||||||
|
certificate_url=valuation.certificate_url,
|
||||||
|
application_maturity=valuation.application_maturity,
|
||||||
|
implementation_stage=valuation.implementation_stage,
|
||||||
|
application_coverage=valuation.application_coverage,
|
||||||
|
coverage_area=valuation.coverage_area,
|
||||||
|
cooperation_depth=valuation.cooperation_depth,
|
||||||
|
collaboration_type=valuation.collaboration_type,
|
||||||
|
offline_activities=valuation.offline_activities,
|
||||||
|
offline_teaching_count=valuation.offline_teaching_count,
|
||||||
|
online_accounts=valuation.online_accounts,
|
||||||
|
platform_accounts=valuation.platform_accounts,
|
||||||
|
sales_volume=valuation.sales_volume,
|
||||||
|
link_views=valuation.link_views,
|
||||||
|
circulation=valuation.circulation,
|
||||||
|
scarcity_level=valuation.scarcity_level,
|
||||||
|
last_market_activity=valuation.last_market_activity,
|
||||||
|
market_activity_time=valuation.market_activity_time,
|
||||||
|
monthly_transaction=valuation.monthly_transaction,
|
||||||
|
monthly_transaction_amount=valuation.monthly_transaction_amount,
|
||||||
|
price_fluctuation=valuation.price_fluctuation,
|
||||||
|
price_range=valuation.price_range,
|
||||||
|
market_price=valuation.market_price,
|
||||||
|
credit_code_or_id=valuation.credit_code_or_id,
|
||||||
|
biz_intro=valuation.biz_intro,
|
||||||
|
infringement_record=valuation.infringement_record,
|
||||||
|
patent_count=valuation.patent_count,
|
||||||
|
esg_value=valuation.esg_value,
|
||||||
|
policy_matching=valuation.policy_matching,
|
||||||
|
online_course_views=valuation.online_course_views,
|
||||||
|
pattern_complexity=valuation.pattern_complexity,
|
||||||
|
normalized_entropy=valuation.normalized_entropy,
|
||||||
|
legal_risk=valuation.legal_risk,
|
||||||
|
base_pledge_rate=valuation.base_pledge_rate,
|
||||||
|
flow_correction=valuation.flow_correction,
|
||||||
|
model_value_b=valuation.model_value_b,
|
||||||
|
market_value_c=valuation.market_value_c,
|
||||||
|
final_value_ab=valuation.final_value_ab,
|
||||||
|
dynamic_pledge_rate=valuation.dynamic_pledge_rate,
|
||||||
|
calculation_result=valuation.calculation_result,
|
||||||
|
calculation_input=valuation.calculation_input,
|
||||||
|
status=valuation.status,
|
||||||
|
admin_notes=valuation.admin_notes,
|
||||||
|
created_at=valuation.created_at,
|
||||||
|
updated_at=valuation.updated_at,
|
||||||
|
is_active=valuation.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
async def _to_user_detail(self, valuation: ValuationAssessment) -> UserValuationDetail:
|
async def _to_user_detail(self, valuation: ValuationAssessment) -> UserValuationDetail:
|
||||||
"""转换为用户端详细模型"""
|
"""转换为用户端详细模型"""
|
||||||
@ -131,7 +197,7 @@ class UserValuationController:
|
|||||||
inheritor_ages=valuation.inheritor_ages,
|
inheritor_ages=valuation.inheritor_ages,
|
||||||
inheritor_age_count=valuation.inheritor_age_count,
|
inheritor_age_count=valuation.inheritor_age_count,
|
||||||
inheritor_certificates=valuation.inheritor_certificates,
|
inheritor_certificates=valuation.inheritor_certificates,
|
||||||
heritage_level=valuation.heritage_level,
|
heritage_level=getattr(valuation, "heritage_level", None),
|
||||||
heritage_asset_level=valuation.heritage_asset_level,
|
heritage_asset_level=valuation.heritage_asset_level,
|
||||||
patent_application_no=valuation.patent_application_no,
|
patent_application_no=valuation.patent_application_no,
|
||||||
patent_remaining_years=valuation.patent_remaining_years,
|
patent_remaining_years=valuation.patent_remaining_years,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from typing import List, Optional
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
from tortoise.expressions import Q
|
from tortoise.expressions import Q
|
||||||
from tortoise.queryset import QuerySet
|
from tortoise.queryset import QuerySet
|
||||||
from tortoise.functions import Count
|
from tortoise.functions import Count
|
||||||
@ -13,6 +14,8 @@ from app.schemas.valuation import (
|
|||||||
ValuationCalculationStepCreate,
|
ValuationCalculationStepCreate,
|
||||||
ValuationCalculationStepOut
|
ValuationCalculationStepOut
|
||||||
)
|
)
|
||||||
|
from app.models.user import AppUser
|
||||||
|
from app.utils.calculation_engine.formula_registry import get_formula_meta
|
||||||
|
|
||||||
|
|
||||||
class ValuationController:
|
class ValuationController:
|
||||||
@ -21,6 +24,80 @@ class ValuationController:
|
|||||||
model = ValuationAssessment
|
model = ValuationAssessment
|
||||||
step_model = ValuationCalculationStep
|
step_model = ValuationCalculationStep
|
||||||
|
|
||||||
|
# 参数说明映射表:将参数名(英文)映射到中文说明
|
||||||
|
PARAM_DESCRIPTIONS = {
|
||||||
|
# 财务价值相关
|
||||||
|
"three_year_income": "近三年收益(万元)",
|
||||||
|
"annual_revenue_3_years": "近三年收益(万元)",
|
||||||
|
"financial_value_f": "财务价值F",
|
||||||
|
|
||||||
|
# 法律强度相关
|
||||||
|
"patent_score": "专利分",
|
||||||
|
"popularity_score": "普及分",
|
||||||
|
"infringement_score": "侵权分",
|
||||||
|
"legal_strength_l": "法律强度L",
|
||||||
|
|
||||||
|
# 发展潜力相关
|
||||||
|
"patent_count": "专利数量",
|
||||||
|
"esg_score": "ESG分",
|
||||||
|
"innovation_ratio": "创新投入比",
|
||||||
|
"development_potential_d": "发展潜力D",
|
||||||
|
|
||||||
|
# 行业系数
|
||||||
|
"industry_coefficient": "行业系数I",
|
||||||
|
"target_industry_roe": "目标行业ROE",
|
||||||
|
"benchmark_industry_roe": "基准行业ROE",
|
||||||
|
|
||||||
|
# 流量因子相关
|
||||||
|
"search_index_s1": "搜索指数S1",
|
||||||
|
"industry_average_s2": "行业均值S2",
|
||||||
|
"social_media_spread_s3": "社交媒体传播度S3",
|
||||||
|
"likes": "点赞数",
|
||||||
|
"comments": "评论数",
|
||||||
|
"shares": "转发数",
|
||||||
|
"sales_volume": "销售量",
|
||||||
|
"link_views": "链接浏览量",
|
||||||
|
|
||||||
|
# 政策乘数相关
|
||||||
|
"implementation_stage": "实施阶段评分",
|
||||||
|
"funding_support": "资金支持度",
|
||||||
|
"policy_match_score": "政策匹配度",
|
||||||
|
|
||||||
|
# 文化价值相关
|
||||||
|
"inheritor_level_coefficient": "传承人等级系数",
|
||||||
|
"offline_sessions": "线下传习次数",
|
||||||
|
"douyin_views": "抖音浏览量",
|
||||||
|
"bilibili_views": "B站浏览量",
|
||||||
|
"kuaishou_views": "快手浏览量",
|
||||||
|
"cross_border_depth": "跨界合作深度",
|
||||||
|
"historical_inheritance": "历史传承度HI",
|
||||||
|
"structure_complexity": "结构复杂度SC",
|
||||||
|
"normalized_entropy": "归一化信息熵H",
|
||||||
|
|
||||||
|
# 风险调整相关
|
||||||
|
"highest_price": "最高价格",
|
||||||
|
"lowest_price": "最低价格",
|
||||||
|
"inheritor_ages": "传承人年龄列表",
|
||||||
|
"lawsuit_status": "诉讼状态",
|
||||||
|
|
||||||
|
# 市场估值相关
|
||||||
|
"manual_bids": "手动竞价列表",
|
||||||
|
"expert_valuations": "专家估值列表",
|
||||||
|
"weighted_average_price": "加权平均价格",
|
||||||
|
"daily_browse_volume": "日均浏览量",
|
||||||
|
"collection_count": "收藏数",
|
||||||
|
"issuance_level": "发行量",
|
||||||
|
"recent_market_activity": "最近市场活动时间",
|
||||||
|
|
||||||
|
# 动态质押率相关
|
||||||
|
"monthly_transaction_amount": "月交易额(万元)",
|
||||||
|
"monthly_amount": "月交易额(万元)",
|
||||||
|
"heritage_asset_level": "非遗等级",
|
||||||
|
"dynamic_pledge_rate": "动态质押率",
|
||||||
|
"base_pledge_rate": "基础质押率",
|
||||||
|
"flow_correction": "流量修正系数",
|
||||||
|
}
|
||||||
|
|
||||||
async def create_calculation_step(self, data: ValuationCalculationStepCreate) -> ValuationCalculationStepOut:
|
async def create_calculation_step(self, data: ValuationCalculationStepCreate) -> ValuationCalculationStepOut:
|
||||||
"""
|
"""
|
||||||
创建估值计算步骤
|
创建估值计算步骤
|
||||||
@ -40,6 +117,152 @@ class ValuationController:
|
|||||||
)
|
)
|
||||||
return ValuationCalculationStepOut.model_validate(step)
|
return ValuationCalculationStepOut.model_validate(step)
|
||||||
|
|
||||||
|
async def log_formula_step(
|
||||||
|
self,
|
||||||
|
valuation_id: int,
|
||||||
|
formula_code: str,
|
||||||
|
*,
|
||||||
|
status: str = "processing",
|
||||||
|
input_params: Optional[Dict[str, Any]] = None,
|
||||||
|
output_result: Optional[Dict[str, Any]] = None,
|
||||||
|
error_message: Optional[str] = None,
|
||||||
|
step_description: Optional[str] = None,
|
||||||
|
duration_ms: Optional[int] = None,
|
||||||
|
) -> ValuationCalculationStepOut:
|
||||||
|
"""
|
||||||
|
幂等记录(或更新)某个公式节点的计算过程。
|
||||||
|
"""
|
||||||
|
meta = get_formula_meta(formula_code)
|
||||||
|
description = step_description or meta.formula
|
||||||
|
create_payload: Dict[str, Any] = {
|
||||||
|
"valuation_id": valuation_id,
|
||||||
|
"formula_code": meta.code,
|
||||||
|
"formula_name": meta.name,
|
||||||
|
"formula_text": meta.formula,
|
||||||
|
"parent_formula_code": meta.parent_code,
|
||||||
|
"group_code": meta.group_code,
|
||||||
|
"step_order": meta.order,
|
||||||
|
"step_name": meta.name,
|
||||||
|
"step_description": description,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
if input_params is not None:
|
||||||
|
create_payload["input_params"] = input_params
|
||||||
|
if output_result is not None:
|
||||||
|
create_payload["output_result"] = output_result
|
||||||
|
if error_message is not None:
|
||||||
|
create_payload["error_message"] = error_message
|
||||||
|
|
||||||
|
# 准备更新字段
|
||||||
|
update_fields: Dict[str, Any] = {
|
||||||
|
"status": status,
|
||||||
|
"step_description": description,
|
||||||
|
"formula_name": meta.name,
|
||||||
|
"formula_text": meta.formula,
|
||||||
|
"parent_formula_code": meta.parent_code,
|
||||||
|
"group_code": meta.group_code,
|
||||||
|
"step_order": meta.order,
|
||||||
|
"step_name": meta.name,
|
||||||
|
}
|
||||||
|
if input_params is not None:
|
||||||
|
update_fields["input_params"] = input_params
|
||||||
|
if output_result is not None:
|
||||||
|
update_fields["output_result"] = output_result
|
||||||
|
if error_message is not None:
|
||||||
|
update_fields["error_message"] = error_message
|
||||||
|
if duration_ms is not None:
|
||||||
|
result = update_fields.get("output_result") or {}
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
result = {}
|
||||||
|
result["duration_ms"] = duration_ms
|
||||||
|
update_fields["output_result"] = result
|
||||||
|
|
||||||
|
# 先尝试查询是否存在(明确排除 formula_code 为 NULL 的情况)
|
||||||
|
step = await self.step_model.filter(
|
||||||
|
valuation_id=valuation_id,
|
||||||
|
formula_code=meta.code
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# 如果没找到,再检查是否有 formula_code 为 NULL 的旧记录(不应该有,但为了安全)
|
||||||
|
if not step and meta.code:
|
||||||
|
# 检查是否有重复的旧记录(formula_code 为 NULL)
|
||||||
|
old_steps = await self.step_model.filter(
|
||||||
|
valuation_id=valuation_id,
|
||||||
|
formula_code__isnull=True
|
||||||
|
).all()
|
||||||
|
if old_steps:
|
||||||
|
logger.warning(
|
||||||
|
"calcstep.log_formula found old records with NULL formula_code: valuation_id={} count={}",
|
||||||
|
valuation_id,
|
||||||
|
len(old_steps),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"calcstep.log_formula query: valuation_id={} formula_code={} found={}",
|
||||||
|
valuation_id,
|
||||||
|
meta.code,
|
||||||
|
step is not None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if step:
|
||||||
|
# 更新现有记录
|
||||||
|
await step.update_from_dict(update_fields).save()
|
||||||
|
logger.info(
|
||||||
|
"calcstep.log_formula updated valuation_id={} formula_code={}",
|
||||||
|
valuation_id,
|
||||||
|
meta.code,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 尝试创建新记录
|
||||||
|
if duration_ms is not None:
|
||||||
|
result = create_payload.setdefault("output_result", {}) or {}
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
result = {}
|
||||||
|
result["duration_ms"] = duration_ms
|
||||||
|
create_payload["output_result"] = result
|
||||||
|
|
||||||
|
try:
|
||||||
|
step = await self.step_model.create(**create_payload)
|
||||||
|
logger.info(
|
||||||
|
"calcstep.log_formula created valuation_id={} formula_code={}",
|
||||||
|
valuation_id,
|
||||||
|
meta.code,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# 如果因为唯一约束冲突而失败(可能是并发插入),重新查询并更新
|
||||||
|
error_str = str(e).lower()
|
||||||
|
if "duplicate" in error_str or "unique" in error_str or "1062" in error_str:
|
||||||
|
logger.warning(
|
||||||
|
"calcstep.log_formula duplicate key detected, retrying query: {}",
|
||||||
|
str(e),
|
||||||
|
)
|
||||||
|
# 重新查询(可能已被其他请求插入)
|
||||||
|
step = await self.step_model.filter(
|
||||||
|
valuation_id=valuation_id,
|
||||||
|
formula_code=meta.code
|
||||||
|
).first()
|
||||||
|
if step:
|
||||||
|
# 更新刚插入的记录
|
||||||
|
await step.update_from_dict(update_fields).save()
|
||||||
|
logger.info(
|
||||||
|
"calcstep.log_formula updated after duplicate key: valuation_id={} formula_code={}",
|
||||||
|
valuation_id,
|
||||||
|
meta.code,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 如果还是找不到,记录错误但继续
|
||||||
|
logger.error(
|
||||||
|
"calcstep.log_formula failed to find record after duplicate key error: valuation_id={} formula_code={}",
|
||||||
|
valuation_id,
|
||||||
|
meta.code,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
# 其他错误直接抛出
|
||||||
|
raise
|
||||||
|
|
||||||
|
return ValuationCalculationStepOut.model_validate(step)
|
||||||
|
|
||||||
async def update_calculation_step(self, step_id: int, update: dict) -> ValuationCalculationStepOut:
|
async def update_calculation_step(self, step_id: int, update: dict) -> ValuationCalculationStepOut:
|
||||||
step = await self.step_model.filter(id=step_id).first()
|
step = await self.step_model.filter(id=step_id).first()
|
||||||
if not step:
|
if not step:
|
||||||
@ -70,19 +293,386 @@ class ValuationController:
|
|||||||
logger.info("calcstep.list valuation_id={} count={}", valuation_id, len(steps))
|
logger.info("calcstep.list valuation_id={} count={}", valuation_id, len(steps))
|
||||||
return [ValuationCalculationStepOut.model_validate(step) for step in steps]
|
return [ValuationCalculationStepOut.model_validate(step) for step in steps]
|
||||||
|
|
||||||
|
async def get_calculation_report_markdown(self, valuation_id: int) -> str:
|
||||||
|
"""
|
||||||
|
根据估值ID生成计算过程的 Markdown 报告。
|
||||||
|
|
||||||
|
此方法会查询所有相关的计算步骤,按照公式顺序组织,
|
||||||
|
并生成格式化的 Markdown 文档,包含:
|
||||||
|
- 公式名称
|
||||||
|
- 输入参数
|
||||||
|
- 公式文本
|
||||||
|
- 输出结果
|
||||||
|
|
||||||
|
Args:
|
||||||
|
valuation_id (int): 估值的唯一标识符。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Markdown 格式的计算报告。
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 如果找不到对应的估值记录。
|
||||||
|
"""
|
||||||
|
# 验证估值记录是否存在
|
||||||
|
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
|
||||||
|
if not valuation:
|
||||||
|
raise ValueError(f"估值记录不存在: {valuation_id}")
|
||||||
|
|
||||||
|
# 获取所有计算步骤,按 step_order 排序
|
||||||
|
steps = await self.step_model.filter(valuation_id=valuation_id).order_by('step_order')
|
||||||
|
if not steps:
|
||||||
|
return f"# 计算摘要\n\n**估值ID**: {valuation_id}\n\n**资产名称**: {valuation.asset_name}\n\n> 暂无计算步骤记录。\n"
|
||||||
|
|
||||||
|
# 转换为字典列表,便于处理
|
||||||
|
steps_data = []
|
||||||
|
for step in steps:
|
||||||
|
step_dict = ValuationCalculationStepOut.model_validate(step).model_dump()
|
||||||
|
steps_data.append(step_dict)
|
||||||
|
|
||||||
|
# 生成 Markdown
|
||||||
|
markdown = self._generate_markdown(valuation, steps_data)
|
||||||
|
|
||||||
|
logger.info("calcstep.report_markdown generated valuation_id={} steps_count={}", valuation_id, len(steps_data))
|
||||||
|
return markdown
|
||||||
|
|
||||||
|
def _build_formula_tree(self, steps: List[Dict]) -> Dict:
|
||||||
|
"""
|
||||||
|
构建公式的树形结构。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
steps: 计算步骤列表。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 树形结构的字典,key 为 formula_code,value 为步骤数据和子节点。
|
||||||
|
"""
|
||||||
|
# 按 formula_code 索引
|
||||||
|
step_map = {}
|
||||||
|
for step in steps:
|
||||||
|
code = step.get('formula_code')
|
||||||
|
if code:
|
||||||
|
step_map[code] = step
|
||||||
|
|
||||||
|
# 构建树形结构
|
||||||
|
tree = {}
|
||||||
|
processed = set()
|
||||||
|
|
||||||
|
# 第一遍:创建所有节点
|
||||||
|
for step in steps:
|
||||||
|
code = step.get('formula_code')
|
||||||
|
if not code or code in processed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
node = {
|
||||||
|
'step': step,
|
||||||
|
'children': []
|
||||||
|
}
|
||||||
|
tree[code] = node
|
||||||
|
processed.add(code)
|
||||||
|
|
||||||
|
# 第二遍:建立父子关系
|
||||||
|
root_nodes = []
|
||||||
|
for step in steps:
|
||||||
|
code = step.get('formula_code')
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parent_code = step.get('parent_formula_code')
|
||||||
|
node = tree[code]
|
||||||
|
|
||||||
|
if parent_code and parent_code in tree:
|
||||||
|
# 有父节点,添加到父节点的 children
|
||||||
|
tree[parent_code]['children'].append(node)
|
||||||
|
else:
|
||||||
|
# 根节点
|
||||||
|
root_nodes.append(node)
|
||||||
|
|
||||||
|
# 按 step_order 排序
|
||||||
|
def sort_nodes(nodes):
|
||||||
|
nodes.sort(key=lambda n: float(n['step'].get('step_order', 0)))
|
||||||
|
for node in nodes:
|
||||||
|
if node['children']:
|
||||||
|
sort_nodes(node['children'])
|
||||||
|
|
||||||
|
sort_nodes(root_nodes)
|
||||||
|
|
||||||
|
return {'roots': root_nodes, 'all': tree}
|
||||||
|
|
||||||
|
def _generate_markdown(self, valuation, steps_data: List[Dict]) -> str:
|
||||||
|
"""
|
||||||
|
生成 Markdown 格式的报告。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
valuation: 估值评估对象。
|
||||||
|
steps_data: 计算步骤列表(已按 step_order 排序)。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Markdown 格式的字符串。
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
lines.append("# 计算摘要")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# 遍历所有步骤,按顺序生成
|
||||||
|
for step in steps_data:
|
||||||
|
name = step.get('formula_name', step.get('step_name', '未知'))
|
||||||
|
formula_text = step.get('formula_text', step.get('step_description', ''))
|
||||||
|
input_params = step.get('input_params')
|
||||||
|
output_result = step.get('output_result')
|
||||||
|
|
||||||
|
# 公式标题(二级标题)
|
||||||
|
lines.append(f"## {name}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# 参数部分
|
||||||
|
if input_params:
|
||||||
|
lines.append("**参数:**")
|
||||||
|
lines.append("")
|
||||||
|
# 格式化参数显示
|
||||||
|
param_lines = self._format_params(input_params)
|
||||||
|
lines.extend(param_lines)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# 公式部分
|
||||||
|
if formula_text:
|
||||||
|
lines.append("**公式:**")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("```")
|
||||||
|
lines.append(formula_text)
|
||||||
|
lines.append("```")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# 计算过程部分(显示详细的计算步骤)
|
||||||
|
if output_result and isinstance(output_result, dict):
|
||||||
|
# 首先检查 calculation_detail 字段
|
||||||
|
calculation_detail = output_result.get('calculation_detail')
|
||||||
|
if calculation_detail and isinstance(calculation_detail, dict):
|
||||||
|
lines.append("**计算过程:**")
|
||||||
|
lines.append("")
|
||||||
|
# 按步骤顺序显示
|
||||||
|
steps = []
|
||||||
|
for key in sorted(calculation_detail.keys()):
|
||||||
|
if key.startswith('step'):
|
||||||
|
steps.append(f"> {calculation_detail[key]}")
|
||||||
|
if steps:
|
||||||
|
lines.extend(steps)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# 然后检查旧的 calculation 字段
|
||||||
|
calculation = output_result.get('calculation')
|
||||||
|
if calculation and not calculation_detail:
|
||||||
|
lines.append("**计算过程:**")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"> {calculation}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# 结果部分
|
||||||
|
if output_result:
|
||||||
|
# 提取主要结果值
|
||||||
|
result_value = self._extract_main_result(output_result, name)
|
||||||
|
if result_value is not None:
|
||||||
|
lines.append("**结果:**")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"`{result_value}`")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _format_params(self, params: Dict[str, Any]) -> List[str]:
|
||||||
|
"""
|
||||||
|
格式化参数显示,优先使用列表格式(如果是数组),否则显示为列表项。
|
||||||
|
参数名会附带中文说明(如果存在)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
params: 参数字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: 格式化后的参数行列表
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
def _get_param_label(key: str) -> str:
|
||||||
|
"""获取参数标签,包含中文说明"""
|
||||||
|
description = self.PARAM_DESCRIPTIONS.get(key)
|
||||||
|
if description:
|
||||||
|
return f"{key}({description})"
|
||||||
|
return key
|
||||||
|
|
||||||
|
# 如果参数只有一个键,且值是数组,直接显示数组(不带参数名,符合示例格式)
|
||||||
|
if len(params) == 1:
|
||||||
|
key, value = next(iter(params.items()))
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
# 格式化为列表:- [12.2, 13.2, 14.2]
|
||||||
|
value_str = json.dumps(list(value), ensure_ascii=False)
|
||||||
|
lines.append(f"- {value_str}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
# 多个参数或非数组,显示为列表项(带说明)
|
||||||
|
for key, value in params.items():
|
||||||
|
param_label = _get_param_label(key)
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
value_str = json.dumps(list(value), ensure_ascii=False)
|
||||||
|
lines.append(f"- **{param_label}**: {value_str}")
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
value_str = json.dumps(value, ensure_ascii=False)
|
||||||
|
lines.append(f"- **{param_label}**: {value_str}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- **{param_label}**: {value}")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _extract_main_result(self, output_result: Dict[str, Any], formula_name: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从输出结果中提取主要结果值。
|
||||||
|
|
||||||
|
优先顺序:
|
||||||
|
1. 如果结果中只有一个数值类型的值,返回该值
|
||||||
|
2. 如果结果中包含与公式名称相关的字段(如 "财务价值 F" -> "financial_value_f"),返回该值
|
||||||
|
3. 如果结果中包含常见的计算结果字段(如 "result", "value", "output"),返回该值
|
||||||
|
4. 返回第一个数值类型的值
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_result: 输出结果字典
|
||||||
|
formula_name: 公式名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[str]: 主要结果值的字符串表示,如果找不到则返回 None
|
||||||
|
"""
|
||||||
|
if not output_result or not isinstance(output_result, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 调试:打印B3的output_result
|
||||||
|
if 'risk_value_b3' in str(output_result) or 'legal_risk' in str(output_result):
|
||||||
|
print(f"=== _extract_main_result 调试 ===")
|
||||||
|
print(f"formula_name: {formula_name}")
|
||||||
|
print(f"output_result keys: {list(output_result.keys())}")
|
||||||
|
print(f"output_result: {output_result}")
|
||||||
|
print(f"================================")
|
||||||
|
|
||||||
|
# 移除 duration_ms 等元数据字段
|
||||||
|
filtered_result = {k: v for k, v in output_result.items()
|
||||||
|
if k not in ['duration_ms', 'duration', 'timestamp', 'status']}
|
||||||
|
|
||||||
|
if not filtered_result:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 如果只有一个值,直接返回
|
||||||
|
if len(filtered_result) == 1:
|
||||||
|
value = next(iter(filtered_result.values()))
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return str(value)
|
||||||
|
elif isinstance(value, (list, tuple)) and len(value) == 1:
|
||||||
|
return str(value[0])
|
||||||
|
else:
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 优先查找常见的结果字段(优先级从高到低)
|
||||||
|
# 这个列表的顺序很重要,确保正确的结果字段优先被选中
|
||||||
|
common_result_keys = [
|
||||||
|
# 计算引擎实际使用的结果字段名
|
||||||
|
'risk_value_b3', # 风险调整系数B3
|
||||||
|
'risk_adjustment_b3', # 风险调整系数B3(备选)
|
||||||
|
'economic_value_b1', # 经济价值B1
|
||||||
|
'cultural_value_b2', # 文化价值B2
|
||||||
|
'model_value_b', # 模型估值B
|
||||||
|
'market_value_c', # 市场估值C
|
||||||
|
'final_value_a', # 最终估值A
|
||||||
|
'final_value_ab', # 最终估值AB
|
||||||
|
'basic_value_b11', # 基础价值B11
|
||||||
|
'traffic_factor_b12', # 流量因子B12
|
||||||
|
'policy_multiplier_b13', # 政策乘数B13
|
||||||
|
'living_heritage_b21', # 活态传承系数B21
|
||||||
|
'pattern_gene_b22', # 纹样基因值B22
|
||||||
|
# 通用结果字段
|
||||||
|
'result', 'value', 'output', 'final_value', 'calculated_value',
|
||||||
|
# 子计算结果字段
|
||||||
|
'financial_value_f', 'legal_strength_l', 'development_potential_d',
|
||||||
|
'social_media_spread_s3', 'interaction_index', 'coverage_index', 'conversion_efficiency',
|
||||||
|
'market_bid_c1', 'heat_coefficient_c2', 'scarcity_multiplier_c3', 'timeliness_decay_c4',
|
||||||
|
'teaching_frequency', 'inheritor_level_coefficient',
|
||||||
|
'risk_score_sum', # 风险评分总和R
|
||||||
|
'dynamic_pledge_rate', # 动态质押率
|
||||||
|
]
|
||||||
|
|
||||||
|
# 首先检查常见结果字段(这个优先级最高,避免错误匹配子风险值)
|
||||||
|
for key in common_result_keys:
|
||||||
|
if key in filtered_result:
|
||||||
|
value = filtered_result[key]
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
if 'risk' in formula_name.lower() or 'b3' in formula_name.lower():
|
||||||
|
print(f"=== 返回值调试 (common_keys) ===")
|
||||||
|
print(f"formula_name: {formula_name}")
|
||||||
|
print(f"matched key: {key}")
|
||||||
|
print(f"返回值: {value}")
|
||||||
|
print(f"================================")
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
# 尝试根据公式名称匹配字段
|
||||||
|
# 例如:"财务价值 F" -> 查找 "financial_value_f", "财务价值F" 等
|
||||||
|
# 提取公式名称中的关键部分(通常是最后一个字母或单词)
|
||||||
|
name_parts = formula_name.split()
|
||||||
|
if name_parts:
|
||||||
|
# 获取最后一个部分(通常是字母,如 "F", "L", "D")
|
||||||
|
last_part = name_parts[-1].lower()
|
||||||
|
# 构建可能的字段名:如 "financial_value_f", "legal_strength_l" 等
|
||||||
|
# 将中文名称转换为可能的英文字段名模式
|
||||||
|
possible_keys = []
|
||||||
|
|
||||||
|
# 1. 直接匹配包含最后部分的字段(如包含 "f", "l", "d")
|
||||||
|
for key in filtered_result.keys():
|
||||||
|
if last_part in key.lower() or key.lower().endswith(f"_{last_part}"):
|
||||||
|
possible_keys.append(key)
|
||||||
|
|
||||||
|
# 2. 尝试匹配常见的命名模式
|
||||||
|
# 例如:"财务价值 F" -> "financial_value_f"
|
||||||
|
# 这里我们尝试匹配以最后部分结尾的字段
|
||||||
|
suffix_patterns = [
|
||||||
|
f"_{last_part}",
|
||||||
|
f"_{last_part}_",
|
||||||
|
last_part,
|
||||||
|
]
|
||||||
|
|
||||||
|
for key in filtered_result.keys():
|
||||||
|
key_lower = key.lower()
|
||||||
|
for pattern in suffix_patterns:
|
||||||
|
if key_lower.endswith(pattern) or pattern in key_lower:
|
||||||
|
if key not in possible_keys:
|
||||||
|
possible_keys.append(key)
|
||||||
|
|
||||||
|
# 按优先级匹配
|
||||||
|
for key in possible_keys:
|
||||||
|
if key in filtered_result:
|
||||||
|
value = filtered_result[key]
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
# 返回第一个数值类型的值
|
||||||
|
for key, value in filtered_result.items():
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
# 如果都没有,返回整个结果的 JSON(但简化显示)
|
||||||
|
return json.dumps(filtered_result, ensure_ascii=False)
|
||||||
|
|
||||||
async def create(self, data: ValuationAssessmentCreate, user_id: int) -> ValuationAssessmentOut:
|
async def create(self, data: ValuationAssessmentCreate, user_id: int) -> ValuationAssessmentOut:
|
||||||
"""创建估值评估"""
|
"""创建估值评估"""
|
||||||
# 将用户ID添加到数据中
|
# 将用户ID添加到数据中
|
||||||
create_data = data.model_dump()
|
create_data = data.model_dump()
|
||||||
create_data['user_id'] = user_id
|
create_data['user_id'] = user_id
|
||||||
valuation = await self.model.create(**create_data)
|
valuation = await self.model.create(**create_data)
|
||||||
return ValuationAssessmentOut.model_validate(valuation)
|
out = ValuationAssessmentOut.model_validate(valuation)
|
||||||
|
return await self._attach_user_phone(out)
|
||||||
|
|
||||||
async def get_by_id(self, valuation_id: int) -> Optional[ValuationAssessmentOut]:
|
async def get_by_id(self, valuation_id: int) -> Optional[ValuationAssessmentOut]:
|
||||||
"""根据ID获取估值评估"""
|
"""根据ID获取估值评估"""
|
||||||
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
|
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
|
||||||
if valuation:
|
if valuation:
|
||||||
return ValuationAssessmentOut.model_validate(valuation)
|
out = ValuationAssessmentOut.model_validate(valuation)
|
||||||
|
return await self._attach_user_phone(out)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def update(self, valuation_id: int, data: ValuationAssessmentUpdate) -> Optional[ValuationAssessmentOut]:
|
async def update(self, valuation_id: int, data: ValuationAssessmentUpdate) -> Optional[ValuationAssessmentOut]:
|
||||||
@ -93,10 +683,51 @@ class ValuationController:
|
|||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
if update_data:
|
if update_data:
|
||||||
|
if 'certificate_url' in update_data and update_data.get('certificate_url'):
|
||||||
|
from datetime import datetime
|
||||||
|
update_data['audited_at'] = datetime.now()
|
||||||
|
update_data['updated_at'] = datetime.now()
|
||||||
|
else:
|
||||||
|
from datetime import datetime
|
||||||
|
update_data['updated_at'] = datetime.now()
|
||||||
await valuation.update_from_dict(update_data)
|
await valuation.update_from_dict(update_data)
|
||||||
await valuation.save()
|
await valuation.save()
|
||||||
|
from datetime import datetime
|
||||||
|
valuation.status ="pending"
|
||||||
|
if not getattr(valuation, "audited_at", None):
|
||||||
|
valuation.audited_at = datetime.now()
|
||||||
|
valuation.updated_at = datetime.now()
|
||||||
|
await valuation.save()
|
||||||
|
|
||||||
return ValuationAssessmentOut.model_validate(valuation)
|
out = ValuationAssessmentOut.model_validate(valuation)
|
||||||
|
return await self._attach_user_phone(out)
|
||||||
|
|
||||||
|
async def update1(self, valuation_id: int, data: ValuationAssessmentUpdate) -> Optional[ValuationAssessmentOut]:
|
||||||
|
"""更新估值评估"""
|
||||||
|
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
|
||||||
|
if not valuation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
if update_data:
|
||||||
|
if 'certificate_url' in update_data and update_data.get('certificate_url'):
|
||||||
|
from datetime import datetime
|
||||||
|
update_data['audited_at'] = datetime.now()
|
||||||
|
update_data['updated_at'] = datetime.now()
|
||||||
|
else:
|
||||||
|
from datetime import datetime
|
||||||
|
update_data['updated_at'] = datetime.now()
|
||||||
|
await valuation.update_from_dict(update_data)
|
||||||
|
await valuation.save()
|
||||||
|
from datetime import datetime
|
||||||
|
valuation.status ="success"
|
||||||
|
if not getattr(valuation, "audited_at", None):
|
||||||
|
valuation.audited_at = datetime.now()
|
||||||
|
valuation.updated_at = datetime.now()
|
||||||
|
await valuation.save()
|
||||||
|
|
||||||
|
out = ValuationAssessmentOut.model_validate(valuation)
|
||||||
|
return await self._attach_user_phone(out)
|
||||||
|
|
||||||
async def delete(self, valuation_id: int) -> bool:
|
async def delete(self, valuation_id: int) -> bool:
|
||||||
"""软删除估值评估"""
|
"""软删除估值评估"""
|
||||||
@ -121,6 +752,7 @@ class ValuationController:
|
|||||||
|
|
||||||
# 转换为输出模型
|
# 转换为输出模型
|
||||||
items = [ValuationAssessmentOut.model_validate(v) for v in valuations]
|
items = [ValuationAssessmentOut.model_validate(v) for v in valuations]
|
||||||
|
items = await self._attach_user_phone_bulk(items)
|
||||||
# 计算总页数
|
# 计算总页数
|
||||||
pages = (total + query.size - 1) // query.size
|
pages = (total + query.size - 1) // query.size
|
||||||
|
|
||||||
@ -155,6 +787,37 @@ class ValuationController:
|
|||||||
if hasattr(query, 'status') and query.status:
|
if hasattr(query, 'status') and query.status:
|
||||||
queryset = queryset.filter(status=query.status)
|
queryset = queryset.filter(status=query.status)
|
||||||
|
|
||||||
|
if getattr(query, 'phone', None):
|
||||||
|
queryset = queryset.filter(user__phone__icontains=query.phone)
|
||||||
|
|
||||||
|
def _parse_time(v: Optional[str]):
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
iv = int(v)
|
||||||
|
from datetime import datetime
|
||||||
|
return datetime.fromtimestamp(iv / 1000)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
return datetime.fromisoformat(v)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
s_dt = _parse_time(getattr(query, 'submitted_start', None))
|
||||||
|
e_dt = _parse_time(getattr(query, 'submitted_end', None))
|
||||||
|
if s_dt:
|
||||||
|
queryset = queryset.filter(created_at__gte=s_dt)
|
||||||
|
if e_dt:
|
||||||
|
queryset = queryset.filter(created_at__lte=e_dt)
|
||||||
|
|
||||||
|
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(updated_at__gte=a_s_dt)
|
||||||
|
if a_e_dt:
|
||||||
|
queryset = queryset.filter(updated_at__lte=a_e_dt)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
async def get_statistics(self) -> dict:
|
async def get_statistics(self) -> dict:
|
||||||
@ -195,6 +858,7 @@ class ValuationController:
|
|||||||
|
|
||||||
# 转换为输出模型
|
# 转换为输出模型
|
||||||
items = [ValuationAssessmentOut.model_validate(v) for v in valuations]
|
items = [ValuationAssessmentOut.model_validate(v) for v in valuations]
|
||||||
|
items = await self._attach_user_phone_bulk(items)
|
||||||
|
|
||||||
# 计算总页数
|
# 计算总页数
|
||||||
pages = (total + size - 1) // size
|
pages = (total + size - 1) // size
|
||||||
@ -213,12 +877,14 @@ class ValuationController:
|
|||||||
if not valuation:
|
if not valuation:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
update_data = {"status": "approved"}
|
from datetime import datetime
|
||||||
|
update_data = {"status": "success", "audited_at": datetime.now(), "updated_at": datetime.now()}
|
||||||
if admin_notes:
|
if admin_notes:
|
||||||
update_data["admin_notes"] = admin_notes
|
update_data["admin_notes"] = admin_notes
|
||||||
|
|
||||||
await valuation.update_from_dict(update_data).save()
|
await valuation.update_from_dict(update_data).save()
|
||||||
return ValuationAssessmentOut.model_validate(valuation)
|
out = ValuationAssessmentOut.model_validate(valuation)
|
||||||
|
return await self._attach_user_phone(out)
|
||||||
|
|
||||||
async def reject_valuation(self, valuation_id: int, admin_notes: Optional[str] = None) -> Optional[ValuationAssessmentOut]:
|
async def reject_valuation(self, valuation_id: int, admin_notes: Optional[str] = None) -> Optional[ValuationAssessmentOut]:
|
||||||
"""审核拒绝估值评估"""
|
"""审核拒绝估值评估"""
|
||||||
@ -226,12 +892,14 @@ class ValuationController:
|
|||||||
if not valuation:
|
if not valuation:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
update_data = {"status": "rejected"}
|
from datetime import datetime
|
||||||
|
update_data = {"status": "rejected", "audited_at": datetime.now(), "updated_at": datetime.now()}
|
||||||
if admin_notes:
|
if admin_notes:
|
||||||
update_data["admin_notes"] = admin_notes
|
update_data["admin_notes"] = admin_notes
|
||||||
|
|
||||||
await valuation.update_from_dict(update_data).save()
|
await valuation.update_from_dict(update_data).save()
|
||||||
return ValuationAssessmentOut.model_validate(valuation)
|
out = ValuationAssessmentOut.model_validate(valuation)
|
||||||
|
return await self._attach_user_phone(out)
|
||||||
|
|
||||||
async def update_admin_notes(self, valuation_id: int, admin_notes: str) -> Optional[ValuationAssessmentOut]:
|
async def update_admin_notes(self, valuation_id: int, admin_notes: str) -> Optional[ValuationAssessmentOut]:
|
||||||
"""更新管理员备注"""
|
"""更新管理员备注"""
|
||||||
@ -239,8 +907,37 @@ class ValuationController:
|
|||||||
if not valuation:
|
if not valuation:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
await valuation.update_from_dict({"admin_notes": admin_notes}).save()
|
from datetime import datetime
|
||||||
return ValuationAssessmentOut.model_validate(valuation)
|
await valuation.update_from_dict({"admin_notes": admin_notes, "updated_at": datetime.now()}).save()
|
||||||
|
out = ValuationAssessmentOut.model_validate(valuation)
|
||||||
|
return await self._attach_user_phone(out)
|
||||||
|
|
||||||
|
async def update_calc(self, valuation_id: int, data: ValuationAssessmentUpdate) -> Optional[ValuationAssessmentOut]:
|
||||||
|
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
|
||||||
|
if not valuation:
|
||||||
|
return None
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
valuation.status ="pending"
|
||||||
|
if update_data:
|
||||||
|
await valuation.update_from_dict(update_data)
|
||||||
|
await valuation.save()
|
||||||
|
out = ValuationAssessmentOut.model_validate(valuation)
|
||||||
|
return await self._attach_user_phone(out)
|
||||||
|
|
||||||
|
async def _attach_user_phone(self, out: ValuationAssessmentOut) -> ValuationAssessmentOut:
|
||||||
|
user = await AppUser.filter(id=out.user_id).first()
|
||||||
|
out.user_phone = getattr(user, "phone", None) if user else None
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def _attach_user_phone_bulk(self, items: List[ValuationAssessmentOut]) -> List[ValuationAssessmentOut]:
|
||||||
|
ids = list({item.user_id for item in items if item.user_id})
|
||||||
|
if not ids:
|
||||||
|
return items
|
||||||
|
users = await AppUser.filter(id__in=ids).values("id", "phone")
|
||||||
|
phone_map = {u["id"]: u["phone"] for u in users}
|
||||||
|
for item in items:
|
||||||
|
item.user_phone = phone_map.get(item.user_id)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
# 创建控制器实例
|
# 创建控制器实例
|
||||||
|
|||||||
@ -16,6 +16,7 @@ async def DoesNotExistHandle(req: Request, exc: DoesNotExist) -> JSONResponse:
|
|||||||
content = dict(
|
content = dict(
|
||||||
code=404,
|
code=404,
|
||||||
msg=f"Object has not found, exc: {exc}, query_params: {req.query_params}",
|
msg=f"Object has not found, exc: {exc}, query_params: {req.query_params}",
|
||||||
|
data={},
|
||||||
)
|
)
|
||||||
return JSONResponse(content=content, status_code=404)
|
return JSONResponse(content=content, status_code=404)
|
||||||
|
|
||||||
@ -24,20 +25,21 @@ async def IntegrityHandle(_: Request, exc: IntegrityError) -> JSONResponse:
|
|||||||
content = dict(
|
content = dict(
|
||||||
code=500,
|
code=500,
|
||||||
msg=f"IntegrityError,{exc}",
|
msg=f"IntegrityError,{exc}",
|
||||||
|
data={},
|
||||||
)
|
)
|
||||||
return JSONResponse(content=content, status_code=500)
|
return JSONResponse(content=content, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
async def HttpExcHandle(_: Request, exc: HTTPException) -> JSONResponse:
|
async def HttpExcHandle(_: Request, exc: HTTPException) -> JSONResponse:
|
||||||
content = dict(code=exc.status_code, msg=exc.detail, data=None)
|
content = dict(code=exc.status_code, msg=exc.detail, data={})
|
||||||
return JSONResponse(content=content, status_code=exc.status_code)
|
return JSONResponse(content=content, status_code=exc.status_code)
|
||||||
|
|
||||||
|
|
||||||
async def RequestValidationHandle(_: Request, exc: RequestValidationError) -> JSONResponse:
|
async def RequestValidationHandle(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
content = dict(code=422, msg=f"RequestValidationError, {exc}")
|
content = dict(code=422, msg=f"RequestValidationError, {exc}", data={})
|
||||||
return JSONResponse(content=content, status_code=422)
|
return JSONResponse(content=content, status_code=422)
|
||||||
|
|
||||||
|
|
||||||
async def ResponseValidationHandle(_: Request, exc: ResponseValidationError) -> JSONResponse:
|
async def ResponseValidationHandle(_: Request, exc: ResponseValidationError) -> JSONResponse:
|
||||||
content = dict(code=500, msg=f"ResponseValidationError, {exc}")
|
content = dict(code=500, msg=f"ResponseValidationError, {exc}", data={})
|
||||||
return JSONResponse(content=content, status_code=500)
|
return JSONResponse(content=content, status_code=500)
|
||||||
|
|||||||
@ -279,12 +279,117 @@ async def init_menus():
|
|||||||
|
|
||||||
|
|
||||||
async def init_apis():
|
async def init_apis():
|
||||||
apis = await api_controller.model.exists()
|
|
||||||
if not apis:
|
|
||||||
await api_controller.refresh_api()
|
await api_controller.refresh_api()
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_role_api_bindings():
|
||||||
|
"""确保角色与API权限绑定是最新的:管理员拥有全部API,普通用户拥有基础API"""
|
||||||
|
from tortoise.expressions import Q
|
||||||
|
try:
|
||||||
|
admin_role = await Role.filter(name="管理员").first()
|
||||||
|
if admin_role:
|
||||||
|
all_apis = await Api.all()
|
||||||
|
current = await admin_role.apis.all()
|
||||||
|
current_keys = {(a.method, a.path) for a in current}
|
||||||
|
missing = [a for a in all_apis if (a.method, a.path) not in current_keys]
|
||||||
|
if missing:
|
||||||
|
await admin_role.apis.add(*missing)
|
||||||
|
user_role = await Role.filter(name="普通用户").first()
|
||||||
|
if user_role:
|
||||||
|
basic_apis = await Api.filter(Q(method__in=["GET"]) | Q(tags="基础模块"))
|
||||||
|
current_u = await user_role.apis.all()
|
||||||
|
current_u_keys = {(a.method, a.path) for a in current_u}
|
||||||
|
missing_u = [a for a in basic_apis if (a.method, a.path) not in current_u_keys]
|
||||||
|
if missing_u:
|
||||||
|
await user_role.apis.add(*missing_u)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_unique_index():
|
||||||
|
"""确保 valuation_calculation_steps 表的唯一索引存在"""
|
||||||
|
try:
|
||||||
|
conn_alias = settings.TORTOISE_ORM["apps"]["models"]["default_connection"]
|
||||||
|
from tortoise import connections
|
||||||
|
conn = connections.get(conn_alias)
|
||||||
|
|
||||||
|
# 检查表是否存在
|
||||||
|
result = await conn.execute_query(
|
||||||
|
"SHOW TABLES LIKE 'valuation_calculation_steps'"
|
||||||
|
)
|
||||||
|
if not result or len(result[1]) == 0:
|
||||||
|
logger.info("Table valuation_calculation_steps does not exist, skipping index check")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查唯一索引是否存在
|
||||||
|
# 查找包含 valuation_id 和 formula_code 的唯一索引
|
||||||
|
index_result = await conn.execute_query(
|
||||||
|
"SHOW INDEX FROM `valuation_calculation_steps` WHERE Non_unique = 0 AND Column_name IN ('valuation_id', 'formula_code')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查找是否存在 (valuation_id, formula_code) 的唯一索引
|
||||||
|
# 对于复合索引,SHOW INDEX 会返回多行,每行对应一个列
|
||||||
|
# 需要检查是否有同一个 Key_name 包含两个列
|
||||||
|
has_unique_index = False
|
||||||
|
if index_result and len(index_result) > 1:
|
||||||
|
# 按 Key_name 分组
|
||||||
|
index_groups = {}
|
||||||
|
for row in index_result[1]:
|
||||||
|
if len(row) >= 5:
|
||||||
|
key_name = row[2] if len(row) > 2 else ""
|
||||||
|
non_unique = row[1] if len(row) > 1 else 1
|
||||||
|
column_name = row[4] if len(row) > 4 else ""
|
||||||
|
seq_in_index = row[3] if len(row) > 3 else 0
|
||||||
|
if non_unique == 0 and column_name in ('valuation_id', 'formula_code'):
|
||||||
|
if key_name not in index_groups:
|
||||||
|
index_groups[key_name] = []
|
||||||
|
index_groups[key_name].append(column_name)
|
||||||
|
|
||||||
|
# 检查是否有索引包含两个列
|
||||||
|
for key_name, columns in index_groups.items():
|
||||||
|
if 'valuation_id' in columns and 'formula_code' in columns:
|
||||||
|
has_unique_index = True
|
||||||
|
logger.debug(f"Found unique index: {key_name} on (valuation_id, formula_code)")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not has_unique_index:
|
||||||
|
logger.warning("Unique index on (valuation_id, formula_code) not found, attempting to create...")
|
||||||
|
try:
|
||||||
|
# 先删除可能存在的重复记录
|
||||||
|
await conn.execute_query("""
|
||||||
|
DELETE t1 FROM `valuation_calculation_steps` t1
|
||||||
|
INNER JOIN `valuation_calculation_steps` t2
|
||||||
|
WHERE t1.id > t2.id
|
||||||
|
AND t1.valuation_id = t2.valuation_id
|
||||||
|
AND t1.formula_code = t2.formula_code
|
||||||
|
AND t1.formula_code IS NOT NULL
|
||||||
|
""")
|
||||||
|
logger.info("Cleaned up duplicate records")
|
||||||
|
|
||||||
|
# 创建唯一索引
|
||||||
|
await conn.execute_query("""
|
||||||
|
CREATE UNIQUE INDEX `uidx_valuation_formula`
|
||||||
|
ON `valuation_calculation_steps` (`valuation_id`, `formula_code`)
|
||||||
|
""")
|
||||||
|
logger.info("Created unique index on (valuation_id, formula_code)")
|
||||||
|
except Exception as idx_err:
|
||||||
|
error_str = str(idx_err).lower()
|
||||||
|
if "duplicate key name" in error_str or "already exists" in error_str:
|
||||||
|
logger.info("Unique index already exists (different name)")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to create unique index: {idx_err}")
|
||||||
|
else:
|
||||||
|
logger.debug("Unique index on (valuation_id, formula_code) already exists")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to ensure unique index: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from tortoise import Tortoise
|
||||||
|
from tortoise.exceptions import OperationalError
|
||||||
|
|
||||||
command = Command(tortoise_config=settings.TORTOISE_ORM)
|
command = Command(tortoise_config=settings.TORTOISE_ORM)
|
||||||
try:
|
try:
|
||||||
await command.init_db(safe=True)
|
await command.init_db(safe=True)
|
||||||
@ -292,14 +397,84 @@ async def init_db():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
await command.init()
|
await command.init()
|
||||||
|
|
||||||
|
# 检查并清理可能冲突的迁移文件(避免交互式提示)
|
||||||
|
# Aerich 在检测到迁移文件已存在时会交互式提示,我们提前删除冲突文件
|
||||||
|
migrations_dir = Path("migrations/models")
|
||||||
|
if migrations_dir.exists():
|
||||||
|
# 查找包含 "update" 的迁移文件(通常是自动生成的冲突文件)
|
||||||
|
for migration_file in migrations_dir.glob("*update*.py"):
|
||||||
|
if migration_file.name != "__init__.py":
|
||||||
|
logger.info(f"Removing conflicting migration file: {migration_file.name}")
|
||||||
|
migration_file.unlink()
|
||||||
|
|
||||||
|
# 尝试执行 migrate
|
||||||
try:
|
try:
|
||||||
await command.migrate()
|
await command.migrate()
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
logger.warning("unable to retrieve model history from database, model history will be created from scratch")
|
logger.warning("unable to retrieve model history from database, model history will be created from scratch")
|
||||||
shutil.rmtree("migrations")
|
shutil.rmtree("migrations")
|
||||||
await command.init_db(safe=True)
|
await command.init_db(safe=True)
|
||||||
|
except Exception as e:
|
||||||
|
# 如果 migrate 失败,记录警告但继续执行 upgrade
|
||||||
|
logger.warning(f"Migrate failed: {e}, continuing with upgrade...")
|
||||||
|
|
||||||
|
# 在 upgrade 之前,先检查表是否存在,如果不存在则先创建表
|
||||||
|
try:
|
||||||
await command.upgrade(run_in_transaction=True)
|
await command.upgrade(run_in_transaction=True)
|
||||||
|
# upgrade 成功后,验证并修复唯一索引
|
||||||
|
await _ensure_unique_index()
|
||||||
|
except (OperationalError, Exception) as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
# 如果是因为表不存在而失败,先让 Tortoise 生成表结构
|
||||||
|
if "doesn't exist" in error_msg.lower() or ("table" in error_msg.lower() and "valuation_calculation_steps" in error_msg):
|
||||||
|
logger.warning(f"Table not found during upgrade: {error_msg}, generating schemas first...")
|
||||||
|
# 确保 Tortoise 已初始化(Aerich 的 init 应该已经初始化了,但为了安全再检查)
|
||||||
|
try:
|
||||||
|
# 生成表结构(safe=True 表示如果表已存在则跳过)
|
||||||
|
await Tortoise.generate_schemas(safe=True)
|
||||||
|
logger.info("Tables generated successfully, retrying upgrade...")
|
||||||
|
# 重新尝试 upgrade(这次应该会成功,因为表已经存在)
|
||||||
|
try:
|
||||||
|
await command.upgrade(run_in_transaction=True)
|
||||||
|
except Exception as upgrade_err:
|
||||||
|
# 如果 upgrade 仍然失败,可能是迁移文件的问题,记录警告但继续
|
||||||
|
logger.warning(f"Upgrade still failed after generating schemas: {upgrade_err}, continuing anyway...")
|
||||||
|
except Exception as gen_err:
|
||||||
|
logger.error(f"Failed to generate schemas: {gen_err}")
|
||||||
|
raise
|
||||||
|
# 如果是重复字段错误,说明迁移已经执行过,直接跳过并确保索引
|
||||||
|
elif "duplicate column name" in error_msg.lower():
|
||||||
|
logger.warning(f"Duplicate column detected during upgrade: {error_msg}, skipping migration step and ensuring schema integrity...")
|
||||||
|
await _ensure_unique_index()
|
||||||
|
# 如果是重复索引错误,删除表并重新创建(最简单可靠的方法)
|
||||||
|
elif "duplicate key" in error_msg.lower() or "duplicate key name" in error_msg.lower():
|
||||||
|
logger.warning(f"Duplicate index detected: {error_msg}, dropping and recreating table...")
|
||||||
|
try:
|
||||||
|
# Aerich 的 command.init() 已经初始化了 Tortoise,直接使用连接
|
||||||
|
# 连接别名是 "mysql"(从配置中读取)
|
||||||
|
conn_alias = settings.TORTOISE_ORM["apps"]["models"]["default_connection"]
|
||||||
|
from tortoise import connections
|
||||||
|
# 尝试获取连接,如果失败则重新初始化
|
||||||
|
try:
|
||||||
|
conn = connections.get(conn_alias)
|
||||||
|
except Exception:
|
||||||
|
# 如果连接不存在,重新初始化 Tortoise
|
||||||
|
await Tortoise.init(config=settings.TORTOISE_ORM)
|
||||||
|
conn = connections.get(conn_alias)
|
||||||
|
|
||||||
|
# 删除表
|
||||||
|
await conn.execute_query("DROP TABLE IF EXISTS `valuation_calculation_steps`")
|
||||||
|
logger.info("Dropped valuation_calculation_steps table")
|
||||||
|
# 重新生成表结构(包含正确的唯一索引)
|
||||||
|
# 使用 safe=True 避免尝试创建已存在的其他表(如 user_role),只创建不存在的表
|
||||||
|
await Tortoise.generate_schemas(safe=True)
|
||||||
|
logger.info("Table regenerated successfully with correct unique index")
|
||||||
|
except Exception as recreate_err:
|
||||||
|
logger.error(f"Failed to recreate table: {recreate_err}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def init_roles():
|
async def init_roles():
|
||||||
@ -446,4 +621,5 @@ async def init_data():
|
|||||||
await init_menus()
|
await init_menus()
|
||||||
await init_apis()
|
await init_apis()
|
||||||
await init_roles()
|
await init_roles()
|
||||||
|
await sync_role_api_bindings()
|
||||||
await init_demo_transactions()
|
await init_demo_transactions()
|
||||||
|
|||||||
@ -149,7 +149,8 @@ class HttpAuditLogMiddleware(BaseHTTPMiddleware):
|
|||||||
try:
|
try:
|
||||||
return json.loads(stripped)
|
return json.loads(stripped)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return stripped
|
# 将非 JSON 字符串包装为字典,以便 JSONField 能够正确存储
|
||||||
|
return {"text": stripped}
|
||||||
|
|
||||||
if isinstance(value, (dict, list, int, float, bool)):
|
if isinstance(value, (dict, list, int, float, bool)):
|
||||||
return value
|
return value
|
||||||
|
|||||||
13
app/core/token_blacklist.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.models.token_blacklist import TokenBlacklist
|
||||||
|
|
||||||
|
|
||||||
|
async def add_to_blacklist(token: str, user_id: int, exp: Optional[datetime] = None, jti: Optional[str] = None) -> None:
|
||||||
|
await TokenBlacklist.create(token=token, user_id=user_id, exp=exp, jti=jti)
|
||||||
|
|
||||||
|
|
||||||
|
async def is_blacklisted(token: str) -> bool:
|
||||||
|
return await TokenBlacklist.filter(token=token).exists()
|
||||||
|
|
||||||
@ -7,3 +7,4 @@ from .policy import *
|
|||||||
from .user import *
|
from .user import *
|
||||||
from .valuation import *
|
from .valuation import *
|
||||||
from .invoice import *
|
from .invoice import *
|
||||||
|
from .token_blacklist import *
|
||||||
|
|||||||
@ -12,6 +12,7 @@ class InvoiceHeader(BaseModel, TimestampMixin):
|
|||||||
bank_name = fields.CharField(max_length=128, description="开户银行")
|
bank_name = fields.CharField(max_length=128, description="开户银行")
|
||||||
bank_account = fields.CharField(max_length=64, description="银行账号")
|
bank_account = fields.CharField(max_length=64, description="银行账号")
|
||||||
email = fields.CharField(max_length=128, description="接收邮箱")
|
email = fields.CharField(max_length=128, description="接收邮箱")
|
||||||
|
is_default = fields.BooleanField(default=False, description="是否默认抬头", index=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "invoice_header"
|
table = "invoice_header"
|
||||||
@ -29,7 +30,7 @@ class Invoice(BaseModel, TimestampMixin):
|
|||||||
register_phone = fields.CharField(max_length=32, description="注册电话")
|
register_phone = fields.CharField(max_length=32, description="注册电话")
|
||||||
bank_name = fields.CharField(max_length=128, description="开户银行")
|
bank_name = fields.CharField(max_length=128, description="开户银行")
|
||||||
bank_account = fields.CharField(max_length=64, 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)
|
app_user_id = fields.IntField(null=True, description="App用户ID", index=True)
|
||||||
header = fields.ForeignKeyField("models.InvoiceHeader", related_name="invoices", null=True, description="抬头关联")
|
header = fields.ForeignKeyField("models.InvoiceHeader", related_name="invoices", null=True, description="抬头关联")
|
||||||
wechat = fields.CharField(max_length=64, null=True, description="微信号", index=True)
|
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="付款凭证图片地址")
|
url = fields.CharField(max_length=512, description="付款凭证图片地址")
|
||||||
note = fields.CharField(max_length=256, null=True, description="备注")
|
note = fields.CharField(max_length=256, null=True, description="备注")
|
||||||
verified = fields.BooleanField(default=False, description="是否已核验")
|
verified = fields.BooleanField(default=False, description="是否已核验")
|
||||||
|
extra = fields.JSONField(null=True, description="额外信息:邮件发送相关")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "payment_receipt"
|
table = "payment_receipt"
|
||||||
|
|||||||
15
app/models/token_blacklist.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from tortoise import fields
|
||||||
|
|
||||||
|
from .base import BaseModel, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TokenBlacklist(BaseModel, TimestampMixin):
|
||||||
|
token = fields.TextField(description="JWT令牌")
|
||||||
|
jti = fields.CharField(max_length=64, null=True, description="令牌唯一ID", index=True)
|
||||||
|
user_id = fields.IntField(description="用户ID", index=True)
|
||||||
|
exp = fields.DatetimeField(null=True, description="过期时间", index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "token_blacklist"
|
||||||
|
table_description = "JWT令牌黑名单"
|
||||||
|
|
||||||
@ -21,6 +21,8 @@ class AppUser(BaseModel, TimestampMixin):
|
|||||||
last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True)
|
last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True)
|
||||||
remaining_quota = fields.IntField(default=0, description="剩余估值次数", index=True)
|
remaining_quota = fields.IntField(default=0, description="剩余估值次数", index=True)
|
||||||
notes = fields.CharField(max_length=256, null=True, description="备注")
|
notes = fields.CharField(max_length=256, null=True, description="备注")
|
||||||
|
is_deleted = fields.BooleanField(default=False, description="是否已注销", index=True)
|
||||||
|
deleted_at = fields.DatetimeField(null=True, description="注销时间", index=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "app_user"
|
table = "app_user"
|
||||||
|
|||||||
@ -82,10 +82,11 @@ class ValuationAssessment(Model):
|
|||||||
|
|
||||||
# 系统字段
|
# 系统字段
|
||||||
user = fields.ForeignKeyField("models.AppUser", related_name="valuations", description="提交用户")
|
user = fields.ForeignKeyField("models.AppUser", related_name="valuations", description="提交用户")
|
||||||
status = fields.CharField(max_length=20, default="success", description="评估状态: pending(待审核), success(已通过), fail(已拒绝)")
|
status = fields.CharField(max_length=20, default="pending", description="评估状态: pending(待审核), success(已通过), rejected(已拒绝)")
|
||||||
admin_notes = fields.TextField(null=True, description="管理员备注")
|
admin_notes = fields.TextField(null=True, description="管理员备注")
|
||||||
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
|
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
|
||||||
updated_at = fields.DatetimeField(auto_now=True, description="更新时间")
|
updated_at = fields.DatetimeField(null=True, description="更新时间")
|
||||||
|
audited_at = fields.DatetimeField(null=True, description="审核时间")
|
||||||
is_active = fields.BooleanField(default=True, description="是否激活")
|
is_active = fields.BooleanField(default=True, description="是否激活")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -100,19 +101,28 @@ class ValuationCalculationStep(Model):
|
|||||||
"""估值计算步骤模型"""
|
"""估值计算步骤模型"""
|
||||||
id = fields.IntField(pk=True, description="主键ID")
|
id = fields.IntField(pk=True, description="主键ID")
|
||||||
valuation = fields.ForeignKeyField("models.ValuationAssessment", related_name="calculation_steps", description="关联的估值评估")
|
valuation = fields.ForeignKeyField("models.ValuationAssessment", related_name="calculation_steps", description="关联的估值评估")
|
||||||
|
formula_code = fields.CharField(max_length=64, null=True, description="公式编码")
|
||||||
|
formula_name = fields.CharField(max_length=255, null=True, description="公式名称")
|
||||||
|
formula_text = fields.TextField(null=True, description="公式说明")
|
||||||
|
parent_formula_code = fields.CharField(max_length=64, null=True, description="父级公式编码")
|
||||||
|
group_code = fields.CharField(max_length=64, null=True, description="分组编码")
|
||||||
step_order = fields.DecimalField(max_digits=8, decimal_places=3, description="步骤顺序")
|
step_order = fields.DecimalField(max_digits=8, decimal_places=3, description="步骤顺序")
|
||||||
step_name = fields.CharField(max_length=255, description="步骤名称")
|
step_name = fields.CharField(max_length=255, description="步骤名称")
|
||||||
step_description = fields.TextField(null=True, description="步骤描述")
|
step_description = fields.TextField(null=True, description="步骤描述")
|
||||||
input_params = fields.JSONField(null=True, description="输入参数")
|
input_params = fields.JSONField(null=True, description="输入参数")
|
||||||
output_result = fields.JSONField(null=True, description="输出结果")
|
output_result = fields.JSONField(null=True, description="输出结果")
|
||||||
status = fields.CharField(max_length=20, default="SUCCESS", description="步骤状态: SUCCESS, FAILED")
|
status = fields.CharField(max_length=20, default="processing", description="步骤状态: processing, completed, failed")
|
||||||
error_message = fields.TextField(null=True, description="错误信息")
|
error_message = fields.TextField(null=True, description="错误信息")
|
||||||
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
|
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
|
||||||
|
updated_at = fields.DatetimeField(null=True, description="更新时间")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "valuation_calculation_steps"
|
table = "valuation_calculation_steps"
|
||||||
table_description = "估值计算步骤表"
|
table_description = "估值计算步骤表"
|
||||||
ordering = ["step_order"]
|
ordering = ["step_order"]
|
||||||
|
# 唯一索引:同一估值ID下,同一公式编码只能有一条记录
|
||||||
|
# 注意:formula_code 允许为 NULL,但新逻辑中 formula_code 总是有值
|
||||||
|
unique_together = [("valuation", "formula_code")]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"估值ID {self.valuation_id} - 步骤 {self.step_order}: {self.step_name}"
|
return f"估值ID {self.valuation_id} - 步骤 {self.step_order}: {self.step_name}"
|
||||||
@ -62,6 +62,7 @@ class AppUserUpdateSchema(BaseModel):
|
|||||||
company_contact: Optional[str] = Field(None, description="公司联系人")
|
company_contact: Optional[str] = Field(None, description="公司联系人")
|
||||||
company_phone: Optional[str] = Field(None, description="公司电话")
|
company_phone: Optional[str] = Field(None, description="公司电话")
|
||||||
company_email: Optional[str] = Field(None, description="公司邮箱")
|
company_email: Optional[str] = Field(None, description="公司邮箱")
|
||||||
|
notes: Optional[str] = Field(None, description="备注")
|
||||||
|
|
||||||
|
|
||||||
class AppUserChangePasswordSchema(BaseModel):
|
class AppUserChangePasswordSchema(BaseModel):
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from pydantic import BaseModel, Field
|
|||||||
from pydantic.generics import GenericModel
|
from pydantic.generics import GenericModel
|
||||||
|
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
|
||||||
|
|
||||||
class Success(JSONResponse):
|
class Success(JSONResponse):
|
||||||
@ -13,9 +14,9 @@ class Success(JSONResponse):
|
|||||||
data: Optional[Any] = None,
|
data: Optional[Any] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
content = {"code": code, "msg": msg, "data": data}
|
content = {"code": code, "msg": msg, "data": ({} if data is None else data)}
|
||||||
content.update(kwargs)
|
content.update(kwargs)
|
||||||
super().__init__(content=content, status_code=code)
|
super().__init__(content=jsonable_encoder(content), status_code=code)
|
||||||
|
|
||||||
|
|
||||||
class Fail(JSONResponse):
|
class Fail(JSONResponse):
|
||||||
@ -26,9 +27,9 @@ class Fail(JSONResponse):
|
|||||||
data: Optional[Any] = None,
|
data: Optional[Any] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
content = {"code": code, "msg": msg, "data": data}
|
content = {"code": code, "msg": msg, "data": ({} if data is None else data)}
|
||||||
content.update(kwargs)
|
content.update(kwargs)
|
||||||
super().__init__(content=content, status_code=code)
|
super().__init__(content=jsonable_encoder(content), status_code=code)
|
||||||
|
|
||||||
|
|
||||||
class SuccessExtra(JSONResponse):
|
class SuccessExtra(JSONResponse):
|
||||||
@ -51,7 +52,7 @@ class SuccessExtra(JSONResponse):
|
|||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
}
|
}
|
||||||
content.update(kwargs)
|
content.update(kwargs)
|
||||||
super().__init__(content=content, status_code=code)
|
super().__init__(content=jsonable_encoder(content), status_code=code)
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|||||||
@ -1,26 +1,49 @@
|
|||||||
from typing import Optional, List
|
from typing import Optional, List, Union, Dict, Any
|
||||||
from pydantic import BaseModel, Field, EmailStr
|
from pydantic import BaseModel, Field, EmailStr, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
class InvoiceHeaderCreate(BaseModel):
|
class InvoiceHeaderCreate(BaseModel):
|
||||||
company_name: str = Field(..., min_length=1, max_length=128)
|
company_name: str = Field(..., min_length=1, max_length=128)
|
||||||
tax_number: str = Field(..., min_length=1, max_length=32)
|
tax_number: str = Field(..., min_length=1, max_length=32)
|
||||||
register_address: str = Field(..., min_length=1, max_length=256)
|
register_address: Optional[str] = Field(None, min_length=1, max_length=256)
|
||||||
register_phone: str = Field(..., min_length=1, max_length=32)
|
register_phone: Optional[str] = Field(None, min_length=1, max_length=32)
|
||||||
bank_name: str = Field(..., min_length=1, max_length=128)
|
bank_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||||
bank_account: str = Field(..., min_length=1, max_length=64)
|
bank_account: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
is_default: Optional[bool] = False
|
||||||
|
|
||||||
|
@field_validator('register_address', 'register_phone', 'bank_name', 'bank_account', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def _empty_to_none(cls, v):
|
||||||
|
if isinstance(v, str) and v.strip() == "":
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class InvoiceHeaderOut(BaseModel):
|
class InvoiceHeaderOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
app_user_id: Optional[int] = None
|
||||||
company_name: str
|
company_name: str
|
||||||
tax_number: str
|
tax_number: str
|
||||||
register_address: str
|
register_address: str
|
||||||
register_phone: str
|
register_phone: str
|
||||||
bank_name: str
|
bank_name: str
|
||||||
bank_account: str
|
bank_account: str
|
||||||
email: EmailStr
|
email: Optional[str] = None
|
||||||
|
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, 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):
|
class InvoiceCreate(BaseModel):
|
||||||
@ -30,10 +53,10 @@ class InvoiceCreate(BaseModel):
|
|||||||
email: EmailStr
|
email: EmailStr
|
||||||
company_name: str = Field(..., min_length=1, max_length=128)
|
company_name: str = Field(..., min_length=1, max_length=128)
|
||||||
tax_number: str = Field(..., min_length=1, max_length=32)
|
tax_number: str = Field(..., min_length=1, max_length=32)
|
||||||
register_address: str = Field(..., min_length=1, max_length=256)
|
register_address: str = Field(..., max_length=256)
|
||||||
register_phone: str = Field(..., min_length=1, max_length=32)
|
register_phone: str = Field(..., max_length=32)
|
||||||
bank_name: str = Field(..., min_length=1, max_length=128)
|
bank_name: str = Field(..., max_length=128)
|
||||||
bank_account: str = Field(..., min_length=1, max_length=64)
|
bank_account: str = Field(..., max_length=64)
|
||||||
app_user_id: Optional[int] = None
|
app_user_id: Optional[int] = None
|
||||||
header_id: Optional[int] = None
|
header_id: Optional[int] = None
|
||||||
wechat: Optional[str] = None
|
wechat: Optional[str] = None
|
||||||
@ -92,6 +115,7 @@ class UpdateType(BaseModel):
|
|||||||
class PaymentReceiptCreate(BaseModel):
|
class PaymentReceiptCreate(BaseModel):
|
||||||
url: str = Field(..., min_length=1, max_length=512)
|
url: str = Field(..., min_length=1, max_length=512)
|
||||||
note: Optional[str] = Field(None, max_length=256)
|
note: Optional[str] = Field(None, max_length=256)
|
||||||
|
extra: Optional[Union[List[str], Dict[str, Any]]] = None
|
||||||
|
|
||||||
|
|
||||||
class PaymentReceiptOut(BaseModel):
|
class PaymentReceiptOut(BaseModel):
|
||||||
@ -100,3 +124,77 @@ class PaymentReceiptOut(BaseModel):
|
|||||||
note: Optional[str]
|
note: Optional[str]
|
||||||
verified: bool
|
verified: bool
|
||||||
created_at: str
|
created_at: str
|
||||||
|
extra: Optional[Union[List[str], Dict[str, Any]]] = 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: Optional[Union[str, List[str]]] = Field(None)
|
||||||
|
receipt_urls: Optional[List[str]] = None
|
||||||
|
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):
|
||||||
|
cleaned: List[str] = []
|
||||||
|
for item in v:
|
||||||
|
if isinstance(item, str):
|
||||||
|
s = item.strip()
|
||||||
|
if s.startswith('`') and s.endswith('`'):
|
||||||
|
s = s[1:-1].strip()
|
||||||
|
while s.endswith('\\'):
|
||||||
|
s = s[:-1].strip()
|
||||||
|
if s:
|
||||||
|
cleaned.append(s)
|
||||||
|
return cleaned or None
|
||||||
|
if isinstance(v, str):
|
||||||
|
s = v.strip()
|
||||||
|
if s.startswith('`') and s.endswith('`'):
|
||||||
|
s = s[1:-1].strip()
|
||||||
|
while s.endswith('\\'):
|
||||||
|
s = s[:-1].strip()
|
||||||
|
return s or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
@field_validator('receipt_urls', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def _clean_receipt_urls(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = [v]
|
||||||
|
if isinstance(v, list):
|
||||||
|
seen = set()
|
||||||
|
cleaned = []
|
||||||
|
for item in v:
|
||||||
|
if isinstance(item, str):
|
||||||
|
s = item.strip()
|
||||||
|
if s.startswith('`') and s.endswith('`'):
|
||||||
|
s = s[1:-1].strip()
|
||||||
|
while s.endswith('\\'):
|
||||||
|
s = s[:-1].strip()
|
||||||
|
if s and s not in seen:
|
||||||
|
seen.add(s)
|
||||||
|
cleaned.append(s)
|
||||||
|
return cleaned or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
@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,19 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, EmailStr
|
||||||
from typing import Optional
|
from typing import Optional, List, Union
|
||||||
|
|
||||||
|
|
||||||
class SendEmailRequest(BaseModel):
|
class SendEmailRequest(BaseModel):
|
||||||
email: str = Field(..., description="邮箱地址")
|
receipt_id: Optional[int] = Field(None, description="付款凭证ID")
|
||||||
|
email: EmailStr = Field(..., description="邮箱地址")
|
||||||
subject: Optional[str] = Field(None, description="邮件主题")
|
subject: Optional[str] = Field(None, description="邮件主题")
|
||||||
body: str = Field(..., description="文案内容")
|
body: str = Field(..., description="文案内容")
|
||||||
file_url: Optional[str] = Field(None, description="附件URL")
|
file_urls: Optional[List[str]] = Field(None, description="附件URL列表")
|
||||||
|
file_url: Optional[Union[str, List[str]]] = Field(None, description="附件URL或列表(兼容前端传参)")
|
||||||
|
status: Optional[str] = Field(None, description="开票状态标记: success|invoiced|rejected|refunded")
|
||||||
|
|
||||||
|
|
||||||
|
class SendEmailBody(BaseModel):
|
||||||
|
data: SendEmailRequest
|
||||||
|
|
||||||
|
|
||||||
class SendEmailResponse(BaseModel):
|
class SendEmailResponse(BaseModel):
|
||||||
@ -23,3 +30,7 @@ class EmailSendLogOut(BaseModel):
|
|||||||
file_name: Optional[str]
|
file_name: Optional[str]
|
||||||
file_url: Optional[str]
|
file_url: Optional[str]
|
||||||
status: str
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class SendEmailBody(BaseModel):
|
||||||
|
data: SendEmailRequest
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional, Any, Dict, Union
|
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
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
@ -100,44 +100,101 @@ class ValuationAssessmentUpdate(BaseModel):
|
|||||||
|
|
||||||
# 非遗等级与技术
|
# 非遗等级与技术
|
||||||
inheritor_level: Optional[str] = Field(None, description="非遗传承人等级")
|
inheritor_level: Optional[str] = Field(None, description="非遗传承人等级")
|
||||||
|
inheritor_ages: Optional[List[int]] = Field(None, description="传承人年龄列表")
|
||||||
inheritor_age_count: Optional[List[Any]] = Field(None, description="非遗传承人年龄水平及数量")
|
inheritor_age_count: Optional[List[Any]] = Field(None, description="非遗传承人年龄水平及数量")
|
||||||
inheritor_certificates: Optional[List[Any]] = Field(None, description="非遗传承人等级证书")
|
inheritor_certificates: Optional[List[Any]] = Field(None, description="非遗传承人等级证书")
|
||||||
heritage_level: Optional[str] = Field(None, description="非遗等级")
|
heritage_level: Optional[str] = Field(None, description="非遗等级")
|
||||||
|
heritage_asset_level: Optional[str] = Field(None, description="非遗资产等级")
|
||||||
patent_application_no: Optional[str] = Field(None, description="非遗资产所用专利的申请号")
|
patent_application_no: Optional[str] = Field(None, description="非遗资产所用专利的申请号")
|
||||||
historical_evidence: Optional[List[Any]] = Field(None, description="非遗资产历史证明证据及数量")
|
patent_remaining_years: Optional[str] = Field(None, description="专利剩余年限")
|
||||||
|
historical_evidence: Optional[Dict[str, int]] = Field(None, description="非遗资产历史证明证据及数量")
|
||||||
patent_certificates: Optional[List[Any]] = Field(None, description="非遗资产所用专利的证书")
|
patent_certificates: Optional[List[Any]] = Field(None, description="非遗资产所用专利的证书")
|
||||||
pattern_images: Optional[List[Any]] = Field(None, description="非遗纹样图片")
|
pattern_images: Optional[List[Any]] = Field(None, description="非遗纹样图片")
|
||||||
report_url: Optional[str] = Field(None, description="评估报告URL")
|
report_url: Optional[str] = Field(None, description="评估报告URL")
|
||||||
certificate_url: Optional[str] = Field(None, description="证书URL")
|
certificate_url: Optional[str] = Field(None, description="证书URL")
|
||||||
|
|
||||||
# 非遗应用与推广
|
# 非遗应用与推广
|
||||||
|
implementation_stage: Optional[str] = Field(None, description="非遗资产应用成熟度")
|
||||||
application_maturity: Optional[str] = Field(None, description="非遗资产应用成熟度")
|
application_maturity: Optional[str] = Field(None, description="非遗资产应用成熟度")
|
||||||
application_coverage: Optional[str] = Field(None, description="非遗资产应用覆盖范围")
|
application_coverage: Optional[str] = Field(None, description="非遗资产应用覆盖范围")
|
||||||
|
coverage_area: Optional[str] = Field(None, description="应用覆盖范围")
|
||||||
cooperation_depth: Optional[str] = Field(None, description="非遗资产跨界合作深度")
|
cooperation_depth: Optional[str] = Field(None, description="非遗资产跨界合作深度")
|
||||||
|
collaboration_type: Optional[str] = Field(None, description="跨界合作类型")
|
||||||
offline_activities: Optional[str] = Field(None, description="近12个月线下相关宣讲活动次数")
|
offline_activities: Optional[str] = Field(None, description="近12个月线下相关宣讲活动次数")
|
||||||
|
offline_teaching_count: Optional[int] = Field(None, description="近12个月线下相关演讲活动次数")
|
||||||
online_accounts: Optional[List[Any]] = Field(None, description="线上相关宣传账号信息")
|
online_accounts: Optional[List[Any]] = Field(None, description="线上相关宣传账号信息")
|
||||||
|
platform_accounts: Optional[Dict[str, Dict[str, Union[str, int]]]] = Field(None, description="线上相关宣传账号信息")
|
||||||
|
|
||||||
# 非遗资产衍生商品信息
|
# 非遗资产衍生商品信息
|
||||||
sales_volume: Optional[str] = Field(None, description="该商品近12个月销售量")
|
sales_volume: Optional[str] = Field(None, description="该商品近12个月销售量")
|
||||||
link_views: Optional[str] = Field(None, description="该商品近12个月的链接浏览量")
|
link_views: Optional[str] = Field(None, description="该商品近12个月的链接浏览量")
|
||||||
circulation: Optional[str] = Field(None, description="该商品的发行量")
|
circulation: Optional[str] = Field(None, description="该商品的发行量")
|
||||||
|
scarcity_level: Optional[str] = Field(None, description="稀缺等级")
|
||||||
last_market_activity: Optional[str] = Field(None, description="该商品最近一次市场活动时间")
|
last_market_activity: Optional[str] = Field(None, description="该商品最近一次市场活动时间")
|
||||||
|
market_activity_time: Optional[str] = Field(None, description="市场活动的时间")
|
||||||
monthly_transaction: Optional[str] = Field(None, description="月交易额")
|
monthly_transaction: Optional[str] = Field(None, description="月交易额")
|
||||||
|
monthly_transaction_amount: Optional[str] = Field(None, description="月交易额")
|
||||||
price_fluctuation: Optional[List[Union[str, int, float]]] = Field(None, description="该商品近30天价格波动区间")
|
price_fluctuation: Optional[List[Union[str, int, float]]] = Field(None, description="该商品近30天价格波动区间")
|
||||||
|
price_range: Optional[Dict[str, Union[int, float]]] = Field(None, description="资产商品的价格波动率")
|
||||||
|
market_price: Optional[Union[int, float]] = Field(None, description="市场价格(单位:万元)")
|
||||||
credit_code_or_id: Optional[str] = Field(None, description="统一社会信用代码或身份证号")
|
credit_code_or_id: Optional[str] = Field(None, description="统一社会信用代码或身份证号")
|
||||||
biz_intro: Optional[str] = Field(None, description="业务/传承介绍")
|
biz_intro: Optional[str] = Field(None, description="业务/传承介绍")
|
||||||
|
|
||||||
|
# 内置API计算字段
|
||||||
|
infringement_record: Optional[str] = Field(None, description="侵权记录")
|
||||||
|
patent_count: Optional[str] = Field(None, description="专利使用量")
|
||||||
|
esg_value: Optional[str] = Field(None, description="ESG关联价值")
|
||||||
|
policy_matching: Optional[str] = Field(None, description="政策匹配度")
|
||||||
|
online_course_views: Optional[int] = Field(None, description="线上课程点击量")
|
||||||
|
pattern_complexity: Optional[str] = Field(None, description="结构复杂度")
|
||||||
|
normalized_entropy: Optional[str] = Field(None, description="归一化信息熵")
|
||||||
|
legal_risk: Optional[str] = Field(None, description="法律风险-侵权诉讼历史")
|
||||||
|
base_pledge_rate: Optional[str] = Field(None, description="基础质押率")
|
||||||
|
flow_correction: Optional[str] = Field(None, description="流量修正系数")
|
||||||
|
|
||||||
|
# 计算结果字段
|
||||||
|
model_value_b: Optional[float] = Field(None, description="模型估值B(万元)")
|
||||||
|
market_value_c: Optional[float] = Field(None, description="市场估值C(万元)")
|
||||||
|
final_value_ab: Optional[float] = Field(None, description="最终估值AB(万元)")
|
||||||
|
dynamic_pledge_rate: Optional[float] = Field(None, description="动态质押率")
|
||||||
|
calculation_result: Optional[Dict[str, Any]] = Field(None, description="完整计算结果JSON")
|
||||||
|
calculation_input: Optional[Dict[str, Any]] = Field(None, description="计算输入参数JSON")
|
||||||
|
|
||||||
|
# 系统字段
|
||||||
|
status: Optional[str] = Field(None, description="评估状态: pending(待审核), success(已通过), fail(已拒绝)")
|
||||||
|
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||||
|
|
||||||
is_active: Optional[bool] = Field(None, description="是否激活")
|
is_active: Optional[bool] = Field(None, description="是否激活")
|
||||||
|
|
||||||
|
@field_validator('report_url', 'certificate_url', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def _coerce_url(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
class ValuationAssessmentOut(ValuationAssessmentBase):
|
class ValuationAssessmentOut(ValuationAssessmentBase):
|
||||||
"""估值评估输出模型"""
|
"""估值评估输出模型"""
|
||||||
id: int = Field(..., description="主键ID")
|
id: int = Field(..., description="主键ID")
|
||||||
user_id: int = Field(..., description="用户ID")
|
user_id: int = Field(..., description="用户ID")
|
||||||
|
user_phone: Optional[str] = Field(None, description="用户手机号")
|
||||||
|
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="评估状态")
|
status: str = Field(..., description="评估状态")
|
||||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||||
created_at: datetime = Field(..., description="创建时间")
|
created_at: datetime = Field(..., description="创建时间")
|
||||||
updated_at: datetime = Field(..., description="更新时间")
|
updated_at: Optional[datetime] = Field(None, description="更新时间")
|
||||||
|
audited_at: Optional[datetime] = Field(None, description="审核时间")
|
||||||
is_active: bool = Field(..., description="是否激活")
|
is_active: bool = Field(..., description="是否激活")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@ -148,6 +205,29 @@ class ValuationAssessmentOut(ValuationAssessmentBase):
|
|||||||
# 确保所有字段都被序列化,包括None值
|
# 确保所有字段都被序列化,包括None值
|
||||||
exclude_none = False
|
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):
|
class UserValuationCreate(ValuationAssessmentBase):
|
||||||
@ -159,10 +239,14 @@ class UserValuationOut(ValuationAssessmentBase):
|
|||||||
"""用户端估值评估输出模型"""
|
"""用户端估值评估输出模型"""
|
||||||
id: int = Field(..., description="主键ID")
|
id: int = Field(..., description="主键ID")
|
||||||
user_id: Optional[int] = Field(None, description="用户ID")
|
user_id: Optional[int] = Field(None, description="用户ID")
|
||||||
|
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="评估状态")
|
status: str = Field(..., description="评估状态")
|
||||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||||
created_at: datetime = Field(..., description="创建时间")
|
created_at: datetime = Field(..., description="创建时间")
|
||||||
updated_at: datetime = Field(..., description="更新时间")
|
updated_at: Optional[datetime] = Field(None, description="更新时间")
|
||||||
is_active: Optional[bool] = Field(None, description="是否激活")
|
is_active: Optional[bool] = Field(None, description="是否激活")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@ -172,14 +256,41 @@ class UserValuationOut(ValuationAssessmentBase):
|
|||||||
}
|
}
|
||||||
exclude_none = False
|
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):
|
class UserValuationDetail(ValuationAssessmentBase):
|
||||||
"""用户端详细估值评估模型"""
|
"""用户端详细估值评估模型"""
|
||||||
id: int = Field(..., description="主键ID")
|
id: int = Field(..., description="主键ID")
|
||||||
|
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="评估状态")
|
status: str = Field(..., description="评估状态")
|
||||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||||
created_at: datetime = Field(..., description="创建时间")
|
created_at: datetime = Field(..., description="创建时间")
|
||||||
updated_at: datetime = Field(..., description="更新时间")
|
updated_at: Optional[datetime] = Field(None, description="更新时间")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@ -187,6 +298,29 @@ class UserValuationDetail(ValuationAssessmentBase):
|
|||||||
datetime: lambda v: v.isoformat()
|
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):
|
class UserValuationList(BaseModel):
|
||||||
"""用户端估值评估列表模型"""
|
"""用户端估值评估列表模型"""
|
||||||
@ -228,8 +362,13 @@ class ValuationAssessmentQuery(BaseModel):
|
|||||||
institution: Optional[str] = Field(None, description="所属机构")
|
institution: Optional[str] = Field(None, description="所属机构")
|
||||||
industry: Optional[str] = Field(None, description="所属行业")
|
industry: Optional[str] = Field(None, description="所属行业")
|
||||||
heritage_level: Optional[str] = Field(None, description="非遗等级")
|
heritage_level: Optional[str] = Field(None, description="非遗等级")
|
||||||
status: Optional[str] = Field(None, description="评估状态: pending(待审核), approved(已通过), rejected(已拒绝)")
|
status: Optional[str] = Field(None, description="评估状态: pending(待审核), success(已通过), rejected(已拒绝)")
|
||||||
is_active: Optional[bool] = Field(None, description="是否激活")
|
is_active: Optional[bool] = Field(None, description="是否激活")
|
||||||
|
phone: Optional[str] = Field(None, description="手机号模糊查询")
|
||||||
|
submitted_start: Optional[str] = Field(None, description="提交时间开始(毫秒时间戳或ISO字符串)")
|
||||||
|
submitted_end: Optional[str] = Field(None, description="提交时间结束(毫秒时间戳或ISO字符串)")
|
||||||
|
audited_start: Optional[str] = Field(None, description="审核时间开始(证书修改时间,毫秒时间戳或ISO字符串)")
|
||||||
|
audited_end: Optional[str] = Field(None, description="审核时间结束(证书修改时间,毫秒时间戳或ISO字符串)")
|
||||||
page: int = Field(1, ge=1, description="页码")
|
page: int = Field(1, ge=1, description="页码")
|
||||||
size: int = Field(10, ge=1, le=100, description="每页数量")
|
size: int = Field(10, ge=1, le=100, description="每页数量")
|
||||||
|
|
||||||
@ -252,8 +391,13 @@ class ValuationCalculationStepBase(BaseModel):
|
|||||||
step_description: Optional[str] = Field(None, description="步骤描述")
|
step_description: Optional[str] = Field(None, description="步骤描述")
|
||||||
input_params: Optional[Dict[str, Any]] = Field(None, description="输入参数")
|
input_params: Optional[Dict[str, Any]] = Field(None, description="输入参数")
|
||||||
output_result: Optional[Dict[str, Any]] = Field(None, description="输出结果")
|
output_result: Optional[Dict[str, Any]] = Field(None, description="输出结果")
|
||||||
status: str = Field(..., description="步骤状态")
|
status: str = Field(..., description="步骤状态: processing/completed/failed")
|
||||||
error_message: Optional[str] = Field(None, description="错误信息")
|
error_message: Optional[str] = Field(None, description="错误信息")
|
||||||
|
formula_code: Optional[str] = Field(None, description="公式编码")
|
||||||
|
formula_name: Optional[str] = Field(None, description="公式名称")
|
||||||
|
formula_text: Optional[str] = Field(None, description="公式说明")
|
||||||
|
parent_formula_code: Optional[str] = Field(None, description="父级公式编码")
|
||||||
|
group_code: Optional[str] = Field(None, description="分组编码")
|
||||||
|
|
||||||
@field_validator('step_order', mode='before')
|
@field_validator('step_order', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -278,6 +422,7 @@ class ValuationCalculationStepOut(ValuationCalculationStepBase):
|
|||||||
id: int = Field(..., description="主键ID")
|
id: int = Field(..., description="主键ID")
|
||||||
valuation_id: int = Field(..., description="关联的估值评估ID")
|
valuation_id: int = Field(..., description="关联的估值评估ID")
|
||||||
created_at: datetime = Field(..., description="创建时间")
|
created_at: datetime = Field(..., description="创建时间")
|
||||||
|
updated_at: Optional[datetime] = Field(None, description="更新时间")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from email.mime.base import MIMEBase
|
|||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email import encoders
|
from email import encoders
|
||||||
from typing import Optional
|
from typing import Optional, List, Tuple
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.settings.config import settings
|
from app.settings.config import settings
|
||||||
@ -27,6 +27,14 @@ class EmailClient:
|
|||||||
part.add_header("Content-Disposition", f"attachment; filename=\"{file_name}\"")
|
part.add_header("Content-Disposition", f"attachment; filename=\"{file_name}\"")
|
||||||
msg.attach(part)
|
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:
|
if settings.SMTP_TLS:
|
||||||
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30)
|
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30)
|
||||||
server.starttls()
|
server.starttls()
|
||||||
@ -43,7 +51,16 @@ class EmailClient:
|
|||||||
server.quit()
|
server.quit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if isinstance(e, smtplib.SMTPRecipientsRefused):
|
||||||
|
return {"status": "FAIL", "error": "收件方地址不存在或暂时不可用"}
|
||||||
return {"status": "FAIL", "error": str(e)}
|
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()
|
||||||
@ -86,7 +86,7 @@ class SMSClient:
|
|||||||
返回体映射字典
|
返回体映射字典
|
||||||
"""
|
"""
|
||||||
key = settings.ALIYUN_SMS_TEMPLATE_PARAM_CODE_KEY or "code"
|
key = settings.ALIYUN_SMS_TEMPLATE_PARAM_CODE_KEY or "code"
|
||||||
template = settings.ALIYUN_SMS_TEMPLATE_CODE_VERIFY or "SMS_498190229"
|
template = settings.ALIYUN_SMS_TEMPLATE_CODE_VERIFY
|
||||||
logger.info("sms.send_code using key={} template={} phone={}", key, template, phone)
|
logger.info("sms.send_code using key={} template={} phone={}", key, template, phone)
|
||||||
return self.send_by_template(phone, template, {key: code})
|
return self.send_by_template(phone, template, {key: code})
|
||||||
|
|
||||||
@ -99,7 +99,7 @@ class SMSClient:
|
|||||||
Returns:
|
Returns:
|
||||||
返回体映射字典
|
返回体映射字典
|
||||||
"""
|
"""
|
||||||
template = settings.ALIYUN_SMS_TEMPLATE_CODE_REPORT or "SMS_498140213"
|
template = settings.ALIYUN_SMS_TEMPLATE_CODE_REPORT
|
||||||
logger.info("sms.send_report using template={} phone={}", template, phone)
|
logger.info("sms.send_report using template={} phone={}", template, phone)
|
||||||
return self.send_by_template(phone, template, {})
|
return self.send_by_template(phone, template, {})
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ class VerificationStore:
|
|||||||
self.codes: Dict[str, Tuple[str, float]] = {}
|
self.codes: Dict[str, Tuple[str, float]] = {}
|
||||||
self.sends: Dict[str, Dict[str, float]] = {}
|
self.sends: Dict[str, Dict[str, float]] = {}
|
||||||
self.failures: Dict[str, Dict[str, float]] = {}
|
self.failures: Dict[str, Dict[str, float]] = {}
|
||||||
|
self.verified: Dict[str, float] = {}
|
||||||
|
|
||||||
def generate_code(self) -> str:
|
def generate_code(self) -> str:
|
||||||
"""生成数字验证码
|
"""生成数字验证码
|
||||||
@ -144,5 +145,13 @@ class VerificationStore:
|
|||||||
"""
|
"""
|
||||||
self.failures.pop(phone, None)
|
self.failures.pop(phone, None)
|
||||||
|
|
||||||
|
def mark_verified(self, phone: str, ttl_seconds: int = 300) -> None:
|
||||||
|
until = time.time() + ttl_seconds
|
||||||
|
self.verified[phone] = until
|
||||||
|
|
||||||
|
def is_recently_verified(self, phone: str) -> bool:
|
||||||
|
until = self.verified.get(phone, 0.0)
|
||||||
|
return until > time.time()
|
||||||
|
|
||||||
|
|
||||||
store = VerificationStore()
|
store = VerificationStore()
|
||||||
@ -100,18 +100,19 @@ class Settings(BaseSettings):
|
|||||||
ALIYUN_SMS_SIGN_NAME: typing.Optional[str] = "成都文化产权交易所"
|
ALIYUN_SMS_SIGN_NAME: typing.Optional[str] = "成都文化产权交易所"
|
||||||
ALIYUN_SMS_ENDPOINT: str = "dysmsapi.aliyuncs.com"
|
ALIYUN_SMS_ENDPOINT: str = "dysmsapi.aliyuncs.com"
|
||||||
ALIYUN_SMS_TEMPLATE_CODE_VERIFY: typing.Optional[str] = "SMS_498140213"
|
ALIYUN_SMS_TEMPLATE_CODE_VERIFY: typing.Optional[str] = "SMS_498140213"
|
||||||
ALIYUN_SMS_TEMPLATE_CODE_REPORT: typing.Optional[str] = "SMS_49190229"
|
ALIYUN_SMS_TEMPLATE_CODE_REPORT: typing.Optional[str] = "SMS_498190229"
|
||||||
SMS_CODE_DIGITS: int = 6
|
SMS_CODE_DIGITS: int = 6
|
||||||
SMS_DEBUG_LOG_CODE: bool = True
|
SMS_DEBUG_LOG_CODE: bool = True
|
||||||
ALIYUN_USE_DEFAULT_CREDENTIALS: bool = False
|
ALIYUN_USE_DEFAULT_CREDENTIALS: bool = False
|
||||||
ALIYUN_SMS_TEMPLATE_PARAM_CODE_KEY: typing.Optional[str] = "code"
|
ALIYUN_SMS_TEMPLATE_PARAM_CODE_KEY: typing.Optional[str] = "code"
|
||||||
|
SMS_BYPASS_CODE: typing.Optional[str] = "202511"
|
||||||
|
|
||||||
SMTP_HOST: typing.Optional[str] = None
|
SMTP_HOST: typing.Optional[str] = "smtp.qiye.aliyun.com"
|
||||||
SMTP_PORT: typing.Optional[int] = None
|
SMTP_PORT: typing.Optional[int] = 465
|
||||||
SMTP_USERNAME: typing.Optional[str] = None
|
SMTP_USERNAME: typing.Optional[str] = "value@cdcee.net"
|
||||||
SMTP_PASSWORD: typing.Optional[str] = None
|
SMTP_PASSWORD: typing.Optional[str] = "PPXbILdGlRCn2VOx"
|
||||||
SMTP_TLS: bool = True
|
SMTP_TLS: bool = False
|
||||||
SMTP_FROM: typing.Optional[str] = None
|
SMTP_FROM: typing.Optional[str] = "value@cdcee.net"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
%PDF-1.4
|
|
||||||
%粤マモ
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
%PDF-1.4
|
|
||||||
%粤マモ
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
%PDF-1.4
|
|
||||||
%粤マモ
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
%PDF-1.4
|
|
||||||
%粤マモ
|
|
||||||
@ -1 +0,0 @@
|
|||||||
%PDF-1.4
|
|
||||||
@ -1 +0,0 @@
|
|||||||
%PDF-1.4
|
|
||||||
@ -1 +0,0 @@
|
|||||||
%PDF-1.4
|
|
||||||
@ -1 +0,0 @@
|
|||||||
%PDF-1.4
|
|
||||||
2
app/static/files/valuation_assessments.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"id" "asset_name" "institution" "industry" "annual_revenue" "rd_investment" "three_year_income" "funding_status" "inheritor_level" "inheritor_ages" "inheritor_age_count" "inheritor_certificates" "heritage_asset_level" "patent_application_no" "patent_remaining_years" "historical_evidence" "patent_certificates" "pattern_images" "implementation_stage" "application_coverage" "cooperation_depth" "offline_activities" "platform_accounts" "sales_volume" "link_views" "circulation" "scarcity_level" "last_market_activity" "market_activity_time" "monthly_transaction_amount" "price_fluctuation" "price_range" "market_price" "infringement_record" "patent_count" "esg_value" "policy_matching" "online_course_views" "pattern_complexity" "normalized_entropy" "legal_risk" "base_pledge_rate" "flow_correction" "model_value_b" "market_value_c" "final_value_ab" "dynamic_pledge_rate" "calculation_result" "calculation_input" "status" "admin_notes" "created_at" "updated_at" "is_active" "user_id"
|
||||||
|
"19" "蜀锦" "成都古蜀蜀锦研究所" "纺织业" "169" "32" "[169,169,169]" "无资助" "省级传承人" "[0,0,2]" "[0,0,2]" "[]" "国家级非遗" "" "{""artifacts"":2,""ancient_literature"":5,""inheritor_testimony"":5,""modern_research"":6}" "[]" "[]" "成熟应用" "1" "1" "50" "{""douyin"":{""account"":""huguangjing3691"",""likes"":""67000"",""comments"":""800"",""shares"":""500""}}" "5000" "296000" "限量:总发行份数 ≤100份" "限量:总发行份数 ≤100份" "0" "近一周" "月交易额>100万<500万" "[1580,3980]" "success" "2025-11-17 18:13:17.435287+08:00" "2025-11-17 18:13:17.435322+08:00" "1" "30"
|
||||||
|
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 |
@ -3,6 +3,7 @@ from typing import Optional
|
|||||||
import jwt
|
import jwt
|
||||||
from fastapi import HTTPException, status, Depends, Header
|
from fastapi import HTTPException, status, Depends, Header
|
||||||
from app.controllers.app_user import app_user_controller
|
from app.controllers.app_user import app_user_controller
|
||||||
|
from app.core.token_blacklist import is_blacklisted
|
||||||
from app.schemas.app_user import AppUserJWTPayload
|
from app.schemas.app_user import AppUserJWTPayload
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
@ -48,18 +49,24 @@ def verify_app_user_token(token: str) -> Optional[AppUserJWTPayload]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_current_app_user_id(token: str = Header(None)) -> int:
|
async def get_current_app_user_id(token: str = Header(None)) -> int:
|
||||||
"""
|
"""
|
||||||
从令牌中获取当前AppUser ID
|
从令牌中获取当前AppUser ID
|
||||||
"""
|
"""
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="无效的认证凭据",
|
detail="未登录,请重新登录",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
if token and token != "dev":
|
||||||
|
try:
|
||||||
|
if await is_blacklisted(token):
|
||||||
|
raise credentials_exception
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
payload = verify_app_user_token(token)
|
payload = verify_app_user_token(token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
|
|||||||
@ -1,53 +1,53 @@
|
|||||||
'''
|
"""
|
||||||
这是非物质文化遗产IP知识产权评估系统的核心计算引擎包。
|
非遗资产估值计算引擎包。
|
||||||
'''
|
提供各类计算器,并通过懒加载避免循环依赖。
|
||||||
from app.utils.calculation_engine.economic_value_b1 import EconomicValueB1Calculator
|
"""
|
||||||
from app.utils.calculation_engine.economic_value_b1.sub_formulas import (
|
from importlib import import_module
|
||||||
BasicValueB11Calculator,
|
from typing import Any
|
||||||
TrafficFactorB12Calculator,
|
|
||||||
PolicyMultiplierB13Calculator
|
|
||||||
)
|
|
||||||
from app.utils.calculation_engine.cultural_value_b2 import CulturalValueB2Calculator
|
|
||||||
from app.utils.calculation_engine.cultural_value_b2.sub_formulas import (
|
|
||||||
LivingHeritageB21Calculator,
|
|
||||||
PatternGeneB22Calculator
|
|
||||||
)
|
|
||||||
from app.utils.calculation_engine.risk_adjustment_b3 import RiskAdjustmentB3Calculator
|
|
||||||
from app.utils.calculation_engine.market_value_c import MarketValueCCalculator
|
|
||||||
from app.utils.calculation_engine.market_value_c.sub_formulas import (
|
|
||||||
MarketBiddingC1Calculator,
|
|
||||||
HeatCoefficientC2Calculator,
|
|
||||||
ScarcityMultiplierC3Calculator,
|
|
||||||
TemporalDecayC4Calculator
|
|
||||||
)
|
|
||||||
from app.utils.calculation_engine.final_value_ab import FinalValueACalculator
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.0.0"
|
||||||
__author__ = "Assessment Team"
|
__author__ = "Assessment Team"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# 经济价值B1模块
|
|
||||||
"EconomicValueB1Calculator",
|
"EconomicValueB1Calculator",
|
||||||
"BasicValueB11Calculator",
|
"BasicValueB11Calculator",
|
||||||
"TrafficFactorB12Calculator",
|
"TrafficFactorB12Calculator",
|
||||||
"PolicyMultiplierB13Calculator",
|
"PolicyMultiplierB13Calculator",
|
||||||
|
|
||||||
# 文化价值B2模块
|
|
||||||
"CulturalValueB2Calculator",
|
"CulturalValueB2Calculator",
|
||||||
"LivingHeritageB21Calculator",
|
"LivingHeritageB21Calculator",
|
||||||
"PatternGeneB22Calculator",
|
"PatternGeneB22Calculator",
|
||||||
|
|
||||||
# 风险调整系数B3模块
|
|
||||||
"RiskAdjustmentB3Calculator",
|
"RiskAdjustmentB3Calculator",
|
||||||
|
|
||||||
# 市场估值C模块
|
|
||||||
"MarketValueCCalculator",
|
"MarketValueCCalculator",
|
||||||
"MarketBiddingC1Calculator",
|
"MarketBiddingC1Calculator",
|
||||||
"HeatCoefficientC2Calculator",
|
"HeatCoefficientC2Calculator",
|
||||||
"ScarcityMultiplierC3Calculator",
|
"ScarcityMultiplierC3Calculator",
|
||||||
"TemporalDecayC4Calculator",
|
"TemporalDecayC4Calculator",
|
||||||
|
"FinalValueACalculator",
|
||||||
|
|
||||||
# 最终估值A模块
|
|
||||||
"FinalValueACalculator"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_EXPORT_MAP = {
|
||||||
|
"EconomicValueB1Calculator": "app.utils.calculation_engine.economic_value_b1",
|
||||||
|
"BasicValueB11Calculator": "app.utils.calculation_engine.economic_value_b1.sub_formulas.basic_value_b11",
|
||||||
|
"TrafficFactorB12Calculator": "app.utils.calculation_engine.economic_value_b1.sub_formulas.traffic_factor_b12",
|
||||||
|
"PolicyMultiplierB13Calculator": "app.utils.calculation_engine.economic_value_b1.sub_formulas.policy_multiplier_b13",
|
||||||
|
"CulturalValueB2Calculator": "app.utils.calculation_engine.cultural_value_b2.cultural_value_b2",
|
||||||
|
"LivingHeritageB21Calculator": "app.utils.calculation_engine.cultural_value_b2.sub_formulas.living_heritage_b21",
|
||||||
|
"PatternGeneB22Calculator": "app.utils.calculation_engine.cultural_value_b2.sub_formulas.pattern_gene_b22",
|
||||||
|
"RiskAdjustmentB3Calculator": "app.utils.calculation_engine.risk_adjustment_b3.sub_formulas.risk_adjustment_b3",
|
||||||
|
"MarketValueCCalculator": "app.utils.calculation_engine.market_value_c.market_value_c",
|
||||||
|
"MarketBiddingC1Calculator": "app.utils.calculation_engine.market_value_c.sub_formulas.market_bidding_c1",
|
||||||
|
"HeatCoefficientC2Calculator": "app.utils.calculation_engine.market_value_c.sub_formulas.heat_coefficient_c2",
|
||||||
|
"ScarcityMultiplierC3Calculator": "app.utils.calculation_engine.market_value_c.sub_formulas.scarcity_multiplier_c3",
|
||||||
|
"TemporalDecayC4Calculator": "app.utils.calculation_engine.market_value_c.sub_formulas.temporal_decay_c4",
|
||||||
|
"FinalValueACalculator": "app.utils.calculation_engine.final_value_ab.final_value_a",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
module_path = _EXPORT_MAP.get(name)
|
||||||
|
if not module_path:
|
||||||
|
raise AttributeError(f"module {__name__} has no attribute {name}")
|
||||||
|
module = import_module(module_path)
|
||||||
|
attr = getattr(module, name)
|
||||||
|
globals()[name] = attr
|
||||||
|
return attr
|
||||||
|
|||||||
@ -18,13 +18,12 @@ try:
|
|||||||
from .sub_formulas.living_heritage_b21 import LivingHeritageB21Calculator
|
from .sub_formulas.living_heritage_b21 import LivingHeritageB21Calculator
|
||||||
from .sub_formulas.pattern_gene_b22 import PatternGeneB22Calculator
|
from .sub_formulas.pattern_gene_b22 import PatternGeneB22Calculator
|
||||||
from app.controllers.valuation import ValuationController
|
from app.controllers.valuation import ValuationController
|
||||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# 绝对导入(当直接运行时)
|
# 绝对导入(当直接运行时)
|
||||||
from sub_formulas.living_heritage_b21 import LivingHeritageB21Calculator
|
from sub_formulas.living_heritage_b21 import LivingHeritageB21Calculator
|
||||||
from sub_formulas.pattern_gene_b22 import PatternGeneB22Calculator
|
from sub_formulas.pattern_gene_b22 import PatternGeneB22Calculator
|
||||||
from app.controllers.valuation import ValuationController
|
from app.controllers.valuation import ValuationController
|
||||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
from app.controllers.valuation import ValuationController
|
||||||
|
|
||||||
|
|
||||||
class CulturalValueB2Calculator:
|
class CulturalValueB2Calculator:
|
||||||
@ -54,7 +53,7 @@ class CulturalValueB2Calculator:
|
|||||||
|
|
||||||
return cultural_value
|
return cultural_value
|
||||||
|
|
||||||
async def calculate_complete_cultural_value_b2(self, valuation_id: int, input_data: Dict) -> float:
|
async def calculate_complete_cultural_value_b2(self, valuation_id: int, input_data: Dict) -> Dict[str, float]:
|
||||||
"""
|
"""
|
||||||
计算完整的文化价值B2,并记录所有计算步骤。
|
计算完整的文化价值B2,并记录所有计算步骤。
|
||||||
|
|
||||||
@ -73,38 +72,59 @@ class CulturalValueB2Calculator:
|
|||||||
}
|
}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
float: 计算得出的文化价值B2。
|
Dict[str, float]: 包含文化价值B2及子公式结果的字典。
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: 在计算过程中遇到的任何异常都会被捕获、记录,并重新抛出。
|
Exception: 在计算过程中遇到的任何异常都会被捕获、记录,并重新抛出。
|
||||||
"""
|
"""
|
||||||
step = await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id,
|
"MODEL_B_CULTURAL_B2",
|
||||||
step_order=2.2,
|
status="processing",
|
||||||
step_name="文化价值B2计算",
|
|
||||||
step_description="开始计算文化价值B2,公式为:活态传承系数B21 × 0.6 + (纹样基因值B22 / 10) × 0.4",
|
|
||||||
input_params=input_data,
|
input_params=input_data,
|
||||||
status="in_progress"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# 计算活态传承系数B21
|
# 计算活态传承系数B21
|
||||||
living_heritage_b21 = self.living_heritage_calculator.calculate_living_heritage_b21(
|
teaching_frequency = self.living_heritage_calculator.calculate_teaching_frequency(
|
||||||
input_data['inheritor_level_coefficient'],
|
|
||||||
self.living_heritage_calculator.calculate_teaching_frequency(
|
|
||||||
input_data["offline_sessions"],
|
input_data["offline_sessions"],
|
||||||
input_data["douyin_views"],
|
input_data["douyin_views"],
|
||||||
input_data["kuaishou_views"],
|
input_data["kuaishou_views"],
|
||||||
input_data["bilibili_views"]
|
input_data["bilibili_views"]
|
||||||
),
|
)
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_CULTURAL_B21_TEACHING_FREQ",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"offline_sessions": input_data.get("offline_sessions"),
|
||||||
|
"douyin_views": input_data.get("douyin_views"),
|
||||||
|
"kuaishou_views": input_data.get("kuaishou_views"),
|
||||||
|
"bilibili_views": input_data.get("bilibili_views"),
|
||||||
|
},
|
||||||
|
output_result={"teaching_frequency": teaching_frequency},
|
||||||
|
)
|
||||||
|
|
||||||
|
living_heritage_b21 = self.living_heritage_calculator.calculate_living_heritage_b21(
|
||||||
|
input_data['inheritor_level_coefficient'],
|
||||||
|
teaching_frequency,
|
||||||
input_data['cross_border_depth']
|
input_data['cross_border_depth']
|
||||||
)
|
)
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id, step_order=2.21, step_name="活态传承系数B21",
|
"MODEL_B_CULTURAL_B21",
|
||||||
output_result={'living_heritage_b21': living_heritage_b21}, status="completed"
|
status="completed",
|
||||||
)
|
input_params={
|
||||||
|
"inheritor_level_coefficient": input_data.get("inheritor_level_coefficient"),
|
||||||
|
"offline_sessions": input_data.get("offline_sessions"),
|
||||||
|
"douyin_views": input_data.get("douyin_views"),
|
||||||
|
"kuaishou_views": input_data.get("kuaishou_views"),
|
||||||
|
"bilibili_views": input_data.get("bilibili_views"),
|
||||||
|
"cross_border_depth": input_data.get("cross_border_depth"),
|
||||||
|
},
|
||||||
|
output_result={
|
||||||
|
"living_heritage_b21": living_heritage_b21,
|
||||||
|
"teaching_frequency": teaching_frequency,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算纹样基因值B22
|
# 计算纹样基因值B22
|
||||||
@ -113,11 +133,16 @@ class CulturalValueB2Calculator:
|
|||||||
input_data['normalized_entropy'],
|
input_data['normalized_entropy'],
|
||||||
input_data['historical_inheritance']
|
input_data['historical_inheritance']
|
||||||
)
|
)
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id, step_order=2.22, step_name="纹样基因值B22",
|
"MODEL_B_CULTURAL_B22",
|
||||||
output_result={'pattern_gene_b22': pattern_gene_b22}, status="completed"
|
status="completed",
|
||||||
)
|
input_params={
|
||||||
|
"structure_complexity": input_data.get("structure_complexity"),
|
||||||
|
"normalized_entropy": input_data.get("normalized_entropy"),
|
||||||
|
"historical_inheritance": input_data.get("historical_inheritance"),
|
||||||
|
},
|
||||||
|
output_result={"pattern_gene_b22": pattern_gene_b22},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算文化价值B2
|
# 计算文化价值B2
|
||||||
@ -126,14 +151,24 @@ class CulturalValueB2Calculator:
|
|||||||
pattern_gene_b22
|
pattern_gene_b22
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.valuation_controller.update_calculation_step(
|
result = {
|
||||||
step.id, {"status": "completed", "output_result": {"cultural_value_b2": cultural_value_b2}}
|
"cultural_value_b2": cultural_value_b2,
|
||||||
|
"living_heritage_b21": living_heritage_b21,
|
||||||
|
"pattern_gene_b22": pattern_gene_b22,
|
||||||
|
}
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_CULTURAL_B2",
|
||||||
|
status="completed",
|
||||||
|
output_result=result,
|
||||||
)
|
)
|
||||||
return cultural_value_b2
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = f"文化价值B2计算失败: {e}"
|
await self.valuation_controller.log_formula_step(
|
||||||
await self.valuation_controller.update_calculation_step(
|
valuation_id,
|
||||||
step.id, {"status": "failed", "error_message": error_message}
|
"MODEL_B_CULTURAL_B2",
|
||||||
|
status="failed",
|
||||||
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
2. pattern_gene_b22: 纹样基因值B22计算
|
2. pattern_gene_b22: 纹样基因值B22计算
|
||||||
- 结构复杂度SC = Σ(元素权重 × 复杂度系数) / 总元素数
|
- 结构复杂度SC = Σ(元素权重 × 复杂度系数) / 总元素数
|
||||||
- 归一化信息熵H = -Σ(p_i × log2(p_i)) / log2(n)
|
- 归一化信息熵H = -Σ(p_i × log2(p_i)) / log2(n)
|
||||||
- 历史传承度HI = 传承年限权重 × 0.4 + 文化意义权重 × 0.3 + 保护状况权重 × 0.3
|
- 历史传承度HI = 出土实物×1.0 + 古代文献×0.8 + 传承人佐证×0.6 + 现代研究×0.4
|
||||||
- 纹样基因值B22 = (结构复杂度SC × 0.6 + 归一化信息熵H × 0.4) × 历史传承度HI × 10
|
- 纹样基因值B22 = (结构复杂度SC × 0.6 + 归一化信息熵H × 0.4) × 历史传承度HI × 10
|
||||||
- 文化价值B2 = 活态传承系数B21 × 0.6 + (纹样基因值B22 / 10) × 0.4
|
- 文化价值B2 = 活态传承系数B21 × 0.6 + (纹样基因值B22 / 10) × 0.4
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from app.controllers.valuation import ValuationController
|
from app.controllers.valuation import ValuationController
|
||||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
|
||||||
|
|
||||||
from .sub_formulas.basic_value_b11 import BasicValueB11Calculator
|
from .sub_formulas.basic_value_b11 import BasicValueB11Calculator
|
||||||
from .sub_formulas.traffic_factor_b12 import TrafficFactorB12Calculator
|
from .sub_formulas.traffic_factor_b12 import TrafficFactorB12Calculator
|
||||||
@ -46,7 +45,7 @@ class EconomicValueB1Calculator:
|
|||||||
|
|
||||||
return economic_value
|
return economic_value
|
||||||
|
|
||||||
async def calculate_complete_economic_value_b1(self, valuation_id: int, input_data: Dict) -> float:
|
async def calculate_complete_economic_value_b1(self, valuation_id: int, input_data: Dict) -> Dict[str, float]:
|
||||||
"""
|
"""
|
||||||
计算完整的经济价值B1,并记录所有计算步骤。
|
计算完整的经济价值B1,并记录所有计算步骤。
|
||||||
|
|
||||||
@ -66,81 +65,229 @@ class EconomicValueB1Calculator:
|
|||||||
}
|
}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
float: 计算得出的经济价值B1。
|
Dict[str, float]: 包含经济价值B1及各子公式结果的字典。
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: 在计算过程中发生的任何异常都会被捕获、记录,并重新抛出。
|
Exception: 在计算过程中发生的任何异常都会被捕获、记录,并重新抛出。
|
||||||
"""
|
"""
|
||||||
step = await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id,
|
"MODEL_B_ECON_B1",
|
||||||
step_order=2.1,
|
status="processing",
|
||||||
step_name="经济价值B1计算",
|
|
||||||
step_description="开始计算经济价值B1,公式为:基础价值B11 × (1 + 流量因子B12) × 政策乘数B13",
|
|
||||||
input_params=input_data,
|
input_params=input_data,
|
||||||
status="in_progress"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# 计算基础价值B11
|
financial_value = self.basic_value_calculator.calculate_financial_value_f(input_data["three_year_income"])
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_ECON_B11_FINANCIAL_VALUE",
|
||||||
|
status="completed",
|
||||||
|
input_params={"three_year_income": input_data.get("three_year_income")},
|
||||||
|
output_result={"financial_value_f": financial_value},
|
||||||
|
)
|
||||||
|
|
||||||
|
legal_strength = self.basic_value_calculator.calculate_legal_strength_l(
|
||||||
|
input_data["patent_score"],
|
||||||
|
input_data["popularity_score"],
|
||||||
|
input_data["infringement_score"],
|
||||||
|
)
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_ECON_B11_LEGAL_STRENGTH",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"patent_score": input_data.get("patent_score"),
|
||||||
|
"popularity_score": input_data.get("popularity_score"),
|
||||||
|
"infringement_score": input_data.get("infringement_score"),
|
||||||
|
},
|
||||||
|
output_result={"legal_strength_l": legal_strength},
|
||||||
|
)
|
||||||
|
|
||||||
|
development_potential = self.basic_value_calculator.calculate_development_potential_d(
|
||||||
|
input_data["patent_count"],
|
||||||
|
input_data["esg_score"],
|
||||||
|
input_data["innovation_ratio"],
|
||||||
|
)
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_ECON_B11_DEVELOPMENT_POTENTIAL",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"patent_count": input_data.get("patent_count"),
|
||||||
|
"esg_score": input_data.get("esg_score"),
|
||||||
|
"innovation_ratio": input_data.get("innovation_ratio"),
|
||||||
|
},
|
||||||
|
output_result={"development_potential_d": development_potential},
|
||||||
|
)
|
||||||
|
|
||||||
|
industry_coefficient = input_data["industry_coefficient"]
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_ECON_B11_INDUSTRY_COEFFICIENT",
|
||||||
|
status="completed",
|
||||||
|
input_params={"industry_coefficient": industry_coefficient},
|
||||||
|
output_result={"industry_coefficient": industry_coefficient},
|
||||||
|
)
|
||||||
|
|
||||||
basic_value_b11 = self.basic_value_calculator.calculate_basic_value_b11(
|
basic_value_b11 = self.basic_value_calculator.calculate_basic_value_b11(
|
||||||
self.basic_value_calculator.calculate_financial_value_f(input_data["three_year_income"]),
|
financial_value,
|
||||||
self.basic_value_calculator.calculate_legal_strength_l(input_data["patent_score"], input_data["popularity_score"], input_data["infringement_score"]),
|
legal_strength,
|
||||||
self.basic_value_calculator.calculate_development_potential_d(input_data["patent_count"], input_data["esg_score"], input_data["innovation_ratio"]),
|
development_potential,
|
||||||
input_data["industry_coefficient"]
|
industry_coefficient,
|
||||||
)
|
|
||||||
await self.valuation_controller.create_calculation_step(
|
|
||||||
ValuationCalculationStepCreate(
|
|
||||||
valuation_id=valuation_id, step_order=2.11, step_name="基础价值B11",
|
|
||||||
output_result={'basic_value_b11': basic_value_b11}, status="completed"
|
|
||||||
)
|
)
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_ECON_B11",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"financial_value_f": financial_value,
|
||||||
|
"legal_strength_l": legal_strength,
|
||||||
|
"development_potential_d": development_potential,
|
||||||
|
"industry_coefficient": industry_coefficient,
|
||||||
|
},
|
||||||
|
output_result={
|
||||||
|
"basic_value_b11": basic_value_b11,
|
||||||
|
"financial_value_f": financial_value,
|
||||||
|
"legal_strength_l": legal_strength,
|
||||||
|
"development_potential_d": development_potential,
|
||||||
|
"industry_coefficient": industry_coefficient,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interaction_index = self.traffic_factor_calculator.calculate_interaction_index(
|
||||||
|
input_data["likes"],
|
||||||
|
input_data["comments"],
|
||||||
|
input_data["shares"],
|
||||||
|
)
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_FLOW_B12_INTERACTION_INDEX",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"likes": input_data.get("likes"),
|
||||||
|
"comments": input_data.get("comments"),
|
||||||
|
"shares": input_data.get("shares"),
|
||||||
|
},
|
||||||
|
output_result={"interaction_index": interaction_index},
|
||||||
|
)
|
||||||
|
|
||||||
|
coverage_index = self.traffic_factor_calculator.calculate_coverage_index(input_data.get("followers", 0))
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_FLOW_B12_COVERAGE_INDEX",
|
||||||
|
status="completed",
|
||||||
|
input_params={"followers": input_data.get("followers", 0)},
|
||||||
|
output_result={"coverage_index": coverage_index},
|
||||||
|
)
|
||||||
|
|
||||||
|
conversion_efficiency = self.traffic_factor_calculator.calculate_conversion_efficiency(
|
||||||
|
input_data["sales_volume"],
|
||||||
|
input_data["link_views"],
|
||||||
|
)
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_FLOW_B12_CONVERSION_EFFICIENCY",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"sales_volume": input_data.get("sales_volume"),
|
||||||
|
"link_views": input_data.get("link_views"),
|
||||||
|
},
|
||||||
|
output_result={"conversion_efficiency": conversion_efficiency},
|
||||||
|
)
|
||||||
|
|
||||||
|
social_media_spread_s3 = self.traffic_factor_calculator.calculate_social_media_spread_s3(
|
||||||
|
interaction_index,
|
||||||
|
coverage_index,
|
||||||
|
conversion_efficiency,
|
||||||
|
)
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_FLOW_B12_SOCIAL_SPREAD",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"interaction_index": interaction_index,
|
||||||
|
"coverage_index": coverage_index,
|
||||||
|
"conversion_efficiency": conversion_efficiency,
|
||||||
|
},
|
||||||
|
output_result={"social_media_spread_s3": social_media_spread_s3},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算流量因子B12
|
|
||||||
traffic_factor_b12 = self.traffic_factor_calculator.calculate_traffic_factor_b12(
|
traffic_factor_b12 = self.traffic_factor_calculator.calculate_traffic_factor_b12(
|
||||||
input_data['search_index_s1'],
|
input_data["search_index_s1"],
|
||||||
input_data['industry_average_s2'],
|
input_data["industry_average_s2"],
|
||||||
self.traffic_factor_calculator.calculate_social_media_spread_s3(
|
social_media_spread_s3,
|
||||||
self.traffic_factor_calculator.calculate_interaction_index(input_data["likes"], input_data["comments"], input_data["shares"]),
|
|
||||||
self.traffic_factor_calculator.calculate_coverage_index(0),
|
|
||||||
self.traffic_factor_calculator.calculate_conversion_efficiency(input_data["sales_volume"], input_data["link_views"])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await self.valuation_controller.create_calculation_step(
|
|
||||||
ValuationCalculationStepCreate(
|
|
||||||
valuation_id=valuation_id, step_order=2.12, step_name="流量因子B12",
|
|
||||||
output_result={'traffic_factor_b12': traffic_factor_b12}, status="completed"
|
|
||||||
)
|
)
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_FLOW_B12",
|
||||||
|
status="completed",
|
||||||
|
input_params={
|
||||||
|
"search_index_s1": input_data.get("search_index_s1"),
|
||||||
|
"industry_average_s2": input_data.get("industry_average_s2"),
|
||||||
|
},
|
||||||
|
output_result={
|
||||||
|
"traffic_factor_b12": traffic_factor_b12,
|
||||||
|
"social_media_spread_s3": social_media_spread_s3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
policy_compatibility = self.policy_multiplier_calculator.calculate_policy_compatibility_score(
|
||||||
|
input_data["policy_match_score"],
|
||||||
|
input_data["implementation_stage"],
|
||||||
|
input_data["funding_support"],
|
||||||
|
)
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_POLICY_B13",
|
||||||
|
status="processing",
|
||||||
|
input_params={
|
||||||
|
"policy_match_score": input_data.get("policy_match_score"),
|
||||||
|
"implementation_stage": input_data.get("implementation_stage"),
|
||||||
|
"funding_support": input_data.get("funding_support"),
|
||||||
|
},
|
||||||
|
output_result={"policy_compatibility_score": policy_compatibility},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算政策乘数B13
|
|
||||||
policy_multiplier_b13 = self.policy_multiplier_calculator.calculate_policy_multiplier_b13(
|
policy_multiplier_b13 = self.policy_multiplier_calculator.calculate_policy_multiplier_b13(
|
||||||
self.policy_multiplier_calculator.calculate_policy_compatibility_score(
|
policy_compatibility,
|
||||||
input_data["policy_match_score"], input_data["implementation_stage"], input_data["funding_support"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await self.valuation_controller.create_calculation_step(
|
|
||||||
ValuationCalculationStepCreate(
|
|
||||||
valuation_id=valuation_id, step_order=2.13, step_name="政策乘数B13",
|
|
||||||
output_result={'policy_multiplier_b13': policy_multiplier_b13}, status="completed"
|
|
||||||
)
|
)
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_POLICY_B13",
|
||||||
|
status="completed",
|
||||||
|
output_result={
|
||||||
|
"policy_multiplier_b13": policy_multiplier_b13,
|
||||||
|
"policy_compatibility_score": policy_compatibility,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算经济价值B1
|
|
||||||
economic_value_b1 = self.calculate_economic_value_b1(
|
economic_value_b1 = self.calculate_economic_value_b1(
|
||||||
basic_value_b11,
|
basic_value_b11,
|
||||||
traffic_factor_b12,
|
traffic_factor_b12,
|
||||||
policy_multiplier_b13
|
policy_multiplier_b13,
|
||||||
)
|
)
|
||||||
|
result = {
|
||||||
await self.valuation_controller.update_calculation_step(
|
"economic_value_b1": economic_value_b1,
|
||||||
step.id, {"status": "completed", "output_result": {"economic_value_b1": economic_value_b1}}
|
"basic_value_b11": basic_value_b11,
|
||||||
|
"traffic_factor_b12": traffic_factor_b12,
|
||||||
|
"policy_multiplier_b13": policy_multiplier_b13,
|
||||||
|
"financial_value_f": financial_value,
|
||||||
|
"legal_strength_l": legal_strength,
|
||||||
|
"development_potential_d": development_potential,
|
||||||
|
}
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_ECON_B1",
|
||||||
|
status="completed",
|
||||||
|
output_result=result,
|
||||||
)
|
)
|
||||||
return economic_value_b1
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = f"经济价值B1计算失败: {e}"
|
await self.valuation_controller.log_formula_step(
|
||||||
await self.valuation_controller.update_calculation_step(
|
valuation_id,
|
||||||
step.id, {"status": "failed", "error_message": error_message}
|
"MODEL_B_ECON_B1",
|
||||||
|
status="failed",
|
||||||
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@ -106,7 +106,7 @@ class BasicValueB11Calculator:
|
|||||||
# 使用两个增长率的平均值
|
# 使用两个增长率的平均值
|
||||||
avg_growth_rate = (growth_rate_1 + growth_rate_2) / 2
|
avg_growth_rate = (growth_rate_1 + growth_rate_2) / 2
|
||||||
|
|
||||||
return avg_growth_rate
|
return max(avg_growth_rate, 0.0)
|
||||||
|
|
||||||
def calculate_legal_strength_l(self,
|
def calculate_legal_strength_l(self,
|
||||||
patent_score: float,
|
patent_score: float,
|
||||||
|
|||||||
@ -49,6 +49,20 @@ class TrafficFactorB12Calculator:
|
|||||||
traffic_factor = (math.log(search_index_s1 / industry_average_s2) * 0.3 +
|
traffic_factor = (math.log(search_index_s1 / industry_average_s2) * 0.3 +
|
||||||
social_media_spread_s3 * 0.7)
|
social_media_spread_s3 * 0.7)
|
||||||
|
|
||||||
|
"""
|
||||||
|
为什么需要
|
||||||
|
|
||||||
|
- 经济价值 B1 的公式是 B1 = B11 × (1 + B12) × B13 (app/utils/calculation_engine/economic_value_b1/economic_value_b1.py:34-45)。
|
||||||
|
- 如果 B12 < -1 ,则 (1 + B12) 会变成负数,导致 B1 翻成负值并把模型估值 B(final_value_ab/model_value_b.py:48-50)拉到巨负。
|
||||||
|
- 通过设置 B12 ≥ -0.9 ,确保 (1 + B12) ≥ 0.1 ,即乘数始终为正且不至于过小。
|
||||||
|
直观示例
|
||||||
|
|
||||||
|
- 原始计算得到 B12 = -1.8 (例如 ln(S1/S2) 很大负、社交传播度 S3 又很低),则 (1 + B12) = -0.8 ,会让 B1 变负。
|
||||||
|
- 裁剪后 B12 = -0.9 ,则 (1 + B12) = 0.1 , B1 保持为正,避免最终估值出现大幅负值。
|
||||||
|
"""
|
||||||
|
if traffic_factor < -0.9:
|
||||||
|
traffic_factor = -0.9
|
||||||
|
|
||||||
return traffic_factor
|
return traffic_factor
|
||||||
|
|
||||||
def calculate_social_media_spread_s3(self,
|
def calculate_social_media_spread_s3(self,
|
||||||
@ -263,26 +277,19 @@ def calculate_heat_score(daily_views: float, favorites: int) -> float:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
# 30天搜索指数S1
|
# 30天搜索指数S1
|
||||||
def calculate_search_index_s1(baidu_index: float,
|
def calculate_search_index_s1(wechat_index: float) -> float:
|
||||||
wechat_index: float,
|
|
||||||
weibo_index: float) -> float:
|
|
||||||
"""
|
"""
|
||||||
计算近30天搜索指数S1
|
计算近30天搜索指数S1
|
||||||
|
|
||||||
近30天搜索指数S1 = 百度搜索指数 × 0.4 + 微信搜索指数 × 0.3 + 微博搜索指数 × 0.3
|
近30天搜索指数S1 = 微信指数 / 10
|
||||||
|
|
||||||
args:
|
args:
|
||||||
baidu_index: 百度搜索指数 (API获取)
|
|
||||||
wechat_index: 微信搜索指数 (API获取)
|
wechat_index: 微信搜索指数 (API获取)
|
||||||
weibo_index: 微博搜索指数 (API获取)
|
|
||||||
|
|
||||||
returns:
|
returns:
|
||||||
float: 近30天搜索指数S1
|
float: 近30天搜索指数S1
|
||||||
"""
|
"""
|
||||||
#
|
search_index = wechat_index / 10.0
|
||||||
search_index = (baidu_index * 0.4 +
|
|
||||||
wechat_index * 0.3 +
|
|
||||||
weibo_index * 0.3)
|
|
||||||
|
|
||||||
return search_index
|
return search_index
|
||||||
# 示例使用
|
# 示例使用
|
||||||
@ -292,10 +299,8 @@ if __name__ == "__main__":
|
|||||||
processor = PlatformDataProcessor()
|
processor = PlatformDataProcessor()
|
||||||
|
|
||||||
# 示例数据
|
# 示例数据
|
||||||
# 搜索指数数据 (API获取)
|
# 微信指数数据 (API获取)
|
||||||
baidu_index = 6000.0
|
|
||||||
wechat_index = 4500.0
|
wechat_index = 4500.0
|
||||||
weibo_index = 3000.0
|
|
||||||
|
|
||||||
# 行业均值 (系统配置)
|
# 行业均值 (系统配置)
|
||||||
industry_average = 5000.0
|
industry_average = 5000.0
|
||||||
@ -325,7 +330,7 @@ if __name__ == "__main__":
|
|||||||
view_count = 200
|
view_count = 200
|
||||||
|
|
||||||
# 计算各项指标
|
# 计算各项指标
|
||||||
search_index_s1 = calculate_search_index_s1(baidu_index, wechat_index, weibo_index)
|
search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10
|
||||||
interaction_index, coverage_index = processor.calculate_multi_platform_interaction(platform_data)
|
interaction_index, coverage_index = processor.calculate_multi_platform_interaction(platform_data)
|
||||||
conversion_efficiency = calculator.calculate_conversion_efficiency(click_count, view_count)
|
conversion_efficiency = calculator.calculate_conversion_efficiency(click_count, view_count)
|
||||||
# 互动量指数 × 0.4 + 覆盖人群指数 × 0.3 + 转化效率 × 0.3
|
# 互动量指数 × 0.4 + 覆盖人群指数 × 0.3 + 转化效率 × 0.3
|
||||||
|
|||||||
@ -20,13 +20,11 @@ try:
|
|||||||
from .model_value_b import ModelValueBCalculator
|
from .model_value_b import ModelValueBCalculator
|
||||||
from ..market_value_c import MarketValueCCalculator
|
from ..market_value_c import MarketValueCCalculator
|
||||||
from app.controllers.valuation import ValuationController
|
from app.controllers.valuation import ValuationController
|
||||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# 直接运行时的绝对导入
|
# 直接运行时的绝对导入
|
||||||
from app.utils.calculation_engine.final_value_ab.model_value_b import ModelValueBCalculator
|
from app.utils.calculation_engine.final_value_ab.model_value_b import ModelValueBCalculator
|
||||||
from app.utils.calculation_engine.market_value_c import MarketValueCCalculator
|
from app.utils.calculation_engine.market_value_c import MarketValueCCalculator
|
||||||
from app.controllers.valuation import ValuationController
|
from app.controllers.valuation import ValuationController
|
||||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
|
||||||
|
|
||||||
|
|
||||||
class FinalValueACalculator:
|
class FinalValueACalculator:
|
||||||
@ -94,25 +92,17 @@ class FinalValueACalculator:
|
|||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
step_order = 1
|
|
||||||
|
|
||||||
# 记录输入参数
|
|
||||||
logger.info("final_value_a.calculation_start input_data_keys={} model_data_keys={} market_data_keys={}",
|
logger.info("final_value_a.calculation_start input_data_keys={} model_data_keys={} market_data_keys={}",
|
||||||
list(input_data.keys()),
|
list(input_data.keys()),
|
||||||
list(input_data.get('model_data', {}).keys()),
|
list(input_data.get('model_data', {}).keys()),
|
||||||
list(input_data.get('market_data', {}).keys()))
|
list(input_data.get('market_data', {}).keys()))
|
||||||
|
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id,
|
"FINAL_A",
|
||||||
step_order=step_order,
|
status="processing",
|
||||||
step_name="开始计算最终估值A",
|
|
||||||
step_description="接收输入参数,准备开始计算。",
|
|
||||||
input_params=input_data,
|
input_params=input_data,
|
||||||
status="processing"
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
step_order += 1
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 详细记录模型数据参数
|
# 详细记录模型数据参数
|
||||||
@ -165,11 +155,12 @@ class FinalValueACalculator:
|
|||||||
logger.info("final_value_a.calculating_model_value_b 开始计算模型估值B")
|
logger.info("final_value_a.calculating_model_value_b 开始计算模型估值B")
|
||||||
model_start_time = time.time()
|
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,
|
valuation_id,
|
||||||
input_data['model_data']
|
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
|
model_duration = time.time() - model_start_time
|
||||||
|
|
||||||
logger.info("final_value_a.model_value_b_calculated 模型估值B计算完成: 模型估值B={}万元 耗时={}ms 返回字段={}",
|
logger.info("final_value_a.model_value_b_calculated 模型估值B计算完成: 模型估值B={}万元 耗时={}ms 返回字段={}",
|
||||||
@ -177,47 +168,22 @@ class FinalValueACalculator:
|
|||||||
int(model_duration * 1000),
|
int(model_duration * 1000),
|
||||||
list(model_result.keys()))
|
list(model_result.keys()))
|
||||||
|
|
||||||
await self.valuation_controller.create_calculation_step(
|
|
||||||
ValuationCalculationStepCreate(
|
|
||||||
valuation_id=valuation_id,
|
|
||||||
step_order=step_order,
|
|
||||||
step_name="计算模型估值B",
|
|
||||||
step_description="调用ModelValueBCalculator计算模型估值B。",
|
|
||||||
input_params=input_data.get('model_data', {}),
|
|
||||||
output_result=model_result,
|
|
||||||
status="completed"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
step_order += 1
|
|
||||||
|
|
||||||
# 计算市场估值C
|
# 计算市场估值C
|
||||||
logger.info("final_value_a.calculating_market_value_c 开始计算市场估值C")
|
logger.info("final_value_a.calculating_market_value_c 开始计算市场估值C")
|
||||||
market_start_time = time.time()
|
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,
|
valuation_id,
|
||||||
input_data['market_data']
|
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
|
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,
|
market_value_c,
|
||||||
int(market_duration * 1000),
|
int(market_duration * 1000),
|
||||||
list(market_result.keys()))
|
input_data['market_data'])
|
||||||
|
|
||||||
await self.valuation_controller.create_calculation_step(
|
|
||||||
ValuationCalculationStepCreate(
|
|
||||||
valuation_id=valuation_id,
|
|
||||||
step_order=step_order,
|
|
||||||
step_name="计算市场估值C",
|
|
||||||
step_description="调用MarketValueCCalculator计算市场估值C。",
|
|
||||||
input_params=input_data.get('market_data', {}),
|
|
||||||
output_result=market_result,
|
|
||||||
status="completed"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
step_order += 1
|
|
||||||
|
|
||||||
# 计算最终估值A
|
# 计算最终估值A
|
||||||
logger.info("final_value_a.calculating_final_value_a 开始计算最终估值A: 模型估值B={}万元 市场估值C={}万元",
|
logger.info("final_value_a.calculating_final_value_a 开始计算最终估值A: 模型估值B={}万元 市场估值C={}万元",
|
||||||
@ -238,16 +204,18 @@ class FinalValueACalculator:
|
|||||||
int(model_duration * 1000),
|
int(model_duration * 1000),
|
||||||
int(market_duration * 1000))
|
int(market_duration * 1000))
|
||||||
|
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id,
|
"FINAL_A",
|
||||||
step_order=step_order,
|
status="completed",
|
||||||
step_name="计算最终估值A",
|
output_result={
|
||||||
step_description="最终估值A = 模型估值B × 0.7 + 市场估值C × 0.3",
|
"model_value_b": model_value_b,
|
||||||
input_params={"model_value_b": model_value_b, "market_value_c": market_value_c},
|
"market_value_c": market_value_c,
|
||||||
output_result={"final_value_a": final_value_a},
|
"final_value_ab": final_value_a,
|
||||||
status="completed"
|
"model_duration_ms": int(model_duration * 1000),
|
||||||
)
|
"market_duration_ms": int(market_duration * 1000),
|
||||||
|
"total_duration_ms": int(total_duration * 1000),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"model_value_b": model_value_b,
|
"model_value_b": model_value_b,
|
||||||
@ -257,15 +225,11 @@ class FinalValueACalculator:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("final_value_a.calculation_failed 计算失败: 错误={}", str(e))
|
logger.error("final_value_a.calculation_failed 计算失败: 错误={}", str(e))
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id,
|
"FINAL_A",
|
||||||
step_order=step_order,
|
|
||||||
step_name="计算失败",
|
|
||||||
step_description="计算过程中发生错误。",
|
|
||||||
status="failed",
|
status="failed",
|
||||||
error_message=str(e)
|
error_message=str(e),
|
||||||
)
|
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@ -13,13 +13,11 @@ try:
|
|||||||
from ..economic_value_b1.economic_value_b1 import EconomicValueB1Calculator
|
from ..economic_value_b1.economic_value_b1 import EconomicValueB1Calculator
|
||||||
from ..cultural_value_b2.cultural_value_b2 import CulturalValueB2Calculator
|
from ..cultural_value_b2.cultural_value_b2 import CulturalValueB2Calculator
|
||||||
from app.controllers.valuation import ValuationController
|
from app.controllers.valuation import ValuationController
|
||||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# 绝对导入(当直接运行时)
|
# 绝对导入(当直接运行时)
|
||||||
from app.utils.calculation_engine.economic_value_b1.economic_value_b1 import EconomicValueB1Calculator
|
from app.utils.calculation_engine.economic_value_b1.economic_value_b1 import EconomicValueB1Calculator
|
||||||
from app.utils.calculation_engine.cultural_value_b2.cultural_value_b2 import CulturalValueB2Calculator
|
from app.utils.calculation_engine.cultural_value_b2.cultural_value_b2 import CulturalValueB2Calculator
|
||||||
from app.controllers.valuation import ValuationController
|
from app.controllers.valuation import ValuationController
|
||||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
|
||||||
|
|
||||||
|
|
||||||
class ModelValueBCalculator:
|
class ModelValueBCalculator:
|
||||||
@ -51,7 +49,7 @@ class ModelValueBCalculator:
|
|||||||
|
|
||||||
return model_value
|
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,并记录详细的计算步骤。
|
计算完整的模型估值B,并记录详细的计算步骤。
|
||||||
|
|
||||||
@ -69,107 +67,84 @@ class ModelValueBCalculator:
|
|||||||
}
|
}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
float: 计算得出的模型估值B。
|
Dict[str, float]: 包含中间结果和最终模型估值B的字典。
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: 在计算过程中遇到的任何异常都会被捕获、记录,然后重新抛出。
|
Exception: 在计算过程中遇到的任何异常都会被捕获、记录,然后重新抛出。
|
||||||
"""
|
"""
|
||||||
step_order = 1
|
await self.valuation_controller.log_formula_step(
|
||||||
await self.valuation_controller.create_calculation_step(
|
valuation_id,
|
||||||
ValuationCalculationStepCreate(
|
"MODEL_B",
|
||||||
valuation_id=valuation_id,
|
status="processing",
|
||||||
step_order=step_order,
|
|
||||||
step_name="开始计算模型估值B",
|
|
||||||
step_description="接收输入参数,准备开始计算。",
|
|
||||||
input_params=input_data,
|
input_params=input_data,
|
||||||
status="processing"
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
step_order += 1
|
|
||||||
|
|
||||||
|
current_stage = "初始化模型估值B参数"
|
||||||
try:
|
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并等待异步完成)
|
# 计算经济价值B1(传入估值ID并等待异步完成)
|
||||||
economic_value_b1 = await self.economic_value_calculator.calculate_complete_economic_value_b1(
|
current_stage = "经济价值B1计算"
|
||||||
|
economic_result = await self.economic_value_calculator.calculate_complete_economic_value_b1(
|
||||||
valuation_id,
|
valuation_id,
|
||||||
input_data['economic_data']
|
input_data['economic_data']
|
||||||
)
|
)
|
||||||
await self.valuation_controller.create_calculation_step(
|
economic_value_b1 = economic_result["economic_value_b1"]
|
||||||
ValuationCalculationStepCreate(
|
|
||||||
valuation_id=valuation_id,
|
|
||||||
step_order=step_order,
|
|
||||||
step_name="计算经济价值B1",
|
|
||||||
step_description="调用EconomicValueB1Calculator计算经济价值B1。",
|
|
||||||
input_params=input_data.get('economic_data', {}),
|
|
||||||
output_result={"economic_value_b1": economic_value_b1},
|
|
||||||
status="completed"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
step_order += 1
|
|
||||||
|
|
||||||
# 计算文化价值B2(传入估值ID并等待异步完成)
|
# 计算文化价值B2(传入估值ID并等待异步完成)
|
||||||
cultural_value_b2 = await self.cultural_value_calculator.calculate_complete_cultural_value_b2(
|
current_stage = "文化价值B2计算"
|
||||||
|
cultural_result = await self.cultural_value_calculator.calculate_complete_cultural_value_b2(
|
||||||
valuation_id,
|
valuation_id,
|
||||||
input_data['cultural_data']
|
input_data['cultural_data']
|
||||||
)
|
)
|
||||||
await self.valuation_controller.create_calculation_step(
|
cultural_value_b2 = cultural_result["cultural_value_b2"]
|
||||||
ValuationCalculationStepCreate(
|
|
||||||
valuation_id=valuation_id,
|
|
||||||
step_order=step_order,
|
|
||||||
step_name="计算文化价值B2",
|
|
||||||
step_description="调用CulturalValueB2Calculator计算文化价值B2。",
|
|
||||||
input_params=input_data.get('cultural_data', {}),
|
|
||||||
output_result={"cultural_value_b2": cultural_value_b2},
|
|
||||||
status="completed"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
step_order += 1
|
|
||||||
|
|
||||||
# 计算风险调整系数B3(传入估值ID并等待异步完成)
|
# 计算风险调整系数B3(传入估值ID并等待异步完成)
|
||||||
risk_value_b3 = await self.risk_adjustment_calculator.calculate_complete_risky_value_b3(
|
current_stage = "风险调整系数B3计算"
|
||||||
|
risk_result = await self.risk_adjustment_calculator.calculate_complete_risky_value_b3(
|
||||||
valuation_id,
|
valuation_id,
|
||||||
input_data['risky_data']
|
input_data['risky_data']
|
||||||
)
|
)
|
||||||
await self.valuation_controller.create_calculation_step(
|
risk_value_b3 = risk_result["risk_value_b3"]
|
||||||
ValuationCalculationStepCreate(
|
|
||||||
valuation_id=valuation_id,
|
|
||||||
step_order=step_order,
|
|
||||||
step_name="计算风险调整系数B3",
|
|
||||||
step_description="调用RiskAdjustmentB3Calculator计算风险调整系数B3。",
|
|
||||||
input_params=input_data.get('risky_data', {}),
|
|
||||||
output_result={"risk_adjustment_b3": risk_value_b3},
|
|
||||||
status="completed"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
step_order += 1
|
|
||||||
|
|
||||||
# 计算模型估值B
|
# 计算模型估值B
|
||||||
|
current_stage = "模型估值B汇总"
|
||||||
model_value_b = self.calculate_model_value_b(
|
model_value_b = self.calculate_model_value_b(
|
||||||
economic_value_b1,
|
economic_value_b1,
|
||||||
cultural_value_b2,
|
cultural_value_b2,
|
||||||
risk_value_b3
|
risk_value_b3
|
||||||
)
|
)
|
||||||
await self.valuation_controller.create_calculation_step(
|
result = {
|
||||||
ValuationCalculationStepCreate(
|
"economic_value_b1": economic_value_b1,
|
||||||
valuation_id=valuation_id,
|
"cultural_value_b2": cultural_value_b2,
|
||||||
step_order=step_order,
|
"risk_value_b3": risk_value_b3,
|
||||||
step_name="计算模型估值B",
|
"model_value_b": model_value_b,
|
||||||
step_description="模型估值B = (经济价值B1*0.7+文化价值B2*0.3)*风险调整系数B3",
|
"economic_details": economic_result,
|
||||||
input_params={"economic_value_b1": economic_value_b1, "cultural_value_b2": cultural_value_b2, "risk_value_b3": risk_value_b3},
|
"cultural_details": cultural_result,
|
||||||
output_result={"model_value_b": model_value_b},
|
"risk_details": risk_result,
|
||||||
status="completed"
|
}
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B",
|
||||||
|
status="completed",
|
||||||
|
output_result=result,
|
||||||
)
|
)
|
||||||
)
|
return result
|
||||||
return model_value_b
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id,
|
"MODEL_B",
|
||||||
step_order=step_order,
|
|
||||||
step_name="计算失败",
|
|
||||||
step_description="计算过程中发生错误。",
|
|
||||||
status="failed",
|
status="failed",
|
||||||
error_message=str(e)
|
error_message=f"{current_stage}失败: {e}",
|
||||||
)
|
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
358
app/utils/calculation_engine/formula_registry.py
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
"""
|
||||||
|
公式元数据注册表
|
||||||
|
|
||||||
|
用于将计算引擎中的每个公式节点(含子公式)映射到唯一的 code、名称、公式说明以及排序,
|
||||||
|
以便在 valuation_calculation_steps 表中进行结构化记录,并最终生成可读的计算报告。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FormulaMeta:
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
formula: str
|
||||||
|
order: Decimal
|
||||||
|
parent_code: Optional[str]
|
||||||
|
group_code: str
|
||||||
|
|
||||||
|
|
||||||
|
FormulaTreeNode = Dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
def _node(
|
||||||
|
code: str,
|
||||||
|
name: str,
|
||||||
|
formula: str,
|
||||||
|
order: str,
|
||||||
|
children: Optional[List[FormulaTreeNode]] = None,
|
||||||
|
group: Optional[str] = None,
|
||||||
|
) -> FormulaTreeNode:
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"name": name,
|
||||||
|
"formula": formula,
|
||||||
|
"order": order,
|
||||||
|
"group_code": group,
|
||||||
|
"children": children or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
FORMULA_TREE: List[FormulaTreeNode] = [
|
||||||
|
_node(
|
||||||
|
"FINAL_A",
|
||||||
|
"最终估值A",
|
||||||
|
"最终估值A = 模型估值B × 0.7 + 市场估值C × 0.3",
|
||||||
|
"10",
|
||||||
|
children=[
|
||||||
|
_node(
|
||||||
|
"MODEL_B",
|
||||||
|
"模型估值B",
|
||||||
|
"模型估值B = (经济价值B1 × 0.7 + 文化价值B2 × 0.3) × 风险调整系数B3",
|
||||||
|
"20",
|
||||||
|
group="MODEL_B",
|
||||||
|
children=[
|
||||||
|
_node(
|
||||||
|
"MODEL_B_ECON_B1",
|
||||||
|
"经济价值B1",
|
||||||
|
"经济价值B1 = 基础价值B11 × (1 + 流量因子B12) × 政策乘数B13",
|
||||||
|
"21",
|
||||||
|
children=[
|
||||||
|
_node(
|
||||||
|
"MODEL_B_ECON_B11",
|
||||||
|
"基础价值B11",
|
||||||
|
"基础价值B11 = 财务价值F × (0.45 + 0.05 × 行业系数I) + 法律强度L × (0.35 + 0.05 × 行业系数I) + 发展潜力D × 0.2",
|
||||||
|
"21.1",
|
||||||
|
children=[
|
||||||
|
_node(
|
||||||
|
"MODEL_B_ECON_B11_FINANCIAL_VALUE",
|
||||||
|
"财务价值F",
|
||||||
|
"财务价值F = [3年内年均收益 × (1 + 增长率)^5] ÷ 5",
|
||||||
|
"21.11",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_ECON_B11_LEGAL_STRENGTH",
|
||||||
|
"法律强度L",
|
||||||
|
"法律强度L = 专利分 × 0.4 + 普及分 × 0.3 + 侵权分 × 0.3",
|
||||||
|
"21.12",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_ECON_B11_DEVELOPMENT_POTENTIAL",
|
||||||
|
"发展潜力D",
|
||||||
|
"发展潜力D = 专利分 × 0.5 + ESG分 × 0.2 + 创新投入比 × 0.3",
|
||||||
|
"21.13",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_ECON_B11_INDUSTRY_COEFFICIENT",
|
||||||
|
"行业系数I",
|
||||||
|
"行业系数I = (目标行业平均ROE - 基准行业ROE) ÷ 基准行业ROE",
|
||||||
|
"21.14",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_FLOW_B12",
|
||||||
|
"流量因子B12",
|
||||||
|
"流量因子B12 = ln(S1 ÷ S2) × 0.3 + 社交媒体传播度S3 × 0.7",
|
||||||
|
"21.2",
|
||||||
|
children=[
|
||||||
|
_node(
|
||||||
|
"MODEL_B_FLOW_B12_INTERACTION_INDEX",
|
||||||
|
"互动量指数",
|
||||||
|
"互动量指数 = (点赞 + 评论 + 分享) ÷ 1000",
|
||||||
|
"21.21",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_FLOW_B12_COVERAGE_INDEX",
|
||||||
|
"覆盖人群指数",
|
||||||
|
"覆盖人群指数 = 粉丝数 ÷ 10000",
|
||||||
|
"21.22",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_FLOW_B12_CONVERSION_EFFICIENCY",
|
||||||
|
"转化效率",
|
||||||
|
"转化效率 = 商品链接点击量 ÷ 内容浏览量",
|
||||||
|
"21.23",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_FLOW_B12_SOCIAL_SPREAD",
|
||||||
|
"社交媒体传播度S3",
|
||||||
|
"社交媒体传播度S3 = 互动量指数 × 0.4 + 覆盖人群指数 × 0.3 + 转化效率 × 0.3",
|
||||||
|
"21.24",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_POLICY_B13",
|
||||||
|
"政策乘数B13",
|
||||||
|
"政策乘数B13 = 1 + 政策契合度评分P × 0.15,其中 P = 政策匹配度 × 0.4 + 实施阶段评分 × 0.3 + 资金支持度 × 0.3",
|
||||||
|
"21.3",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_CULTURAL_B2",
|
||||||
|
"文化价值B2",
|
||||||
|
"文化价值B2 = 活态传承系数B21 × 0.6 + (纹样基因值B22 ÷ 10) × 0.4",
|
||||||
|
"22",
|
||||||
|
children=[
|
||||||
|
_node(
|
||||||
|
"MODEL_B_CULTURAL_B21",
|
||||||
|
"活态传承系数B21",
|
||||||
|
"活态传承系数B21 = 传承人等级系数 × 0.4 + 教学传播频次 × 0.3 + 跨界合作深度 × 0.3",
|
||||||
|
"22.1",
|
||||||
|
children=[
|
||||||
|
_node(
|
||||||
|
"MODEL_B_CULTURAL_B21_TEACHING_FREQ",
|
||||||
|
"教学传播频次",
|
||||||
|
"教学传播频次 = 线下传习次数 × 0.6 + 线上课程点击量(万) × 0.4",
|
||||||
|
"22.11",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_CULTURAL_B22",
|
||||||
|
"纹样基因值B22",
|
||||||
|
"纹样基因值B22 = (结构复杂度SC × 0.6 + 归一化信息熵H × 0.4) × 历史传承度HI × 10",
|
||||||
|
"22.2",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_RISK_B3",
|
||||||
|
"风险调整系数B3",
|
||||||
|
"风险调整系数B3 = 0.8 + 风险评分总和R × 0.4,其中 R = 市场风险 × 0.3 + 法律风险 × 0.4 + 传承风险 × 0.3",
|
||||||
|
"23",
|
||||||
|
children=[
|
||||||
|
_node(
|
||||||
|
"MODEL_B_RISK_B3_MARKET",
|
||||||
|
"市场风险",
|
||||||
|
"市场风险依据价格波动率:波动率 ≤5% 计10分,5-15%计5分,>15%计0分",
|
||||||
|
"23.1",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_RISK_B3_LEGAL",
|
||||||
|
"法律风险",
|
||||||
|
"法律风险根据诉讼状态评分(无诉讼/已解决/未解决)",
|
||||||
|
"23.2",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MODEL_B_RISK_B3_INHERITANCE",
|
||||||
|
"传承风险",
|
||||||
|
"传承风险依据传承人年龄:≤50岁10分,50-70岁5分,>70岁0分,取最高分",
|
||||||
|
"23.3",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MARKET_C",
|
||||||
|
"市场估值C",
|
||||||
|
"市场估值C = 市场竞价C1 × 热度系数C2 × 稀缺性乘数C3 × 时效性衰减C4",
|
||||||
|
"30",
|
||||||
|
group="MARKET_C",
|
||||||
|
children=[
|
||||||
|
_node(
|
||||||
|
"MARKET_C_C1",
|
||||||
|
"市场竞价C1",
|
||||||
|
"市场竞价C1 结合历史交易价格、人工竞价与专家估值的加权结果",
|
||||||
|
"30.1",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MARKET_C_C2",
|
||||||
|
"热度系数C2",
|
||||||
|
"热度系数C2 = 1 + 浏览热度分(依据日均浏览量与收藏数量)",
|
||||||
|
"30.2",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MARKET_C_C3",
|
||||||
|
"稀缺性乘数C3",
|
||||||
|
"稀缺性乘数C3 = 1 + 稀缺等级分",
|
||||||
|
"30.3",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MARKET_C_C4",
|
||||||
|
"时效性衰减C4",
|
||||||
|
"时效性衰减C4 依据距最近市场活动天数的衰减系数",
|
||||||
|
"30.4",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"DYNAMIC_PLEDGE_RATE",
|
||||||
|
"动态质押率DPR",
|
||||||
|
"动态质押率DPR = 基础质押率 × (1 + 流量修正系数) + 政策加成系数 - 流动性调节因子",
|
||||||
|
"40",
|
||||||
|
group="DYNAMIC_PLEDGE",
|
||||||
|
),
|
||||||
|
# API查询结果记录
|
||||||
|
_node(
|
||||||
|
"API_ESG_QUERY",
|
||||||
|
"ESG评分查询",
|
||||||
|
"根据行业名称查询ESG基准分",
|
||||||
|
"50.1",
|
||||||
|
group="API_QUERY",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"API_INDUSTRY_QUERY",
|
||||||
|
"行业系数查询",
|
||||||
|
"根据行业名称查询行业修正系数I",
|
||||||
|
"50.2",
|
||||||
|
group="API_QUERY",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"API_POLICY_QUERY",
|
||||||
|
"政策匹配度查询",
|
||||||
|
"根据行业名称查询政策匹配度评分",
|
||||||
|
"50.3",
|
||||||
|
group="API_QUERY",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"API_JUDICIAL_QUERY",
|
||||||
|
"司法诉讼查询",
|
||||||
|
"根据机构名称查询诉讼状态,映射为法律风险评分(无诉讼:10分, 已解决:7分, 未解决:0分)",
|
||||||
|
"50.4",
|
||||||
|
group="API_QUERY",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"API_PATENT_QUERY",
|
||||||
|
"专利信息查询",
|
||||||
|
"根据专利申请号查询专利数量和剩余年限,计算专利评分",
|
||||||
|
"50.5",
|
||||||
|
group="API_QUERY",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"API_WECHAT_INDEX",
|
||||||
|
"微信指数查询",
|
||||||
|
"根据资产名称查询微信指数,计算搜索指数S1 = 微信指数 / 10",
|
||||||
|
"50.6",
|
||||||
|
group="API_QUERY",
|
||||||
|
),
|
||||||
|
# 参数映射记录
|
||||||
|
_node(
|
||||||
|
"MAPPING_CROSS_BORDER_DEPTH",
|
||||||
|
"跨界合作深度映射",
|
||||||
|
"用户选项映射为评分:无(0分), 品牌联名(3分), 科技载体(5分), 国家外交礼品(10分)",
|
||||||
|
"51.1",
|
||||||
|
group="PARAM_MAPPING",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"MAPPING_INHERITOR_LEVEL",
|
||||||
|
"传承人等级映射",
|
||||||
|
"用户选项映射为系数:国家级(10分), 省级(7分), 市级及以下(4分)",
|
||||||
|
"51.2",
|
||||||
|
group="PARAM_MAPPING",
|
||||||
|
),
|
||||||
|
# 权重计算记录
|
||||||
|
_node(
|
||||||
|
"CALC_HISTORICAL_INHERITANCE",
|
||||||
|
"历史传承度计算",
|
||||||
|
"HI = 出土实物×1.0 + 古代文献×0.8 + 传承人佐证×0.6 + 现代研究×0.4",
|
||||||
|
"52.1",
|
||||||
|
group="PARAM_CALC",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"CALC_INHERITANCE_RISK",
|
||||||
|
"传承风险年龄转换",
|
||||||
|
"根据各年龄段传承人数量计算传承风险评分:≤50岁(10分), 50-70岁(5分), >70岁(0分), 取最高分",
|
||||||
|
"52.2",
|
||||||
|
group="PARAM_CALC",
|
||||||
|
),
|
||||||
|
_node(
|
||||||
|
"CALC_MARKET_RISK",
|
||||||
|
"市场风险价格波动",
|
||||||
|
"根据30天价格波动计算市场风险评分:波动率≤5%(10分), 5-15%(5分), >15%(0分)",
|
||||||
|
"52.3",
|
||||||
|
group="PARAM_CALC",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_index() -> Dict[str, FormulaMeta]:
|
||||||
|
index: Dict[str, FormulaMeta] = {}
|
||||||
|
|
||||||
|
def dfs(nodes: List[FormulaTreeNode], parent_code: Optional[str], group_code: Optional[str]):
|
||||||
|
for node in nodes:
|
||||||
|
code = node["code"]
|
||||||
|
name = node["name"]
|
||||||
|
formula = node["formula"]
|
||||||
|
order = Decimal(str(node["order"]))
|
||||||
|
explicit_group = node.get("group_code")
|
||||||
|
if explicit_group:
|
||||||
|
current_group = explicit_group
|
||||||
|
elif parent_code is None:
|
||||||
|
current_group = code
|
||||||
|
else:
|
||||||
|
current_group = group_code or parent_code
|
||||||
|
meta = FormulaMeta(
|
||||||
|
code=code,
|
||||||
|
name=name,
|
||||||
|
formula=formula,
|
||||||
|
order=order,
|
||||||
|
parent_code=parent_code,
|
||||||
|
group_code=current_group,
|
||||||
|
)
|
||||||
|
index[code] = meta
|
||||||
|
dfs(node.get("children", []), code, current_group)
|
||||||
|
|
||||||
|
dfs(FORMULA_TREE, None, None)
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
FORMULA_INDEX: Dict[str, FormulaMeta] = _build_index()
|
||||||
|
|
||||||
|
|
||||||
|
def get_formula_meta(code: str) -> FormulaMeta:
|
||||||
|
meta = FORMULA_INDEX.get(code)
|
||||||
|
if not meta:
|
||||||
|
raise KeyError(f"公式编码未注册: {code}")
|
||||||
|
return meta
|
||||||
|
|
||||||
@ -20,7 +20,6 @@ try:
|
|||||||
from .sub_formulas.temporal_decay_c4 import TemporalDecayC4Calculator
|
from .sub_formulas.temporal_decay_c4 import TemporalDecayC4Calculator
|
||||||
from .market_data_analyzer import market_data_analyzer
|
from .market_data_analyzer import market_data_analyzer
|
||||||
from app.controllers.valuation import ValuationController
|
from app.controllers.valuation import ValuationController
|
||||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# 绝对导入(当直接运行时)
|
# 绝对导入(当直接运行时)
|
||||||
from sub_formulas.market_bidding_c1 import MarketBiddingC1Calculator
|
from sub_formulas.market_bidding_c1 import MarketBiddingC1Calculator
|
||||||
@ -29,7 +28,6 @@ except ImportError:
|
|||||||
from sub_formulas.temporal_decay_c4 import TemporalDecayC4Calculator
|
from sub_formulas.temporal_decay_c4 import TemporalDecayC4Calculator
|
||||||
from market_data_analyzer import market_data_analyzer
|
from market_data_analyzer import market_data_analyzer
|
||||||
from app.controllers.valuation import ValuationController
|
from app.controllers.valuation import ValuationController
|
||||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -101,9 +99,9 @@ class MarketValueCCalculator:
|
|||||||
market_value = (market_bidding_c1 * heat_coefficient_c2 *
|
market_value = (market_bidding_c1 * heat_coefficient_c2 *
|
||||||
scarcity_multiplier_c3 * temporal_decay_c4)
|
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:
|
async def calculate_complete_market_value_c(self, valuation_id: int, input_data: Dict) -> Dict[str, float]:
|
||||||
"""
|
"""
|
||||||
计算完整的市场估值C,并记录每一步的计算过程。
|
计算完整的市场估值C,并记录每一步的计算过程。
|
||||||
|
|
||||||
@ -124,20 +122,16 @@ class MarketValueCCalculator:
|
|||||||
}
|
}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
float: 计算得出的市场估值C。
|
Dict[str, float]: 包含市场估值C及子公式结果的字典。
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: 如果在计算过程中发生任何错误,将记录失败状态并重新抛出异常。
|
Exception: 如果在计算过程中发生任何错误,将记录失败状态并重新抛出异常。
|
||||||
"""
|
"""
|
||||||
step = await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id,
|
"MARKET_C",
|
||||||
step_order=3,
|
status="processing",
|
||||||
step_name="市场估值C计算",
|
|
||||||
step_description="开始计算市场估值C,公式为:市场竞价C1 × 热度系数C2 × 稀缺性乘数C3 × 时效性衰减C4",
|
|
||||||
input_params=input_data,
|
input_params=input_data,
|
||||||
status="in_progress"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# 计算市场竞价C1
|
# 计算市场竞价C1
|
||||||
@ -146,11 +140,16 @@ class MarketValueCCalculator:
|
|||||||
manual_bids=input_data.get('manual_bids', []),
|
manual_bids=input_data.get('manual_bids', []),
|
||||||
expert_valuations=input_data.get('expert_valuations', [])
|
expert_valuations=input_data.get('expert_valuations', [])
|
||||||
)
|
)
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id, step_order=3.1, step_name="市场竞价C1",
|
"MARKET_C_C1",
|
||||||
output_result={'market_bidding_c1': market_bidding_c1}, status="completed"
|
status="completed",
|
||||||
)
|
input_params={
|
||||||
|
"weighted_average_price": input_data.get('weighted_average_price'),
|
||||||
|
"manual_bids": input_data.get('manual_bids'),
|
||||||
|
"expert_valuations": input_data.get('expert_valuations'),
|
||||||
|
},
|
||||||
|
output_result={'market_bidding_c1': market_bidding_c1},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算热度系数C2
|
# 计算热度系数C2
|
||||||
@ -158,33 +157,39 @@ class MarketValueCCalculator:
|
|||||||
input_data.get('daily_browse_volume', 500.0),
|
input_data.get('daily_browse_volume', 500.0),
|
||||||
input_data.get('collection_count', 50)
|
input_data.get('collection_count', 50)
|
||||||
)
|
)
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id, step_order=3.2, step_name="热度系数C2",
|
"MARKET_C_C2",
|
||||||
output_result={'heat_coefficient_c2': heat_coefficient_c2}, status="completed"
|
status="completed",
|
||||||
)
|
input_params={
|
||||||
|
"daily_browse_volume": input_data.get('daily_browse_volume'),
|
||||||
|
"collection_count": input_data.get('collection_count'),
|
||||||
|
},
|
||||||
|
output_result={'heat_coefficient_c2': heat_coefficient_c2},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算稀缺性乘数C3
|
# 计算稀缺性乘数C3
|
||||||
scarcity_multiplier_c3 = self.scarcity_multiplier_calculator.calculate_scarcity_multiplier_c3(
|
scarcity_multiplier_c3 = self.scarcity_multiplier_calculator.calculate_scarcity_multiplier_c3(
|
||||||
input_data.get('issuance_level', '限量')
|
input_data.get('issuance_level', '限量')
|
||||||
)
|
)
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id, step_order=3.3, step_name="稀缺性乘数C3",
|
"MARKET_C_C3",
|
||||||
output_result={'scarcity_multiplier_c3': scarcity_multiplier_c3}, status="completed"
|
status="completed",
|
||||||
)
|
input_params={'issuance_level': input_data.get('issuance_level')},
|
||||||
|
output_result={'scarcity_multiplier_c3': scarcity_multiplier_c3},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算时效性衰减C4
|
# 计算时效性衰减C4
|
||||||
temporal_decay_c4 = self.temporal_decay_calculator.calculate_temporal_decay_c4(
|
temporal_decay_c4 = self.temporal_decay_calculator.calculate_temporal_decay_c4(
|
||||||
input_data.get('recent_market_activity', '2024-01-15')
|
input_data.get('recent_market_activity', '2024-01-15')
|
||||||
)
|
)
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id, step_order=3.4, step_name="时效性衰减C4",
|
"MARKET_C_C4",
|
||||||
output_result={'temporal_decay_c4': temporal_decay_c4}, status="completed"
|
status="completed",
|
||||||
)
|
input_params={'recent_market_activity': input_data.get('recent_market_activity')},
|
||||||
|
output_result={'temporal_decay_c4': temporal_decay_c4},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算市场估值C
|
# 计算市场估值C
|
||||||
@ -195,15 +200,28 @@ class MarketValueCCalculator:
|
|||||||
temporal_decay_c4
|
temporal_decay_c4
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.valuation_controller.update_calculation_step(
|
result = {
|
||||||
step.id, {"status": "completed", "output_result": {"market_value_c": market_value_c}}
|
"market_value_c": market_value_c,
|
||||||
|
"market_bidding_c1": market_bidding_c1,
|
||||||
|
"heat_coefficient_c2": heat_coefficient_c2,
|
||||||
|
"scarcity_multiplier_c3": scarcity_multiplier_c3,
|
||||||
|
"temporal_decay_c4": temporal_decay_c4,
|
||||||
|
}
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MARKET_C",
|
||||||
|
status="completed",
|
||||||
|
output_result=result,
|
||||||
)
|
)
|
||||||
return market_value_c
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = f"市场估值C计算失败: {e}"
|
error_message = f"市场估值C计算失败: {e}"
|
||||||
logger.error(error_message, exc_info=True)
|
logger.error(error_message, exc_info=True)
|
||||||
await self.valuation_controller.update_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
step.id, {"status": "failed", "error_message": error_message}
|
valuation_id,
|
||||||
|
"MARKET_C",
|
||||||
|
status="failed",
|
||||||
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@ -15,9 +15,7 @@ sys.path.append(os.path.join(current_dir, '..', '..', '..', '..'))
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from app.controllers.valuation import ValuationController
|
from app.controllers.valuation import ValuationController
|
||||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# 处理可能的导入错误
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class RiskAdjustmentB3Calculator:
|
class RiskAdjustmentB3Calculator:
|
||||||
@ -167,7 +165,7 @@ class RiskAdjustmentB3Calculator:
|
|||||||
|
|
||||||
return max_score
|
return max_score
|
||||||
|
|
||||||
async def calculate_complete_risky_value_b3(self, valuation_id: int, input_data: Dict) -> float:
|
async def calculate_complete_risky_value_b3(self, valuation_id: int, input_data: Dict) -> Dict[str, float]:
|
||||||
"""
|
"""
|
||||||
计算完整的风险调整系数B3,并记录所有计算步骤。
|
计算完整的风险调整系数B3,并记录所有计算步骤。
|
||||||
|
|
||||||
@ -187,43 +185,48 @@ class RiskAdjustmentB3Calculator:
|
|||||||
}
|
}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
float: 计算得出的风险调整系数B3。
|
Dict[str, float]: 包含各项风险评分和风险调整系数的字典。
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: 在计算过程中遇到的任何异常都会被捕获、记录,并重新抛出。
|
Exception: 在计算过程中遇到的任何异常都会被捕获、记录,并重新抛出。
|
||||||
"""
|
"""
|
||||||
step = await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id,
|
"MODEL_B_RISK_B3",
|
||||||
step_order=2.3,
|
status="processing",
|
||||||
step_name="风险调整系数B3计算",
|
|
||||||
step_description="开始计算风险调整系数B3,公式为:0.8 + 风险评分总和R × 0.4",
|
|
||||||
input_params=input_data,
|
input_params=input_data,
|
||||||
status="in_progress"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# 计算各项风险评分
|
# 计算各项风险评分
|
||||||
market_risk = self.calculate_market_risk(input_data["highest_price"], input_data["lowest_price"])
|
market_risk = self.calculate_market_risk(input_data["highest_price"], input_data["lowest_price"])
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id, step_order=2.31, step_name="市场风险评分",
|
"MODEL_B_RISK_B3_MARKET",
|
||||||
output_result={'market_risk': market_risk}, status="completed"
|
status="completed",
|
||||||
)
|
input_params={
|
||||||
|
"highest_price": input_data.get("highest_price"),
|
||||||
|
"lowest_price": input_data.get("lowest_price"),
|
||||||
|
},
|
||||||
|
output_result={'market_risk': market_risk},
|
||||||
)
|
)
|
||||||
legal_risk = self.calculate_legal_risk(input_data["lawsuit_status"])
|
legal_risk = self.calculate_legal_risk(input_data["lawsuit_status"])
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id, step_order=2.32, step_name="法律风险评分",
|
"MODEL_B_RISK_B3_LEGAL",
|
||||||
output_result={'legal_risk': legal_risk}, status="completed"
|
status="completed",
|
||||||
)
|
input_params={"lawsuit_status": input_data.get("lawsuit_status")},
|
||||||
|
output_result={'legal_risk': legal_risk},
|
||||||
)
|
)
|
||||||
inheritance_risk = self.calculate_inheritance_risk(input_data["inheritor_ages"])
|
inheritance_risk = self.calculate_inheritance_risk(input_data["inheritor_ages"])
|
||||||
await self.valuation_controller.create_calculation_step(
|
await self.valuation_controller.log_formula_step(
|
||||||
ValuationCalculationStepCreate(
|
valuation_id,
|
||||||
valuation_id=valuation_id, step_order=2.33, step_name="传承风险评分",
|
"MODEL_B_RISK_B3_INHERITANCE",
|
||||||
output_result={'inheritance_risk': inheritance_risk}, status="completed"
|
status="completed",
|
||||||
)
|
input_params={
|
||||||
|
"inheritor_ages": input_data.get("inheritor_ages"),
|
||||||
|
"score_rule": "≤50岁:10分, 50-70岁:5分, >70岁:0分, 取最高分"
|
||||||
|
},
|
||||||
|
output_result={'inheritance_risk': inheritance_risk},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算风险评分总和R
|
# 计算风险评分总和R
|
||||||
@ -232,14 +235,44 @@ class RiskAdjustmentB3Calculator:
|
|||||||
# 计算风险调整系数B3
|
# 计算风险调整系数B3
|
||||||
risk_adjustment_b3 = self.calculate_risk_adjustment_b3(risk_score_sum)
|
risk_adjustment_b3 = self.calculate_risk_adjustment_b3(risk_score_sum)
|
||||||
|
|
||||||
await self.valuation_controller.update_calculation_step(
|
# 调试输出:打印B3计算的关键值
|
||||||
step.id, {"status": "completed", "output_result": {'risk_adjustment_b3': risk_adjustment_b3}}
|
print(f"=== B3计算调试 ===")
|
||||||
|
print(f"市场风险: {market_risk}")
|
||||||
|
print(f"法律风险: {legal_risk}")
|
||||||
|
print(f"传承风险: {inheritance_risk}")
|
||||||
|
print(f"风险评分总和R: {risk_score_sum}")
|
||||||
|
print(f"风险调整系数B3: {risk_adjustment_b3}")
|
||||||
|
print(f"=== B3计算完成 ===")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"risk_value_b3": risk_adjustment_b3,
|
||||||
|
"risk_score_sum": risk_score_sum,
|
||||||
|
"market_risk": market_risk,
|
||||||
|
"legal_risk": legal_risk,
|
||||||
|
"inheritance_risk": inheritance_risk,
|
||||||
|
# 详细计算过程
|
||||||
|
"calculation_detail": {
|
||||||
|
"step1_market_risk": f"市场风险 = {market_risk}分 (波动率评分)",
|
||||||
|
"step2_legal_risk": f"法律风险 = {legal_risk}分 (诉讼状态评分)",
|
||||||
|
"step3_inheritance_risk": f"传承风险 = {inheritance_risk}分 (年龄评分)",
|
||||||
|
"step4_risk_score_sum": f"R = ({market_risk}×0.3 + {legal_risk}×0.4 + {inheritance_risk}×0.3) / 10 = {risk_score_sum}",
|
||||||
|
"step5_risk_adjustment_b3": f"B3 = 0.8 + {risk_score_sum}×0.4 = {risk_adjustment_b3}",
|
||||||
|
"formula": "风险调整系数B3 = 0.8 + R × 0.4, R = (市场风险×0.3 + 法律风险×0.4 + 传承风险×0.3) / 10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self.valuation_controller.log_formula_step(
|
||||||
|
valuation_id,
|
||||||
|
"MODEL_B_RISK_B3",
|
||||||
|
status="completed",
|
||||||
|
output_result=result,
|
||||||
)
|
)
|
||||||
return risk_adjustment_b3
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = f"风险调整系数B3计算失败: {e}"
|
await self.valuation_controller.log_formula_step(
|
||||||
await self.valuation_controller.update_calculation_step(
|
valuation_id,
|
||||||
step.id, {"status": "failed", "error_message": error_message}
|
"MODEL_B_RISK_B3",
|
||||||
|
status="failed",
|
||||||
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
nginx
|
# nginx
|
||||||
|
|
||||||
python run.py
|
python run.py
|
||||||
@ -21,6 +21,7 @@ server {
|
|||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /api/ {
|
location ^~ /api/ {
|
||||||
proxy_pass http://127.0.0.1:9999;
|
proxy_pass http://127.0.0.1:9999;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
-- 完整菜单初始化SQL
|
|
||||||
-- 创建时间: 2025-11-20
|
|
||||||
-- 说明: 包含所有新增的菜单项和权限分配
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 1. 工作台菜单
|
|
||||||
-- ========================================
|
|
||||||
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(22, '工作台', 'menu', 'carbon:dashboard', '/workbench', 1, 0, 0, '/workbench', 1, NULL, datetime('now'), datetime('now'));
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 2. 交易管理菜单
|
|
||||||
-- ========================================
|
|
||||||
-- 插入一级目录:交易管理
|
|
||||||
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(16, '交易管理', 'catalog', 'carbon:receipt', '/transaction', 3, 0, 0, 'Layout', 0, '/transaction/invoice', datetime('now'), datetime('now'));
|
|
||||||
|
|
||||||
-- 插入二级菜单:交易管理
|
|
||||||
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(17, '交易管理', 'menu', 'carbon:document', 'invoice', 1, 16, 0, '/transaction/invoice', 0, NULL, datetime('now'), datetime('now'));
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 3. 估值管理菜单
|
|
||||||
-- ========================================
|
|
||||||
-- 插入一级目录:估值管理
|
|
||||||
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(18, '估值管理', 'catalog', 'carbon:calculator', '/valuation', 4, 0, 0, 'Layout', 0, '/valuation/audit', datetime('now'), datetime('now'));
|
|
||||||
|
|
||||||
-- 插入二级菜单:审核列表
|
|
||||||
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(19, '审核列表', 'menu', 'carbon:task-approved', 'audit', 1, 18, 0, '/valuation/audit', 0, NULL, datetime('now'), datetime('now'));
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 4. 用户管理菜单
|
|
||||||
-- ========================================
|
|
||||||
-- 插入一级目录:用户管理
|
|
||||||
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(20, '用户管理', 'catalog', 'carbon:user-multiple', '/user-management', 5, 0, 0, 'Layout', 0, '/user-management/user-list', datetime('now'), datetime('now'));
|
|
||||||
|
|
||||||
-- 插入二级菜单:用户列表
|
|
||||||
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(21, '用户列表', 'menu', 'carbon:user', 'user-list', 1, 20, 0, '/user-management/user-list', 0, NULL, datetime('now'), datetime('now'));
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 角色权限分配
|
|
||||||
-- ========================================
|
|
||||||
|
|
||||||
-- 为管理员角色(role_id=1)分配所有菜单权限
|
|
||||||
INSERT INTO role_menu (role_id, menu_id)
|
|
||||||
VALUES
|
|
||||||
(1, 22), -- 工作台
|
|
||||||
(1, 16), -- 交易管理
|
|
||||||
(1, 17), -- 交易管理
|
|
||||||
(1, 18), -- 估值管理
|
|
||||||
(1, 19), -- 审核列表
|
|
||||||
(1, 20), -- 用户管理
|
|
||||||
(1, 21); -- 用户列表
|
|
||||||
|
|
||||||
-- 为普通用户角色(role_id=2)分配基础菜单权限
|
|
||||||
INSERT INTO role_menu (role_id, menu_id)
|
|
||||||
VALUES
|
|
||||||
(2, 22), -- 工作台
|
|
||||||
(2, 16), -- 交易管理
|
|
||||||
(2, 17), -- 交易管理
|
|
||||||
(2, 18), -- 估值管理
|
|
||||||
(2, 19); -- 审核列表
|
|
||||||
-- 注意:普通用户不分配用户管理权限
|
|
||||||
BIN
requirements.txt
@ -189,7 +189,7 @@ def build_sample_payload() -> Dict[str, Any]:
|
|||||||
"market_activity_time": "近一周",
|
"market_activity_time": "近一周",
|
||||||
"monthly_transaction_amount": "月交易额>100万<500万",
|
"monthly_transaction_amount": "月交易额>100万<500万",
|
||||||
"platform_accounts": {
|
"platform_accounts": {
|
||||||
"douyin": {"account": "成都文交所", "likes": "500000", "comments": "89222", "shares": "97412"}
|
"douyin": {"account": "成都文交所", "likes": "500000", "comments": "89222", "shares": "97412", "views": "100000"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
# 若 application_coverage 为占位,则用 coverage_area 回填
|
# 若 application_coverage 为占位,则用 coverage_area 回填
|
||||||
|
|||||||
104
scripts/response_format_check.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import json
|
||||||
|
from typing import Dict, Any, List, Tuple
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def load_openapi(app: FastAPI) -> Dict[str, Any]:
|
||||||
|
return app.openapi()
|
||||||
|
|
||||||
|
|
||||||
|
def is_object_schema(schema: Dict[str, Any]) -> bool:
|
||||||
|
return schema.get("type") == "object"
|
||||||
|
|
||||||
|
|
||||||
|
def get_schema_props(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return schema.get("properties", {}) if schema else {}
|
||||||
|
|
||||||
|
|
||||||
|
def check_success_schema(props: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
||||||
|
issues: List[str] = []
|
||||||
|
code_prop = props.get("code")
|
||||||
|
msg_prop = props.get("msg")
|
||||||
|
data_prop = props.get("data")
|
||||||
|
if code_prop is None:
|
||||||
|
issues.append("缺少字段: code")
|
||||||
|
elif code_prop.get("type") != "integer":
|
||||||
|
issues.append(f"code类型错误: {code_prop.get('type')}")
|
||||||
|
if msg_prop is None:
|
||||||
|
issues.append("缺少字段: msg")
|
||||||
|
elif msg_prop.get("type") != "string":
|
||||||
|
issues.append(f"msg类型错误: {msg_prop.get('type')}")
|
||||||
|
if data_prop is None:
|
||||||
|
issues.append("缺少字段: data")
|
||||||
|
else:
|
||||||
|
tp = data_prop.get("type")
|
||||||
|
if tp != "object":
|
||||||
|
issues.append(f"data类型错误: {tp}")
|
||||||
|
return (len(issues) == 0, issues)
|
||||||
|
|
||||||
|
|
||||||
|
def check_paths(openapi: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
paths = openapi.get("paths", {})
|
||||||
|
compliant: List[Dict[str, Any]] = []
|
||||||
|
non_compliant: List[Dict[str, Any]] = []
|
||||||
|
for path, ops in paths.items():
|
||||||
|
for method, meta in ops.items():
|
||||||
|
op_id = meta.get("operationId")
|
||||||
|
tags = meta.get("tags", [])
|
||||||
|
responses = meta.get("responses", {})
|
||||||
|
success = responses.get("200") or responses.get("201")
|
||||||
|
if not success:
|
||||||
|
non_compliant.append({
|
||||||
|
"path": path,
|
||||||
|
"method": method.upper(),
|
||||||
|
"operationId": op_id,
|
||||||
|
"tags": tags,
|
||||||
|
"issues": ["无成功响应模型(200/201)"],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
content = success.get("content", {}).get("application/json", {})
|
||||||
|
schema = content.get("schema")
|
||||||
|
if not schema:
|
||||||
|
non_compliant.append({
|
||||||
|
"path": path,
|
||||||
|
"method": method.upper(),
|
||||||
|
"operationId": op_id,
|
||||||
|
"tags": tags,
|
||||||
|
"issues": ["成功响应未声明JSON Schema"],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
props = get_schema_props(schema)
|
||||||
|
ok, issues = check_success_schema(props)
|
||||||
|
rec = {
|
||||||
|
"path": path,
|
||||||
|
"method": method.upper(),
|
||||||
|
"operationId": op_id,
|
||||||
|
"tags": tags,
|
||||||
|
}
|
||||||
|
if ok:
|
||||||
|
compliant.append(rec)
|
||||||
|
else:
|
||||||
|
non_compliant.append({**rec, "issues": issues})
|
||||||
|
total = len(compliant) + len(non_compliant)
|
||||||
|
rate = 0 if total == 0 else round(len(compliant) / total * 100, 2)
|
||||||
|
return {
|
||||||
|
"compliant": compliant,
|
||||||
|
"non_compliant": non_compliant,
|
||||||
|
"stats": {"total": total, "compliant": len(compliant), "non_compliant": len(non_compliant), "rate": rate},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
app = create_app()
|
||||||
|
openapi = load_openapi(app)
|
||||||
|
result = check_paths(openapi)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
with open("scripts/response_format_report.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -346,6 +346,18 @@ async def main() -> None:
|
|||||||
d_ok = (d_code == 200 and isinstance(d_data, dict) and d_data.get("data", {}).get("deleted"))
|
d_ok = (d_code == 200 and isinstance(d_data, dict) and d_data.get("data", {}).get("deleted"))
|
||||||
results.append({"name": "删除估值", "status": "PASS" if d_ok else "FAIL", "message": "删除成功" if d_ok else "删除失败", "detail": {"http": d_code, "body": d_data}})
|
results.append({"name": "删除估值", "status": "PASS" if d_ok else "FAIL", "message": "删除成功" if d_ok else "删除失败", "detail": {"http": d_code, "body": d_data}})
|
||||||
|
|
||||||
|
# 注销账号
|
||||||
|
da_resp = await client.delete(make_url(base, "/app-user/account"), headers={"token": use_token})
|
||||||
|
da_code = da_resp.status_code
|
||||||
|
da_data = _ensure_dict(da_resp.json() if da_resp.headers.get("content-type", "").startswith("application/json") else {"raw": da_resp.text})
|
||||||
|
da_ok = (da_code == 200 and isinstance(da_data, dict))
|
||||||
|
results.append({"name": "注销账号", "status": "PASS" if da_ok else "FAIL", "message": "注销成功" if da_ok else "注销失败", "detail": {"http": da_code, "body": da_data}})
|
||||||
|
|
||||||
|
# 注销后旧token访问应失败
|
||||||
|
vt2_code, vt2_data = await api_get(client, make_url(base, "/app-user/validate-token"), headers={"token": use_token})
|
||||||
|
vt2_ok = (vt2_code in (401, 403))
|
||||||
|
results.append({"name": "注销后token访问", "status": "PASS" if vt2_ok else "FAIL", "message": "拒绝访问" if vt2_ok else "未拒绝", "detail": {"http": vt2_code, "body": vt2_data}})
|
||||||
|
|
||||||
# 登出
|
# 登出
|
||||||
lo_code, lo_data = await api_post_json(client, make_url(base, "/app-user/logout"), {}, headers={"token": use_token})
|
lo_code, lo_data = await api_post_json(client, make_url(base, "/app-user/logout"), {}, headers={"token": use_token})
|
||||||
lo_ok = (lo_code == 200)
|
lo_ok = (lo_code == 200)
|
||||||
|
|||||||
2
web/.env
@ -1,3 +1,3 @@
|
|||||||
VITE_TITLE = 'Vue FastAPI Admin'
|
VITE_TITLE = '估值后台管理系统'
|
||||||
|
|
||||||
VITE_PORT = 3100
|
VITE_PORT = 3100
|
||||||
@ -5,5 +5,5 @@ VITE_PUBLIC_PATH = '/'
|
|||||||
VITE_USE_PROXY = true
|
VITE_USE_PROXY = true
|
||||||
|
|
||||||
# base api
|
# base api
|
||||||
# VITE_BASE_API = 'http://139.224.70.152:9990/api/v1'
|
VITE_BASE_API = 'http://139.224.70.152:9990/api/v1'
|
||||||
VITE_BASE_API = 'http://127.0.0.1:9999/api/v1'
|
# VITE_BASE_API = 'http://127.0.0.1:9999/api/v1'
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lang": "中文",
|
"lang": "中文",
|
||||||
"app_name": "Vue FastAPI Admin",
|
"app_name": "估值后台管理系统",
|
||||||
"header": {
|
"header": {
|
||||||
"label_profile": "个人信息",
|
"label_profile": "个人信息",
|
||||||
"label_logout": "退出登录",
|
"label_logout": "退出登录",
|
||||||
|
|||||||