feat: Implement user account soft deletion with token blacklisting, admin management, and SMS verification tracking.

This commit is contained in:
邹方成 2025-12-18 19:14:03 +08:00
parent 90c0f85972
commit 1157704d4b
30 changed files with 1070 additions and 550 deletions

View File

@ -1,131 +0,0 @@
## 目标概述
* 加强“交易管理-发票附件与发送邮箱”能力,完善数据记录与事务保障。
* 改进“开票弹窗附件上传”支持多文件上传(后端存储结构)。
* 优化“用户管理备注与操作记录”,区分备注维度并完善日志。
* 覆盖单元/集成测试、数据库迁移、API文档与审计日志。
## 数据库与模型变更
* EmailSendLog
* 新增:`extra: JSONField(null=True)` 完整记录发送邮箱的请求数据(收件人、主题、正文、附件列表、重试信息等)。
* Invoice或与开票弹窗相关的业务模型
* 新增:`attachments: JSONField(null=True)` 支持多个附件URL与弹窗上传对应
* <br />
* 迁移脚本:创建/修改上述字段;保留历史数据不丢失。
## 事务与原子性
* 发送邮箱流程(交易管理)
* 封装在 `tortoise.transactions.in_transaction()`邮件发送、EmailSendLog写入含attachments/extra原子提交失败回滚。
* extra写入内容完整`SendEmailRequest`收件人、主题、正文、附件URL/文件名、重试次数、客户端UA等
* 多文件上传至发票附件(开票弹窗)
* 更新发票的 `attachments` 字段在同一事务内写入如任一URL校验失败则回滚。
## 后端接口改造
* 上传组件(后端)
* 新增:`POST /api/v1/upload/files` 接收 `files: List[UploadFile]`,统一返回 `urls: string[]`;保留现有单文件接口向后兼容。
* 交易管理(管理员)
* `POST /api/v1/transactions/send-email` 扩展:
* 入参支持 `file_urls: string[]`(与/或单文件),服务端聚合附件;
* 在事务中记录 EmailSendLog含attachments与extra返回log\_id与状态
* 回显接口(详情/列表)新增 `extra` 字段完整展示发送记录。
* 开票弹窗附件(管理员/或对应端)
* 新增/改造:`PUT /api/v1/invoice/{id}/attachments` 入参 `urls: string[]`,更新发票 `attachments`
* 列表/详情回显 `attachments: string[]`
* 用户管理备注优化(管理员端)
* 新接口:`PUT /api/v1/app-user-admin/{user_id}/notes`
* 入参:`system_notes?: string``user_notes?: string`(可选择性更新)
* 逻辑:仅更新提供的字段;不影响其他字段。
* 修复:`POST /api/v1/app-user-admin/quota` 仅调整次数,不再自动写入 `user.notes`
* 操作日志:在调整配额、更新备注、停用启用等操作时写入 `AppUserOperationLog`
## 前端改造(要点)
* 多文件上传组件
* 改为多选/拖拽支持;对每个文件显示上传进度与失败重试;
* 成功后收集URL数组写入发票 `attachments` 或作为邮件附件来源;
* 兼容旧接口若后端仅返回单URL前端仍正常显示降级为单文件模式
* 开票弹窗
* 支持附件列表预览与移除;提交时调用 `PUT /invoice/{id}/attachments`
* 邮件发送弹窗
* 选择附件来源(已上传的附件/本地文件再上传);提交后在详情页面完整回显 `extra`(含附件清单与正文等)。
## 审计与日志
* 关键操作:邮件发送、发票附件更新、用户备注更新、配额调整
* 统一调用审计记录(路由中间件已存在,补充结构化日志:`logger.info()` + DB审计表/操作日志表写入)。
## 测试方案
* 单元测试
* Email发送控制器事务成功/失败回滚(模拟抛错)
* 多文件上传文件类型校验、URL数组返回、尾随空白处理
* 备注更新:选择性字段更新、不影响其他字段
* 集成测试FastAPI + httpx.AsyncClient
* 发送邮箱请求→持久化校验attachments/extra→回显接口校验
* 附件上传:批量上传、更新发票、列表/详情回显
* 用户备注接口调用→DB值校验→操作日志存在
## 迁移与兼容
* 使用现有迁移工具如Aerich生成并应用迁移新增JSON/Text字段
* 前端保留旧接口兼容若上传仍走单文件后端返回数组长度1
* API文档OpenAPI
* 补充/更新以上端点的schema说明、示例请求/响应、错误码约定;
## 实施步骤与交付
1. 数据模型与迁移脚本编写与应用EmailSendLog、Invoice、AppUser/OperationLog
2. 后端接口改造与事务封装(邮件发送、多文件上传、发票附件更新、备注接口)。
3. 前端组件与弹窗改造多文件上传、进度与错误处理、回显extra
4. 审计日志与结构化日志补充。
5. 单元与集成测试编写,覆盖核心路径。
6. 更新接口文档与部署回归验证。

View File

@ -1,34 +0,0 @@
## 目标
* 添加抬头时将 `公司名称``公司税号``电子邮箱`设为必填,其他字段可为空。
* 现状确认(代码引用)
- 后端必填:`app/schemas/invoice.py:612``InvoiceHeaderCreate` 已要求 `company_name``tax_number` 为必填,`email: EmailStr` 为必填。
- API 入口:`app/api/v1/app_invoices/app_invoices.py:5558` 新增抬头接口使用 `InvoiceHeaderCreate`,后端将严格校验三项必填。
## 修改方案
1. 统一前端校验文案
* 统一三项必填的错误提示为简洁中文,如:“请输入公司名称 / 公司税号 / 电子邮箱”。
* 邮箱格式提示统一为:“请输入有效的电子邮箱”。
2. 后端校验与返回确认
* 保持 `InvoiceHeaderCreate` 的必填与格式限制不变(`app/schemas/invoice.py:612`)。
* 确认更新接口 `InvoiceHeaderUpdate``app/schemas/invoice.py:3239`)允许局部更新、但不影响创建必填逻辑。
3. 验证与测试
* 后端接口验证:对 `POST /app-invoices/headers``app/api/v1/app_invoices/app_invoices.py:5558`)进行用例:缺失任一必填字段应返回 422全部正确应 200/201。
* 可补充最小化单元测试Pydantic 校验用例覆盖必填与格式。
## 交付内容。
* 完成基本交互与接口验证,确保行为符合预期。

