Compare commits

..

No commits in common. "main" and "hhm" have entirely different histories.
main ... hhm

149 changed files with 4724 additions and 11719 deletions

View File

@ -1,3 +1 @@
web/node_modules web/node_modules
web1/node_modules
migrations

View File

@ -0,0 +1,59 @@
## 输出目标
- 以 `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/BodyBody 引用 `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`(处理器)。
## 示例格式(两条)
- `appPOST /api/v1/sms/send-code`v1
- 功能:发送登录验证码到手机号
- 公开/认证:公开
- 请求参数Body`SendCodeRequest`
- 响应结构:`SendResponse``{code,msg,data}`
- 错误码:`400/422/500`
- 代码参照:`app/api/v1/sms/sms.py:68`
- `adminGET /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` 分类的计划;确认后我将开始生成完整文档并交付。

View File

@ -0,0 +1,184 @@
## 现状速览
* 后端框架FastAPIORMTortoise权限`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`
* Bodymultipart 或 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` 下载后附加。
* 错误处理:
* 参数校验失败返回 422SMTP 异常记录 `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`
——请确认方案后,我将按上述步骤开始落地实现、编写迁移与测试。

View File

@ -0,0 +1,71 @@
## 目标与范围
- 针对“估值二期”需求(用户端、管理端)设计完整 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-53403 无权限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 条)。
- 异步任务完成 ≤ 5sQPS单实例读 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 方案,确认后我将开始后端路由/控制器/模型实现并提供前端对接示例。

View File

@ -0,0 +1,184 @@
## 目标
* 完整设计并落实“估值计算步骤”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. 经济价值 B1economic\_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. 文化价值 B2cultural\_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. 风险调整 B3risk\_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. 市场价值 Cmarket\_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. 最终估值 ABfinal\_value\_ab
* 模型估值 B`B = B1 + B2`;再叠加风险调整:`B_adj = B * B3`
* 市场估值:`C`
* 最终:`Final = f(B_adj, C)`(例如加权平均或规则合成)
## 变量定义与来源映射
* 用户输入UserValuationCreateapp/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、社交传播S3app/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`:失败描述
* 步骤编号建议:
* 经济价值 B12.1xB11=2.11B12=2.12B13=2.13汇总B1=2.19
* 文化价值 B22.2xB21=2.21B22=2.22汇总B2=2.29
* 风险调整 B32.3x总评R=2.30B3=2.31
* 市场价值 C3.1xC1=3.11C2=3.12C3=3.13C4=3.14汇总C=3.19
* 最终估值 AB4.1xB组合=4.11B×B3=4.12Final=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/Capp/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()` 修复)。
## 交付
* 我将按上述规范逐步在计算引擎各子模块与统一计算入口中补充“步骤写入”,并确保管理端端点返回可序列化的数据结构;完成后会提供一份面向管理员的“估值步骤查看”前后端对接说明(端点与字段)。

View File

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

View File