225
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,225 @@
# 非遗资产估值系统 - 部署文档
## 项目概述
非遗资产估值系统是一个基于 Vue.js + FastAPI 的全栈应用,用于非物质文化遗产资产的价值评估。
- **前端**: Vue.js + Vite + pnpm
- **后端**: Python 3.11 + FastAPI + Tortoise ORM
- **数据库**: MySQL
- **容器化**: Docker
---
## 目录结构
```
youshu-guzhi/
├── app/ # 后端 FastAPI 应用
│ ├── api/ # API 路由
│ ├── controllers/ # 业务控制器
│ ├── models/ # 数据库模型
│ ├── schemas/ # Pydantic 数据模型
│ ├── settings/ # 配置文件
│ └── utils/ # 工具函数和计算引擎
├── web/ # 前端 Vue.js 应用
├── deploy/ # 部署相关文件
│ ├── entrypoint.sh # 容器启动脚本
│ └── web.conf # Nginx 配置
├── Dockerfile # Docker 构建文件
├── requirements.txt # Python 依赖
└── run.py # 应用启动入口
```
---
## 环境配置
### 数据库配置
#### 使用 Docker 部署 MySQL
```bash
# 创建数据目录
mkdir -p ~/mysql-data
# 启动 MySQL 容器
docker run -d \
--name mysql-valuation \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=your_password \
-e MYSQL_DATABASE=valuation_service \
-v ~/mysql-data:/var/lib/mysql \
--restart=unless-stopped \
mysql:8.0
```
#### 应用配置
配置文件位置: `app/settings/config.py`
```python
TORTOISE_ORM = {
"connections": {
"mysql": {
"engine": "tortoise.backends.mysql",
"credentials": {
"host": "your_mysql_host", # 数据库主机地址
"port": 3306, # 数据库端口
"user": "root", # 数据库用户名
"password": "your_password", # 数据库密码
"database": "valuation_service", # 数据库名称
},
},
},
...
}
```
### 第三方服务配置
| 服务 | 配置项 | 说明 |
|-----|-------|------|
| 阿里云短信 | `ALIBABA_CLOUD_ACCESS_KEY_ID/SECRET` | 短信验证码发送 |
| 阿里云邮件 | `SMTP_*` | 邮件发送 |
---
## 本地开发
### 1. 安装依赖
```bash
# 安装 Python 依赖
pip install -r requirements.txt
# 安装前端依赖
cd web
pnpm install
```
### 2. 启动服务
```bash
# 启动后端 (端口 9999)
python run.py
# 启动前端开发服务器 (另一个终端)
cd web
pnpm run dev
```
---
## Docker 部署
### 1. 构建镜像
```bash
# 设置平台 (M1/M2 Mac 需要)
export DOCKER_DEFAULT_PLATFORM=linux/amd64
# 构建镜像
docker build -t zfc931912343/guzhi-fastapi-admin:v3.9 .
# 推送到 Docker Hub
docker push zfc931912343/guzhi-fastapi-admin:v3.9
```
### 2. 部署到服务器
#### 生产环境
```bash
# 创建数据目录
mkdir -p ~/guzhi-data/static/images
# 拉取并运行
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
&& docker rm -f guzhi_pro \
&& docker run -itd \
--name=guzhi_pro \
-p 8080:9999 \
-v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images \
--restart=unless-stopped \
-e TZ=Asia/Shanghai \
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
```
#### 开发/测试环境
```bash
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
&& docker rm -f guzhi_dev \
&& docker run -itd \
--name=guzhi_dev \
-p 9990:9999 \
-v ~/guzhi-data/static:/opt/vue-fastapi-admin/app/static \
--restart=unless-stopped \
-e TZ=Asia/Shanghai \
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
```
---
## 端口说明
| 环境 | 容器名 | 主机端口 | 容器端口 |
|-----|-------|---------|---------|
| 生产 | guzhi_pro | 8080 | 9999 |
| 开发 | guzhi_dev | 9990 | 9999 |
---
## 数据持久化
容器挂载的数据目录:
```
~/guzhi-data/static/images -> /opt/vue-fastapi-admin/app/static/images
```
用于存储用户上传的图片文件(如非遗纹样图片、证书图片等)。
---
## 常用运维命令
```bash
# 查看容器日志
docker logs -f guzhi_pro
# 进入容器
docker exec -it guzhi_pro bash
# 重启容器
docker restart guzhi_pro
# 查看容器状态
docker ps | grep guzhi
```
---
## API 接口说明
| 模块 | 路径前缀 | 说明 |
|-----|---------|------|
| 用户端估值 | `/api/v1/app-valuations/` | 用户提交估值请求 |
| 管理端估值 | `/api/v1/valuations/` | 管理后台查看/审核 |
| 计算报告 | `/api/v1/valuations/{id}/report` | 获取计算过程报告 |
---
## 版本历史
| 版本 | 日期 | 说明 |
|-----|------|------|
| v3.9 | 2025-12-18 | 修复风险调整系数B3显示问题添加计算过程详情 |
| v3.8 | 2025-12-18 | 修复历史传承度HI权重计算 |
---
## 联系信息
如有问题,请联系项目负责人。

View File

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

View File

@ -16,6 +16,7 @@ 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), id: Optional[str] = Query(None),
created_start: Optional[str] = Query(None), created_start: Optional[str] = Query(None),
created_end: Optional[str] = Query(None), created_end: Optional[str] = Query(None),
@ -24,6 +25,8 @@ async def list_app_users(
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(): if id is not None and id.strip().isdigit():
qs = qs.filter(id=int(id.strip())) qs = qs.filter(id=int(id.strip()))
if phone: if phone:
@ -133,3 +136,11 @@ async def update_app_user(user_id: int, data: AppUserUpdateSchema):
"updated_at": user.updated_at.isoformat() if user.updated_at else "", "updated_at": user.updated_at.isoformat() if user.updated_at else "",
"remaining_quota": int(getattr(user, "remaining_quota", 0) or 0), "remaining_quota": int(getattr(user, "remaining_quota", 0) or 0),
}, msg="更新成功") }, 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

@ -15,11 +15,20 @@ from app.schemas.base import BasicResponse, MessageOut, Success
from app.utils.app_user_jwt import ( from app.utils.app_user_jwt import (
create_app_user_access_token, create_app_user_access_token,
get_current_app_user, get_current_app_user,
ACCESS_TOKEN_EXPIRE_MINUTES ACCESS_TOKEN_EXPIRE_MINUTES,
verify_app_user_token
) )
from app.models.user import AppUser from app.models.user import AppUser
from app.controllers.user_valuation import user_valuation_controller from app.controllers.user_valuation import user_valuation_controller
from app.controllers.invoice import invoice_controller from app.controllers.invoice import invoice_controller
from app.core.token_blacklist import add_to_blacklist
from fastapi import Header
from pydantic import BaseModel, Field
from typing import Optional
import time
from app.models.valuation import ValuationAssessment
from app.services.sms_store import store
from app.settings import settings
router = APIRouter() router = APIRouter()
@ -78,6 +87,50 @@ async def logout(current_user: AppUser = Depends(get_current_app_user)):
return Success(data={"message": "登出成功"}) return Success(data={"message": "登出成功"})
class DeleteAccountRequest(BaseModel):
code: Optional[str] = Field(None, description="短信验证码或绕过码")
@router.delete("/account", summary="注销用户信息", response_model=BasicResponse[dict])
async def delete_account(current_user: AppUser = Depends(get_current_app_user), token: str = Header(None), payload: Optional[DeleteAccountRequest] = None):
if payload and payload.code:
if settings.SMS_BYPASS_CODE and payload.code == settings.SMS_BYPASS_CODE:
store.mark_verified(current_user.phone)
else:
ok, reason = store.can_verify(current_user.phone)
if not ok:
raise HTTPException(status_code=423, detail=str(reason))
record = store.get_code(current_user.phone)
if not record:
raise HTTPException(status_code=400, detail="验证码已过期")
code_stored, expires_at = record
if time.time() > expires_at:
store.clear_code(current_user.phone)
raise HTTPException(status_code=400, detail="验证码已过期")
if payload.code != code_stored:
count, locked = store.record_verify_failure(current_user.phone)
if locked:
raise HTTPException(status_code=423, detail="尝试次数过多,已锁定")
raise HTTPException(status_code=401, detail="验证码错误")
store.clear_code(current_user.phone)
store.reset_failures(current_user.phone)
store.mark_verified(current_user.phone)
else:
if not store.is_recently_verified(current_user.phone):
raise HTTPException(status_code=403, detail="请先完成手机号验证码验证")
remaining_quota = int(getattr(current_user, "remaining_quota", 0) or 0)
if remaining_quota > 0:
raise HTTPException(status_code=400, detail="当前剩余估值次数大于0无法注销账号")
ok = await app_user_controller.delete_user_account(current_user.id)
if token:
payload = verify_app_user_token(token)
exp = getattr(payload, "exp", None) if payload else None
await add_to_blacklist(token, current_user.id, exp)
if not ok:
raise HTTPException(status_code=404, detail="用户不存在")
return Success(data={"message": "账号已注销"})
@router.get("/profile", response_model=BasicResponse[dict], summary="获取用户信息") @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)):
""" """

View File

@ -37,13 +37,13 @@ from app.utils.wechat_index_calculator import wechat_index_calculator
app_valuations_router = APIRouter(tags=["用户端估值评估"]) app_valuations_router = APIRouter(tags=["用户端估值评估"])
async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate): async def _perform_valuation_calculation(user_id: int, valuation_id: int, data: UserValuationCreate):
""" """
后台任务执行估值计算 后台任务执行估值计算
""" """
try: try:
start_ts = time.monotonic() start_ts = time.monotonic()
logger.info("valuation.calc_start user_id={} asset_name={} industry={}", user_id, logger.info("valuation.calc_start user_id={} valuation_id={} asset_name={} industry={}", user_id, valuation_id,
getattr(data, 'asset_name', None), getattr(data, 'industry', None)) getattr(data, 'asset_name', None), getattr(data, 'industry', None))
# 根据行业查询 ESG 基准分(优先用行业名称匹配,如用的是行业代码就把 name 改成 code # 根据行业查询 ESG 基准分(优先用行业名称匹配,如用的是行业代码就把 name 改成 code
@ -84,25 +84,53 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
# 政策匹配度 # 政策匹配度
input_data_by_b1["policy_match_score"] = policy_match_score input_data_by_b1["policy_match_score"] = policy_match_score
# 侵权分 默认 6 # 法律风险/侵权记录通过司法API查询诉讼状态
# 评分规则:无诉讼(10分), 已解决诉讼(7分), 未解决诉讼(0分)
lawsuit_status_text = "无诉讼" # 默认无诉讼
judicial_api_response = {} # 保存API原始返回用于日志
try: try:
judicial_data = universal_api.query_judicial_data(data.institution) judicial_data = universal_api.query_judicial_data(data.institution)
_data = judicial_data["data"].get("target", None) # 诉讼标的 _data = judicial_data.get("data", {})
if _data: judicial_api_response = _data # 保存原始返回
infringement_score = 0.0 target = _data.get("target", None) # 诉讼标的
total = _data.get("total", 0) # 诉讼总数
if target or total > 0:
# 有诉讼记录,检查是否已解决
settled = _data.get("settled", False)
if settled:
lawsuit_status_text = "已解决诉讼"
infringement_score = 7.0
else:
lawsuit_status_text = "未解决诉讼"
infringement_score = 0.0
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 {}
@ -113,16 +141,17 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
# 查询匹配申请号的记录集合 # 查询匹配申请号的记录集合
matched = [item for item in data_list if matched = [item for item in data_list if
isinstance(item, dict) and item.get("SQH") == getattr(data, 'patent_application_no', None)] isinstance(item, dict) and item.get("SQH") == getattr(data, 'patent_application_no', None)]
patent_matched_count = len(matched)
if matched: if matched:
patent_count_score = min(len(matched) * 2.5, 10.0) patent_count_score = min(len(matched) * 2.5, 10.0)
input_data_by_b1["patent_count"] = float(patent_count_score) input_data_by_b1["patent_count"] = float(patent_count_score)
else: else:
input_data_by_b1["patent_count"] = 0.0 input_data_by_b1["patent_count"] = 0.0
years_total = calculate_total_years(data_list) patent_years_total = calculate_total_years(data_list)
if years_total > 10: if patent_years_total > 10:
patent_score = 10.0 patent_score = 10.0
elif years_total >= 5: elif patent_years_total >= 5:
patent_score = 7.0 patent_score = 7.0
else: else:
patent_score = 3.0 patent_score = 3.0
@ -152,18 +181,7 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
} }
calculator = FinalValueACalculator() calculator = FinalValueACalculator()
# 先创建估值记录以获取ID方便步骤落库关联
initial_detail = await user_valuation_controller.create_valuation(
user_id=user_id,
data=data,
calculation_result=None,
calculation_input=None,
drp_result=None,
status='pending'
)
valuation_id = initial_detail.id
logger.info("valuation.init_created user_id={} valuation_id={}", user_id, valuation_id)
# 步骤1立即更新计算输入参数不管后续是否成功 # 步骤1立即更新计算输入参数不管后续是否成功
try: try:
await valuation_controller.update_calc( await valuation_controller.update_calc(
@ -187,10 +205,9 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
# 政策匹配度 # 政策匹配度
api_calc_fields["policy_matching"] = str(policy_match_score) if policy_match_score is not None else None api_calc_fields["policy_matching"] = str(policy_match_score) if policy_match_score is not None else None
# 侵权记录/法律风险 # 侵权记录/法律风险 - 使用实际查询到的诉讼状态
infringement_record_value = "有侵权记录" if infringement_score == 0.0 else "无侵权记录" api_calc_fields["infringement_record"] = lawsuit_status_for_display
api_calc_fields["infringement_record"] = infringement_record_value api_calc_fields["legal_risk"] = lawsuit_status_for_display
api_calc_fields["legal_risk"] = infringement_record_value
# 专利使用量 # 专利使用量
patent_count_value = input_data_by_b1.get("patent_count", 0.0) patent_count_value = input_data_by_b1.get("patent_count", 0.0)
@ -220,6 +237,130 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
except Exception as e: except Exception as e:
logger.warning("valuation.failed_to_update_api_calc_fields valuation_id={} err={}", valuation_id, repr(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)
@ -339,8 +480,12 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
api_calc_fields["esg_value"] = str(esg_score) if esg_score is not None else None api_calc_fields["esg_value"] = str(esg_score) if esg_score is not None else None
if 'policy_match_score' in locals(): if 'policy_match_score' in locals():
api_calc_fields["policy_matching"] = str(policy_match_score) if policy_match_score is not None else None api_calc_fields["policy_matching"] = str(policy_match_score) if policy_match_score is not None else None
if 'infringement_score' in locals(): if 'lawsuit_status_for_display' in locals():
infringement_record_value = "有侵权记录" if infringement_score == 0.0 else "无侵权记录" 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["infringement_record"] = infringement_record_value
api_calc_fields["legal_risk"] = infringement_record_value api_calc_fields["legal_risk"] = infringement_record_value
if 'input_data_by_b1' in locals(): if 'input_data_by_b1' in locals():
@ -468,17 +613,30 @@ async def calculate_valuation(
except Exception: except Exception:
pass pass
background_tasks.add_task(_perform_valuation_calculation, user_id, data) # 先创建估值记录以获取ID方便用户查询
initial_detail = await user_valuation_controller.create_valuation(
user_id=user_id,
data=data,
calculation_result=None,
calculation_input=None,
drp_result=None,
status='pending'
)
valuation_id = initial_detail.id
logger.info("valuation.task_queued user_id={} asset_name={} industry={}", background_tasks.add_task(_perform_valuation_calculation, user_id, valuation_id, data)
user_id, getattr(data, 'asset_name', None), getattr(data, 'industry', None))
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)
} }
) )
@ -525,8 +683,7 @@ async def _extract_calculation_params_b1(
innovation_ratio = 0.0 innovation_ratio = 0.0
# 流量因子B12相关参数 # 流量因子B12相关参数
# 近30天搜索指数S1 - 从社交媒体数据计算 TODO 需要使用第三方API # 近30天搜索指数S1 - 使用微信指数除以10计算
baidu_index = 1
# 获取微信指数并计算近30天平均值 # 获取微信指数并计算近30天平均值
try: try:
@ -535,10 +692,9 @@ 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 = 1 wechat_index = 10 # 失败时默认值,使得 S1 = 1
weibo_index = 1 search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10
search_index_s1 = calculate_search_index_s1(baidu_index, wechat_index, weibo_index) # 默认值实际应从API获取
# 行业均值S2 - 从数据库查询行业数据计算 # 行业均值S2 - 从数据库查询行业数据计算
from app.utils.industry_calculator import calculate_industry_average_s2 from app.utils.industry_calculator import calculate_industry_average_s2
@ -585,6 +741,7 @@ 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 目前未参与计算,先移除
@ -629,10 +786,18 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
kuaishou_views = safe_float(rs.get("kuaishou", None).get("likes", 0)) if rs.get("kuaishou", None) else 0 kuaishou_views = safe_float(rs.get("kuaishou", None).get("likes", 0)) if rs.get("kuaishou", None) else 0
bilibili_views = safe_float(rs.get("bilibili", None).get("likes", 0)) if rs.get("bilibili", None) else 0 bilibili_views = safe_float(rs.get("bilibili", None).get("likes", 0)) if rs.get("bilibili", None) else 0
# 跨界合作深度:将枚举映射为项目数;若为数值字符串则直接取数值 # 跨界合作深度:将枚举映射为分值
# 前端传入的是数字字符串 ("0", "1", "2", "3"),后端也支持中文标签
try: try:
val = getattr(data, 'cooperation_depth', None) val = getattr(data, 'cooperation_depth', None)
mapping = { mapping = {
# 前端传入的数字字符串
"0": 0.0, # 无
"1": 3.0, # 品牌联名
"2": 5.0, # 科技载体
"3": 10.0, # 国家外交礼品
# 兼容中文标签(以防其他入口传入)
"": 0.0,
"品牌联名": 3.0, "品牌联名": 3.0,
"科技载体": 5.0, "科技载体": 5.0,
"国家外交礼品": 10.0, "国家外交礼品": 10.0,
@ -647,14 +812,28 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
# 纹样基因值B22相关参数 # 纹样基因值B22相关参数
# 以下三项需由后续模型/服务计算;此处提供默认可计算占位 # 以下三项需由后续模型/服务计算;此处提供默认可计算占位
#
# 历史传承度HI(用户填写) # 历史传承度HI(用户填写)
# HI = 证据数量 × 对应权重后加总
# 权重分配:出土实物(1.0) + 古代文献(0.8) + 传承人佐证(0.6) + 现代研究(0.4)
# 示例: (2*1 + 5*0.8 + 5*0.6 + 6*0.4) = 11.4
historical_inheritance = 0.0 historical_inheritance = 0.0
try: try:
evidence_weights = {
"artifacts": 1.0, # 出土实物
"ancient_literature": 0.8, # 古代文献
"inheritor_testimony": 0.6, # 传承人佐证
"modern_research": 0.4, # 现代研究
}
if isinstance(data.historical_evidence, dict): if isinstance(data.historical_evidence, dict):
historical_inheritance = sum([safe_float(v) for v in data.historical_evidence.values()]) for key, weight in evidence_weights.items():
count = safe_float(data.historical_evidence.get(key, 0))
historical_inheritance += count * weight
elif isinstance(data.historical_evidence, (list, tuple)): elif isinstance(data.historical_evidence, (list, tuple)):
historical_inheritance = sum([safe_float(i) for i in data.historical_evidence]) # 列表顺序:[出土实物, 古代文献, 传承人佐证, 现代研究]
weights = [1.0, 0.8, 0.6, 0.4]
for i, weight in enumerate(weights):
if i < len(data.historical_evidence):
historical_inheritance += safe_float(data.historical_evidence[i]) * weight
except Exception: except Exception:
historical_inheritance = 0.0 historical_inheritance = 0.0
structure_complexity = 1.5 # 默认值 纹样基因熵值B22(系统计算) structure_complexity = 1.5 # 默认值 纹样基因熵值B22(系统计算)
@ -686,17 +865,36 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
} }
# 获取 文化价值B2 相关参数 # 获取 风险调整系数B3 相关参数
async def _extract_calculation_params_b3(data: UserValuationCreate) -> Dict[str, Any]: async def _extract_calculation_params_b3(data: UserValuationCreate) -> Dict[str, Any]:
# 过去30天最高价格 过去30天最低价格 TODO 需要根据字样进行切分获取最高价和最低价 转换成 float 类型 # 过去30天最高价格 过去30天最低价格
price_fluctuation = [float(i) for i in data.price_fluctuation] price_fluctuation = [float(i) for i in data.price_fluctuation]
highest_price, lowest_price = max(price_fluctuation), min(price_fluctuation) highest_price, lowest_price = max(price_fluctuation), min(price_fluctuation)
# lawsuit_status = "无诉讼" # 诉讼状态 TODO (API获取)
inheritor_ages = [float(i) for i in 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,
} }
@ -791,6 +989,7 @@ 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