@ -0,0 +1,23 @@
## 问题
- 后台任务在提取B1参数时引用未定义函数calculate_popularity_score、calculate_patent_score导致计算中止步骤未入库。
## 修复方案
1) 移除未定义函数引用,在 `_extract_calculation_params_b1` 内实现本地计算:
- 普及地域分mapping {全球覆盖:10全国覆盖:7区域覆盖:4}默认7
- 专利分:按剩余年限近似 {>10年:105-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.updatedAdmin脚本打印“后台估值计算步骤”。
——确认后我将按以上方案进行代码调整并回填日志。

View File

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

View File

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

View File

@ -0,0 +1,29 @@
## 目标
- 汇总所有前后端需求与 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. 删除分散文档,仅保留总览
——请确认上述方案,确认后我将生成唯一的总览文档并删除分散文档。

View File

@ -0,0 +1,26 @@
## 问题
- 计算步骤创建时报 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并执行验证保证不改变计算逻辑仅解决类型不匹配问题。

View File

@ -0,0 +1,17 @@
## 目标
- 让每次用户估值的所有中间步骤写入 `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`
- 运行脚本并查看详细步骤输出

View File

@ -0,0 +1,99 @@
## 目标与范围
- 接入阿里云短信服务,封装发送客户端
- 提供两类发送接口:验证码通知、报告生成通知,供 App 调用
- 支持模板动态调用与验证码变量 `${code}` 的正确替换
- 记录发送日志并融入现有审计体系
- 实现同一手机号每分钟不超过 1 条的频率限制
- 安全存储 AccessKey 等敏感信息(环境变量/配置)
## 技术选型
- 后端框架FastAPI现有工程
- 短信 SDKAlibaba Cloud SMS Python SDKTea/OpenAPI V2`alibabacloud_dysmsapi20170525`
- 端点(中国站):`dysmsapi.aliyuncs.com`
- 关键请求字段:`PhoneNumbers``SignName``TemplateCode``TemplateParam`
- 日志:沿用 `app/log` 的 Loguru 与审计中间件
- 频率限制:服务内共享的内存限流(后续可升级为 Redis
- 安全通过环境变量注入凭证Pydantic Settings 读取
- 参考文档:
- Alibaba Cloud SDK V2Python示例SendSmshttps://www.alibabacloud.com/help/en/sdk/developer-reference/v2-python-integrated-sdk
- 短信服务 SendSms 接口2017-05-25https://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` 为 48 位数字(可按需约束)
## 模板与变量替换
- 验证码模板:`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 文档)
## 交付物
- 新增短信客户端与路由模块
- 两个可调用接口(验证码发送、报告通知发送)
- 限流与日志落地,配置基于环境变量

View File

@ -0,0 +1,41 @@
## 目标
- 编写一个一次性可运行的接口测试脚本,按照总览文档顺序执行:
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` 并填充完整逻辑;默认数据内置,必要处留参数。
- 如你需要,我可在脚本创建后直接运行,输出结果供你检查。

View File

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

View File

@ -0,0 +1,20 @@
## 目标
- 将 `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`
- 观察输出:用户侧成功提交,后台列表/详情显示完整数据,步骤列表非空。
——确认后我将直接更新脚本并提交。

View File

@ -0,0 +1,48 @@
## 目标
* 将“用户端”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中查看。

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,103 @@
## 工作范围与目标
- 范围:梳理 `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 个文档并提交供评审。

View File

@ -1,225 +0,0 @@
# 非遗资产估值系统 - 部署文档
## 项目概述
非遗资产估值系统是一个基于 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权重计算 |
---
## 联系信息
如有问题,请联系项目负责人。

View File

@ -1,14 +1,8 @@
FROM node:18-alpine AS web FROM node:18.12.0-alpine3.16 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 Normal file
View File

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

View File

@ -22,7 +22,6 @@ 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()
@ -51,7 +50,6 @@ 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", tags=["admin-发票管理"]) v1_router.include_router(invoice_router, prefix="/invoice", dependencies=[DependAuth, DependPermission], 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-发票管理"])

View File

@ -1,153 +0,0 @@
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="获取成功")

View File

@ -1,9 +1,8 @@
from fastapi import APIRouter, Query, Depends, HTTPException from fastapi import APIRouter, Query, Depends, HTTPException
from typing import Optional, List from typing import Optional
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, AppUserUpdateSchema from app.schemas.app_user import AppUserQuotaUpdateSchema, AppUserQuotaLogOut
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
@ -16,53 +15,18 @@ 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,
@ -70,7 +34,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": getattr(last_log, "op_type", None), "user_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="获取成功")
@ -88,9 +52,9 @@ async def update_quota(payload: AppUserQuotaUpdateSchema, operator=Depends(AuthC
) )
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
# if payload.remark is not None: 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="调整成功")
@ -114,33 +78,3 @@ 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="账号已注销")

View File

@ -11,29 +11,20 @@ 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, Success from app.schemas.base import BasicResponse, MessageOut
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[dict], summary="用户注册") @router.post("/register", response_model=BasicResponse[AppUserRegisterOut], summary="用户注册")
async def register( async def register(
register_data: AppUserRegisterSchema register_data: AppUserRegisterSchema
): ):
@ -43,16 +34,20 @@ async def register(
""" """
try: try:
user = await app_user_controller.register(register_data) user = await app_user_controller.register(register_data)
return Success(data={ return {
"user_id": user.id, "code": 200,
"phone": user.phone, "msg": "注册成功",
"default_password": register_data.phone[-6:] "data": {
}) "user_id": user.id,
"phone": user.phone,
"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=BasicResponse[dict], summary="用户登录") @router.post("/login", response_model=AppUserJWTOut, summary="用户登录")
async def login( async def login(
login_data: AppUserLoginSchema login_data: AppUserLoginSchema
): ):
@ -72,90 +67,30 @@ 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 Success(data={ return AppUserJWTOut(
"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[dict]) @router.post("/logout", summary="用户登出", response_model=BasicResponse[MessageOut])
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 Success(data={"message": "登出成功"}) return {"code": 200, "msg": "OK", "data": {"message": "登出成功"}}
class DeleteAccountRequest(BaseModel): @router.get("/profile", response_model=AppUserInfoOut, summary="获取用户信息")
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)):
""" """
获取当前用户信息 获取当前用户信息
""" """
user_info = AppUserInfoOut( return current_user
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=BasicResponse[dict], summary="用户首页摘要") @router.get("/dashboard", response_model=AppUserDashboardOut, 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)):
""" """
用户首页摘要 用户首页摘要
@ -180,12 +115,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 = current_user.remaining_quota remaining_quota = 0
return Success(data={"remaining_quota": remaining_quota, "latest_valuation": latest_out, "pending_invoices": pending_invoices}) return AppUserDashboardOut(remaining_quota=remaining_quota, latest_valuation=latest_out, pending_invoices=pending_invoices)
@router.get("/quota", response_model=BasicResponse[dict], summary="剩余估值次数") @router.get("/quota", response_model=AppUserQuotaOut, 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)):
""" """
剩余估值次数查询 剩余估值次数查询
@ -193,11 +128,12 @@ async def get_quota(current_user: AppUser = Depends(get_current_app_user)):
- 当前实现返回默认 0 次与用户类型占位 - 当前实现返回默认 0 次与用户类型占位
- 若后续接入配额系统可从数据库中读取真实值 - 若后续接入配额系统可从数据库中读取真实值
""" """
remaining_count = current_user.remaining_quota remaining_count = 0
return Success(data={"remaining_count": remaining_count}) user_type = "体验用户"
return AppUserQuotaOut(remaining_count=remaining_count, user_type=user_type)
@router.put("/profile", response_model=BasicResponse[dict], summary="更新用户信息") @router.put("/profile", response_model=AppUserInfoOut, 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)
@ -209,26 +145,10 @@ async def update_profile(
if not updated_user: if not updated_user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
user_info = AppUserInfoOut( return updated_user
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[dict]) @router.post("/change-password", summary="修改密码", response_model=BasicResponse[MessageOut])
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)
@ -245,12 +165,19 @@ async def change_password(
if not success: if not success:
raise HTTPException(status_code=400, detail="原密码错误") raise HTTPException(status_code=400, detail="原密码错误")
return Success(data={"message": "密码修改成功"}) return {"code": 200, "msg": "OK", "data": {"message": "密码修改成功"}}
@router.get("/validate-token", summary="验证token", response_model=BasicResponse[dict]) @router.get("/validate-token", summary="验证token", response_model=BasicResponse[TokenValidateOut])
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 Success(data={"user_id": current_user.id, "phone": current_user.phone}) return {
"code": 200,
"msg": "token有效",
"data": {
"user_id": current_user.id,
"phone": current_user.phone
}
}

View File

@ -18,7 +18,7 @@ from app.schemas.valuation import (
UserValuationOut, UserValuationOut,
UserValuationDetail UserValuationDetail
) )
from app.schemas.base import Success, BasicResponse from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse
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, valuation_id: int, data: UserValuationCreate): async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate):
""" """
后台任务执行估值计算 后台任务执行估值计算
""" """
try: try:
start_ts = time.monotonic() start_ts = time.monotonic()
logger.info("valuation.calc_start user_id={} valuation_id={} asset_name={} industry={}", user_id, valuation_id, logger.info("valuation.calc_start user_id={} asset_name={} industry={}", user_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,12 +71,8 @@ async def _perform_valuation_calculation(user_id: int, valuation_id: int, data:
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( input_data_by_b1 = await _extract_calculation_params_b1(data)
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
@ -84,53 +80,25 @@ async def _perform_valuation_calculation(user_id: int, valuation_id: int, data:
# 政策匹配度 # 政策匹配度
input_data_by_b1["policy_match_score"] = policy_match_score input_data_by_b1["policy_match_score"] = policy_match_score
# 法律风险/侵权记录通过司法API查询诉讼状态 # 侵权分 默认 6
# 评分规则:无诉讼(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.get("data", {}) _data = judicial_data["data"].get("target", None) # 诉讼标的
judicial_api_response = _data # 保存原始返回 if _data:
target = _data.get("target", None) # 诉讼标的 infringement_score = 0.0
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
else: else:
lawsuit_status_text = "无诉讼"
infringement_score = 10.0 infringement_score = 10.0
except:
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 infringement_score = 0.0
judicial_api_response = {"error": str(e)}
input_data_by_b1["infringement_score"] = infringement_score input_data_by_b1["infringement_score"] = infringement_score
# 保存诉讼状态文本,用于前端展示
lawsuit_status_for_display = lawsuit_status_text
# 获取专利信息 # 获取专利信息 TODO 参数
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 {}
@ -141,17 +109,16 @@ async def _perform_valuation_calculation(user_id: int, valuation_id: int, data:
# 查询匹配申请号的记录集合 # 查询匹配申请号的记录集合
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
patent_years_total = calculate_total_years(data_list) years_total = calculate_total_years(data_list)
if patent_years_total > 10: if years_total > 10:
patent_score = 10.0 patent_score = 10.0
elif patent_years_total >= 5: elif years_total >= 5:
patent_score = 7.0 patent_score = 7.0
else: else:
patent_score = 3.0 patent_score = 3.0
@ -180,212 +147,23 @@ async def _perform_valuation_calculation(user_id: int, valuation_id: int, data:
"market_data": input_data_by_c, "market_data": input_data_by_c,
} }
calculator = FinalValueACalculator() calculator = FinalValueACalculator()
# 先创建估值记录以获取ID方便步骤落库关联
# 步骤1立即更新计算输入参数不管后续是否成功 initial_detail = await user_valuation_controller.create_valuation(
try: user_id=user_id,
await valuation_controller.update_calc( data=data,
valuation_id, calculation_result=None,
ValuationAssessmentUpdate( calculation_input=None,
calculation_input=input_data, drp_result=None,
) status='pending'
) )
logger.info("valuation.input_updated valuation_id={}", valuation_id) valuation_id = initial_detail.id
except Exception as e: logger.info("valuation.init_created user_id={} valuation_id={}", user_id, valuation_id)
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()
''' '''
@ -394,45 +172,7 @@ async def _perform_valuation_calculation(user_id: int, valuation_id: int, data:
''' '''
# 解析月交易额字符串为数值 # 解析月交易额字符串为数值
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:
@ -448,15 +188,33 @@ async def _perform_valuation_calculation(user_id: int, valuation_id: int, data:
except Exception: except Exception:
pass pass
# 步骤4计算完成保持状态为 pending等待后台审核 # 更新估值评估记录(写入计算结果与输入摘要)
try: update_data = ValuationAssessmentUpdate(
result = await valuation_controller.get_by_id(valuation_id) model_value_b=calculation_result.get('model_value_b'),
logger.info("valuation.calc_finished valuation_id={} status=pending", valuation_id) market_value_c=calculation_result.get('market_value_c'),
except Exception as e: final_value_ab=calculation_result.get('final_value_ab'),
logger.warning("valuation.failed_to_fetch_after_calc valuation_id={} err={}", valuation_id, repr(e)) dynamic_pledge_rate=drp_result,
result = None calculation_result=calculation_result,
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, valuation_id) logger.info("valuation.background_calc_success user_id={} valuation_id={}", user_id, result.id)
except Exception as e: except Exception as e:
import traceback import traceback
@ -466,57 +224,8 @@ async def _perform_valuation_calculation(user_id: int, valuation_id: int, data:
# 计算失败时更新记录为失败状态 # 计算失败时更新记录为失败状态
try: try:
if 'valuation_id' in locals(): if 'valuation_id' in locals():
# 准备失败时需要更新的字段 fail_update = ValuationAssessmentUpdate(status='rejected')
fail_update_fields = {"status": "rejected"} await valuation_controller.update(valuation_id, fail_update)
# 如果 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')
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))
@ -592,52 +301,20 @@ async def calculate_valuation(
""" """
try: try:
from app.models.user import AppUser, AppUserQuotaLog # 添加后台任务
user = await AppUser.filter(id=user_id).first() background_tasks.add_task(_perform_valuation_calculation, user_id, data)
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
# 先创建估值记录以获取ID方便用户查询 logger.info("valuation.task_queued user_id={} asset_name={} industry={}",
initial_detail = await user_valuation_controller.create_valuation( user_id, getattr(data, 'asset_name', None), getattr(data, 'industry', None))
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, },
"order_no": str(valuation_id) msg="估值计算任务已启动"
}
) )
except Exception as e: except Exception as e:
@ -645,12 +322,7 @@ 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( async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str, Any]:
data: UserValuationCreate,
esg_score: float = 0.0,
industry_coefficient: float = 0.0,
policy_match_score: float = 0.0,
) -> Dict[str, Any]:
""" """
从用户提交的数据中提取计算所需的参数 从用户提交的数据中提取计算所需的参数
@ -683,7 +355,8 @@ async def _extract_calculation_params_b1(
innovation_ratio = 0.0 innovation_ratio = 0.0
# 流量因子B12相关参数 # 流量因子B12相关参数
# 近30天搜索指数S1 - 使用微信指数除以10计算 # 近30天搜索指数S1 - 从社交媒体数据计算 TODO 需要使用第三方API
baidu_index = 1
# 获取微信指数并计算近30天平均值 # 获取微信指数并计算近30天平均值
try: try:
@ -692,9 +365,10 @@ async def _extract_calculation_params_b1(
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 = 10 # 失败时默认值,使得 S1 = 1 wechat_index = 1
search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10 weibo_index = 1
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
@ -741,7 +415,6 @@ async def _extract_calculation_params_b1(
'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 目前未参与计算,先移除
@ -749,10 +422,7 @@ async def _extract_calculation_params_b1(
'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),
} }
@ -786,18 +456,10 @@ 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,
@ -812,28 +474,14 @@ 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):
for key, weight in evidence_weights.items(): historical_inheritance = sum([safe_float(v) for v in data.historical_evidence.values()])
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(系统计算)
@ -865,36 +513,17 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
} }
# 获取 风险调整系数B3 相关参数 # 获取 文化价值B2 相关参数
async def _extract_calculation_params_b3(data: UserValuationCreate) -> Dict[str, Any]: async def _extract_calculation_params_b3(data: UserValuationCreate) -> Dict[str, Any]:
# 过去30天最高价格 过去30天最低价格 # 过去30天最高价格 过去30天最低价格 TODO 需要根据字样进行切分获取最高价和最低价 转换成 float 类型
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,
} }
@ -989,14 +618,13 @@ 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=BasicResponse[dict]) @app_valuations_router.get("/", summary="获取我的估值评估列表", response_model=PageResponse[UserValuationOut])
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)
@ -1013,14 +641,13 @@ 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 Success( return SuccessExtra(
data={ data=serialized_items,
"items": serialized_items, total=result.total,
"total": result.total, page=result.page,
"page": result.page, page_size=result.size,
"page_size": result.size, pages=result.pages,
"pages": result.pages, msg="获取估值评估列表成功"
}
) )
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
@ -1029,7 +656,7 @@ async def get_my_valuations(
) )
@app_valuations_router.get("/{valuation_id}", summary="获取估值评估详情", response_model=BasicResponse[dict]) @app_valuations_router.get("/{valuation_id}", summary="获取估值评估详情", response_model=BasicResponse[UserValuationDetail])
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)
@ -1052,7 +679,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) return Success(data=result_dict, msg="获取估值评估详情成功")
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -1073,7 +700,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) return Success(data=result, msg="获取统计信息成功")
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,
@ -1101,7 +728,7 @@ async def delete_valuation(
detail="估值评估记录不存在或已被删除" detail="估值评估记录不存在或已被删除"
) )
return Success(data={"deleted": True}) return Success(data={"deleted": True}, msg="删除估值评估成功")
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@ -276,9 +276,11 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
# 流量因子B12相关参数 # 流量因子B12相关参数
# 近30天搜索指数S1 - 使用微信指数除以10计算 # 近30天搜索指数S1 - 从社交媒体数据计算 TODO 需要使用第三方API
wechat_index = wechat_index_calculator.process_wechat_index_response(universal_api.wx_index(data.asset_name)) # 通过资产信息获取微信指数 baidu_index = 0.0
search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10 wechat_index = wechat_index_calculator.process_wechat_index_response(universal_api.wx_index(data.asset_name)) # 通过资产信息获取微信指数 TODO 这里返回的没确认指数参数,有可能返回的图示是指数信息
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 未找到对应参数
@ -342,22 +344,8 @@ 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
# 前端传入的是数字字符串 ("0", "1", "2", "3"),后端也支持中文标签 cross_border_depth = float(data.cooperation_depth)
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相关参数

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Query, Depends, Header, HTTPException from fastapi import APIRouter, Query
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,25 +8,19 @@ 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], dependencies=[DependAuth, DependPermission]) @invoice_router.get("/list", summary="获取发票列表", response_model=PageResponse[InvoiceOut])
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),
@ -34,7 +28,6 @@ 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),
): ):
@ -52,14 +45,13 @@ 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], dependencies=[DependAuth, DependPermission]) @invoice_router.get("/detail", summary="发票详情", response_model=BasicResponse[InvoiceOut])
async def invoice_detail(id: int = Query(...)): async def invoice_detail(id: int = Query(...)):
""" """
根据ID获取发票详情 根据ID获取发票详情
@ -70,7 +62,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], dependencies=[DependAuth, DependPermission]) @invoice_router.post("/create", summary="创建发票", response_model=BasicResponse[InvoiceOut])
async def create_invoice(data: InvoiceCreate): async def create_invoice(data: InvoiceCreate):
""" """
创建发票记录 创建发票记录
@ -80,7 +72,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], dependencies=[DependAuth, DependPermission]) @invoice_router.post("/update", summary="更新发票", response_model=BasicResponse[InvoiceOut])
async def update_invoice(data: InvoiceUpdate, id: int = Query(...)): async def update_invoice(data: InvoiceUpdate, id: int = Query(...)):
""" """
更新发票记录 更新发票记录
@ -90,7 +82,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], dependencies=[DependAuth, DependPermission]) @invoice_router.delete("/delete", summary="删除发票", response_model=BasicResponse[MessageOut])
async def delete_invoice(id: int = Query(...)): async def delete_invoice(id: int = Query(...)):
""" """
删除发票记录 删除发票记录
@ -103,7 +95,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], dependencies=[DependAuth, DependPermission]) @invoice_router.post("/update-status", summary="更新发票状态", response_model=BasicResponse[InvoiceOut])
async def update_invoice_status(data: UpdateStatus): async def update_invoice_status(data: UpdateStatus):
""" """
更新发票状态pending|invoiced|rejected|refunded 更新发票状态pending|invoiced|rejected|refunded
@ -114,37 +106,25 @@ async def update_invoice_status(data: UpdateStatus):
@invoice_router.post("/{id}/receipt", summary="上传付款凭证", response_model=BasicResponse[dict], dependencies=[DependAuth, DependPermission]) @invoice_router.post("/{id}/receipt", summary="上传付款凭证", response_model=BasicResponse[PaymentReceiptOut])
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)
detail = await invoice_controller.get_receipt_by_id(receipt.id) return Success(data=receipt, msg="上传成功")
return Success(data=detail, msg="上传成功")
@invoice_router.get("/headers", summary="发票抬头列表", response_model=PageResponse[InvoiceHeaderOut], dependencies=[DependAuth, DependPermission]) @invoice_router.get("/headers", summary="发票抬头列表", response_model=BasicResponse[list[InvoiceHeaderOut]])
async def get_invoice_headers( async def get_invoice_headers(app_user_id: Optional[int] = Query(None)):
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),
):
""" """
管理端抬头列表管理员token支持按 App 用户过滤与分页 获取发票抬头列表可按 AppUser 过滤
""" """
uid = app_user_id if app_user_id is not None else user_id headers = await invoice_controller.get_headers(user_id=app_user_id)
qs = InvoiceHeader.all() return Success(data=headers, msg="获取成功")
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], dependencies=[DependAuth, DependPermission]) @invoice_router.get("/headers/{id}", summary="发票抬头详情", response_model=BasicResponse[InvoiceHeaderOut])
async def get_invoice_header_by_id(id: int): async def get_invoice_header_by_id(id: int):
""" """
获取发票抬头详情 获取发票抬头详情
@ -153,7 +133,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], dependencies=[DependAuth, DependPermission]) @invoice_router.post("/headers", summary="新增发票抬头", response_model=BasicResponse[InvoiceHeaderOut])
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)):
""" """
新增发票抬头 新增发票抬头
@ -162,7 +142,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], dependencies=[DependAuth, DependPermission]) @invoice_router.put("/{id}/type", summary="更新发票类型", response_model=BasicResponse[InvoiceOut])
async def update_invoice_type(id: int, data: UpdateType): async def update_invoice_type(id: int, data: UpdateType):
""" """
更新发票的电子/纸质与专票/普票类型 更新发票的电子/纸质与专票/普票类型
@ -171,13 +151,4 @@ 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 "未找到")
@invoice_router.delete("/headers/{id}", summary="删除发票抬头", response_model=BasicResponse[MessageOut], dependencies=[DependAuth, DependPermission]) # 对公转账记录接口在 transactions 路由中统一暴露
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 "未找到")

View File

@ -9,7 +9,6 @@ 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):
@ -45,8 +44,8 @@ rate_limiter = PhoneRateLimiter(60)
router = APIRouter(tags=["短信服务"]) router = APIRouter(tags=["短信服务"])
@router.post("/send-code", response_model=BasicResponse[dict], summary="验证码发送") @router.post("/send-code", response_model=SendResponse, summary="验证码发送")
async def send_code(payload: SendCodeRequest) -> BasicResponse[dict]: async def send_code(payload: SendCodeRequest) -> SendResponse:
"""发送验证码短信 """发送验证码短信
Args: Args:
@ -69,13 +68,7 @@ async def send_code(payload: SendCodeRequest) -> BasicResponse[dict]:
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 Success( return SendResponse(status="OK", message="sent", request_id=str(rid) if rid else None)
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))
@ -86,8 +79,8 @@ async def send_code(payload: SendCodeRequest) -> BasicResponse[dict]:
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=BasicResponse[dict], summary="报告通知发送", dependencies=[DependAuth]) @router.post("/send-report", response_model=SendResponse, summary="报告通知发送", dependencies=[DependAuth])
async def send_report(payload: SendReportRequest) -> BasicResponse[dict]: async def send_report(payload: SendReportRequest) -> SendResponse:
"""发送报告通知短信 """发送报告通知短信
Args: Args:
@ -105,13 +98,7 @@ async def send_report(payload: SendReportRequest) -> BasicResponse[dict]:
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 Success( return SendResponse(status="OK", message="sent", request_id=str(rid) if rid else None)
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))
@ -122,8 +109,8 @@ async def send_report(payload: SendReportRequest) -> BasicResponse[dict]:
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=BasicResponse[dict]) @router.post("/verify-code", summary="验证码验证", response_model=VerifyResponse)
async def verify_code(payload: VerifyCodeRequest) -> BasicResponse[dict]: async def verify_code(payload: VerifyCodeRequest) -> VerifyResponse:
"""验证验证码 """验证验证码
Args: Args:
@ -132,11 +119,6 @@ async def verify_code(payload: VerifyCodeRequest) -> BasicResponse[dict]:
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))
@ -155,8 +137,7 @@ async def verify_code(payload: VerifyCodeRequest) -> BasicResponse[dict]:
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)
store.mark_verified(payload.phone) return VerifyResponse(status="OK", message="verified")
return Success(data={"status": "OK", "message": "verified"})
class SMSLoginRequest(BaseModel): class SMSLoginRequest(BaseModel):
@ -165,26 +146,23 @@ class SMSLoginRequest(BaseModel):
device_id: Optional[str] = Field(None) device_id: Optional[str] = Field(None)
@router.post("/login", summary="短信验证码登录", response_model=BasicResponse[dict]) @router.post("/login", summary="短信验证码登录", response_model=SMSLoginResponse)
async def sms_login(payload: SMSLoginRequest) -> BasicResponse[dict]: async def sms_login(payload: SMSLoginRequest) -> SMSLoginResponse:
from app.settings import settings ok, reason = store.can_verify(payload.phone_number)
bypass = settings.SMS_BYPASS_CODE and payload.verification_code == settings.SMS_BYPASS_CODE if not ok:
if not bypass: raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=str(reason))
ok, reason = store.can_verify(payload.phone_number) record = store.get_code(payload.phone_number)
if not ok: if not record:
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=str(reason)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="验证码过期")
record = store.get_code(payload.phone_number) code, expires_at = record
if not record: if time.time() > expires_at:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="验证码过期") store.clear_code(payload.phone_number)
code, expires_at = record raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="验证码过期")
if time.time() > expires_at: if payload.verification_code != code:
store.clear_code(payload.phone_number) count, locked = store.record_verify_failure(payload.phone_number)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="验证码过期") if locked:
if payload.verification_code != code: raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="尝试次数过多,已锁定")
count, locked = store.record_verify_failure(payload.phone_number) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="验证码错误")
if locked:
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="尝试次数过多,已锁定")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="验证码错误")
from app.controllers.app_user import app_user_controller from app.controllers.app_user import app_user_controller
from app.schemas.app_user import AppUserRegisterSchema, AppUserInfoOut, AppUserJWTOut from app.schemas.app_user import AppUserRegisterSchema, AppUserInfoOut, AppUserJWTOut
@ -196,16 +174,15 @@ async def sms_login(payload: SMSLoginRequest) -> BasicResponse[dict]:
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)
user_info = AppUserInfoOut( user_info = AppUserInfoOut(
id=user.id, id=user.id,
phone=user.phone, phone=user.phone,
nickname=getattr(user, "alias", None), nickname=user.nickname,
avatar=None, avatar=user.avatar,
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,
@ -215,10 +192,9 @@ async def sms_login(payload: SMSLoginRequest) -> BasicResponse[dict]:
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 Success(data={"user": user_info.model_dump(), "token": token_out.model_dump()}) return SMSLoginResponse(user=user_info, token=token_out)
class VerifyCodeRequest(BaseModel): class VerifyCodeRequest(BaseModel):
phone: str = Field(...) phone: str = Field(...)
code: str = Field(...) code: str = Field(...)

View File

@ -4,8 +4,6 @@ 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
@ -67,87 +65,47 @@ 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]) @transactions_router.post("/send-email", summary="发送邮件", response_model=BasicResponse[SendEmailResponse])
async def send_email(payload: SendEmailRequest = Body(...)): 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="文案内容不能为空")
attachments = [] file_bytes = None
urls = [] file_name = None
try: if file is not None:
domain = payload.email.split("@")[-1] file_bytes = await file.read()
import dns.resolver file_name = file.filename
try: elif data.file_url:
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:
for u in urls: r = await client.get(data.file_url)
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={}", payload.email, payload.subject or "") logger.info("transactions.email_send_start email={} subject={}", data.email, data.subject or "")
try: result = email_client.send(data.email, data.subject, data.body, file_bytes, file_name, getattr(file, "content_type", None))
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 = payload.body body_summary = data.body[:500]
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=payload.email, email=data.email,
subject=payload.subject, subject=data.subject,
body_summary=body_summary, body_summary=body_summary,
file_name=first_name, file_name=file_name,
file_url=first_url, file_url=data.file_url,
status=status, status=status,
error=error, error=error,
) )
if status == "OK": if status == "OK":
logger.info("transactions.email_send_ok email={}", payload.email) logger.info("transactions.email_send_ok email={}", data.email)
else: else:
logger.error("transactions.email_send_fail email={} err={}", payload.email, error) logger.error("transactions.email_send_fail email={} err={}", data.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 "发送失败")

View File

@ -1,11 +1,22 @@
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("/file", response_model=BasicResponse[dict], summary="统一上传接口") @router.post("/image", response_model=ImageUploadResponse, summary="上传图片")
async def upload(file: UploadFile = File(...)) -> BasicResponse[dict]: async def upload_image(file: UploadFile = File(...)) -> ImageUploadResponse:
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)

View File

@ -60,37 +60,10 @@ 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.update1(valuation_id, data) result = await valuation_controller.update(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
@ -114,14 +87,8 @@ 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(
@ -131,13 +98,8 @@ 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=page_size if page_size is not None else size size=size
) )
result = await valuation_controller.get_list(query) result = await valuation_controller.get_list(query)
import json import json
@ -156,11 +118,10 @@ 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, page_size if page_size is not None else size) result = await valuation_controller.search(keyword, page, 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(

View File

@ -21,15 +21,6 @@ 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="手机号已存在")
# 生成默认密码:手机号后六位 # 生成默认密码:手机号后六位
@ -51,7 +42,7 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
用户认证 用户认证
""" """
user = await self.model.filter( user = await self.model.filter(
phone=login_data.phone, is_active=True, is_deleted=False phone=login_data.phone, is_active=True
).first() ).first()
if not user: if not user:
@ -66,13 +57,13 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
""" """
根据ID获取用户 根据ID获取用户
""" """
return await self.model.filter(id=user_id, is_active=True, is_deleted=False).first() return await self.model.filter(id=user_id, is_active=True).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, is_deleted=False).first() return await self.model.filter(phone=phone, is_active=True).first()
async def update_last_login(self, user_id: int) -> bool: async def update_last_login(self, user_id: int) -> bool:
""" """
@ -95,9 +86,6 @@ 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)
@ -125,9 +113,6 @@ 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:
@ -158,27 +143,6 @@ 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()

View File

@ -9,7 +9,6 @@ from app.schemas.invoice import (
InvoiceOut, InvoiceOut,
InvoiceList, InvoiceList,
InvoiceHeaderCreate, InvoiceHeaderCreate,
InvoiceHeaderUpdate,
InvoiceHeaderOut, InvoiceHeaderOut,
UpdateStatus, UpdateStatus,
UpdateType, UpdateType,
@ -33,14 +32,7 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
返回: 返回:
InvoiceHeaderOut: 抬头输出对象 InvoiceHeaderOut: 抬头输出对象
""" """
payload = data.model_dump() header = await InvoiceHeader.create(app_user_id=user_id, **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]:
@ -68,36 +60,6 @@ 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:
""" """
获取发票列表支持筛选与分页 获取发票列表支持筛选与分页
@ -121,8 +83,6 @@ 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)
@ -199,7 +159,6 @@ 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:
@ -271,23 +230,14 @@ 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({
"id": r.id,
"invoice_id": getattr(inv, "id", None),
"submitted_at": r.created_at.isoformat() if r.created_at else "", "submitted_at": r.created_at.isoformat() if r.created_at else "",
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "", "receipt": {
"extra": r.extra, "id": r.id,
"receipts": receipts, "url": r.url,
"note": r.note,
"verified": r.verified,
},
"phone": inv.phone, "phone": inv.phone,
"wechat": inv.wechat, "wechat": inv.wechat,
"company_name": inv.company_name, "company_name": inv.company_name,
@ -316,23 +266,14 @@ 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 {
"id": r.id,
"invoice_id": getattr(inv, "id", None),
"submitted_at": r.created_at.isoformat() if r.created_at else "", "submitted_at": r.created_at.isoformat() if r.created_at else "",
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "", "receipt": {
"extra": r.extra, "id": r.id,
"receipts": receipts, "url": r.url,
"note": r.note,
"verified": r.verified,
},
"phone": inv.phone, "phone": inv.phone,
"wechat": inv.wechat, "wechat": inv.wechat,
"company_name": inv.company_name, "company_name": inv.company_name,

View File

@ -15,9 +15,8 @@ class UploadController:
:param file: 上传的图片文件 :param file: 上传的图片文件
:return: 图片URL和文件名 :return: 图片URL和文件名
""" """
ext = os.path.splitext(file.filename or "")[1].lower() # 检查文件类型
image_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"} if not file.content_type.startswith('image/'):
if not (file.content_type.startswith('image/') or ext in image_exts):
raise ValueError("只支持上传图片文件") raise ValueError("只支持上传图片文件")
# 获取项目根目录 # 获取项目根目录
@ -62,32 +61,8 @@ 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",
} }
allowed_exts = { if file.content_type not in allowed:
".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
@ -120,9 +95,7 @@ class UploadController:
统一上传入口自动识别图片与非图片类型 统一上传入口自动识别图片与非图片类型
返回统一结构url, filename, content_type 返回统一结构url, filename, content_type
""" """
image_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"} if file.content_type and file.content_type.startswith("image/"):
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")
# 非图片类型复用原文件上传校验 # 非图片类型复用原文件上传校验

View File

@ -114,73 +114,7 @@ class UserValuationController:
async def _to_user_out(self, valuation: ValuationAssessment) -> UserValuationOut: async def _to_user_out(self, valuation: ValuationAssessment) -> UserValuationOut:
"""转换为用户端输出模型""" """转换为用户端输出模型"""
return UserValuationOut( return UserValuationOut.model_validate(valuation)
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:
"""转换为用户端详细模型""" """转换为用户端详细模型"""
@ -197,7 +131,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=getattr(valuation, "heritage_level", None), heritage_level=valuation.heritage_level,
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,

View File

@ -1,5 +1,4 @@
import json from typing import List, Optional
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
@ -14,8 +13,6 @@ 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:
@ -24,80 +21,6 @@ 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:
""" """
创建估值计算步骤 创建估值计算步骤
@ -117,152 +40,6 @@ 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:
@ -293,386 +70,19 @@ 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_codevalue 为步骤数据和子节点
"""
# 按 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)
out = ValuationAssessmentOut.model_validate(valuation) return 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:
out = ValuationAssessmentOut.model_validate(valuation) return 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]:
@ -683,51 +93,10 @@ 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()
out = ValuationAssessmentOut.model_validate(valuation) return 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:
"""软删除估值评估""" """软删除估值评估"""
@ -752,7 +121,6 @@ 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
@ -787,37 +155,6 @@ 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:
@ -858,7 +195,6 @@ 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
@ -877,14 +213,12 @@ class ValuationController:
if not valuation: if not valuation:
return None return None
from datetime import datetime update_data = {"status": "approved"}
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()
out = ValuationAssessmentOut.model_validate(valuation) return 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]:
"""审核拒绝估值评估""" """审核拒绝估值评估"""
@ -892,14 +226,12 @@ class ValuationController:
if not valuation: if not valuation:
return None return None
from datetime import datetime update_data = {"status": "rejected"}
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()
out = ValuationAssessmentOut.model_validate(valuation) return 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]:
"""更新管理员备注""" """更新管理员备注"""
@ -907,37 +239,8 @@ class ValuationController:
if not valuation: if not valuation:
return None return None
from datetime import datetime await valuation.update_from_dict({"admin_notes": admin_notes}).save()
await valuation.update_from_dict({"admin_notes": admin_notes, "updated_at": datetime.now()}).save() return ValuationAssessmentOut.model_validate(valuation)
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
# 创建控制器实例 # 创建控制器实例

View File

@ -16,7 +16,6 @@ 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)
@ -25,21 +24,20 @@ 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={}) content = dict(code=exc.status_code, msg=exc.detail, data=None)
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}", data={}) content = dict(code=422, msg=f"RequestValidationError, {exc}")
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}", data={}) content = dict(code=500, msg=f"ResponseValidationError, {exc}")
return JSONResponse(content=content, status_code=500) return JSONResponse(content=content, status_code=500)

View File

@ -279,117 +279,12 @@ async def init_menus():
async def init_apis(): async def init_apis():
await api_controller.refresh_api() apis = await api_controller.model.exists()
if not apis:
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)
@ -397,84 +292,14 @@ 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 之前,先检查表是否存在,如果不存在则先创建表 await command.upgrade(run_in_transaction=True)
try:
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():
@ -621,5 +446,4 @@ 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()

View File

@ -149,8 +149,7 @@ class HttpAuditLogMiddleware(BaseHTTPMiddleware):
try: try:
return json.loads(stripped) return json.loads(stripped)
except (ValueError, TypeError): except (ValueError, TypeError):
# 将非 JSON 字符串包装为字典,以便 JSONField 能够正确存储 return stripped
return {"text": stripped}
if isinstance(value, (dict, list, int, float, bool)): if isinstance(value, (dict, list, int, float, bool)):
return value return value

View File

@ -1,13 +0,0 @@
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()

View File

@ -7,4 +7,3 @@ 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 *

View File

@ -12,7 +12,6 @@ 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"
@ -30,7 +29,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, default="pending") status = fields.CharField(max_length=16, description="状态: pending|invoiced|rejected|refunded", index=True)
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)
@ -45,7 +44,6 @@ 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"

View File

@ -1,15 +0,0 @@
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令牌黑名单"

View File

@ -21,8 +21,6 @@ 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"

View File

@ -82,11 +82,10 @@ 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="pending", description="评估状态: pending(待审核), success(已通过), rejected(已拒绝)") status = fields.CharField(max_length=20, default="success", description="评估状态: pending(待审核), success(已通过), fail(已拒绝)")
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(null=True, description="更新时间") updated_at = fields.DatetimeField(auto_now=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:
@ -101,28 +100,19 @@ 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="processing", description="步骤状态: processing, completed, failed") status = fields.CharField(max_length=20, default="SUCCESS", description="步骤状态: SUCCESS, 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}"

View File

@ -62,7 +62,6 @@ 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):

View File

@ -3,7 +3,6 @@ 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):
@ -14,9 +13,9 @@ class Success(JSONResponse):
data: Optional[Any] = None, data: Optional[Any] = None,
**kwargs, **kwargs,
): ):
content = {"code": code, "msg": msg, "data": ({} if data is None else data)} content = {"code": code, "msg": msg, "data": data}
content.update(kwargs) content.update(kwargs)
super().__init__(content=jsonable_encoder(content), status_code=code) super().__init__(content=content, status_code=code)
class Fail(JSONResponse): class Fail(JSONResponse):
@ -27,9 +26,9 @@ class Fail(JSONResponse):
data: Optional[Any] = None, data: Optional[Any] = None,
**kwargs, **kwargs,
): ):
content = {"code": code, "msg": msg, "data": ({} if data is None else data)} content = {"code": code, "msg": msg, "data": data}
content.update(kwargs) content.update(kwargs)
super().__init__(content=jsonable_encoder(content), status_code=code) super().__init__(content=content, status_code=code)
class SuccessExtra(JSONResponse): class SuccessExtra(JSONResponse):
@ -52,7 +51,7 @@ class SuccessExtra(JSONResponse):
"page_size": page_size, "page_size": page_size,
} }
content.update(kwargs) content.update(kwargs)
super().__init__(content=jsonable_encoder(content), status_code=code) super().__init__(content=content, status_code=code)
T = TypeVar("T") T = TypeVar("T")

View File

@ -1,49 +1,26 @@
from typing import Optional, List, Union, Dict, Any from typing import Optional, List
from pydantic import BaseModel, Field, EmailStr, field_validator, model_validator from pydantic import BaseModel, Field, EmailStr
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: Optional[str] = Field(None, min_length=1, max_length=256) register_address: str = Field(..., min_length=1, max_length=256)
register_phone: Optional[str] = Field(None, min_length=1, max_length=32) register_phone: str = Field(..., min_length=1, max_length=32)
bank_name: Optional[str] = Field(None, min_length=1, max_length=128) bank_name: str = Field(..., min_length=1, max_length=128)
bank_account: Optional[str] = Field(None, min_length=1, max_length=64) bank_account: str = Field(..., 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: Optional[str] = None email: EmailStr
class Config:
from_attributes = True
is_default: Optional[bool] = False
class InvoiceHeaderUpdate(BaseModel):
company_name: Optional[str] = Field(None, min_length=1, max_length=128)
tax_number: Optional[str] = Field(None, min_length=1, max_length=32)
register_address: Optional[str] = Field(None, 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):
@ -53,10 +30,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(..., max_length=256) register_address: str = Field(..., min_length=1, max_length=256)
register_phone: str = Field(..., max_length=32) register_phone: str = Field(..., min_length=1, max_length=32)
bank_name: str = Field(..., max_length=128) bank_name: str = Field(..., min_length=1, max_length=128)
bank_account: str = Field(..., max_length=64) bank_account: str = Field(..., min_length=1, 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
@ -115,7 +92,6 @@ 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):
@ -124,77 +100,3 @@ 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

View File

@ -1,19 +1,12 @@
from pydantic import BaseModel, Field, EmailStr from pydantic import BaseModel, Field
from typing import Optional, List, Union from typing import Optional
class SendEmailRequest(BaseModel): class SendEmailRequest(BaseModel):
receipt_id: Optional[int] = Field(None, description="付款凭证ID") email: str = Field(..., description="邮箱地址")
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_urls: Optional[List[str]] = Field(None, description="附件URL列表") file_url: Optional[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):
@ -30,7 +23,3 @@ 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

View File

@ -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, model_validator from pydantic import BaseModel, Field, field_validator
from decimal import Decimal from decimal import Decimal
@ -100,101 +100,44 @@ 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="非遗资产所用专利的申请号")
patent_remaining_years: Optional[str] = Field(None, description="专利剩余年限") historical_evidence: Optional[List[Any]] = 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: Optional[datetime] = Field(None, description="更新时间") updated_at: datetime = Field(..., description="更新时间")
audited_at: Optional[datetime] = Field(None, description="审核时间")
is_active: bool = Field(..., description="是否激活") is_active: bool = Field(..., description="是否激活")
class Config: class Config:
@ -205,29 +148,6 @@ 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):
@ -239,14 +159,10 @@ 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: Optional[datetime] = Field(None, description="更新时间") updated_at: datetime = Field(..., description="更新时间")
is_active: Optional[bool] = Field(None, description="是否激活") is_active: Optional[bool] = Field(None, description="是否激活")
class Config: class Config:
@ -256,41 +172,14 @@ 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: Optional[datetime] = Field(None, description="更新时间") updated_at: datetime = Field(..., description="更新时间")
class Config: class Config:
from_attributes = True from_attributes = True
@ -298,29 +187,6 @@ 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):
"""用户端估值评估列表模型""" """用户端估值评估列表模型"""
@ -362,13 +228,8 @@ 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(待审核), success(已通过), rejected(已拒绝)") status: Optional[str] = Field(None, description="评估状态: pending(待审核), approved(已通过), 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="每页数量")
@ -391,13 +252,8 @@ 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="步骤状态: processing/completed/failed") status: str = Field(..., description="步骤状态")
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
@ -422,7 +278,6 @@ 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

View File

@ -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, List, Tuple from typing import Optional
import httpx import httpx
from app.settings.config import settings from app.settings.config import settings
@ -27,14 +27,6 @@ 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()
@ -51,16 +43,7 @@ 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()

View File

@ -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 template = settings.ALIYUN_SMS_TEMPLATE_CODE_VERIFY or "SMS_498190229"
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 template = settings.ALIYUN_SMS_TEMPLATE_CODE_REPORT or "SMS_498140213"
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, {})

View File

@ -28,7 +28,6 @@ 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:
"""生成数字验证码 """生成数字验证码
@ -145,13 +144,5 @@ 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()

View File

@ -100,19 +100,18 @@ 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_498190229" ALIYUN_SMS_TEMPLATE_CODE_REPORT: typing.Optional[str] = "SMS_49190229"
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] = "smtp.qiye.aliyun.com" SMTP_HOST: typing.Optional[str] = None
SMTP_PORT: typing.Optional[int] = 465 SMTP_PORT: typing.Optional[int] = None
SMTP_USERNAME: typing.Optional[str] = "value@cdcee.net" SMTP_USERNAME: typing.Optional[str] = None
SMTP_PASSWORD: typing.Optional[str] = "PPXbILdGlRCn2VOx" SMTP_PASSWORD: typing.Optional[str] = None
SMTP_TLS: bool = False SMTP_TLS: bool = True
SMTP_FROM: typing.Optional[str] = "value@cdcee.net" SMTP_FROM: typing.Optional[str] = None
settings = Settings() settings = Settings()

View File

@ -0,0 +1,2 @@
%PDF-1.4
%粤マモ

View File

@ -0,0 +1,2 @@
%PDF-1.4
%粤マモ

View File

@ -0,0 +1,2 @@
%PDF-1.4
%粤マモ

View File

@ -0,0 +1,2 @@
%PDF-1.4
%粤マモ

View File

@ -0,0 +1 @@
%PDF-1.4

View File

@ -0,0 +1 @@
%PDF-1.4

View File

@ -0,0 +1 @@
%PDF-1.4

View File

@ -0,0 +1 @@
%PDF-1.4

View File

@ -1,2 +0,0 @@
"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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View File

@ -3,7 +3,6 @@ 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
@ -49,24 +48,18 @@ def verify_app_user_token(token: str) -> Optional[AppUserJWTPayload]:
return None return None
async def get_current_app_user_id(token: str = Header(None)) -> int: 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:

View File

@ -1,53 +1,53 @@
""" '''
非遗资产估值计算引擎包 这是非物质文化遗产IP知识产权评估系统的核心计算引擎包
提供各类计算器并通过懒加载避免循环依赖 '''
""" from app.utils.calculation_engine.economic_value_b1 import EconomicValueB1Calculator
from importlib import import_module from app.utils.calculation_engine.economic_value_b1.sub_formulas import (
from typing import Any BasicValueB11Calculator,
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

View File

@ -18,12 +18,13 @@ 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.controllers.valuation import ValuationController from app.schemas.valuation import ValuationCalculationStepCreate
class CulturalValueB2Calculator: class CulturalValueB2Calculator:
@ -53,7 +54,7 @@ class CulturalValueB2Calculator:
return cultural_value return cultural_value
async def calculate_complete_cultural_value_b2(self, valuation_id: int, input_data: Dict) -> Dict[str, float]: async def calculate_complete_cultural_value_b2(self, valuation_id: int, input_data: Dict) -> float:
""" """
计算完整的文化价值B2并记录所有计算步骤 计算完整的文化价值B2并记录所有计算步骤
@ -72,59 +73,38 @@ class CulturalValueB2Calculator:
} }
Returns: Returns:
Dict[str, float]: 包含文化价值B2及子公式结果的字典 float: 计算得出的文化价值B2
Raises: Raises:
Exception: 在计算过程中遇到的任何异常都会被捕获记录并重新抛出 Exception: 在计算过程中遇到的任何异常都会被捕获记录并重新抛出
""" """
await self.valuation_controller.log_formula_step( step = await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B_CULTURAL_B2", valuation_id=valuation_id,
status="processing", step_order=2.2,
input_params=input_data, step_name="文化价值B2计算",
step_description="开始计算文化价值B2公式为活态传承系数B21 × 0.6 + (纹样基因值B22 / 10) × 0.4",
input_params=input_data,
status="in_progress"
)
) )
try: try:
# 计算活态传承系数B21 # 计算活态传承系数B21
teaching_frequency = self.living_heritage_calculator.calculate_teaching_frequency(
input_data["offline_sessions"],
input_data["douyin_views"],
input_data["kuaishou_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( living_heritage_b21 = self.living_heritage_calculator.calculate_living_heritage_b21(
input_data['inheritor_level_coefficient'], input_data['inheritor_level_coefficient'],
teaching_frequency, self.living_heritage_calculator.calculate_teaching_frequency(
input_data["offline_sessions"],
input_data["douyin_views"],
input_data["kuaishou_views"],
input_data["bilibili_views"]
),
input_data['cross_border_depth'] input_data['cross_border_depth']
) )
await self.valuation_controller.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B_CULTURAL_B21", valuation_id=valuation_id, step_order=2.21, step_name="活态传承系数B21",
status="completed", output_result={'living_heritage_b21': living_heritage_b21}, 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
@ -133,16 +113,11 @@ class CulturalValueB2Calculator:
input_data['normalized_entropy'], input_data['normalized_entropy'],
input_data['historical_inheritance'] input_data['historical_inheritance']
) )
await self.valuation_controller.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B_CULTURAL_B22", valuation_id=valuation_id, step_order=2.22, step_name="纹样基因值B22",
status="completed", output_result={'pattern_gene_b22': pattern_gene_b22}, 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
@ -151,24 +126,14 @@ class CulturalValueB2Calculator:
pattern_gene_b22 pattern_gene_b22
) )
result = { await self.valuation_controller.update_calculation_step(
"cultural_value_b2": cultural_value_b2, step.id, {"status": "completed", "output_result": {"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 result return cultural_value_b2
except Exception as e: except Exception as e:
await self.valuation_controller.log_formula_step( error_message = f"文化价值B2计算失败: {e}"
valuation_id, await self.valuation_controller.update_calculation_step(
"MODEL_B_CULTURAL_B2", step.id, {"status": "failed", "error_message": error_message}
status="failed",
error_message=str(e),
) )
raise raise

View File

@ -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 = 出土实物×1.0 + 古代文献×0.8 + 传承人佐证×0.6 + 现代研究×0.4 - 历史传承度HI = 传承年限权重 × 0.4 + 文化意义权重 × 0.3 + 保护状况权重 × 0.3
- 纹样基因值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

View File

@ -7,6 +7,7 @@
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
@ -45,7 +46,7 @@ class EconomicValueB1Calculator:
return economic_value return economic_value
async def calculate_complete_economic_value_b1(self, valuation_id: int, input_data: Dict) -> Dict[str, float]: async def calculate_complete_economic_value_b1(self, valuation_id: int, input_data: Dict) -> float:
""" """
计算完整的经济价值B1并记录所有计算步骤 计算完整的经济价值B1并记录所有计算步骤
@ -65,229 +66,81 @@ class EconomicValueB1Calculator:
} }
Returns: Returns:
Dict[str, float]: 包含经济价值B1及各子公式结果的字典 float: 计算得出的经济价值B1
Raises: Raises:
Exception: 在计算过程中发生的任何异常都会被捕获记录并重新抛出 Exception: 在计算过程中发生的任何异常都会被捕获记录并重新抛出
""" """
await self.valuation_controller.log_formula_step( step = await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B_ECON_B1", valuation_id=valuation_id,
status="processing", step_order=2.1,
input_params=input_data, step_name="经济价值B1计算",
step_description="开始计算经济价值B1公式为基础价值B11 × (1 + 流量因子B12) × 政策乘数B13",
input_params=input_data,
status="in_progress"
)
) )
try: try:
financial_value = self.basic_value_calculator.calculate_financial_value_f(input_data["three_year_income"]) # 计算基础价值B11
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(
financial_value, self.basic_value_calculator.calculate_financial_value_f(input_data["three_year_income"]),
legal_strength, self.basic_value_calculator.calculate_legal_strength_l(input_data["patent_score"], input_data["popularity_score"], input_data["infringement_score"]),
development_potential, self.basic_value_calculator.calculate_development_potential_d(input_data["patent_count"], input_data["esg_score"], input_data["innovation_ratio"]),
industry_coefficient, input_data["industry_coefficient"]
) )
await self.valuation_controller.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B_ECON_B11", valuation_id=valuation_id, step_order=2.11, step_name="基础价值B11",
status="completed", output_result={'basic_value_b11': basic_value_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'],
social_media_spread_s3, self.traffic_factor_calculator.calculate_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.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B_FLOW_B12", valuation_id=valuation_id, step_order=2.12, step_name="流量因子B12",
status="completed", output_result={'traffic_factor_b12': traffic_factor_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(
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( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B_POLICY_B13", valuation_id=valuation_id, step_order=2.13, step_name="政策乘数B13",
status="completed", output_result={'policy_multiplier_b13': policy_multiplier_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 = {
"economic_value_b1": economic_value_b1, await self.valuation_controller.update_calculation_step(
"basic_value_b11": basic_value_b11, step.id, {"status": "completed", "output_result": {"economic_value_b1": economic_value_b1}}
"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 result return economic_value_b1
except Exception as e: except Exception as e:
await self.valuation_controller.log_formula_step( error_message = f"经济价值B1计算失败: {e}"
valuation_id, await self.valuation_controller.update_calculation_step(
"MODEL_B_ECON_B1", step.id, {"status": "failed", "error_message": error_message}
status="failed",
error_message=str(e),
) )
raise raise

View File

@ -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 max(avg_growth_rate, 0.0) return avg_growth_rate
def calculate_legal_strength_l(self, def calculate_legal_strength_l(self,
patent_score: float, patent_score: float,

View File

@ -49,20 +49,6 @@ 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 翻成负值并把模型估值 Bfinal_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,
@ -277,19 +263,26 @@ 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(wechat_index: float) -> float: def calculate_search_index_s1(baidu_index: float,
wechat_index: float,
weibo_index: float) -> float:
""" """
计算近30天搜索指数S1 计算近30天搜索指数S1
近30天搜索指数S1 = 微信指数 / 10 近30天搜索指数S1 = 百度搜索指数 × 0.4 + 微信搜索指数 × 0.3 + 微博搜索指数 × 0.3
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
# 示例使用 # 示例使用
@ -299,8 +292,10 @@ 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
@ -330,7 +325,7 @@ if __name__ == "__main__":
view_count = 200 view_count = 200
# 计算各项指标 # 计算各项指标
search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10 search_index_s1 = calculate_search_index_s1(baidu_index, wechat_index, weibo_index)
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

View File

@ -20,11 +20,13 @@ 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:
@ -92,17 +94,25 @@ 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.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"FINAL_A", valuation_id=valuation_id,
status="processing", step_order=step_order,
input_params=input_data, step_name="开始计算最终估值A",
step_description="接收输入参数,准备开始计算。",
input_params=input_data,
status="processing"
)
) )
step_order += 1
try: try:
# 详细记录模型数据参数 # 详细记录模型数据参数
@ -155,12 +165,11 @@ 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_raw = await self.model_value_calculator.calculate_complete_model_value_b( model_result = await self.model_value_calculator.calculate_complete_model_value_b(
valuation_id, valuation_id,
input_data['model_data'] input_data['model_data']
) )
model_result = model_result_raw if isinstance(model_result_raw, dict) else {"model_value_b": model_result_raw} model_value_b = model_result if isinstance(model_result, (int, float)) else model_result.get('model_value_b')
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 返回字段={}",
@ -168,22 +177,47 @@ 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_raw = await self.market_value_calculator.calculate_complete_market_value_c( market_result = await self.market_value_calculator.calculate_complete_market_value_c(
valuation_id, valuation_id,
input_data['market_data'] input_data['market_data']
) )
market_result = market_result_raw if isinstance(market_result_raw, dict) else {"market_value_c": market_result_raw} market_value_c = market_result if isinstance(market_result, (int, float)) else market_result.get('market_value_c')
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),
input_data['market_data']) list(market_result.keys()))
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={}万元",
@ -204,18 +238,16 @@ class FinalValueACalculator:
int(model_duration * 1000), int(model_duration * 1000),
int(market_duration * 1000)) int(market_duration * 1000))
await self.valuation_controller.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"FINAL_A", valuation_id=valuation_id,
status="completed", step_order=step_order,
output_result={ step_name="计算最终估值A",
"model_value_b": model_value_b, step_description="最终估值A = 模型估值B × 0.7 + 市场估值C × 0.3",
"market_value_c": market_value_c, input_params={"model_value_b": model_value_b, "market_value_c": market_value_c},
"final_value_ab": final_value_a, output_result={"final_value_a": final_value_a},
"model_duration_ms": int(model_duration * 1000), status="completed"
"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,
@ -225,11 +257,15 @@ 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.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"FINAL_A", valuation_id=valuation_id,
status="failed", step_order=step_order,
error_message=str(e), step_name="计算失败",
step_description="计算过程中发生错误。",
status="failed",
error_message=str(e)
)
) )
raise raise

View File

@ -13,11 +13,13 @@ 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:
@ -49,7 +51,7 @@ class ModelValueBCalculator:
return model_value return model_value
async def calculate_complete_model_value_b(self, valuation_id: int, input_data: Dict) -> Dict[str, float]: async def calculate_complete_model_value_b(self, valuation_id: int, input_data: Dict) -> float:
""" """
计算完整的模型估值B并记录详细的计算步骤 计算完整的模型估值B并记录详细的计算步骤
@ -67,84 +69,107 @@ class ModelValueBCalculator:
} }
Returns: Returns:
Dict[str, float]: 包含中间结果和最终模型估值B的字典 float: 计算得出的模型估值B
Raises: Raises:
Exception: 在计算过程中遇到的任何异常都会被捕获记录然后重新抛出 Exception: 在计算过程中遇到的任何异常都会被捕获记录然后重新抛出
""" """
await self.valuation_controller.log_formula_step( step_order = 1
valuation_id, await self.valuation_controller.create_calculation_step(
"MODEL_B", ValuationCalculationStepCreate(
status="processing", valuation_id=valuation_id,
input_params=input_data, step_order=step_order,
step_name="开始计算模型估值B",
step_description="接收输入参数,准备开始计算。",
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并等待异步完成
current_stage = "经济价值B1计算" economic_value_b1 = await self.economic_value_calculator.calculate_complete_economic_value_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']
) )
economic_value_b1 = economic_result["economic_value_b1"] await self.valuation_controller.create_calculation_step(
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并等待异步完成
current_stage = "文化价值B2计算" cultural_value_b2 = await self.cultural_value_calculator.calculate_complete_cultural_value_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']
) )
cultural_value_b2 = cultural_result["cultural_value_b2"] await self.valuation_controller.create_calculation_step(
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并等待异步完成
current_stage = "风险调整系数B3计算" risk_value_b3 = await self.risk_adjustment_calculator.calculate_complete_risky_value_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']
) )
risk_value_b3 = risk_result["risk_value_b3"] await self.valuation_controller.create_calculation_step(
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
) )
result = { await self.valuation_controller.create_calculation_step(
"economic_value_b1": economic_value_b1, ValuationCalculationStepCreate(
"cultural_value_b2": cultural_value_b2, valuation_id=valuation_id,
"risk_value_b3": risk_value_b3, step_order=step_order,
"model_value_b": model_value_b, step_name="计算模型估值B",
"economic_details": economic_result, step_description="模型估值B = 经济价值B1*0.7+文化价值B2*0.3*风险调整系数B3",
"cultural_details": cultural_result, input_params={"economic_value_b1": economic_value_b1, "cultural_value_b2": cultural_value_b2, "risk_value_b3": risk_value_b3},
"risk_details": risk_result, output_result={"model_value_b": model_value_b},
} 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.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B", valuation_id=valuation_id,
status="failed", step_order=step_order,
error_message=f"{current_stage}失败: {e}", step_name="计算失败",
step_description="计算过程中发生错误。",
status="failed",
error_message=str(e)
)
) )
raise raise

View File

@ -1,358 +0,0 @@
"""
公式元数据注册表
用于将计算引擎中的每个公式节点含子公式映射到唯一的 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

View File

@ -20,6 +20,7 @@ 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
@ -28,6 +29,7 @@ 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__)
@ -99,9 +101,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 / 10000.0 return market_value
async def calculate_complete_market_value_c(self, valuation_id: int, input_data: Dict) -> Dict[str, float]: async def calculate_complete_market_value_c(self, valuation_id: int, input_data: Dict) -> float:
""" """
计算完整的市场估值C并记录每一步的计算过程 计算完整的市场估值C并记录每一步的计算过程
@ -122,16 +124,20 @@ class MarketValueCCalculator:
} }
Returns: Returns:
Dict[str, float]: 包含市场估值C及子公式结果的字典 float: 计算得出的市场估值C
Raises: Raises:
Exception: 如果在计算过程中发生任何错误将记录失败状态并重新抛出异常 Exception: 如果在计算过程中发生任何错误将记录失败状态并重新抛出异常
""" """
await self.valuation_controller.log_formula_step( step = await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MARKET_C", valuation_id=valuation_id,
status="processing", step_order=3,
input_params=input_data, step_name="市场估值C计算",
step_description="开始计算市场估值C公式为市场竞价C1 × 热度系数C2 × 稀缺性乘数C3 × 时效性衰减C4",
input_params=input_data,
status="in_progress"
)
) )
try: try:
# 计算市场竞价C1 # 计算市场竞价C1
@ -140,16 +146,11 @@ 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.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MARKET_C_C1", valuation_id=valuation_id, step_order=3.1, step_name="市场竞价C1",
status="completed", output_result={'market_bidding_c1': market_bidding_c1}, 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
@ -157,39 +158,33 @@ 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.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MARKET_C_C2", valuation_id=valuation_id, step_order=3.2, step_name="热度系数C2",
status="completed", output_result={'heat_coefficient_c2': heat_coefficient_c2}, 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.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MARKET_C_C3", valuation_id=valuation_id, step_order=3.3, step_name="稀缺性乘数C3",
status="completed", output_result={'scarcity_multiplier_c3': scarcity_multiplier_c3}, 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.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MARKET_C_C4", valuation_id=valuation_id, step_order=3.4, step_name="时效性衰减C4",
status="completed", output_result={'temporal_decay_c4': temporal_decay_c4}, status="completed"
input_params={'recent_market_activity': input_data.get('recent_market_activity')}, )
output_result={'temporal_decay_c4': temporal_decay_c4},
) )
# 计算市场估值C # 计算市场估值C
@ -200,28 +195,15 @@ class MarketValueCCalculator:
temporal_decay_c4 temporal_decay_c4
) )
result = { await self.valuation_controller.update_calculation_step(
"market_value_c": market_value_c, step.id, {"status": "completed", "output_result": {"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 result return market_value_c
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.log_formula_step( await self.valuation_controller.update_calculation_step(
valuation_id, step.id, {"status": "failed", "error_message": error_message}
"MARKET_C",
status="failed",
error_message=str(e),
) )
raise raise

View File

@ -15,7 +15,9 @@ 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:
@ -165,7 +167,7 @@ class RiskAdjustmentB3Calculator:
return max_score return max_score
async def calculate_complete_risky_value_b3(self, valuation_id: int, input_data: Dict) -> Dict[str, float]: async def calculate_complete_risky_value_b3(self, valuation_id: int, input_data: Dict) -> float:
""" """
计算完整的风险调整系数B3并记录所有计算步骤 计算完整的风险调整系数B3并记录所有计算步骤
@ -185,48 +187,43 @@ class RiskAdjustmentB3Calculator:
} }
Returns: Returns:
Dict[str, float]: 包含各项风险评分和风险调整系数的字典 float: 计算得出的风险调整系数B3
Raises: Raises:
Exception: 在计算过程中遇到的任何异常都会被捕获记录并重新抛出 Exception: 在计算过程中遇到的任何异常都会被捕获记录并重新抛出
""" """
await self.valuation_controller.log_formula_step( step = await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B_RISK_B3", valuation_id=valuation_id,
status="processing", step_order=2.3,
input_params=input_data, step_name="风险调整系数B3计算",
step_description="开始计算风险调整系数B3公式为0.8 + 风险评分总和R × 0.4",
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.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B_RISK_B3_MARKET", valuation_id=valuation_id, step_order=2.31, step_name="市场风险评分",
status="completed", output_result={'market_risk': market_risk}, 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.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B_RISK_B3_LEGAL", valuation_id=valuation_id, step_order=2.32, step_name="法律风险评分",
status="completed", output_result={'legal_risk': legal_risk}, 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.log_formula_step( await self.valuation_controller.create_calculation_step(
valuation_id, ValuationCalculationStepCreate(
"MODEL_B_RISK_B3_INHERITANCE", valuation_id=valuation_id, step_order=2.33, step_name="传承风险评分",
status="completed", output_result={'inheritance_risk': inheritance_risk}, 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
@ -235,44 +232,14 @@ 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)
# 调试输出打印B3计算的关键值 await self.valuation_controller.update_calculation_step(
print(f"=== B3计算调试 ===") step.id, {"status": "completed", "output_result": {'risk_adjustment_b3': risk_adjustment_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 result return risk_adjustment_b3
except Exception as e: except Exception as e:
await self.valuation_controller.log_formula_step( error_message = f"风险调整系数B3计算失败: {e}"
valuation_id, await self.valuation_controller.update_calculation_step(
"MODEL_B_RISK_B3", step.id, {"status": "failed", "error_message": error_message}
status="failed",
error_message=str(e),
) )
raise raise

View File

@ -1,6 +1,5 @@
#!/bin/sh #!/bin/sh
set -e set -e
# nginx nginx
python run.py python run.py

View File

@ -21,7 +21,6 @@ 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;

74
menu_init.sql Normal file
View File

@ -0,0 +1,74 @@
-- 完整菜单初始化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); -- 审核列表
-- 注意:普通用户不分配用户管理权限

Binary file not shown.

View File

@ -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", "views": "100000"} "douyin": {"account": "成都文交所", "likes": "500000", "comments": "89222", "shares": "97412"}
} }
} }
# 若 application_coverage 为占位,则用 coverage_area 回填 # 若 application_coverage 为占位,则用 coverage_area 回填

View File

@ -1,104 +0,0 @@
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()

View File

@ -346,18 +346,6 @@ 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)

View File

@ -1,3 +1,3 @@
VITE_TITLE = '估值后台管理系统' VITE_TITLE = 'Vue FastAPI Admin'
VITE_PORT = 3100 VITE_PORT = 3100

View File

@ -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'

View File

@ -1,6 +1,6 @@
{ {
"lang": "中文", "lang": "中文",
"app_name": "估值后台管理系统", "app_name": "Vue FastAPI Admin",
"header": { "header": {
"label_profile": "个人信息", "label_profile": "个人信息",
"label_logout": "退出登录", "label_logout": "退出登录",

Some files were not shown because too many files have changed in this diff Show More