View File

@ -276,11 +276,9 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
# 流量因子B12相关参数 # 流量因子B12相关参数
# 近30天搜索指数S1 - 从社交媒体数据计算 TODO 需要使用第三方API # 近30天搜索指数S1 - 使用微信指数除以10计算
baidu_index = 0.0 wechat_index = wechat_index_calculator.process_wechat_index_response(universal_api.wx_index(data.asset_name)) # 通过资产信息获取微信指数
wechat_index = wechat_index_calculator.process_wechat_index_response(universal_api.wx_index(data.asset_name)) # 通过资产信息获取微信指数 TODO 这里返回的没确认指数参数,有可能返回的图示是指数信息 search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10
weibo_index = 0.0
search_index_s1 = calculate_search_index_s1(baidu_index,wechat_index,weibo_index) # 默认值实际应从API获取
# 行业均值S2 TODO 系统内置 未找到相关内容 # 行业均值S2 TODO 系统内置 未找到相关内容
industry_average_s2 = 0.0 industry_average_s2 = 0.0
# 社交媒体传播度S3 - TODO 需要使用第三方API,click_count view_count 未找到对应参数 # 社交媒体传播度S3 - TODO 需要使用第三方API,click_count view_count 未找到对应参数
@ -344,8 +342,22 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
douyin_views = 0 douyin_views = 0
kuaishou_views= 0 kuaishou_views= 0
bilibili_views= 0 bilibili_views= 0
# 跨界合作深度 品牌联名0.3科技载体0.5国家外交礼品1.0 # 跨界合作深度:将枚举映射为分值
cross_border_depth = float(data.cooperation_depth) # 前端传入的是数字字符串 ("0", "1", "2", "3"),后端也支持中文标签
depth_mapping = {
# 前端传入的数字字符串
"0": 0.0, # 无
"1": 3.0, # 品牌联名
"2": 5.0, # 科技载体
"3": 10.0, # 国家外交礼品
# 兼容中文标签(以防其他入口传入)
"": 0.0,
"品牌联名": 3.0,
"科技载体": 5.0,
"国家外交礼品": 10.0,
}
depth_val = str(data.cooperation_depth) if data.cooperation_depth else "0"
cross_border_depth = depth_mapping.get(depth_val, 0.0)
# 纹样基因值B22相关参数 # 纹样基因值B22相关参数

View File

@ -135,6 +135,7 @@ async def verify_code(payload: VerifyCodeRequest) -> BasicResponse[dict]:
from app.settings import settings from app.settings import settings
if settings.SMS_BYPASS_CODE and payload.code == settings.SMS_BYPASS_CODE: if settings.SMS_BYPASS_CODE and payload.code == settings.SMS_BYPASS_CODE:
logger.info("sms.verify_code bypass phone={}", payload.phone) logger.info("sms.verify_code bypass phone={}", payload.phone)
store.mark_verified(payload.phone)
return Success(data={"status": "OK", "message": "verified"}) 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:
@ -154,6 +155,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 Success(data={"status": "OK", "message": "verified"}) return Success(data={"status": "OK", "message": "verified"})

View File

@ -21,6 +21,15 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
# 检查手机号是否已存在 # 检查手机号是否已存在
existing_user = await self.model.filter(phone=register_data.phone).first() existing_user = await self.model.filter(phone=register_data.phone).first()
if existing_user: if existing_user:
if getattr(existing_user, "is_deleted", False):
default_password = register_data.phone[-6:]
hashed_password = get_password_hash(default_password)
existing_user.is_deleted = False
existing_user.deleted_at = None
existing_user.is_active = True
existing_user.password = hashed_password
await existing_user.save()
return existing_user
raise HTTPException(status_code=400, detail="手机号已存在") raise HTTPException(status_code=400, detail="手机号已存在")
# 生成默认密码:手机号后六位 # 生成默认密码:手机号后六位
@ -42,7 +51,7 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
用户认证 用户认证
""" """
user = await self.model.filter( user = await self.model.filter(
phone=login_data.phone, is_active=True phone=login_data.phone, is_active=True, is_deleted=False
).first() ).first()
if not user: if not user:
@ -57,13 +66,13 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
""" """
根据ID获取用户 根据ID获取用户
""" """
return await self.model.filter(id=user_id, is_active=True).first() return await self.model.filter(id=user_id, is_active=True, is_deleted=False).first()
async def get_user_by_phone(self, phone: str) -> Optional[AppUser]: async def get_user_by_phone(self, phone: str) -> Optional[AppUser]:
""" """
根据手机号获取用户 根据手机号获取用户
""" """
return await self.model.filter(phone=phone, is_active=True).first() return await self.model.filter(phone=phone, is_active=True, is_deleted=False).first()
async def update_last_login(self, user_id: int) -> bool: async def update_last_login(self, user_id: int) -> bool:
""" """
@ -149,6 +158,27 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
return True return True
return False return False
async def delete_user_account(self, user_id: int) -> bool:
user = await self.model.filter(id=user_id).first()
if not user:
return False
user.is_active = False
user.is_deleted = True
user.deleted_at = datetime.now()
masked = f"deleted_{user.id}"
user.username = None
user.alias = None
user.email = None
user.password = ""
user.company_name = None
user.company_address = None
user.company_contact = None
user.company_phone = None
user.company_email = None
user.phone = masked
await user.save()
return True
# 创建控制器实例 # 创建控制器实例
app_user_controller = AppUserController() app_user_controller = AppUserController()

View File

@ -444,6 +444,30 @@ class ValuationController:
lines.append("```") lines.append("```")
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: if output_result:
# 提取主要结果值 # 提取主要结果值
@ -521,6 +545,14 @@ class ValuationController:
if not output_result or not isinstance(output_result, dict): if not output_result or not isinstance(output_result, dict):
return None 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 等元数据字段 # 移除 duration_ms 等元数据字段
filtered_result = {k: v for k, v in output_result.items() filtered_result = {k: v for k, v in output_result.items()
if k not in ['duration_ms', 'duration', 'timestamp', 'status']} if k not in ['duration_ms', 'duration', 'timestamp', 'status']}
@ -538,6 +570,47 @@ class ValuationController:
else: else:
return json.dumps(value, ensure_ascii=False) 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" 等 # 例如:"财务价值 F" -> 查找 "financial_value_f", "财务价值F" 等
# 提取公式名称中的关键部分(通常是最后一个字母或单词) # 提取公式名称中的关键部分(通常是最后一个字母或单词)
@ -577,14 +650,6 @@ class ValuationController:
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
return str(value) return str(value)
# 查找常见的结果字段
common_result_keys = ['result', 'value', 'output', 'final_value', 'calculated_value']
for key in common_result_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(): for key, value in filtered_result.items():
if isinstance(value, (int, float)): if isinstance(value, (int, float)):

View File

@ -0,0 +1,13 @@
from datetime import datetime
from typing import Optional
from app.models.token_blacklist import TokenBlacklist
async def add_to_blacklist(token: str, user_id: int, exp: Optional[datetime] = None, jti: Optional[str] = None) -> None:
await TokenBlacklist.create(token=token, user_id=user_id, exp=exp, jti=jti)
async def is_blacklisted(token: str) -> bool:
return await TokenBlacklist.filter(token=token).exists()

View File

@ -6,4 +6,5 @@ from .industry import *
from .policy import * 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

@ -0,0 +1,15 @@
from tortoise import fields
from .base import BaseModel, TimestampMixin
class TokenBlacklist(BaseModel, TimestampMixin):
token = fields.TextField(description="JWT令牌")
jti = fields.CharField(max_length=64, null=True, description="令牌唯一ID", index=True)
user_id = fields.IntField(description="用户ID", index=True)
exp = fields.DatetimeField(null=True, description="过期时间", index=True)
class Meta:
table = "token_blacklist"
table_description = "JWT令牌黑名单"

View File

@ -21,6 +21,8 @@ class AppUser(BaseModel, TimestampMixin):
last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True) last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True)
remaining_quota = fields.IntField(default=0, description="剩余估值次数", index=True) remaining_quota = fields.IntField(default=0, description="剩余估值次数", index=True)
notes = fields.CharField(max_length=256, null=True, description="备注") notes = fields.CharField(max_length=256, null=True, description="备注")
is_deleted = fields.BooleanField(default=False, description="是否已注销", index=True)
deleted_at = fields.DatetimeField(null=True, description="注销时间", index=True)
class Meta: class Meta:
table = "app_user" table = "app_user"
@ -38,4 +40,4 @@ class AppUserQuotaLog(BaseModel, TimestampMixin):
class Meta: class Meta:
table = "app_user_quota_log" table = "app_user_quota_log"
table_description = "App用户估值次数操作日志" table_description = "App用户估值次数操作日志"

View File

@ -28,6 +28,7 @@ class VerificationStore:
self.codes: Dict[str, Tuple[str, float]] = {} self.codes: Dict[str, Tuple[str, float]] = {}
self.sends: Dict[str, Dict[str, float]] = {} self.sends: Dict[str, Dict[str, float]] = {}
self.failures: Dict[str, Dict[str, float]] = {} self.failures: Dict[str, Dict[str, float]] = {}
self.verified: Dict[str, float] = {}
def generate_code(self) -> str: def generate_code(self) -> str:
"""生成数字验证码 """生成数字验证码
@ -144,5 +145,13 @@ class VerificationStore:
""" """
self.failures.pop(phone, None) self.failures.pop(phone, None)
def mark_verified(self, phone: str, ttl_seconds: int = 300) -> None:
until = time.time() + ttl_seconds
self.verified[phone] = until
store = VerificationStore() def is_recently_verified(self, phone: str) -> bool:
until = self.verified.get(phone, 0.0)
return until > time.time()
store = VerificationStore()

View File

@ -3,6 +3,7 @@ from typing import Optional
import jwt import jwt
from fastapi import HTTPException, status, Depends, Header from fastapi import HTTPException, status, Depends, Header
from app.controllers.app_user import app_user_controller from app.controllers.app_user import app_user_controller
from app.core.token_blacklist import is_blacklisted
from app.schemas.app_user import AppUserJWTPayload from app.schemas.app_user import AppUserJWTPayload
from app.settings import settings from app.settings import settings
@ -48,18 +49,24 @@ def verify_app_user_token(token: str) -> Optional[AppUserJWTPayload]:
return None return None
def get_current_app_user_id(token: str = Header(None)) -> int: async def get_current_app_user_id(token: str = Header(None)) -> int:
""" """
从令牌中获取当前AppUser ID 从令牌中获取当前AppUser ID
""" """
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据", detail="未登录,请重新登录",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if not token: if not token:
raise credentials_exception raise credentials_exception
if token and token != "dev":
try:
if await is_blacklisted(token):
raise credentials_exception
except Exception:
pass
payload = verify_app_user_token(token) payload = verify_app_user_token(token)
if payload is None: if payload is None:
@ -80,4 +87,4 @@ async def get_current_app_user(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在或已被停用" detail="用户不存在或已被停用"
) )
return user return user

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 = 传承年限权重 × 0.4 + 文化意义权重 × 0.3 + 保护状况权重 × 0.3 - 历史传承度HI = 出土实物×1.0 + 古代文献×0.8 + 传承人佐证×0.6 + 现代研究×0.4
- 纹样基因值B22 = (结构复杂度SC × 0.6 + 归一化信息熵H × 0.4) × 历史传承度HI × 10 - 纹样基因值B22 = (结构复杂度SC × 0.6 + 归一化信息熵H × 0.4) × 历史传承度HI × 10
- 文化价值B2 = 活态传承系数B21 × 0.6 + (纹样基因值B22 / 10) × 0.4 - 文化价值B2 = 活态传承系数B21 × 0.6 + (纹样基因值B22 / 10) × 0.4

View File

@ -277,26 +277,19 @@ def calculate_heat_score(daily_views: float, favorites: int) -> float:
return 0.0 return 0.0
# 30天搜索指数S1 # 30天搜索指数S1
def calculate_search_index_s1(baidu_index: float, def calculate_search_index_s1(wechat_index: float) -> float:
wechat_index: float,
weibo_index: float) -> float:
""" """
计算近30天搜索指数S1 计算近30天搜索指数S1
近30天搜索指数S1 = 百度搜索指数 × 0.4 + 微信搜索指数 × 0.3 + 微博搜索指数 × 0.3 近30天搜索指数S1 = 微信指数 / 10
args: args:
baidu_index: 百度搜索指数 (API获取)
wechat_index: 微信搜索指数 (API获取) wechat_index: 微信搜索指数 (API获取)
weibo_index: 微博搜索指数 (API获取)
returns: returns:
float: 近30天搜索指数S1 float: 近30天搜索指数S1
""" """
# search_index = wechat_index / 10.0
search_index = (baidu_index * 0.4 +
wechat_index * 0.3 +
weibo_index * 0.3)
return search_index return search_index
# 示例使用 # 示例使用
@ -306,10 +299,8 @@ if __name__ == "__main__":
processor = PlatformDataProcessor() processor = PlatformDataProcessor()
# 示例数据 # 示例数据
# 搜索指数数据 (API获取) # 微信指数数据 (API获取)
baidu_index = 6000.0
wechat_index = 4500.0 wechat_index = 4500.0
weibo_index = 3000.0
# 行业均值 (系统配置) # 行业均值 (系统配置)
industry_average = 5000.0 industry_average = 5000.0
@ -339,7 +330,7 @@ if __name__ == "__main__":
view_count = 200 view_count = 200
# 计算各项指标 # 计算各项指标
search_index_s1 = calculate_search_index_s1(baidu_index, wechat_index, weibo_index) search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10
interaction_index, coverage_index = processor.calculate_multi_platform_interaction(platform_data) interaction_index, coverage_index = processor.calculate_multi_platform_interaction(platform_data)
conversion_efficiency = calculator.calculate_conversion_efficiency(click_count, view_count) conversion_efficiency = calculator.calculate_conversion_efficiency(click_count, view_count)
# 互动量指数 × 0.4 + 覆盖人群指数 × 0.3 + 转化效率 × 0.3 # 互动量指数 × 0.4 + 覆盖人群指数 × 0.3 + 转化效率 × 0.3

View File

@ -231,6 +231,86 @@ FORMULA_TREE: List[FormulaTreeNode] = [
"40", "40",
group="DYNAMIC_PLEDGE", 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",
),
], ],
) )
] ]

View File

@ -222,7 +222,10 @@ class RiskAdjustmentB3Calculator:
valuation_id, valuation_id,
"MODEL_B_RISK_B3_INHERITANCE", "MODEL_B_RISK_B3_INHERITANCE",
status="completed", status="completed",
input_params={"inheritor_ages": input_data.get("inheritor_ages")}, input_params={
"inheritor_ages": input_data.get("inheritor_ages"),
"score_rule": "≤50岁:10分, 50-70岁:5分, >70岁:0分, 取最高分"
},
output_result={'inheritance_risk': inheritance_risk}, output_result={'inheritance_risk': inheritance_risk},
) )
@ -232,12 +235,30 @@ 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计算的关键值
print(f"=== B3计算调试 ===")
print(f"市场风险: {market_risk}")
print(f"法律风险: {legal_risk}")
print(f"传承风险: {inheritance_risk}")
print(f"风险评分总和R: {risk_score_sum}")
print(f"风险调整系数B3: {risk_adjustment_b3}")
print(f"=== B3计算完成 ===")
result = { result = {
"risk_value_b3": risk_adjustment_b3, "risk_value_b3": risk_adjustment_b3,
"risk_score_sum": risk_score_sum, "risk_score_sum": risk_score_sum,
"market_risk": market_risk, "market_risk": market_risk,
"legal_risk": legal_risk, "legal_risk": legal_risk,
"inheritance_risk": inheritance_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( await self.valuation_controller.log_formula_step(
valuation_id, valuation_id,

View File

@ -1,74 +0,0 @@
-- 完整菜单初始化SQL
-- 创建时间: 2025-11-20
-- 说明: 包含所有新增的菜单项和权限分配
-- ========================================
-- 1. 工作台菜单
-- ========================================
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(22, '工作台', 'menu', 'carbon:dashboard', '/workbench', 1, 0, 0, '/workbench', 1, NULL, datetime('now'), datetime('now'));
-- ========================================
-- 2. 交易管理菜单
-- ========================================
-- 插入一级目录:交易管理
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(16, '交易管理', 'catalog', 'carbon:receipt', '/transaction', 3, 0, 0, 'Layout', 0, '/transaction/invoice', datetime('now'), datetime('now'));
-- 插入二级菜单:交易管理
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(17, '交易管理', 'menu', 'carbon:document', 'invoice', 1, 16, 0, '/transaction/invoice', 0, NULL, datetime('now'), datetime('now'));
-- ========================================
-- 3. 估值管理菜单
-- ========================================
-- 插入一级目录:估值管理
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(18, '估值管理', 'catalog', 'carbon:calculator', '/valuation', 4, 0, 0, 'Layout', 0, '/valuation/audit', datetime('now'), datetime('now'));
-- 插入二级菜单:审核列表
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(19, '审核列表', 'menu', 'carbon:task-approved', 'audit', 1, 18, 0, '/valuation/audit', 0, NULL, datetime('now'), datetime('now'));
-- ========================================
-- 4. 用户管理菜单
-- ========================================
-- 插入一级目录:用户管理
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(20, '用户管理', 'catalog', 'carbon:user-multiple', '/user-management', 5, 0, 0, 'Layout', 0, '/user-management/user-list', datetime('now'), datetime('now'));
-- 插入二级菜单:用户列表
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(21, '用户列表', 'menu', 'carbon:user', 'user-list', 1, 20, 0, '/user-management/user-list', 0, NULL, datetime('now'), datetime('now'));
-- ========================================
-- 角色权限分配
-- ========================================
-- 为管理员角色(role_id=1)分配所有菜单权限
INSERT INTO role_menu (role_id, menu_id)
VALUES
(1, 22), -- 工作台
(1, 16), -- 交易管理
(1, 17), -- 交易管理
(1, 18), -- 估值管理
(1, 19), -- 审核列表
(1, 20), -- 用户管理
(1, 21); -- 用户列表
-- 为普通用户角色(role_id=2)分配基础菜单权限
INSERT INTO role_menu (role_id, menu_id)
VALUES
(2, 22), -- 工作台
(2, 16), -- 交易管理
(2, 17), -- 交易管理
(2, 18), -- 估值管理
(2, 19); -- 审核列表
-- 注意:普通用户不分配用户管理权限

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

View File

@ -346,6 +346,18 @@ async def main() -> None:
d_ok = (d_code == 200 and isinstance(d_data, dict) and d_data.get("data", {}).get("deleted")) d_ok = (d_code == 200 and isinstance(d_data, dict) and d_data.get("data", {}).get("deleted"))
results.append({"name": "删除估值", "status": "PASS" if d_ok else "FAIL", "message": "删除成功" if d_ok else "删除失败", "detail": {"http": d_code, "body": d_data}}) results.append({"name": "删除估值", "status": "PASS" if d_ok else "FAIL", "message": "删除成功" if d_ok else "删除失败", "detail": {"http": d_code, "body": d_data}})
# 注销账号
da_resp = await client.delete(make_url(base, "/app-user/account"), headers={"token": use_token})
da_code = da_resp.status_code
da_data = _ensure_dict(da_resp.json() if da_resp.headers.get("content-type", "").startswith("application/json") else {"raw": da_resp.text})
da_ok = (da_code == 200 and isinstance(da_data, dict))
results.append({"name": "注销账号", "status": "PASS" if da_ok else "FAIL", "message": "注销成功" if da_ok else "注销失败", "detail": {"http": da_code, "body": da_data}})
# 注销后旧token访问应失败
vt2_code, vt2_data = await api_get(client, make_url(base, "/app-user/validate-token"), headers={"token": use_token})
vt2_ok = (vt2_code in (401, 403))
results.append({"name": "注销后token访问", "status": "PASS" if vt2_ok else "FAIL", "message": "拒绝访问" if vt2_ok else "未拒绝", "detail": {"http": vt2_code, "body": vt2_data}})
# 登出 # 登出
lo_code, lo_data = await api_post_json(client, make_url(base, "/app-user/logout"), {}, headers={"token": use_token}) lo_code, lo_data = await api_post_json(client, make_url(base, "/app-user/logout"), {}, headers={"token": use_token})
lo_ok = (lo_code == 200) lo_ok = (lo_code == 200)
@ -378,4 +390,4 @@ async def api_put_json(client: httpx.AsyncClient, url: str, payload: Dict[str, A
data = r.json() data = r.json()
except Exception: except Exception:
data = {"raw": r.text} data = {"raw": r.text}
return r.status_code, data return r.status_code, data

View File

View File

@ -237,7 +237,8 @@ export const generateReport = async (detailData) => {
if (calcResult.model_value_b !== undefined) data.B = formatNumberValue(calcResult.model_value_b) if (calcResult.model_value_b !== undefined) data.B = formatNumberValue(calcResult.model_value_b)
if (calcResult.economic_value_b1 !== undefined) data.B1 = formatNumberValue(calcResult.economic_value_b1) if (calcResult.economic_value_b1 !== undefined) data.B1 = formatNumberValue(calcResult.economic_value_b1)
if (calcResult.cultural_value_b2 !== undefined) data.B2 = formatNumberValue(calcResult.cultural_value_b2) if (calcResult.cultural_value_b2 !== undefined) data.B2 = formatNumberValue(calcResult.cultural_value_b2)
if (calcResult.risk_adjustment_b3 !== undefined) data.B3 = calcResult.risk_adjustment_b3 if (calcResult.risk_value_b3 !== undefined) data.B3 = calcResult.risk_value_b3
else if (calcResult.risk_adjustment_b3 !== undefined) data.B3 = calcResult.risk_adjustment_b3
if (calcResult.market_value_c !== undefined) data.C = formatNumberValue(calcResult.market_value_c) if (calcResult.market_value_c !== undefined) data.C = formatNumberValue(calcResult.market_value_c)
// ========== 6.7 添加动态质押率DPR ========== // ========== 6.7 添加动态质押率DPR ==========

BIN
web1/dist 2.zip Normal file

Binary file not shown.

View File

@ -37,8 +37,8 @@
export DOCKER_DEFAULT_PLATFORM=linux/amd64 export DOCKER_DEFAULT_PLATFORM=linux/amd64
docker build -t zfc931912343/guzhi-fastapi-admin:v3.1 . docker build -t zfc931912343/guzhi-fastapi-admin:v3.8 .
docker push zfc931912343/guzhi-fastapi-admin:v3.1 docker push zfc931912343/guzhi-fastapi-admin:v3.8
# 运行容器 # 运行容器
@ -68,12 +68,12 @@ docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.4 &&
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.4 && docker rm -f guzhi && docker run -itd --name=guzhi -p 8080:80 -v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped --memory=2g --cpus=1.0 -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.4 docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.8 && docker rm -f guzhi && docker run -itd --name=guzhi -p 8080:80 -v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped --memory=2g --cpus=1.0 -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.8
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.7 && docker rm -f guzhi_dev && docker run -itd --name=guzhi_dev -p 9990:80 -v ~/guzhi-data-dev/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.7 docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.7 && docker rm -f guzhi_dev && docker run -itd --name=guzhi_dev -p 9990:80 -v ~/guzhi-data-dev/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.7
1 1
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.1 && docker rm -f guzhi_pro && docker run -itd --name=guzhi_pro -p 8080:80 -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.1 docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.8 && 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.8
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.2 && 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.2 docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.3 && 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.3

225
部署文档.md Normal file
View File

@ -0,0 +1,225 @@
# 非遗资产估值系统 - 部署文档
## 项目概述
非遗资产估值系统是一个基于 Vue.js + FastAPI 的全栈应用,用于非物质文化遗产资产的价值评估。
- **前端**: Vue.js + Vite + pnpm
- **后端**: Python 3.11 + FastAPI + Tortoise ORM
- **数据库**: MySQL
- **容器化**: Docker
---
## 目录结构
```
youshu-guzhi/
├── app/ # 后端 FastAPI 应用
│ ├── api/ # API 路由
│ ├── controllers/ # 业务控制器
│ ├── models/ # 数据库模型
│ ├── schemas/ # Pydantic 数据模型
│ ├── settings/ # 配置文件
│ └── utils/ # 工具函数和计算引擎
├── web/ # 前端 Vue.js 应用
├── deploy/ # 部署相关文件
│ ├── entrypoint.sh # 容器启动脚本
│ └── web.conf # Nginx 配置
├── Dockerfile # Docker 构建文件
├── requirements.txt # Python 依赖
└── run.py # 应用启动入口
```
---
## 环境配置
### 数据库配置
#### 使用 Docker 部署 MySQL
```bash
# 创建数据目录
mkdir -p ~/mysql-data
# 启动 MySQL 容器
docker run -d \
--name mysql-valuation \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=your_password \
-e MYSQL_DATABASE=valuation_service \
-v ~/mysql-data:/var/lib/mysql \
--restart=unless-stopped \
mysql:8.0
```
#### 应用配置
配置文件位置: `app/settings/config.py`
```python
TORTOISE_ORM = {
"connections": {
"mysql": {
"engine": "tortoise.backends.mysql",
"credentials": {
"host": "your_mysql_host", # 数据库主机地址
"port": 3306, # 数据库端口
"user": "root", # 数据库用户名
"password": "your_password", # 数据库密码
"database": "valuation_service", # 数据库名称
},
},
},
...
}
```
### 第三方服务配置
| 服务 | 配置项 | 说明 |
|-----|-------|------|
| 阿里云短信 | `ALIBABA_CLOUD_ACCESS_KEY_ID/SECRET` | 短信验证码发送 |
| 阿里云邮件 | `SMTP_*` | 邮件发送 |
---
## 本地开发
### 1. 安装依赖
```bash
# 安装 Python 依赖
pip install -r requirements.txt
# 安装前端依赖
cd web
pnpm install
```
### 2. 启动服务
```bash
# 启动后端 (端口 9999)
python run.py
# 启动前端开发服务器 (另一个终端)
cd web
pnpm run dev
```
---
## Docker 部署
### 1. 构建镜像
```bash
# 设置平台 (M1/M2 Mac 需要)
export DOCKER_DEFAULT_PLATFORM=linux/amd64
# 构建镜像
docker build -t zfc931912343/guzhi-fastapi-admin:v3.9 .
# 推送到 Docker Hub
docker push zfc931912343/guzhi-fastapi-admin:v3.9
```
### 2. 部署到服务器
#### 生产环境
```bash
# 创建数据目录
mkdir -p ~/guzhi-data/static/images
# 拉取并运行
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
&& docker rm -f guzhi_pro \
&& docker run -itd \
--name=guzhi_pro \
-p 8080:9999 \
-v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images \
--restart=unless-stopped \
-e TZ=Asia/Shanghai \
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
```
#### 开发/测试环境
```bash
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
&& docker rm -f guzhi_dev \
&& docker run -itd \
--name=guzhi_dev \
-p 9990:9999 \
-v ~/guzhi-data/static:/opt/vue-fastapi-admin/app/static \
--restart=unless-stopped \
-e TZ=Asia/Shanghai \
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
```
---
## 端口说明
| 环境 | 容器名 | 主机端口 | 容器端口 |
|-----|-------|---------|---------|
| 生产 | guzhi_pro | 8080 | 9999 |
| 开发 | guzhi_dev | 9990 | 9999 |
---
## 数据持久化
容器挂载的数据目录:
```
~/guzhi-data/static/images -> /opt/vue-fastapi-admin/app/static/images
```
用于存储用户上传的图片文件(如非遗纹样图片、证书图片等)。
---
## 常用运维命令
```bash
# 查看容器日志
docker logs -f guzhi_pro
# 进入容器
docker exec -it guzhi_pro bash
# 重启容器
docker restart guzhi_pro
# 查看容器状态
docker ps | grep guzhi
```
---
## API 接口说明
| 模块 | 路径前缀 | 说明 |
|-----|---------|------|
| 用户端估值 | `/api/v1/app-valuations/` | 用户提交估值请求 |
| 管理端估值 | `/api/v1/valuations/` | 管理后台查看/审核 |
| 计算报告 | `/api/v1/valuations/{id}/report` | 获取计算过程报告 |
---
## 版本历史
| 版本 | 日期 | 说明 |
|-----|------|------|
| v3.9 | 2025-12-18 | 修复风险调整系数B3显示问题添加计算过程详情 |
| v3.8 | 2025-12-18 | 修复历史传承度HI权重计算 |
---
## 联系信息
如有问题,请联系项目负责人。

View File

@ -1,186 +0,0 @@
# 需求文档
### 1.1 项目范围
包含范围:
- 非遗IP价值评估计算引擎
- 用户管理和权限控制系统
- 评估申请和审核流程
- 评估报告生成
- 第三方支付集成
- 第三方登录集成
- 第三方数据集成
## 2. 用户角色定义
### 2.1 管理端用户
#### 2.1.1 系统管理员
角色描述:负责系统整体管理和维护
主要职责:
- 用户账号管理和权限分配
- 系统配置和参数设置
- 基础数据维护行业、ESG、政策等
- 系统监控和日志管理
- 第三方API配置管理
权限范围:
- 所有功能模块的完整访问权限
- 用户创建、编辑、删除权限
- 系统配置修改权限
- 数据导入导出权限
#### 2.1.2 业务审核员
角色描述:负责评估申请的审核和质量控制
主要职责:
- 评估申请的初步审核
- 数据完整性和合理性检查
- 计算结果的人工复核
- 评估报告的审批发布
- 异常情况的处理
权限范围:
- 评估申请查看和审核权限
- 审核状态修改权限
- 审核备注添加权限
- 报告生成和发布权限
### 2.2 应用端用户
#### 2.2.1 个人用户
角色描述:非遗传承人、文化工作者等个人申请者
主要需求:
- 提交个人非遗资产评估申请
- 查看评估进度和结果
- 下载评估报告
- 管理个人信息
## 3. 功能需求
### 3.1 用户管理系统
#### 3.1.1 用户注册登录
功能描述:提供用户注册、登录、密码管理功能
详细需求:
FR-001 手机号注册
- 用户可使用手机号进行注册
- 支持短信验证码验证
- 注册时需填写基本信息(姓名、机构等)
- 系统自动分配默认权限
FR-002 手机号登录
- 支持手机号+密码登录
- 支持手机号+验证码登录
#### 3.1.2 权限管理
功能描述基于RBAC模型的权限控制系统
详细需求:
FR-004 角色管理
- 支持创建、编辑、删除角色
- 角色可分配菜单权限和API权限
- 预设系统管理员、审核员、普通用户角色
### 3.2 估值评估系统
#### 3.2.1 评估申请提交
功能描述:用户提交非遗资产评估申请的完整流程
#### 3.2.2 评估结果管理
功能描述:评估结果的存储、展示和管理
详细需求:
FR-015 结果存储
- 完整计算过程和中间结果保存
- 输入参数和输出结果关联存储
- 计算时间和版本信息记录
FR-016 结果展示
- 估值结果可视化展示
- 计算过程分步骤展示
- 各维度得分雷达图展示
- 风险评估结果展示
FR-017 报告生成
- 自动生成详细评估报告
- 支持PDF格式导出
- 报告包含计算过程和结论
### 3.3 审核管理系统
### 3.4 数据管理系统
#### 3.4.1 基础数据管理
功能描述:系统基础数据的维护和管理
详细需求:
FR-024 行业数据管理
- 行业分类标准维护
- 行业ROE系数管理
FR-025 ESG数据管理
- ESG评级标准维护
- 行业ESG基准分管理
FR-026 政策数据管理
- 政策匹配规则维护
- 资助政策数据库管理
## 4. 移动端开发
### 4.1 微信小程序开发
#### 4.1.1 功能范围
功能描述:基于微信小程序平台的移动端应用开发
详细需求:
FR-027 用户认证
- 支持微信授权登录
- 支持手机号快速登录
- 与PC端用户体系统一
- 自动获取微信用户基本信息
FR-028 评估申请
- 移动端评估表单提交
- 支持拍照上传证书材料
- 表单数据与PC端保持一致
- 支持草稿保存和续填
FR-029 进度查询
- 实时查看评估申请状态
- 接收微信消息推送通知
- 查看评估结果和报告
- 支持报告分享功能