Compare commits
131 Commits
dev-featur
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45aae516b2 | ||
|
|
4110dca428 | ||
| f17c1678c8 | |||
| 1157704d4b | |||
|
|
17b56a1c19 | ||
|
|
6718b51fb9 | ||
|
|
58f16be457 | ||
|
|
90c0f85972 | ||
|
|
7819c60ace | ||
|
|
1d73f6ed54 | ||
|
|
6b5967a4bb | ||
|
|
8926e047d4 | ||
| f1c1db580c | |||
| b10c357a56 | |||
|
|
cd8170ac02 | ||
| 01ed8fb25b | |||
|
|
253ed14c87 | ||
|
|
17d275e18c | ||
|
|
20d8f155c6 | ||
|
|
5093cf8146 | ||
|
|
e7e31213da | ||
|
|
4e2296300b | ||
| 9c219cda83 | |||
|
|
0b2824c3b0 | ||
|
|
81653a257d | ||
|
|
1c493965d0 | ||
|
|
7ded549269 | ||
|
|
f2068c7b16 | ||
|
|
d40e8c2e0d | ||
|
|
a33a80063d | ||
| 0021c94024 | |||
|
|
803106ecf2 | ||
|
|
9d08b3d2cb | ||
|
|
6f99611a0c | ||
| 7df2bde70b | |||
| 97b872aa9b | |||
|
|
df35a1a5bf | ||
|
|
cd4d00b013 | ||
|
|
633f733f1c | ||
|
|
64334ba4e3 | ||
| 99f411b31a | |||
|
|
1b64f7c1fd | ||
|
|
cf19b56b6d | ||
|
|
05f9c02517 | ||
| 5ca0152c55 | |||
| c7e191f096 | |||
|
|
cdc4253a10 | ||
|
|
dc218e3d0e | ||
| 4cac09cf57 | |||
|
|
fd07c81d4b | ||
|
|
7612792e08 | ||
|
|
823230cb2d | ||
| a187d0e8fa | |||
|
|
d98330d5ce | ||
| e99febec4e | |||
|
|
782b433821 | ||
| 3c03eaf353 | |||
| 831560592f | |||
| b0c80c500f | |||
| 3328439241 | |||
|
|
f6a84442d1 | ||
| c15f3e9925 | |||
| 27b6276cdc | |||
|
|
5059e57f19 | ||
|
|
f4714f6ca6 | ||
| d347f1b4c9 | |||
| df7ff7af23 | |||
| 9359666805 | |||
|
|
e990b0eb68 | ||
|
|
7c59d3385a | ||
|
|
5332324b10 | ||
|
|
4b945339d0 | ||
|
|
8972ead5b9 | ||
|
|
2b917878ee | ||
| b1f02e6b7a | |||
| d4b2c801f4 | |||
| 552c02516a | |||
|
|
0694ec28fb | ||
|
|
9657901339 | ||
| 01cdcec0b4 | |||
| de8c4e9cab | |||
|
|
f6243a66a0 | ||
|
|
10ed15d983 | ||
| 19ec7ca25a | |||
|
|
695769076a | ||
|
|
db31e25c61 | ||
| c19b0167e4 | |||
|
|
28a5607dad | ||
|
|
3db6b38e2e | ||
| 62d9fb8516 | |||
| efe2ec6416 | |||
|
|
6432325387 | ||
|
|
9e821e1bd2 | ||
| e803102263 | |||
| c690a95cab | |||
|
|
728ff095cb | ||
| 17a7da123d | |||
|
|
d9c6150ae1 | ||
|
|
def0d75840 | ||
|
|
948ac409df | ||
|
|
1d71f5b8bf | ||
|
|
1a4880ef4a | ||
| 5ad541cf9e | |||
|
|
e2c8744d14 | ||
| ea0f03d0d7 | |||
|
|
a148b94dff | ||
|
|
1b56856995 | ||
|
|
3ccbaef170 | ||
|
|
77af212fc2 | ||
|
|
ba2bc6c53e | ||
|
|
8b17d74359 | ||
|
|
97ce7c5ccc | ||
|
|
85f8fbe694 | ||
|
|
e479bcfa97 | ||
| 1dd9a313e6 | |||
| f536178428 | |||
|
|
a0e857b115 | ||
|
|
2ff5421c27 | ||
|
|
7c64f0c76a | ||
|
|
b050b1c875 | ||
| c905d2492b | |||
| cc352d3184 | |||
|
|
24bf93f0b3 | ||
|
|
671504bb33 | ||
|
|
62354c9e23 | ||
|
|
26701c25cf | ||
|
|
2ec91a6085 | ||
|
|
0eff495f75 | ||
|
|
ee580ff22c | ||
|
|
bde761864b | ||
|
|
2ce82f3401 |
@ -1 +1,3 @@
|
||||
web/node_modules
|
||||
web1/node_modules
|
||||
migrations
|
||||
54
.github/copilot-instructions.md
vendored
@ -1,54 +0,0 @@
|
||||
## 项目(快速)指导 — 供 AI 编码代理使用
|
||||
|
||||
下面的要点帮助你快速理解并在本代码库中高效工作。保持简短、具体并以可执行示例为主。
|
||||
|
||||
- 项目类型:FastAPI 后端 (Python 3.11) + Vue3/Vite 前端(目录 `web/`)。后端使用 Tortoise ORM(配置在 `app/settings/config.py`),前端用 pnpm/vite。
|
||||
|
||||
- 快速启动(后端):在项目根目录
|
||||
- 建议 Python venv,然后安装依赖:`pip install -r requirements.txt`(或使用项目 README 中的 uv/uvenv 过程)。
|
||||
- 启动:`python run.py`。这会通过 `uvicorn` 运行 `app:app`(见 `run.py`),开启 `reload=True`,OpenAPI 在 `/docs`。
|
||||
|
||||
- 快速启动(前端):进入 `web/`,使用 pnpm(或 npm)安装并运行:`pnpm i`,`pnpm dev`。
|
||||
|
||||
- 后端关键入口
|
||||
- `run.py`:应用启动脚本,设置 uvicorn 日志格式并运行 `app:app`。
|
||||
- `app/__init__.py`:创建 FastAPI app(调用 `core/init_app.py` 中的注册函数):init 数据、注册中间件、异常处理与路由(路由前缀为 `/api`)。
|
||||
- `app/core/init_app.py`(注意:此文件包含启动时的路由/中间件/异常注册逻辑,请优先阅读它来理解请求生命周期)。
|
||||
|
||||
- 重要配置点
|
||||
- `app/settings/config.py`:使用 Pydantic Settings,包含 `TORTOISE_ORM`(默认 SQLite,db 文件在项目根 `db.sqlite3`)、JWT、SECRET_KEY、CORS 等。修改环境变量即可覆盖设置。
|
||||
- `app/utils/api_config.py`:提供 `api_config` 全局实例,用来存放第三方 API(示例:`chinaz`、`xiaohongshu`)。常用方法:`api_config.get_api_key(provider)`、`get_endpoint_config(provider, endpoint)`、`add_endpoint(...)`、`save_config()`。
|
||||
|
||||
- 路由与模块约定
|
||||
- API 版本化:`app/api/v1/` 下放置 v1 接口。路由统一由 `core/init_app.py` 通过 `register_routers(..., prefix='/api')` 注册。
|
||||
- 控制器(HTTP handlers)位于 `app/controllers/`,数据模型在 `app/models/`,Pydantic schemas 在 `app/schemas/`。
|
||||
|
||||
- 数据库与迁移
|
||||
- 使用 Tortoise ORM,`TORTOISE_ORM` 在 `app/settings/config.py`。项目把 `aerich.models` 列入 models(见配置),repository 中存在 `migrations/` 文件夹。若需变更模型,按项目现有工具链(如 aerich)执行迁移;在不确定时,先检查 `pyproject.toml`/`requirements.txt` 是否包含 aerich 并复核 README。
|
||||
|
||||
- 日志与持久化
|
||||
- 日志目录:`app/logs`(可在 `settings.LOGS_ROOT` 找到)。运行时可根据 `run.py` 中的 LOGGING_CONFIG 调整格式。
|
||||
|
||||
- 第三方 API 集成(示例)
|
||||
- `api_config` 示例用法(Python):
|
||||
```py
|
||||
from app.utils.api_config import api_config
|
||||
cfg = api_config.get_endpoint_config('xiaohongshu', 'xiaohongshu_note_detail')
|
||||
base = api_config.get_base_url('xiaohongshu')
|
||||
key = api_config.get_api_key('xiaohongshu')
|
||||
```
|
||||
- 环境变量覆盖:CHINAZ_API_KEY、XIAOHONGSHU_TOKEN、EXAMPLE_API_KEY 等会被 `api_config` 或 settings 读取。
|
||||
|
||||
- 编辑/贡献约定(可自动推断的现有模式)
|
||||
- 新增 API:在 `app/api/v1/...` 添加路由模块,控制器放 `app/controllers/`,schema 放 `app/schemas/`,并在 `core/init_app.py` 中确保路由被注册。
|
||||
- 新增模型:更新 `app/models/` 并生成迁移(项目使用 Tortoise + aerich 风格)。先检查 `migrations/models` 是否有对应变更。
|
||||
|
||||
- 调试提示
|
||||
- 本地运行时使用 `python run.py`(reload=True),然后访问 `http://localhost:9999/docs` 查看 OpenAPI,确认路由/依赖注入是否按预期工作。
|
||||
- 常见故障点:环境变量未设置(导致 API keys 丢失)、Tortoise 连接配置错误(检查 `TORTOISE_ORM.connections`)、以及中间件注册顺序会影响异常处理。
|
||||
|
||||
- 其它注意事项(小而具体)
|
||||
- 前端以 `/api` 为后端前缀,修改后端接口时请同步前端 `web/src/api` 的调用。
|
||||
- `app/utils/api_config.py` 会在模块导入时创建 `api_config` 单例;修改该文件时注意导入时机(不要在模块顶层做阻塞网络调用)。
|
||||
|
||||
如果需要我把 README 中的启动说明转成更精确的 shell 命令(或添加 aerich 的迁移示例命令),我可以继续补充。请告诉我你希望强调的额外部分或需要澄清的地方。
|
||||
23
AGENTS.md
@ -1,23 +0,0 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
FastAPI backend code lives in `app/`: routers under `app/api/v1`, orchestration in `app/controllers`, schemas/models in `app/schemas` and `app/models`, and shared helpers in `app/utils`. Config defaults stay in `app/settings/config.py`, migrations in `migrations/`, and the service boots through `run.py`. Frontend assets reside in `web/` with source code in `web/src`, static files in `web/public`, and build toggles in `web/settings`; deployment collateral sits in `deploy/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `make install` (uv) or `pip install -r requirements.txt` prepares backend deps; `pnpm i` handles `web/`.
|
||||
- `make start` / `python run.py` launches the API against `db.sqlite3`; `cd web && pnpm dev` starts the SPA; `pnpm build` prepares production assets.
|
||||
- `make check` runs Black+isort in check mode plus Ruff; `make format` applies fixes; `make lint` is Ruff-only.
|
||||
- `make test` loads `.env` variables into the shell and executes `pytest -vv -s`; target files with `pytest tests/api/test_x.py -k keyword`.
|
||||
- Database maintenance: `make migrate` (generate Aerich migrations), `make upgrade` (apply), `make clean-db` (reset SQLite + migrations).
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Python follows Black (120 columns), isort’s Black profile, and Ruff; keep modules snake_case and Pydantic models PascalCase. Vue code respects the repo ESLint + UnoCSS presets, uses TypeScript script blocks, and keeps component directories kebab-case; run `pnpm lint` or `pnpm lint:fix` as needed.
|
||||
|
||||
## Testing Guidelines
|
||||
Back-end features need pytest coverage mirroring the `app` layout—e.g., `tests/api/v1/test_users.py` for router logic and async tests following the patterns in `test_dynamic_default.py`. Seed deterministic data via fixtures instead of the shared `db.sqlite3`, and document any `.env` flags a test requires. Frontend changes should gain vitest or Playwright checks under `web/tests` before UI regressions reach `main`.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Stick to Conventional Commit prefixes already present (`feat:`, `refactor:`, `debug:`) and keep subject lines imperative with optional scopes (`feat(api):`). Each PR must summarize changes, list verification commands, reference related issues, and attach UI screenshots/GIFs when touching `web/`. Run `make check` and relevant tests locally, avoid committing `web/dist` or SQLite WAL files, and prefer small, reviewable diffs.
|
||||
|
||||
## Security & Configuration Tips
|
||||
Secrets belong in `.env`, which `app/settings/config.py` loads automatically; rotate `SECRET_KEY`, JWT parameters, and database credentials before deployment. Swap the Tortoise connection from SQLite to MySQL/PostgreSQL by editing the provided templates and running `make migrate && make upgrade`. Lock down CORS (`CORS_ORIGINS`) before exposing the API publicly.
|
||||
225
DEPLOYMENT.md
Normal file
@ -0,0 +1,225 @@
|
||||
# 非遗资产估值系统 - 部署文档
|
||||
|
||||
## 项目概述
|
||||
|
||||
非遗资产估值系统是一个基于 Vue.js + FastAPI 的全栈应用,用于非物质文化遗产资产的价值评估。
|
||||
|
||||
- **前端**: Vue.js + Vite + pnpm
|
||||
- **后端**: Python 3.11 + FastAPI + Tortoise ORM
|
||||
- **数据库**: MySQL
|
||||
- **容器化**: Docker
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
youshu-guzhi/
|
||||
├── app/ # 后端 FastAPI 应用
|
||||
│ ├── api/ # API 路由
|
||||
│ ├── controllers/ # 业务控制器
|
||||
│ ├── models/ # 数据库模型
|
||||
│ ├── schemas/ # Pydantic 数据模型
|
||||
│ ├── settings/ # 配置文件
|
||||
│ └── utils/ # 工具函数和计算引擎
|
||||
├── web/ # 前端 Vue.js 应用
|
||||
├── deploy/ # 部署相关文件
|
||||
│ ├── entrypoint.sh # 容器启动脚本
|
||||
│ └── web.conf # Nginx 配置
|
||||
├── Dockerfile # Docker 构建文件
|
||||
├── requirements.txt # Python 依赖
|
||||
└── run.py # 应用启动入口
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 数据库配置
|
||||
|
||||
#### 使用 Docker 部署 MySQL
|
||||
|
||||
```bash
|
||||
# 创建数据目录
|
||||
mkdir -p ~/mysql-data
|
||||
|
||||
# 启动 MySQL 容器
|
||||
docker run -d \
|
||||
--name mysql-valuation \
|
||||
-p 3306:3306 \
|
||||
-e MYSQL_ROOT_PASSWORD=your_password \
|
||||
-e MYSQL_DATABASE=valuation_service \
|
||||
-v ~/mysql-data:/var/lib/mysql \
|
||||
--restart=unless-stopped \
|
||||
mysql:8.0
|
||||
```
|
||||
|
||||
#### 应用配置
|
||||
|
||||
配置文件位置: `app/settings/config.py`
|
||||
|
||||
```python
|
||||
TORTOISE_ORM = {
|
||||
"connections": {
|
||||
"mysql": {
|
||||
"engine": "tortoise.backends.mysql",
|
||||
"credentials": {
|
||||
"host": "your_mysql_host", # 数据库主机地址
|
||||
"port": 3306, # 数据库端口
|
||||
"user": "root", # 数据库用户名
|
||||
"password": "your_password", # 数据库密码
|
||||
"database": "valuation_service", # 数据库名称
|
||||
},
|
||||
},
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 第三方服务配置
|
||||
|
||||
| 服务 | 配置项 | 说明 |
|
||||
|-----|-------|------|
|
||||
| 阿里云短信 | `ALIBABA_CLOUD_ACCESS_KEY_ID/SECRET` | 短信验证码发送 |
|
||||
| 阿里云邮件 | `SMTP_*` | 邮件发送 |
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# 安装 Python 依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 安装前端依赖
|
||||
cd web
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
# 启动后端 (端口 9999)
|
||||
python run.py
|
||||
|
||||
# 启动前端开发服务器 (另一个终端)
|
||||
cd web
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker 部署
|
||||
|
||||
### 1. 构建镜像
|
||||
|
||||
```bash
|
||||
# 设置平台 (M1/M2 Mac 需要)
|
||||
export DOCKER_DEFAULT_PLATFORM=linux/amd64
|
||||
|
||||
# 构建镜像
|
||||
docker build -t zfc931912343/guzhi-fastapi-admin:v3.9 .
|
||||
|
||||
# 推送到 Docker Hub
|
||||
docker push zfc931912343/guzhi-fastapi-admin:v3.9
|
||||
```
|
||||
|
||||
### 2. 部署到服务器
|
||||
|
||||
#### 生产环境
|
||||
|
||||
```bash
|
||||
# 创建数据目录
|
||||
mkdir -p ~/guzhi-data/static/images
|
||||
|
||||
# 拉取并运行
|
||||
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
|
||||
&& docker rm -f guzhi_pro \
|
||||
&& docker run -itd \
|
||||
--name=guzhi_pro \
|
||||
-p 8080:9999 \
|
||||
-v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images \
|
||||
--restart=unless-stopped \
|
||||
-e TZ=Asia/Shanghai \
|
||||
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
|
||||
```
|
||||
|
||||
#### 开发/测试环境
|
||||
|
||||
```bash
|
||||
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
|
||||
&& docker rm -f guzhi_dev \
|
||||
&& docker run -itd \
|
||||
--name=guzhi_dev \
|
||||
-p 9990:9999 \
|
||||
-v ~/guzhi-data/static:/opt/vue-fastapi-admin/app/static \
|
||||
--restart=unless-stopped \
|
||||
-e TZ=Asia/Shanghai \
|
||||
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 端口说明
|
||||
|
||||
| 环境 | 容器名 | 主机端口 | 容器端口 |
|
||||
|-----|-------|---------|---------|
|
||||
| 生产 | guzhi_pro | 8080 | 9999 |
|
||||
| 开发 | guzhi_dev | 9990 | 9999 |
|
||||
|
||||
---
|
||||
|
||||
## 数据持久化
|
||||
|
||||
容器挂载的数据目录:
|
||||
|
||||
```
|
||||
~/guzhi-data/static/images -> /opt/vue-fastapi-admin/app/static/images
|
||||
```
|
||||
|
||||
用于存储用户上传的图片文件(如非遗纹样图片、证书图片等)。
|
||||
|
||||
---
|
||||
|
||||
## 常用运维命令
|
||||
|
||||
```bash
|
||||
# 查看容器日志
|
||||
docker logs -f guzhi_pro
|
||||
|
||||
# 进入容器
|
||||
docker exec -it guzhi_pro bash
|
||||
|
||||
# 重启容器
|
||||
docker restart guzhi_pro
|
||||
|
||||
# 查看容器状态
|
||||
docker ps | grep guzhi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 接口说明
|
||||
|
||||
| 模块 | 路径前缀 | 说明 |
|
||||
|-----|---------|------|
|
||||
| 用户端估值 | `/api/v1/app-valuations/` | 用户提交估值请求 |
|
||||
| 管理端估值 | `/api/v1/valuations/` | 管理后台查看/审核 |
|
||||
| 计算报告 | `/api/v1/valuations/{id}/report` | 获取计算过程报告 |
|
||||
|
||||
---
|
||||
|
||||
## 版本历史
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|-----|------|------|
|
||||
| v3.9 | 2025-12-18 | 修复风险调整系数B3显示问题,添加计算过程详情 |
|
||||
| v3.8 | 2025-12-18 | 修复历史传承度HI权重计算 |
|
||||
|
||||
---
|
||||
|
||||
## 联系信息
|
||||
|
||||
如有问题,请联系项目负责人。
|
||||
10
Dockerfile
@ -1,8 +1,14 @@
|
||||
FROM node:18.12.0-alpine3.16 AS web
|
||||
FROM node:18-alpine AS web
|
||||
|
||||
WORKDIR /opt/vue-fastapi-admin
|
||||
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
|
||||
|
||||
83
aaa.json
@ -1,83 +0,0 @@
|
||||
{
|
||||
"asset_name": "资产名称",
|
||||
"institution": "所属机构",
|
||||
"industry": "农业",
|
||||
"annual_revenue": "22",
|
||||
"rd_investment": "33",
|
||||
"three_year_income": [
|
||||
"11",
|
||||
"22",
|
||||
"33"
|
||||
],
|
||||
"funding_status": "国家级资助",
|
||||
"sales_volume": "22",
|
||||
"link_views": "22",
|
||||
"circulation": "0",
|
||||
"last_market_activity": "0",
|
||||
"monthly_transaction": "0",
|
||||
"price_fluctuation": [
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"application_maturity": "0",
|
||||
"application_coverage": "0",
|
||||
"cooperation_depth": "1",
|
||||
"offline_activities": "3",
|
||||
"online_accounts": [
|
||||
"0",
|
||||
"333"
|
||||
],
|
||||
"inheritor_level": "国家级传承人",
|
||||
"inheritor_age_count": [
|
||||
"55",
|
||||
"66",
|
||||
"77"
|
||||
],
|
||||
"inheritor_certificates": [
|
||||
"http://example.com/国家级非遗传承人证书.jpg"
|
||||
],
|
||||
"heritage_level": "0",
|
||||
"historical_evidence": {
|
||||
"artifacts": "22",
|
||||
"ancient_literature": "33",
|
||||
"inheritor_testimony": "66"
|
||||
},
|
||||
"patent_certificates": [
|
||||
"http://example.com/专利证书1.jpg",
|
||||
"http://example.com/专利证书2.jpg"
|
||||
],
|
||||
"pattern_images": [
|
||||
"pattern1.jpg"
|
||||
],
|
||||
"patent_application_no": "22",
|
||||
"heritage_asset_level": "国家级非遗",
|
||||
"inheritor_ages": [
|
||||
"55",
|
||||
"66",
|
||||
"77"
|
||||
],
|
||||
"implementation_stage": "成熟应用",
|
||||
"coverage_area": "全球覆盖",
|
||||
"collaboration_type": "品牌联名",
|
||||
"platform_accounts": {
|
||||
"bilibili": {
|
||||
"followers_count": 8000,
|
||||
"likes": 1000,
|
||||
"comments": 500,
|
||||
"shares": 500
|
||||
},
|
||||
"douyin": {
|
||||
"followers_count": 8000,
|
||||
"likes": 1000,
|
||||
"comments": 500,
|
||||
"shares": 500
|
||||
}
|
||||
},
|
||||
"scarcity_level": "孤品:全球唯一,不可复制(如特定版权、唯一实物)",
|
||||
"market_activity_time": "近一周",
|
||||
"price_range": {
|
||||
"highest": "2",
|
||||
"lowest": "3"
|
||||
},
|
||||
"monthly_transaction_amount": "月交易额<100万元"
|
||||
}
|
||||
@ -26,11 +26,33 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
openapi_tags = [
|
||||
{"name": "app-用户认证与账户", "description": "用户端账户与认证相关接口(公开/需认证以端点说明为准)"},
|
||||
{"name": "app-估值评估", "description": "用户端估值评估相关接口(需用户端认证)"},
|
||||
{"name": "app-短信服务", "description": "用户端短信验证码与登录相关接口(公开)"},
|
||||
{"name": "app-上传", "description": "用户端文件上传接口(公开)"},
|
||||
{"name": "admin-基础", "description": "后台登录与个人信息接口(部分公开,其他需认证)"},
|
||||
{"name": "admin-用户管理", "description": "后台用户管理接口(需认证与权限)"},
|
||||
{"name": "admin-角色管理", "description": "后台角色管理接口(需认证与权限)"},
|
||||
{"name": "admin-菜单管理", "description": "后台菜单管理接口(需认证与权限)"},
|
||||
{"name": "admin-API权限管理", "description": "后台 API 权限管理接口(需认证与权限)"},
|
||||
{"name": "admin-部门管理", "description": "后台部门管理接口(需认证与权限)"},
|
||||
{"name": "admin-审计日志", "description": "后台审计日志查询接口(需认证与权限)"},
|
||||
{"name": "admin-估值评估", "description": "后台估值评估接口(需认证与权限)"},
|
||||
{"name": "admin-发票管理", "description": "后台发票与抬头管理接口(需认证与权限)"},
|
||||
{"name": "admin-交易管理", "description": "后台交易/对公转账记录接口(需认证与权限)"},
|
||||
{"name": "admin-内置接口", "description": "后台第三方内置接口调用(需认证与权限)"},
|
||||
{"name": "admin-行业管理", "description": "后台行业数据管理(当前公开)"},
|
||||
{"name": "admin-指数管理", "description": "后台指数数据管理(当前公开)"},
|
||||
{"name": "admin-政策管理", "description": "后台政策数据管理(当前公开)"},
|
||||
{"name": "admin-ESG管理", "description": "后台 ESG 数据管理(当前公开)"},
|
||||
]
|
||||
app = FastAPI(
|
||||
title=settings.APP_TITLE,
|
||||
description=settings.APP_DESCRIPTION,
|
||||
version=settings.VERSION,
|
||||
openapi_url="/openapi.json",
|
||||
openapi_tags=openapi_tags,
|
||||
middleware=make_middlewares(),
|
||||
lifespan=lifespan,
|
||||
redirect_slashes=False, # 禁用尾部斜杠重定向
|
||||
|
||||
@ -5,6 +5,7 @@ from app.utils.app_user_jwt import get_current_app_user
|
||||
|
||||
from .apis import apis_router
|
||||
from .app_users import app_users_router
|
||||
from .app_users.admin_manage import admin_app_users_router
|
||||
from .app_valuations import app_valuations_router
|
||||
from .auditlog import auditlog_router
|
||||
from .base import base_router
|
||||
@ -19,28 +20,38 @@ from .third_party_api import third_party_api_router
|
||||
from .upload import router as upload_router
|
||||
from .users import users_router
|
||||
from .valuations import router as valuations_router
|
||||
from .invoice.invoice import invoice_router
|
||||
from .transactions.transactions import transactions_router
|
||||
from .app_invoices.app_invoices import app_invoices_router
|
||||
from .sms.sms import router as sms_router
|
||||
|
||||
v1_router = APIRouter()
|
||||
|
||||
v1_router.include_router(base_router, prefix="/base")
|
||||
v1_router.include_router(app_users_router, prefix="/app-user") # AppUser路由,无需权限依赖
|
||||
v1_router.include_router(base_router, prefix="/base", tags=["admin-基础"])
|
||||
v1_router.include_router(app_users_router, prefix="/app-user", tags=["app-用户认证与账户"]) # AppUser路由,无需权限依赖
|
||||
v1_router.include_router(admin_app_users_router, prefix="/app-user-admin", tags=["admin-App用户管理"])
|
||||
# 注意:app-valuations 路由在各自的端点内部使用 get_current_app_user 进行认证
|
||||
# 这样可以保持App用户认证系统的独立性,不与后台管理权限系统混合
|
||||
v1_router.include_router(app_valuations_router, prefix="/app-valuations") # 用户端估值评估路由
|
||||
v1_router.include_router(users_router, prefix="/user", dependencies=[DependAuth, DependPermission])
|
||||
v1_router.include_router(roles_router, prefix="/role", dependencies=[DependAuth, DependPermission])
|
||||
v1_router.include_router(menus_router, prefix="/menu", dependencies=[DependAuth, DependPermission])
|
||||
v1_router.include_router(apis_router, prefix="/api", dependencies=[DependAuth, DependPermission])
|
||||
v1_router.include_router(depts_router, prefix="/dept", dependencies=[DependAuth, DependPermission])
|
||||
v1_router.include_router(auditlog_router, prefix="/auditlog", dependencies=[DependAuth, DependPermission])
|
||||
v1_router.include_router(esg_router, prefix="/esg")
|
||||
v1_router.include_router(index_router, prefix="/index")
|
||||
v1_router.include_router(industry_router, prefix="/industry")
|
||||
v1_router.include_router(policy_router, prefix="/policy")
|
||||
v1_router.include_router(upload_router, prefix="/upload") # 文件上传路由
|
||||
v1_router.include_router(app_valuations_router, prefix="/app-valuations", tags=["app-估值评估"]) # 用户端估值评估路由
|
||||
v1_router.include_router(users_router, prefix="/user", dependencies=[DependAuth, DependPermission], tags=["admin-用户管理"])
|
||||
v1_router.include_router(roles_router, prefix="/role", dependencies=[DependAuth, DependPermission], tags=["admin-角色管理"])
|
||||
v1_router.include_router(menus_router, prefix="/menu", dependencies=[DependAuth, DependPermission], tags=["admin-菜单管理"])
|
||||
v1_router.include_router(apis_router, prefix="/api", dependencies=[DependAuth, DependPermission], tags=["admin-API权限管理"])
|
||||
v1_router.include_router(depts_router, prefix="/dept", dependencies=[DependAuth, DependPermission], tags=["admin-部门管理"])
|
||||
v1_router.include_router(auditlog_router, prefix="/auditlog", dependencies=[DependAuth, DependPermission], tags=["admin-审计日志"])
|
||||
v1_router.include_router(esg_router, prefix="/esg", tags=["admin-ESG管理"])
|
||||
v1_router.include_router(index_router, prefix="/index", tags=["admin-指数管理"])
|
||||
v1_router.include_router(industry_router, prefix="/industry", tags=["admin-行业管理"])
|
||||
v1_router.include_router(policy_router, prefix="/policy", tags=["admin-政策管理"])
|
||||
v1_router.include_router(upload_router, prefix="/upload", tags=["app-上传"]) # 文件上传路由
|
||||
v1_router.include_router(
|
||||
third_party_api_router,
|
||||
prefix="/third_party_api",
|
||||
dependencies=[DependAuth, DependPermission],
|
||||
tags=["admin-内置接口"],
|
||||
)
|
||||
v1_router.include_router(valuations_router, prefix="/valuations", dependencies=[DependAuth, DependPermission])
|
||||
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(transactions_router, prefix="/transactions", dependencies=[DependAuth, DependPermission], tags=["admin-交易管理"])
|
||||
v1_router.include_router(sms_router, prefix="/sms", tags=["app-短信服务"])
|
||||
v1_router.include_router(app_invoices_router, prefix="/app-invoices", tags=["app-发票管理"])
|
||||
|
||||
@ -3,12 +3,14 @@ from tortoise.expressions import Q
|
||||
|
||||
from app.controllers.api import api_controller
|
||||
from app.schemas import Success, SuccessExtra
|
||||
from app.schemas.base import BasicResponse, PageResponse, MessageOut
|
||||
from app.schemas.apis import BaseApi
|
||||
from app.schemas.apis import *
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/list", summary="查看API列表")
|
||||
@router.get("/list", summary="查看API列表", response_model=PageResponse[BaseApi])
|
||||
async def list_api(
|
||||
page: int = Query(1, description="页码"),
|
||||
page_size: int = Query(10, description="每页数量"),
|
||||
@ -28,7 +30,7 @@ async def list_api(
|
||||
return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/get", summary="查看Api")
|
||||
@router.get("/get", summary="查看Api", response_model=BasicResponse[BaseApi])
|
||||
async def get_api(
|
||||
id: int = Query(..., description="Api"),
|
||||
):
|
||||
@ -37,7 +39,7 @@ async def get_api(
|
||||
return Success(data=data)
|
||||
|
||||
|
||||
@router.post("/create", summary="创建Api")
|
||||
@router.post("/create", summary="创建Api", response_model=BasicResponse[MessageOut])
|
||||
async def create_api(
|
||||
api_in: ApiCreate,
|
||||
):
|
||||
@ -45,7 +47,7 @@ async def create_api(
|
||||
return Success(msg="Created Successfully")
|
||||
|
||||
|
||||
@router.post("/update", summary="更新Api")
|
||||
@router.post("/update", summary="更新Api", response_model=BasicResponse[MessageOut])
|
||||
async def update_api(
|
||||
api_in: ApiUpdate,
|
||||
):
|
||||
@ -53,7 +55,7 @@ async def update_api(
|
||||
return Success(msg="Update Successfully")
|
||||
|
||||
|
||||
@router.delete("/delete", summary="删除Api")
|
||||
@router.delete("/delete", summary="删除Api", response_model=BasicResponse[MessageOut])
|
||||
async def delete_api(
|
||||
api_id: int = Query(..., description="ApiID"),
|
||||
):
|
||||
@ -61,7 +63,7 @@ async def delete_api(
|
||||
return Success(msg="Deleted Success")
|
||||
|
||||
|
||||
@router.post("/refresh", summary="刷新API列表")
|
||||
@router.post("/refresh", summary="刷新API列表", response_model=BasicResponse[MessageOut])
|
||||
async def refresh_api():
|
||||
await api_controller.refresh_api()
|
||||
return Success(msg="OK")
|
||||
|
||||
153
app/api/v1/app_invoices/app_invoices.py
Normal file
@ -0,0 +1,153 @@
|
||||
from fastapi import APIRouter, Query, Depends
|
||||
from typing import Optional
|
||||
|
||||
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse
|
||||
from app.schemas.invoice import InvoiceOut, InvoiceHeaderOut, InvoiceHeaderCreate, InvoiceHeaderUpdate, PaymentReceiptCreate, AppCreateInvoiceWithReceipt, InvoiceCreate
|
||||
from app.controllers.invoice import invoice_controller
|
||||
from app.utils.app_user_jwt import get_current_app_user
|
||||
from app.models.user import AppUser
|
||||
from app.models.invoice import InvoiceHeader
|
||||
|
||||
app_invoices_router = APIRouter(tags=["app-发票管理"])
|
||||
|
||||
|
||||
@app_invoices_router.get("/list", summary="我的发票列表", response_model=PageResponse[InvoiceOut])
|
||||
async def get_my_invoices(
|
||||
status: Optional[str] = Query(None),
|
||||
ticket_type: Optional[str] = Query(None),
|
||||
invoice_type: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
current_user: AppUser = Depends(get_current_app_user),
|
||||
):
|
||||
result = await invoice_controller.list(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
status=status,
|
||||
ticket_type=ticket_type,
|
||||
invoice_type=invoice_type,
|
||||
app_user_id=current_user.id,
|
||||
)
|
||||
return SuccessExtra(
|
||||
data=result.items,
|
||||
total=result.total,
|
||||
page=result.page,
|
||||
page_size=result.page_size,
|
||||
msg="获取成功",
|
||||
)
|
||||
|
||||
|
||||
@app_invoices_router.get("/headers", summary="我的发票抬头", response_model=BasicResponse[list[InvoiceHeaderOut]])
|
||||
async def get_my_headers(current_user: AppUser = Depends(get_current_app_user)):
|
||||
headers = await invoice_controller.get_headers(user_id=current_user.id)
|
||||
return Success(data=headers, msg="获取成功")
|
||||
|
||||
@app_invoices_router.get("/headers/{id}", summary="我的发票抬头详情", response_model=BasicResponse[InvoiceHeaderOut])
|
||||
async def get_my_header_by_id(id: int, current_user: AppUser = Depends(get_current_app_user)):
|
||||
header = await invoice_controller.get_header_by_id(id)
|
||||
if not header or getattr(header, "id", None) is None:
|
||||
return Success(data={}, msg="未找到")
|
||||
# 仅允许访问属于自己的抬头
|
||||
if getattr(header, "app_user_id", None) not in (current_user.id, None):
|
||||
return Success(data={}, msg="未找到")
|
||||
return Success(data=header, msg="获取成功")
|
||||
|
||||
@app_invoices_router.post("/headers", summary="新增我的发票抬头", response_model=BasicResponse[InvoiceHeaderOut])
|
||||
async def create_my_header(data: InvoiceHeaderCreate, current_user: AppUser = Depends(get_current_app_user)):
|
||||
header = await invoice_controller.create_header(user_id=current_user.id, data=data)
|
||||
return Success(data=header, msg="创建成功")
|
||||
|
||||
@app_invoices_router.put("/headers/{id}", summary="更新我的发票抬头", response_model=BasicResponse[InvoiceHeaderOut])
|
||||
async def update_my_header(id: int, data: InvoiceHeaderUpdate, current_user: AppUser = Depends(get_current_app_user)):
|
||||
existing = await invoice_controller.get_header_by_id(id)
|
||||
if not existing or getattr(existing, "id", None) is None:
|
||||
return Success(data={}, msg="未找到")
|
||||
if getattr(existing, "app_user_id", None) != current_user.id:
|
||||
return Success(data={}, msg="未找到")
|
||||
header = await invoice_controller.update_header(id, data)
|
||||
return Success(data=header or {}, msg="更新成功" if header else "未找到")
|
||||
|
||||
@app_invoices_router.delete("/headers/{id}", summary="删除我的发票抬头", response_model=BasicResponse[dict])
|
||||
async def delete_my_header(id: int, current_user: AppUser = Depends(get_current_app_user)):
|
||||
existing = await invoice_controller.get_header_by_id(id)
|
||||
if not existing or getattr(existing, "id", None) is None:
|
||||
return Success(data={"deleted": False}, msg="未找到")
|
||||
if getattr(existing, "app_user_id", None) != current_user.id:
|
||||
return Success(data={"deleted": False}, msg="未找到")
|
||||
ok = await invoice_controller.delete_header(id)
|
||||
return Success(data={"deleted": ok}, msg="删除成功" if ok else "未找到")
|
||||
|
||||
|
||||
@app_invoices_router.post("/receipts/{id}", summary="上传我的付款凭证", response_model=BasicResponse[dict])
|
||||
async def upload_my_receipt(id: int, data: PaymentReceiptCreate, current_user: AppUser = Depends(get_current_app_user)):
|
||||
inv = await invoice_controller.model.filter(id=id, app_user_id=current_user.id).first()
|
||||
if not inv:
|
||||
return Success(data={}, msg="未找到")
|
||||
receipt = await invoice_controller.create_receipt(id, data)
|
||||
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||
return Success(data=detail, msg="上传成功")
|
||||
|
||||
|
||||
@app_invoices_router.post("/create-with-receipt", summary="创建我的发票并上传付款凭证", response_model=BasicResponse[dict])
|
||||
async def create_with_receipt(payload: AppCreateInvoiceWithReceipt, current_user: AppUser = Depends(get_current_app_user)):
|
||||
header = await InvoiceHeader.filter(id=payload.header_id, app_user_id=current_user.id).first()
|
||||
if not header:
|
||||
return Success(data={}, msg="抬头未找到")
|
||||
ticket_type = payload.ticket_type or "electronic"
|
||||
invoice_type = payload.invoice_type
|
||||
if not invoice_type:
|
||||
mapping = {"0": "normal", "1": "special"}
|
||||
invoice_type = mapping.get(str(payload.invoiceTypeIndex)) if payload.invoiceTypeIndex is not None else None
|
||||
if not invoice_type:
|
||||
invoice_type = "normal"
|
||||
inv_data = InvoiceCreate(
|
||||
ticket_type=ticket_type,
|
||||
invoice_type=invoice_type,
|
||||
phone=current_user.phone,
|
||||
email=header.email,
|
||||
company_name=header.company_name,
|
||||
tax_number=header.tax_number,
|
||||
register_address=header.register_address,
|
||||
register_phone=header.register_phone,
|
||||
bank_name=header.bank_name,
|
||||
bank_account=header.bank_account,
|
||||
app_user_id=current_user.id,
|
||||
header_id=header.id,
|
||||
wechat=getattr(current_user, "alias", None),
|
||||
)
|
||||
inv = await invoice_controller.create(inv_data)
|
||||
if payload.receipt_urls:
|
||||
urls = payload.receipt_urls
|
||||
main_url = urls[0] if isinstance(urls, list) and urls else None
|
||||
receipt = await invoice_controller.create_receipt(
|
||||
inv.id,
|
||||
PaymentReceiptCreate(url=main_url, note=payload.note, extra=urls)
|
||||
)
|
||||
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||
return Success(data={"invoice_id": inv.id, "receipts": [detail] if detail else []}, msg="创建并上传成功")
|
||||
if isinstance(payload.receipt_url, list) and payload.receipt_url:
|
||||
urls = payload.receipt_url
|
||||
main_url = urls[0]
|
||||
receipt = await invoice_controller.create_receipt(
|
||||
inv.id,
|
||||
PaymentReceiptCreate(url=main_url, note=payload.note, extra=urls)
|
||||
)
|
||||
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||
return Success(data={"invoice_id": inv.id, "receipts": [detail] if detail else []}, msg="创建并上传成功")
|
||||
if payload.receipt_url:
|
||||
receipt = await invoice_controller.create_receipt(inv.id, PaymentReceiptCreate(url=payload.receipt_url, note=payload.note))
|
||||
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||
return Success(data=detail, msg="创建并上传成功")
|
||||
else:
|
||||
out = await invoice_controller.get_out(inv.id)
|
||||
return Success(data=out.model_dump() if out else {}, msg="创建成功,未上传凭证")
|
||||
@app_invoices_router.get("/headers/list", summary="我的抬头列表(分页)", response_model=PageResponse[InvoiceHeaderOut])
|
||||
async def get_my_headers_paged(page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100), current_user: AppUser = Depends(get_current_app_user)):
|
||||
qs = invoice_controller.model_header.filter(app_user_id=current_user.id) if hasattr(invoice_controller, "model_header") else None
|
||||
# Fallback when controller没有暴露model_header
|
||||
from app.models.invoice import InvoiceHeader
|
||||
qs = InvoiceHeader.filter(app_user_id=current_user.id)
|
||||
total = await qs.count()
|
||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||
items = [InvoiceHeaderOut.model_validate(r) for r in rows]
|
||||
return SuccessExtra(data=[i.model_dump() for i in items], total=total, page=page, page_size=page_size, msg="获取成功")
|
||||
146
app/api/v1/app_users/admin_manage.py
Normal file
@ -0,0 +1,146 @@
|
||||
from fastapi import APIRouter, Query, Depends, HTTPException
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse
|
||||
from app.schemas.app_user import AppUserQuotaUpdateSchema, AppUserQuotaLogOut, AppUserUpdateSchema
|
||||
from app.controllers.app_user import app_user_controller
|
||||
from app.models.user import AppUser, AppUserQuotaLog
|
||||
from app.core.dependency import DependAuth, DependPermission, AuthControl
|
||||
|
||||
|
||||
admin_app_users_router = APIRouter(dependencies=[DependAuth, DependPermission], tags=["admin-App用户管理"])
|
||||
|
||||
|
||||
@admin_app_users_router.get("/list", summary="App用户列表", response_model=PageResponse[dict])
|
||||
async def list_app_users(
|
||||
phone: 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_size: int = Query(10, ge=1, le=100),
|
||||
):
|
||||
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:
|
||||
qs = qs.filter(phone__icontains=phone)
|
||||
if 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()
|
||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||
items = []
|
||||
for u in rows:
|
||||
last_log = await AppUserQuotaLog.filter(app_user_id=u.id).order_by("-created_at").first()
|
||||
items.append({
|
||||
"id": u.id,
|
||||
"phone": u.phone,
|
||||
"wechat": u.alias,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else "",
|
||||
"notes": getattr(u, "notes", "") or "",
|
||||
"remaining_count": int(getattr(u, "remaining_quota", 0) or 0),
|
||||
"user_type": getattr(last_log, "op_type", None),
|
||||
})
|
||||
return SuccessExtra(data=items, total=total, page=page, page_size=page_size, msg="获取成功")
|
||||
|
||||
|
||||
@admin_app_users_router.post("/quota", summary="调整用户剩余估值次数", response_model=BasicResponse[dict])
|
||||
async def update_quota(payload: AppUserQuotaUpdateSchema, operator=Depends(AuthControl.is_authed)):
|
||||
user = await app_user_controller.update_quota(
|
||||
operator_id=getattr(operator, "id", 0),
|
||||
operator_name=getattr(operator, "username", "admin"),
|
||||
user_id=payload.user_id,
|
||||
target_count=payload.target_count,
|
||||
delta=payload.delta,
|
||||
op_type=payload.op_type,
|
||||
remark=payload.remark,
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
# if payload.remark is not None:
|
||||
# user.notes = payload.remark
|
||||
# await user.save()
|
||||
return Success(data={"user_id": user.id, "remaining_quota": user.remaining_quota}, msg="调整成功")
|
||||
|
||||
|
||||
@admin_app_users_router.get("/{user_id}/quota-logs", summary="用户估值次数操作日志", response_model=PageResponse[AppUserQuotaLogOut])
|
||||
async def quota_logs(user_id: int, page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100)):
|
||||
qs = AppUserQuotaLog.filter(app_user_id=user_id)
|
||||
total = await qs.count()
|
||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||
models = [
|
||||
AppUserQuotaLogOut(
|
||||
id=r.id,
|
||||
app_user_id=r.app_user_id,
|
||||
operator_id=r.operator_id,
|
||||
operator_name=r.operator_name,
|
||||
before_count=r.before_count,
|
||||
after_count=r.after_count,
|
||||
op_type=r.op_type,
|
||||
remark=r.remark,
|
||||
created_at=r.created_at.isoformat() if r.created_at else "",
|
||||
) for r in rows
|
||||
]
|
||||
data_items = [m.model_dump() for m in models]
|
||||
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="账号已注销")
|
||||
@ -6,19 +6,34 @@ from app.schemas.app_user import (
|
||||
AppUserJWTOut,
|
||||
AppUserInfoOut,
|
||||
AppUserUpdateSchema,
|
||||
AppUserChangePasswordSchema
|
||||
AppUserChangePasswordSchema,
|
||||
AppUserDashboardOut,
|
||||
AppUserQuotaOut,
|
||||
)
|
||||
from app.schemas.app_user import AppUserRegisterOut, TokenValidateOut
|
||||
from app.schemas.base import BasicResponse, MessageOut, Success
|
||||
from app.utils.app_user_jwt import (
|
||||
create_app_user_access_token,
|
||||
get_current_app_user,
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
verify_app_user_token
|
||||
)
|
||||
from app.models.user import AppUser
|
||||
from app.controllers.user_valuation import user_valuation_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.post("/register", response_model=dict, summary="用户注册")
|
||||
@router.post("/register", response_model=BasicResponse[dict], summary="用户注册")
|
||||
async def register(
|
||||
register_data: AppUserRegisterSchema
|
||||
):
|
||||
@ -28,20 +43,16 @@ async def register(
|
||||
"""
|
||||
try:
|
||||
user = await app_user_controller.register(register_data)
|
||||
return {
|
||||
"code": 200,
|
||||
"message": "注册成功",
|
||||
"data": {
|
||||
"user_id": user.id,
|
||||
"phone": user.phone,
|
||||
"default_password": register_data.phone[-6:] # 返回默认密码供用户知晓
|
||||
}
|
||||
}
|
||||
return Success(data={
|
||||
"user_id": user.id,
|
||||
"phone": user.phone,
|
||||
"default_password": register_data.phone[-6:]
|
||||
})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=200, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/login", response_model=AppUserJWTOut, summary="用户登录")
|
||||
@router.post("/login", response_model=BasicResponse[dict], summary="用户登录")
|
||||
async def login(
|
||||
login_data: AppUserLoginSchema
|
||||
):
|
||||
@ -61,30 +72,132 @@ async def login(
|
||||
# 生成访问令牌
|
||||
access_token = create_app_user_access_token(user.id, user.phone)
|
||||
|
||||
return AppUserJWTOut(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
return Success(data={
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
})
|
||||
|
||||
|
||||
@router.post("/logout", summary="用户登出")
|
||||
@router.post("/logout", summary="用户登出", response_model=BasicResponse[dict])
|
||||
async def logout(current_user: AppUser = Depends(get_current_app_user)):
|
||||
"""
|
||||
用户登出(客户端需要删除本地token)
|
||||
"""
|
||||
return {"code": 200, "message": "登出成功"}
|
||||
return Success(data={"message": "登出成功"})
|
||||
|
||||
|
||||
@router.get("/profile", response_model=AppUserInfoOut, summary="获取用户信息")
|
||||
class DeleteAccountRequest(BaseModel):
|
||||
code: Optional[str] = Field(None, description="短信验证码或绕过码")
|
||||
|
||||
|
||||
@router.delete("/account", summary="注销用户信息", response_model=BasicResponse[dict])
|
||||
async def delete_account(current_user: AppUser = Depends(get_current_app_user), token: str = Header(None), payload: Optional[DeleteAccountRequest] = None):
|
||||
if payload and payload.code:
|
||||
if settings.SMS_BYPASS_CODE and payload.code == settings.SMS_BYPASS_CODE:
|
||||
store.mark_verified(current_user.phone)
|
||||
else:
|
||||
ok, reason = store.can_verify(current_user.phone)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=423, detail=str(reason))
|
||||
record = store.get_code(current_user.phone)
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="验证码已过期")
|
||||
code_stored, expires_at = record
|
||||
if time.time() > expires_at:
|
||||
store.clear_code(current_user.phone)
|
||||
raise HTTPException(status_code=400, detail="验证码已过期")
|
||||
if payload.code != code_stored:
|
||||
count, locked = store.record_verify_failure(current_user.phone)
|
||||
if locked:
|
||||
raise HTTPException(status_code=423, detail="尝试次数过多,已锁定")
|
||||
raise HTTPException(status_code=401, detail="验证码错误")
|
||||
store.clear_code(current_user.phone)
|
||||
store.reset_failures(current_user.phone)
|
||||
store.mark_verified(current_user.phone)
|
||||
else:
|
||||
if not store.is_recently_verified(current_user.phone):
|
||||
raise HTTPException(status_code=403, detail="请先完成手机号验证码验证")
|
||||
remaining_quota = int(getattr(current_user, "remaining_quota", 0) or 0)
|
||||
if remaining_quota > 0:
|
||||
raise HTTPException(status_code=400, detail="当前剩余估值次数大于0,无法注销账号")
|
||||
ok = await app_user_controller.delete_user_account(current_user.id)
|
||||
if token:
|
||||
payload = verify_app_user_token(token)
|
||||
exp = getattr(payload, "exp", None) if payload else None
|
||||
await add_to_blacklist(token, current_user.id, exp)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return Success(data={"message": "账号已注销"})
|
||||
|
||||
|
||||
@router.get("/profile", response_model=BasicResponse[dict], summary="获取用户信息")
|
||||
async def get_profile(current_user: AppUser = Depends(get_current_app_user)):
|
||||
"""
|
||||
获取当前用户信息
|
||||
"""
|
||||
return current_user
|
||||
user_info = AppUserInfoOut(
|
||||
id=current_user.id,
|
||||
phone=current_user.phone,
|
||||
nickname=getattr(current_user, "alias", None),
|
||||
avatar=None,
|
||||
company_name=current_user.company_name,
|
||||
company_address=current_user.company_address,
|
||||
company_contact=current_user.company_contact,
|
||||
company_phone=current_user.company_phone,
|
||||
company_email=current_user.company_email,
|
||||
is_active=current_user.is_active,
|
||||
last_login=current_user.last_login,
|
||||
created_at=current_user.created_at,
|
||||
updated_at=current_user.updated_at,
|
||||
remaining_quota=current_user.remaining_quota,
|
||||
)
|
||||
return Success(data=user_info.model_dump())
|
||||
|
||||
|
||||
@router.put("/profile", response_model=AppUserInfoOut, summary="更新用户信息")
|
||||
@router.get("/dashboard", response_model=BasicResponse[dict], summary="用户首页摘要")
|
||||
async def get_dashboard(current_user: AppUser = Depends(get_current_app_user)):
|
||||
"""
|
||||
用户首页摘要
|
||||
功能:
|
||||
- 返回剩余估值次数(暂以 0 占位,后续可接入配额系统)
|
||||
- 返回最近一条估值评估记录(若有)
|
||||
- 返回待处理发票数量
|
||||
"""
|
||||
# 最近估值记录
|
||||
latest = await user_valuation_controller.model.filter(user_id=current_user.id).order_by("-created_at").first()
|
||||
latest_out = None
|
||||
if latest:
|
||||
latest_out = {
|
||||
"id": latest.id,
|
||||
"asset_name": latest.asset_name,
|
||||
"valuation_result": latest.final_value_ab,
|
||||
"status": latest.status,
|
||||
"created_at": latest.created_at.isoformat() if latest.created_at else "",
|
||||
}
|
||||
# 待处理发票数量
|
||||
try:
|
||||
pending_invoices = await invoice_controller.count_pending_for_user(current_user.id)
|
||||
except Exception:
|
||||
pending_invoices = 0
|
||||
# 剩余估值次数
|
||||
remaining_quota = current_user.remaining_quota
|
||||
return Success(data={"remaining_quota": remaining_quota, "latest_valuation": latest_out, "pending_invoices": pending_invoices})
|
||||
|
||||
|
||||
@router.get("/quota", response_model=BasicResponse[dict], summary="剩余估值次数")
|
||||
async def get_quota(current_user: AppUser = Depends(get_current_app_user)):
|
||||
"""
|
||||
剩余估值次数查询
|
||||
说明:
|
||||
- 当前实现返回默认 0 次与用户类型占位
|
||||
- 若后续接入配额系统,可从数据库中读取真实值
|
||||
"""
|
||||
remaining_count = current_user.remaining_quota
|
||||
return Success(data={"remaining_count": remaining_count})
|
||||
|
||||
|
||||
@router.put("/profile", response_model=BasicResponse[dict], summary="更新用户信息")
|
||||
async def update_profile(
|
||||
update_data: AppUserUpdateSchema,
|
||||
current_user: AppUser = Depends(get_current_app_user)
|
||||
@ -96,10 +209,26 @@ async def update_profile(
|
||||
if not updated_user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
return updated_user
|
||||
user_info = AppUserInfoOut(
|
||||
id=updated_user.id,
|
||||
phone=updated_user.phone,
|
||||
nickname=getattr(updated_user, "alias", None),
|
||||
avatar=None,
|
||||
company_name=updated_user.company_name,
|
||||
company_address=updated_user.company_address,
|
||||
company_contact=updated_user.company_contact,
|
||||
company_phone=updated_user.company_phone,
|
||||
company_email=updated_user.company_email,
|
||||
is_active=updated_user.is_active,
|
||||
last_login=updated_user.last_login,
|
||||
created_at=updated_user.created_at,
|
||||
updated_at=updated_user.updated_at,
|
||||
remaining_quota=updated_user.remaining_quota,
|
||||
)
|
||||
return Success(data=user_info.model_dump())
|
||||
|
||||
|
||||
@router.post("/change-password", summary="修改密码")
|
||||
@router.post("/change-password", summary="修改密码", response_model=BasicResponse[dict])
|
||||
async def change_password(
|
||||
password_data: AppUserChangePasswordSchema,
|
||||
current_user: AppUser = Depends(get_current_app_user)
|
||||
@ -116,19 +245,12 @@ async def change_password(
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
|
||||
return {"code": 200, "message": "密码修改成功"}
|
||||
return Success(data={"message": "密码修改成功"})
|
||||
|
||||
|
||||
@router.get("/validate-token", summary="验证token")
|
||||
@router.get("/validate-token", summary="验证token", response_model=BasicResponse[dict])
|
||||
async def validate_token(current_user: AppUser = Depends(get_current_app_user)):
|
||||
"""
|
||||
验证token是否有效
|
||||
"""
|
||||
return {
|
||||
"code": 200,
|
||||
"message": "token有效",
|
||||
"data": {
|
||||
"user_id": current_user.id,
|
||||
"phone": current_user.phone
|
||||
}
|
||||
}
|
||||
return Success(data={"user_id": current_user.id, "phone": current_user.phone})
|
||||
|
||||
@ -9,6 +9,8 @@ import asyncio
|
||||
import time
|
||||
|
||||
from app.controllers.user_valuation import user_valuation_controller
|
||||
from app.controllers.valuation import valuation_controller
|
||||
from app.schemas.valuation import ValuationAssessmentUpdate
|
||||
from app.schemas.valuation import (
|
||||
UserValuationCreate,
|
||||
UserValuationQuery,
|
||||
@ -16,13 +18,13 @@ from app.schemas.valuation import (
|
||||
UserValuationOut,
|
||||
UserValuationDetail
|
||||
)
|
||||
from app.schemas.base import Success, SuccessExtra
|
||||
from app.schemas.base import Success, BasicResponse
|
||||
from app.utils.app_user_jwt import get_current_app_user_id, get_current_app_user
|
||||
from app.utils.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
|
||||
from app.utils.calculation_engine.drp import DynamicPledgeRateCalculator
|
||||
from app.utils.calculation_engine.economic_value_b1.sub_formulas.basic_value_b11 import calculate_popularity_score, \
|
||||
calculate_infringement_score, calculate_patent_usage_score, calculate_patent_score
|
||||
# from app.utils.calculation_engine.economic_value_b1.sub_formulas.basic_value_b11 import calculate_popularity_score
|
||||
|
||||
from app.utils.calculation_engine.economic_value_b1.sub_formulas.traffic_factor_b12 import calculate_search_index_s1
|
||||
from app.log.log import logger
|
||||
from app.models.esg import ESG
|
||||
@ -35,13 +37,13 @@ from app.utils.wechat_index_calculator import wechat_index_calculator
|
||||
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:
|
||||
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))
|
||||
|
||||
# 根据行业查询 ESG 基准分(优先用行业名称匹配,如用的是行业代码就把 name 改成 code)
|
||||
@ -68,9 +70,13 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
||||
except Exception as 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
|
||||
|
||||
|
||||
# 提取 经济价值B1 计算参数
|
||||
input_data_by_b1 = await _extract_calculation_params_b1(data)
|
||||
input_data_by_b1 = await _extract_calculation_params_b1(
|
||||
data, esg_score=esg_score, industry_coefficient=fix_num_score, policy_match_score=policy_match_score
|
||||
)
|
||||
|
||||
# ESG关联价值 ESG分 (0-10分)
|
||||
input_data_by_b1["esg_score"] = esg_score
|
||||
# 行业修正系数I
|
||||
@ -78,25 +84,53 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
||||
# 政策匹配度
|
||||
input_data_by_b1["policy_match_score"] = policy_match_score
|
||||
|
||||
# 侵权分 默认 6
|
||||
# 法律风险/侵权记录:通过司法API查询诉讼状态
|
||||
# 评分规则:无诉讼(10分), 已解决诉讼(7分), 未解决诉讼(0分)
|
||||
lawsuit_status_text = "无诉讼" # 默认无诉讼
|
||||
judicial_api_response = {} # 保存API原始返回用于日志
|
||||
try:
|
||||
judicial_data = universal_api.query_judicial_data(data.institution)
|
||||
_data = judicial_data["data"].get("target", None) # 诉讼标的
|
||||
if _data:
|
||||
infringement_score = 0.0
|
||||
_data = judicial_data.get("data", {})
|
||||
judicial_api_response = _data # 保存原始返回
|
||||
target = _data.get("target", None) # 诉讼标的
|
||||
total = _data.get("total", 0) # 诉讼总数
|
||||
|
||||
if target or total > 0:
|
||||
# 有诉讼记录,检查是否已解决
|
||||
settled = _data.get("settled", False)
|
||||
if settled:
|
||||
lawsuit_status_text = "已解决诉讼"
|
||||
infringement_score = 7.0
|
||||
else:
|
||||
lawsuit_status_text = "未解决诉讼"
|
||||
infringement_score = 0.0
|
||||
else:
|
||||
lawsuit_status_text = "无诉讼"
|
||||
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
|
||||
judicial_api_response = {"error": str(e)}
|
||||
|
||||
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:
|
||||
patent_data = universal_api.query_patent_info(data.industry)
|
||||
patent_api_response = patent_data # 保存原始返回
|
||||
except Exception as e:
|
||||
logger.warning("valuation.patent_api_error err={}", repr(e))
|
||||
input_data_by_b1["patent_count"] = 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 {}
|
||||
inner_data = patent_dict.get("data", {}) if isinstance(patent_dict.get("data", {}), dict) else {}
|
||||
@ -107,13 +141,20 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
||||
# 查询匹配申请号的记录集合
|
||||
matched = [item for item in data_list if
|
||||
isinstance(item, dict) and item.get("SQH") == getattr(data, 'patent_application_no', None)]
|
||||
patent_matched_count = len(matched)
|
||||
if matched:
|
||||
patent_count = calculate_patent_usage_score(len(matched))
|
||||
input_data_by_b1["patent_count"] = float(patent_count)
|
||||
patent_count_score = min(len(matched) * 2.5, 10.0)
|
||||
input_data_by_b1["patent_count"] = float(patent_count_score)
|
||||
else:
|
||||
input_data_by_b1["patent_count"] = 0.0
|
||||
|
||||
patent_score = calculate_patent_score(calculate_total_years(data_list))
|
||||
patent_years_total = calculate_total_years(data_list)
|
||||
if patent_years_total > 10:
|
||||
patent_score = 10.0
|
||||
elif patent_years_total >= 5:
|
||||
patent_score = 7.0
|
||||
else:
|
||||
patent_score = 3.0
|
||||
input_data_by_b1["patent_score"] = patent_score
|
||||
|
||||
# 提取 文化价值B2 计算参数
|
||||
@ -139,10 +180,211 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
||||
"market_data": input_data_by_c,
|
||||
}
|
||||
|
||||
|
||||
calculator = FinalValueACalculator()
|
||||
# 计算最终估值A(统一计算)
|
||||
calculation_result = await calculator.calculate_complete_final_value_a(input_data)
|
||||
|
||||
# 步骤1:立即更新计算输入参数(不管后续是否成功)
|
||||
try:
|
||||
await valuation_controller.update_calc(
|
||||
valuation_id,
|
||||
ValuationAssessmentUpdate(
|
||||
calculation_input=input_data,
|
||||
)
|
||||
)
|
||||
logger.info("valuation.input_updated valuation_id={}", valuation_id)
|
||||
except Exception as e:
|
||||
logger.warning("valuation.failed_to_update_input valuation_id={} err={}", valuation_id, repr(e))
|
||||
|
||||
# 步骤1.5:更新内置API计算字段
|
||||
try:
|
||||
# 准备内置API计算字段的值
|
||||
api_calc_fields = {}
|
||||
|
||||
# ESG关联价值
|
||||
api_calc_fields["esg_value"] = str(esg_score) if esg_score is not None else None
|
||||
|
||||
# 政策匹配度
|
||||
api_calc_fields["policy_matching"] = str(policy_match_score) if policy_match_score is not None else None
|
||||
|
||||
# 侵权记录/法律风险 - 使用实际查询到的诉讼状态
|
||||
api_calc_fields["infringement_record"] = lawsuit_status_for_display
|
||||
api_calc_fields["legal_risk"] = lawsuit_status_for_display
|
||||
|
||||
# 专利使用量
|
||||
patent_count_value = input_data_by_b1.get("patent_count", 0.0)
|
||||
api_calc_fields["patent_count"] = str(patent_count_value) if patent_count_value is not None else None
|
||||
|
||||
# 结构复杂度(纹样基因熵值B22)
|
||||
structure_complexity_value = input_data_by_b2.get("structure_complexity", 1.5)
|
||||
api_calc_fields["pattern_complexity"] = str(structure_complexity_value) if structure_complexity_value is not None else None
|
||||
|
||||
# 归一化信息熵H
|
||||
normalized_entropy_value = input_data_by_b2.get("normalized_entropy", 9)
|
||||
api_calc_fields["normalized_entropy"] = str(normalized_entropy_value) if normalized_entropy_value is not None else None
|
||||
|
||||
# 线上课程点击量(暂时没有计算逻辑,设为None或默认值)
|
||||
# api_calc_fields["online_course_views"] = None
|
||||
|
||||
# 基础质押率和流量修正系数(暂时没有计算逻辑,设为None或默认值)
|
||||
# api_calc_fields["base_pledge_rate"] = None
|
||||
# api_calc_fields["flow_correction"] = None
|
||||
|
||||
if api_calc_fields:
|
||||
await valuation_controller.update_calc(
|
||||
valuation_id,
|
||||
ValuationAssessmentUpdate(**api_calc_fields)
|
||||
)
|
||||
logger.info("valuation.api_calc_fields_updated valuation_id={} fields={}", valuation_id, list(api_calc_fields.keys()))
|
||||
except Exception as e:
|
||||
logger.warning("valuation.failed_to_update_api_calc_fields valuation_id={} err={}", valuation_id, repr(e))
|
||||
|
||||
# 步骤1.6:记录所有API查询结果和参数映射(便于检查参数匹配)
|
||||
try:
|
||||
# 1. ESG评分查询记录
|
||||
await valuation_controller.log_formula_step(
|
||||
valuation_id, "API_ESG_QUERY",
|
||||
status="completed",
|
||||
input_params={"industry": data.industry},
|
||||
output_result={"esg_score": esg_score, "source": "ESG表"}
|
||||
)
|
||||
|
||||
# 2. 行业系数查询记录
|
||||
await valuation_controller.log_formula_step(
|
||||
valuation_id, "API_INDUSTRY_QUERY",
|
||||
status="completed",
|
||||
input_params={"industry": data.industry},
|
||||
output_result={"industry_coefficient": fix_num_score, "source": "Industry表"}
|
||||
)
|
||||
|
||||
# 3. 政策匹配度查询记录
|
||||
await valuation_controller.log_formula_step(
|
||||
valuation_id, "API_POLICY_QUERY",
|
||||
status="completed",
|
||||
input_params={"industry": data.industry},
|
||||
output_result={"policy_match_score": policy_match_score, "source": "Policy表"}
|
||||
)
|
||||
|
||||
# 4. 司法诉讼查询记录
|
||||
await valuation_controller.log_formula_step(
|
||||
valuation_id, "API_JUDICIAL_QUERY",
|
||||
status="completed",
|
||||
input_params={"institution": data.institution},
|
||||
output_result={
|
||||
"api_response": judicial_api_response, # API原始返回
|
||||
"lawsuit_status": lawsuit_status_for_display,
|
||||
"infringement_score": infringement_score,
|
||||
"calculation": f"诉讼标的={judicial_api_response.get('target', '无')}, 诉讼总数={judicial_api_response.get('total', 0)} → {lawsuit_status_for_display} → {infringement_score}分",
|
||||
"score_rule": "无诉讼:10分, 已解决:7分, 未解决:0分"
|
||||
}
|
||||
)
|
||||
|
||||
# 5. 专利信息查询记录
|
||||
await valuation_controller.log_formula_step(
|
||||
valuation_id, "API_PATENT_QUERY",
|
||||
status="completed",
|
||||
input_params={
|
||||
"industry": data.industry,
|
||||
"patent_application_no": data.patent_application_no
|
||||
},
|
||||
output_result={
|
||||
"api_data_count": len(patent_api_response.get("data", {}).get("dataList", []) if isinstance(patent_api_response.get("data"), dict) else []),
|
||||
"matched_count": patent_matched_count,
|
||||
"years_total": patent_years_total,
|
||||
"patent_count": input_data_by_b1.get("patent_count", 0),
|
||||
"patent_score": input_data_by_b1.get("patent_score", 0),
|
||||
"calculation": f"匹配专利数={patent_matched_count} → 专利数分={input_data_by_b1.get('patent_count', 0)}, 剩余年限合计={patent_years_total}年 → 专利分={patent_score}",
|
||||
"score_rule": "剩余年限>10年:10分, 5-10年:7分, <5年:3分"
|
||||
}
|
||||
)
|
||||
|
||||
# 6. 微信指数查询记录
|
||||
await valuation_controller.log_formula_step(
|
||||
valuation_id, "API_WECHAT_INDEX",
|
||||
status="completed",
|
||||
input_params={"asset_name": data.asset_name},
|
||||
output_result={
|
||||
"search_index_s1": input_data_by_b1.get("search_index_s1", 0),
|
||||
"formula": "S1 = 微信指数 / 10"
|
||||
}
|
||||
)
|
||||
|
||||
# 7. 跨界合作深度映射记录
|
||||
await valuation_controller.log_formula_step(
|
||||
valuation_id, "MAPPING_CROSS_BORDER_DEPTH",
|
||||
status="completed",
|
||||
input_params={
|
||||
"user_input": getattr(data, 'cooperation_depth', None),
|
||||
"mapping": {"0":"无(0分)", "1":"品牌联名(3分)", "2":"科技载体(5分)", "3":"国家外交礼品(10分)"}
|
||||
},
|
||||
output_result={"cross_border_depth": input_data_by_b2.get("cross_border_depth", 0)}
|
||||
)
|
||||
|
||||
# 8. 传承人等级映射记录
|
||||
await valuation_controller.log_formula_step(
|
||||
valuation_id, "MAPPING_INHERITOR_LEVEL",
|
||||
status="completed",
|
||||
input_params={
|
||||
"user_input": data.inheritor_level,
|
||||
"mapping": {"国家级传承人":"10分", "省级传承人":"7分", "市级传承人及以下":"4分"}
|
||||
},
|
||||
output_result={"inheritor_level_coefficient": input_data_by_b2.get("inheritor_level_coefficient", 0)}
|
||||
)
|
||||
|
||||
# 9. 历史传承度HI计算记录
|
||||
await valuation_controller.log_formula_step(
|
||||
valuation_id, "CALC_HISTORICAL_INHERITANCE",
|
||||
status="completed",
|
||||
input_params={
|
||||
"historical_evidence": data.historical_evidence,
|
||||
"weights": {"出土实物":1.0, "古代文献":0.8, "传承人佐证":0.6, "现代研究":0.4}
|
||||
},
|
||||
output_result={
|
||||
"historical_inheritance": input_data_by_b2.get("historical_inheritance", 0),
|
||||
"formula": "HI = 出土实物×1.0 + 古代文献×0.8 + 传承人佐证×0.6 + 现代研究×0.4"
|
||||
}
|
||||
)
|
||||
|
||||
# 11. 市场风险价格波动记录
|
||||
await valuation_controller.log_formula_step(
|
||||
valuation_id, "CALC_MARKET_RISK",
|
||||
status="completed",
|
||||
input_params={
|
||||
"price_fluctuation": data.price_fluctuation,
|
||||
"highest_price": input_data_by_b3.get("highest_price", 0),
|
||||
"lowest_price": input_data_by_b3.get("lowest_price", 0)
|
||||
},
|
||||
output_result={
|
||||
"volatility_rule": "波动率≤5%:10分, 5-15%:5分, >15%:0分"
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("valuation.param_mapping_logged valuation_id={}", valuation_id)
|
||||
except Exception as e:
|
||||
logger.warning("valuation.failed_to_log_param_mapping valuation_id={} err={}", valuation_id, repr(e))
|
||||
|
||||
# 计算最终估值A(统一计算),传入估值ID以关联步骤落库
|
||||
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()
|
||||
@ -152,7 +394,45 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
||||
'''
|
||||
# 解析月交易额字符串为数值
|
||||
monthly_amount = drp_c.parse_monthly_transaction_amount(data.monthly_transaction_amount or "")
|
||||
drp_start_ts = time.monotonic()
|
||||
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:
|
||||
@ -168,46 +448,80 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 创建估值评估记录
|
||||
result = await user_valuation_controller.create_valuation(
|
||||
user_id=user_id,
|
||||
data=data,
|
||||
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()),
|
||||
},
|
||||
drp_result=drp_result,
|
||||
status='success' # 计算成功,设置为approved状态
|
||||
)
|
||||
# 步骤4:计算完成,保持状态为 pending,等待后台审核
|
||||
try:
|
||||
result = await valuation_controller.get_by_id(valuation_id)
|
||||
logger.info("valuation.calc_finished valuation_id={} status=pending", valuation_id)
|
||||
except Exception as e:
|
||||
logger.warning("valuation.failed_to_fetch_after_calc valuation_id={} err={}", valuation_id, repr(e))
|
||||
result = None
|
||||
|
||||
logger.info("valuation.background_calc_success user_id={} valuation_id={}", user_id, result.id)
|
||||
logger.info("valuation.background_calc_success user_id={} valuation_id={}", user_id, valuation_id)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(traceback.format_exc())
|
||||
logger.error("valuation.background_calc_failed user_id={} err={}", user_id, repr(e))
|
||||
|
||||
# 计算失败时也创建记录,状态设置为failed
|
||||
# 计算失败时更新记录为失败状态
|
||||
try:
|
||||
result = await user_valuation_controller.create_valuation(
|
||||
user_id=user_id,
|
||||
data=data,
|
||||
calculation_result=None,
|
||||
calculation_input=None,
|
||||
drp_result=None,
|
||||
status='rejected' # 计算失败,设置为rejected状态
|
||||
)
|
||||
logger.info("valuation.failed_record_created user_id={} valuation_id={}", user_id, result.id)
|
||||
if 'valuation_id' in locals():
|
||||
# 准备失败时需要更新的字段
|
||||
fail_update_fields = {"status": "rejected"}
|
||||
|
||||
# 如果 input_data 已经准备好,确保 calculation_input 被更新(即使计算失败)
|
||||
if 'input_data' in locals():
|
||||
fail_update_fields["calculation_input"] = input_data
|
||||
|
||||
# 如果内置API计算字段已经准备好,也尝试更新(即使计算失败)
|
||||
# 这些字段在步骤1.5中计算,如果步骤1.5执行了,这些变量应该已经存在
|
||||
api_calc_fields = {}
|
||||
if 'esg_score' in locals():
|
||||
api_calc_fields["esg_value"] = str(esg_score) if esg_score is not None else None
|
||||
if 'policy_match_score' in locals():
|
||||
api_calc_fields["policy_matching"] = str(policy_match_score) if policy_match_score is not None else None
|
||||
if 'lawsuit_status_for_display' in locals():
|
||||
api_calc_fields["infringement_record"] = lawsuit_status_for_display
|
||||
api_calc_fields["legal_risk"] = lawsuit_status_for_display
|
||||
elif 'infringement_score' in locals():
|
||||
# 兼容旧逻辑
|
||||
infringement_record_value = "无诉讼" if infringement_score == 10.0 else ("已解决诉讼" if infringement_score == 7.0 else "未解决诉讼")
|
||||
api_calc_fields["infringement_record"] = infringement_record_value
|
||||
api_calc_fields["legal_risk"] = infringement_record_value
|
||||
if 'input_data_by_b1' in locals():
|
||||
patent_count_value = input_data_by_b1.get("patent_count", 0.0)
|
||||
api_calc_fields["patent_count"] = str(patent_count_value) if patent_count_value is not None else None
|
||||
if 'input_data_by_b2' in locals():
|
||||
structure_complexity_value = input_data_by_b2.get("structure_complexity", 1.5)
|
||||
api_calc_fields["pattern_complexity"] = str(structure_complexity_value) if structure_complexity_value is not None else None
|
||||
normalized_entropy_value = input_data_by_b2.get("normalized_entropy", 9)
|
||||
api_calc_fields["normalized_entropy"] = str(normalized_entropy_value) if normalized_entropy_value is not None else None
|
||||
|
||||
# 合并所有需要更新的字段
|
||||
fail_update_fields.update(api_calc_fields)
|
||||
|
||||
try:
|
||||
await valuation_controller.update_calc(
|
||||
valuation_id,
|
||||
ValuationAssessmentUpdate(**fail_update_fields)
|
||||
)
|
||||
logger.info("valuation.failed_but_fields_saved valuation_id={} fields={}", valuation_id, list(fail_update_fields.keys()))
|
||||
except Exception as input_err:
|
||||
logger.warning("valuation.failed_to_save_fields_on_error valuation_id={} err={}", valuation_id, repr(input_err))
|
||||
# 如果保存失败,至少更新状态
|
||||
try:
|
||||
fail_update = ValuationAssessmentUpdate(status='rejected')
|
||||
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:
|
||||
logger.error("valuation.failed_to_create_record user_id={} err={}", user_id, repr(create_error))
|
||||
logger.error("valuation.failed_to_update_record user_id={} err={}", user_id, repr(create_error))
|
||||
|
||||
|
||||
@app_valuations_router.post("/", summary="创建估值评估")
|
||||
@app_valuations_router.post("/", summary="创建估值评估", response_model=BasicResponse[dict])
|
||||
async def calculate_valuation(
|
||||
background_tasks: BackgroundTasks,
|
||||
data: UserValuationCreate,
|
||||
@ -278,20 +592,52 @@ async def calculate_valuation(
|
||||
"""
|
||||
|
||||
try:
|
||||
# 添加后台任务
|
||||
background_tasks.add_task(_perform_valuation_calculation, user_id, data)
|
||||
from app.models.user import AppUser, AppUserQuotaLog
|
||||
user = await AppUser.filter(id=user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if (user.remaining_quota or 0) < 1:
|
||||
raise HTTPException(status_code=400, detail="估值次数不足")
|
||||
before = user.remaining_quota or 0
|
||||
user.remaining_quota = before - 1
|
||||
await user.save()
|
||||
try:
|
||||
await AppUserQuotaLog.create(
|
||||
app_user_id=user_id,
|
||||
operator_id=user_id,
|
||||
operator_name=user.alias or user.username or user.phone or "",
|
||||
before_count=before,
|
||||
after_count=before - 1,
|
||||
remark="发起估值"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("valuation.task_queued user_id={} asset_name={} industry={}",
|
||||
user_id, getattr(data, 'asset_name', None), getattr(data, 'industry', None))
|
||||
# 先创建估值记录以获取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
|
||||
|
||||
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(
|
||||
data={
|
||||
"task_status": "queued",
|
||||
"message": "估值计算任务已提交,正在后台处理中",
|
||||
"user_id": user_id,
|
||||
"asset_name": getattr(data, 'asset_name', None)
|
||||
},
|
||||
msg="估值计算任务已启动"
|
||||
"asset_name": getattr(data, 'asset_name', None),
|
||||
"valuation_id": valuation_id,
|
||||
"order_no": str(valuation_id)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@ -299,7 +645,12 @@ async def calculate_valuation(
|
||||
raise HTTPException(status_code=500, detail=f"任务提交失败: {str(e)}")
|
||||
|
||||
|
||||
async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str, Any]:
|
||||
async def _extract_calculation_params_b1(
|
||||
data: UserValuationCreate,
|
||||
esg_score: float = 0.0,
|
||||
industry_coefficient: float = 0.0,
|
||||
policy_match_score: float = 0.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
从用户提交的数据中提取计算所需的参数
|
||||
|
||||
@ -315,7 +666,13 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
|
||||
|
||||
# 法律强度L相关参数
|
||||
# 普及地域分值 默认 7分
|
||||
popularity_score = calculate_popularity_score(data.application_coverage)
|
||||
# 普及地域分:全球覆盖(10)、全国覆盖(7)、区域覆盖(4),默认全国覆盖(7)
|
||||
try:
|
||||
coverage = data.application_coverage or "全国覆盖"
|
||||
mapping = {"全球覆盖": 10.0, "全国覆盖": 7.0, "区域覆盖": 4.0}
|
||||
popularity_score = mapping.get(coverage, 7.0)
|
||||
except Exception:
|
||||
popularity_score = 7.0
|
||||
|
||||
# 创新投入比 = (研发费用/营收) * 100
|
||||
try:
|
||||
@ -326,8 +683,7 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
|
||||
innovation_ratio = 0.0
|
||||
|
||||
# 流量因子B12相关参数
|
||||
# 近30天搜索指数S1 - 从社交媒体数据计算 TODO 需要使用第三方API
|
||||
baidu_index = 1
|
||||
# 近30天搜索指数S1 - 使用微信指数除以10计算
|
||||
|
||||
# 获取微信指数并计算近30天平均值
|
||||
try:
|
||||
@ -336,10 +692,9 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
|
||||
logger.info(f"资产 '{data.asset_name}' 的微信指数近30天平均值: {wechat_index}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取微信指数失败: {e}")
|
||||
wechat_index = 1
|
||||
wechat_index = 10 # 失败时默认值,使得 S1 = 1
|
||||
|
||||
weibo_index = 1
|
||||
search_index_s1 = calculate_search_index_s1(baidu_index, wechat_index, weibo_index) # 默认值,实际应从API获取
|
||||
search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10
|
||||
|
||||
# 行业均值S2 - 从数据库查询行业数据计算
|
||||
from app.utils.industry_calculator import calculate_industry_average_s2
|
||||
@ -386,6 +741,7 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
|
||||
'likes': safe_float(info["likes"]),
|
||||
'comments': safe_float(info["comments"]),
|
||||
'shares': safe_float(info["shares"]),
|
||||
'views': safe_float(info.get("views", 0)),
|
||||
# followers 非当前计算用键,先移除避免干扰
|
||||
|
||||
# click_count 与 view_count 目前未参与计算,先移除
|
||||
@ -393,7 +749,10 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
|
||||
'link_views': safe_float(data.link_views),
|
||||
# 政策乘数B13相关参数
|
||||
'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),
|
||||
}
|
||||
|
||||
|
||||
@ -427,18 +786,72 @@ 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
|
||||
bilibili_views = safe_float(rs.get("bilibili", None).get("likes", 0)) if rs.get("bilibili", None) else 0
|
||||
|
||||
# 跨界合作深度 品牌联名0.3,科技载体0.5,国家外交礼品1.0
|
||||
cross_border_depth = cross_border_depth_dict(data.cooperation_depth)
|
||||
# 跨界合作深度:将枚举映射为分值
|
||||
# 前端传入的是数字字符串 ("0", "1", "2", "3"),后端也支持中文标签
|
||||
try:
|
||||
val = getattr(data, 'cooperation_depth', None)
|
||||
mapping = {
|
||||
# 前端传入的数字字符串
|
||||
"0": 0.0, # 无
|
||||
"1": 3.0, # 品牌联名
|
||||
"2": 5.0, # 科技载体
|
||||
"3": 10.0, # 国家外交礼品
|
||||
# 兼容中文标签(以防其他入口传入)
|
||||
"无": 0.0,
|
||||
"品牌联名": 3.0,
|
||||
"科技载体": 5.0,
|
||||
"国家外交礼品": 10.0,
|
||||
}
|
||||
if isinstance(val, str):
|
||||
cross_border_depth = mapping.get(val, safe_float(val))
|
||||
else:
|
||||
cross_border_depth = safe_float(val)
|
||||
except Exception:
|
||||
cross_border_depth = 0.0
|
||||
|
||||
# 纹样基因值B22相关参数
|
||||
|
||||
# 以下三项需由后续模型/服务计算;此处提供默认可计算占位
|
||||
#
|
||||
# 历史传承度HI(用户填写)
|
||||
historical_inheritance = sum([safe_float(i) for i in data.historical_evidence])
|
||||
# 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
|
||||
try:
|
||||
evidence_weights = {
|
||||
"artifacts": 1.0, # 出土实物
|
||||
"ancient_literature": 0.8, # 古代文献
|
||||
"inheritor_testimony": 0.6, # 传承人佐证
|
||||
"modern_research": 0.4, # 现代研究
|
||||
}
|
||||
if isinstance(data.historical_evidence, dict):
|
||||
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)):
|
||||
# 列表顺序:[出土实物, 古代文献, 传承人佐证, 现代研究]
|
||||
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:
|
||||
historical_inheritance = 0.0
|
||||
structure_complexity = 1.5 # 默认值 纹样基因熵值B22(系统计算)
|
||||
normalized_entropy = 9 # 默认值 归一化信息熵H(系统计算)
|
||||
|
||||
logger.info(
|
||||
"b2.params inheritor_level_coefficient={} offline_sessions={} douyin_views={} kuaishou_views={} bilibili_views={} cross_border_depth={} historical_inheritance={} structure_complexity={} normalized_entropy={}",
|
||||
inheritor_level_coefficient,
|
||||
offline_sessions,
|
||||
douyin_views,
|
||||
kuaishou_views,
|
||||
bilibili_views,
|
||||
cross_border_depth,
|
||||
historical_inheritance,
|
||||
structure_complexity,
|
||||
normalized_entropy,
|
||||
)
|
||||
|
||||
return {
|
||||
"inheritor_level_coefficient": inheritor_level_coefficient,
|
||||
"offline_sessions": offline_sessions,
|
||||
@ -452,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]:
|
||||
# 过去30天最高价格 过去30天最低价格 TODO 需要根据字样进行切分获取最高价和最低价 转换成 float 类型
|
||||
# 过去30天最高价格 过去30天最低价格
|
||||
price_fluctuation = [float(i) for i in data.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 {
|
||||
"highest_price": highest_price,
|
||||
"lowest_price": lowest_price,
|
||||
|
||||
"inheritor_ages": inheritor_ages,
|
||||
}
|
||||
|
||||
@ -557,13 +989,14 @@ async def _extract_calculation_params_c(data: UserValuationCreate) -> Dict[str,
|
||||
"expert_valuations": expert_valuations, # 专家估值列表 (系统配置)
|
||||
# 计算热度系数C2
|
||||
"daily_browse_volume": daily_browse_volume, # 近7日日均浏览量 (API获取)
|
||||
"platform_views": daily_browse_volume, # 从 platform_accounts/views 或 link_views 获取的浏览量
|
||||
"collection_count": collection_count, # 收藏数
|
||||
"issuance_level": circulation, # 默认 限量发行 计算稀缺性乘数C3
|
||||
"recent_market_activity": recent_market_activity, # 默认 '近一月' 计算市场估值C
|
||||
}
|
||||
|
||||
|
||||
@app_valuations_router.get("/", summary="获取我的估值评估列表")
|
||||
@app_valuations_router.get("/", summary="获取我的估值评估列表", response_model=BasicResponse[dict])
|
||||
async def get_my_valuations(
|
||||
query: UserValuationQuery = Depends(),
|
||||
current_user: AppUser = Depends(get_current_app_user)
|
||||
@ -580,13 +1013,14 @@ async def get_my_valuations(
|
||||
# 使用model_dump_json()来正确序列化datetime,然后解析为dict列表
|
||||
import json
|
||||
serialized_items = [json.loads(item.model_dump_json()) for item in result.items]
|
||||
return SuccessExtra(
|
||||
data=serialized_items,
|
||||
total=result.total,
|
||||
page=result.page,
|
||||
page_size=result.size,
|
||||
pages=result.pages,
|
||||
msg="获取估值评估列表成功"
|
||||
return Success(
|
||||
data={
|
||||
"items": serialized_items,
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
"page_size": result.size,
|
||||
"pages": result.pages,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
@ -595,7 +1029,7 @@ async def get_my_valuations(
|
||||
)
|
||||
|
||||
|
||||
@app_valuations_router.get("/{valuation_id}", summary="获取估值评估详情")
|
||||
@app_valuations_router.get("/{valuation_id}", summary="获取估值评估详情", response_model=BasicResponse[dict])
|
||||
async def get_valuation_detail(
|
||||
valuation_id: int,
|
||||
current_user: AppUser = Depends(get_current_app_user)
|
||||
@ -618,7 +1052,7 @@ async def get_valuation_detail(
|
||||
# 使用model_dump_json()来正确序列化datetime,然后解析为dict
|
||||
import json
|
||||
result_dict = json.loads(result.model_dump_json())
|
||||
return Success(data=result_dict, msg="获取估值评估详情成功")
|
||||
return Success(data=result_dict)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -628,7 +1062,7 @@ async def get_valuation_detail(
|
||||
)
|
||||
|
||||
|
||||
@app_valuations_router.get("/statistics/overview", summary="获取我的估值评估统计")
|
||||
@app_valuations_router.get("/statistics/overview", summary="获取我的估值评估统计", response_model=BasicResponse[dict])
|
||||
async def get_my_valuation_statistics(
|
||||
current_user: AppUser = Depends(get_current_app_user)
|
||||
):
|
||||
@ -639,7 +1073,7 @@ async def get_my_valuation_statistics(
|
||||
result = await user_valuation_controller.get_user_valuation_statistics(
|
||||
user_id=current_user.id
|
||||
)
|
||||
return Success(data=result, msg="获取统计信息成功")
|
||||
return Success(data=result)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@ -647,7 +1081,7 @@ async def get_my_valuation_statistics(
|
||||
)
|
||||
|
||||
|
||||
@app_valuations_router.delete("/{valuation_id}", summary="删除估值评估")
|
||||
@app_valuations_router.delete("/{valuation_id}", summary="删除估值评估", response_model=BasicResponse[dict])
|
||||
async def delete_valuation(
|
||||
valuation_id: int,
|
||||
current_user: AppUser = Depends(get_current_app_user)
|
||||
@ -667,7 +1101,7 @@ async def delete_valuation(
|
||||
detail="估值评估记录不存在或已被删除"
|
||||
)
|
||||
|
||||
return Success(data={"deleted": True}, msg="删除估值评估成功")
|
||||
return Success(data={"deleted": True})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -705,3 +1139,4 @@ def safe_float(v):
|
||||
return float(v)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
from app.log.log import logger
|
||||
|
||||
@ -6,7 +6,7 @@ from app.controllers.user import user_controller
|
||||
from app.core.ctx import CTX_USER_ID
|
||||
from app.core.dependency import DependAuth
|
||||
from app.models.admin import Api, Menu, Role, User
|
||||
from app.schemas.base import Fail, Success
|
||||
from app.schemas.base import Fail, Success, BasicResponse
|
||||
from app.schemas.login import *
|
||||
from app.schemas.users import UpdatePassword
|
||||
from app.settings import settings
|
||||
@ -16,7 +16,7 @@ from app.utils.password import get_password_hash, verify_password
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/access_token", summary="获取token")
|
||||
@router.post("/access_token", summary="获取token", response_model=BasicResponse[JWTOut])
|
||||
async def login_access_token(credentials: CredentialsSchema):
|
||||
user: User = await user_controller.authenticate(credentials)
|
||||
await user_controller.update_last_login(user.id)
|
||||
@ -37,7 +37,7 @@ async def login_access_token(credentials: CredentialsSchema):
|
||||
return Success(data=data.model_dump())
|
||||
|
||||
|
||||
@router.get("/userinfo", summary="查看用户信息", dependencies=[DependAuth])
|
||||
@router.get("/userinfo", summary="查看用户信息", dependencies=[DependAuth], response_model=BasicResponse[dict])
|
||||
async def get_userinfo():
|
||||
user_id = CTX_USER_ID.get()
|
||||
user_obj = await user_controller.get(id=user_id)
|
||||
@ -46,7 +46,7 @@ async def get_userinfo():
|
||||
return Success(data=data)
|
||||
|
||||
|
||||
@router.get("/usermenu", summary="查看用户菜单", dependencies=[DependAuth])
|
||||
@router.get("/usermenu", summary="查看用户菜单", dependencies=[DependAuth], response_model=BasicResponse[list])
|
||||
async def get_user_menu():
|
||||
user_id = CTX_USER_ID.get()
|
||||
user_obj = await User.filter(id=user_id).first()
|
||||
@ -74,7 +74,7 @@ async def get_user_menu():
|
||||
return Success(data=res)
|
||||
|
||||
|
||||
@router.get("/userapi", summary="查看用户API", dependencies=[DependAuth])
|
||||
@router.get("/userapi", summary="查看用户API", dependencies=[DependAuth], response_model=BasicResponse[list])
|
||||
async def get_user_api():
|
||||
user_id = CTX_USER_ID.get()
|
||||
user_obj = await User.filter(id=user_id).first()
|
||||
@ -91,7 +91,7 @@ async def get_user_api():
|
||||
return Success(data=apis)
|
||||
|
||||
|
||||
@router.post("/update_password", summary="修改密码", dependencies=[DependAuth])
|
||||
@router.post("/update_password", summary="修改密码", dependencies=[DependAuth], response_model=BasicResponse[dict])
|
||||
async def update_user_password(req_in: UpdatePassword):
|
||||
user_id = CTX_USER_ID.get()
|
||||
user = await user_controller.get(user_id)
|
||||
|
||||
@ -276,11 +276,9 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
|
||||
|
||||
|
||||
# 流量因子B12相关参数
|
||||
# 近30天搜索指数S1 - 从社交媒体数据计算 TODO 需要使用第三方API
|
||||
baidu_index = 0.0
|
||||
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获取
|
||||
# 近30天搜索指数S1 - 使用微信指数除以10计算
|
||||
wechat_index = wechat_index_calculator.process_wechat_index_response(universal_api.wx_index(data.asset_name)) # 通过资产信息获取微信指数
|
||||
search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10
|
||||
# 行业均值S2 TODO 系统内置 未找到相关内容
|
||||
industry_average_s2 = 0.0
|
||||
# 社交媒体传播度S3 - TODO 需要使用第三方API,click_count view_count 未找到对应参数
|
||||
@ -344,8 +342,22 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
|
||||
douyin_views = 0
|
||||
kuaishou_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相关参数
|
||||
|
||||
|
||||
@ -2,12 +2,14 @@ from fastapi import APIRouter, Query
|
||||
|
||||
from app.controllers.dept import dept_controller
|
||||
from app.schemas import Success
|
||||
from app.schemas.base import BasicResponse, MessageOut
|
||||
from app.schemas.depts import BaseDept
|
||||
from app.schemas.depts import *
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/list", summary="查看部门列表")
|
||||
@router.get("/list", summary="查看部门列表", response_model=BasicResponse[list[BaseDept]])
|
||||
async def list_dept(
|
||||
name: str = Query(None, description="部门名称"),
|
||||
):
|
||||
@ -15,7 +17,7 @@ async def list_dept(
|
||||
return Success(data=dept_tree)
|
||||
|
||||
|
||||
@router.get("/get", summary="查看部门")
|
||||
@router.get("/get", summary="查看部门", response_model=BasicResponse[BaseDept])
|
||||
async def get_dept(
|
||||
id: int = Query(..., description="部门ID"),
|
||||
):
|
||||
@ -24,7 +26,7 @@ async def get_dept(
|
||||
return Success(data=data)
|
||||
|
||||
|
||||
@router.post("/create", summary="创建部门")
|
||||
@router.post("/create", summary="创建部门", response_model=BasicResponse[MessageOut])
|
||||
async def create_dept(
|
||||
dept_in: DeptCreate,
|
||||
):
|
||||
@ -32,7 +34,7 @@ async def create_dept(
|
||||
return Success(msg="Created Successfully")
|
||||
|
||||
|
||||
@router.post("/update", summary="更新部门")
|
||||
@router.post("/update", summary="更新部门", response_model=BasicResponse[MessageOut])
|
||||
async def update_dept(
|
||||
dept_in: DeptUpdate,
|
||||
):
|
||||
@ -40,7 +42,7 @@ async def update_dept(
|
||||
return Success(msg="Update Successfully")
|
||||
|
||||
|
||||
@router.delete("/delete", summary="删除部门")
|
||||
@router.delete("/delete", summary="删除部门", response_model=BasicResponse[MessageOut])
|
||||
async def delete_dept(
|
||||
dept_id: int = Query(..., description="部门ID"),
|
||||
):
|
||||
|
||||
@ -3,12 +3,14 @@ from tortoise.expressions import Q
|
||||
|
||||
from app.controllers.esg import esg_controller
|
||||
from app.schemas import Success, SuccessExtra
|
||||
from app.schemas.base import BasicResponse, PageResponse, MessageOut
|
||||
from app.schemas.esg import ESGResponse
|
||||
from app.schemas.esg import ESGCreate, ESGUpdate, ESGResponse
|
||||
|
||||
router = APIRouter(tags=["ESG管理"])
|
||||
|
||||
|
||||
@router.get("/list", summary="查看ESG列表")
|
||||
@router.get("/list", summary="查看ESG列表", response_model=PageResponse[ESGResponse])
|
||||
async def list_esg(
|
||||
page: int = Query(1, description="页码"),
|
||||
page_size: int = Query(10, description="每页数量"),
|
||||
@ -28,7 +30,7 @@ async def list_esg(
|
||||
return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/get", summary="查看ESG详情")
|
||||
@router.get("/get", summary="查看ESG详情", response_model=BasicResponse[ESGResponse])
|
||||
async def get_esg(
|
||||
id: int = Query(..., description="ESG ID"),
|
||||
):
|
||||
@ -37,7 +39,7 @@ async def get_esg(
|
||||
return Success(data=data)
|
||||
|
||||
|
||||
@router.post("/create", summary="创建ESG")
|
||||
@router.post("/create", summary="创建ESG", response_model=BasicResponse[MessageOut])
|
||||
async def create_esg(
|
||||
esg_in: ESGCreate,
|
||||
):
|
||||
@ -49,7 +51,7 @@ async def create_esg(
|
||||
return Success(msg="创建成功")
|
||||
|
||||
|
||||
@router.post("/update", summary="更新ESG")
|
||||
@router.post("/update", summary="更新ESG", response_model=BasicResponse[MessageOut])
|
||||
async def update_esg(
|
||||
esg_in: ESGUpdate,
|
||||
):
|
||||
@ -63,7 +65,7 @@ async def update_esg(
|
||||
return Success(msg="更新成功")
|
||||
|
||||
|
||||
@router.delete("/delete", summary="删除ESG")
|
||||
@router.delete("/delete", summary="删除ESG", response_model=BasicResponse[MessageOut])
|
||||
async def delete_esg(
|
||||
esg_id: int = Query(..., description="ESG ID"),
|
||||
):
|
||||
|
||||
@ -3,12 +3,14 @@ from tortoise.expressions import Q
|
||||
|
||||
from app.controllers.index import index_controller
|
||||
from app.schemas import Success, SuccessExtra
|
||||
from app.schemas.base import BasicResponse, PageResponse, MessageOut
|
||||
from app.schemas.index import IndexResponse
|
||||
from app.schemas.index import IndexCreate, IndexUpdate, IndexResponse
|
||||
|
||||
router = APIRouter(tags=["指数管理"])
|
||||
|
||||
|
||||
@router.get("/list", summary="查看指数列表")
|
||||
@router.get("/list", summary="查看指数列表", response_model=PageResponse[IndexResponse])
|
||||
async def list_index(
|
||||
page: int = Query(1, description="页码"),
|
||||
page_size: int = Query(10, description="每页数量"),
|
||||
@ -25,7 +27,7 @@ async def list_index(
|
||||
return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/get", summary="查看指数详情")
|
||||
@router.get("/get", summary="查看指数详情", response_model=BasicResponse[IndexResponse])
|
||||
async def get_index(
|
||||
id: int = Query(..., description="指数 ID"),
|
||||
):
|
||||
@ -34,7 +36,7 @@ async def get_index(
|
||||
return Success(data=data)
|
||||
|
||||
|
||||
@router.post("/create", summary="创建指数")
|
||||
@router.post("/create", summary="创建指数", response_model=BasicResponse[MessageOut])
|
||||
async def create_index(
|
||||
index_in: IndexCreate,
|
||||
):
|
||||
@ -46,7 +48,7 @@ async def create_index(
|
||||
return Success(msg="创建成功")
|
||||
|
||||
|
||||
@router.post("/update", summary="更新指数")
|
||||
@router.post("/update", summary="更新指数", response_model=BasicResponse[MessageOut])
|
||||
async def update_index(
|
||||
index_in: IndexUpdate,
|
||||
):
|
||||
@ -60,7 +62,7 @@ async def update_index(
|
||||
return Success(msg="更新成功")
|
||||
|
||||
|
||||
@router.delete("/delete", summary="删除指数")
|
||||
@router.delete("/delete", summary="删除指数", response_model=BasicResponse[MessageOut])
|
||||
async def delete_index(
|
||||
index_id: int = Query(..., description="指数 ID"),
|
||||
):
|
||||
|
||||
@ -3,12 +3,13 @@ from tortoise.expressions import Q
|
||||
|
||||
from app.controllers.industry import industry_controller
|
||||
from app.schemas import Success, SuccessExtra
|
||||
from app.schemas.base import BasicResponse, PageResponse, MessageOut
|
||||
from app.schemas.industry import IndustryCreate, IndustryUpdate, IndustryResponse
|
||||
|
||||
router = APIRouter(tags=["行业管理"])
|
||||
|
||||
|
||||
@router.get("/list", summary="查看行业列表")
|
||||
@router.get("/list", summary="查看行业列表", response_model=PageResponse[IndustryResponse])
|
||||
async def list_industry(
|
||||
page: int = Query(1, description="页码"),
|
||||
page_size: int = Query(10, description="每页数量"),
|
||||
@ -25,7 +26,7 @@ async def list_industry(
|
||||
return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/get", summary="查看行业详情")
|
||||
@router.get("/get", summary="查看行业详情", response_model=BasicResponse[IndustryResponse])
|
||||
async def get_industry(
|
||||
id: int = Query(..., description="行业 ID"),
|
||||
):
|
||||
@ -34,7 +35,7 @@ async def get_industry(
|
||||
return Success(data=data)
|
||||
|
||||
|
||||
@router.post("/create", summary="创建行业")
|
||||
@router.post("/create", summary="创建行业", response_model=BasicResponse[MessageOut])
|
||||
async def create_industry(
|
||||
industry_in: IndustryCreate,
|
||||
):
|
||||
@ -46,7 +47,7 @@ async def create_industry(
|
||||
return Success(msg="创建成功")
|
||||
|
||||
|
||||
@router.post("/update", summary="更新行业")
|
||||
@router.post("/update", summary="更新行业", response_model=BasicResponse[MessageOut])
|
||||
async def update_industry(
|
||||
industry_in: IndustryUpdate,
|
||||
):
|
||||
@ -60,7 +61,7 @@ async def update_industry(
|
||||
return Success(msg="更新成功")
|
||||
|
||||
|
||||
@router.delete("/delete", summary="删除行业")
|
||||
@router.delete("/delete", summary="删除行业", response_model=BasicResponse[MessageOut])
|
||||
async def delete_industry(
|
||||
industry_id: int = Query(..., description="行业 ID"),
|
||||
):
|
||||
|
||||
3
app/api/v1/invoice/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .invoice import invoice_router
|
||||
|
||||
__all__ = ["invoice_router"]
|
||||
183
app/api/v1/invoice/invoice.py
Normal file
@ -0,0 +1,183 @@
|
||||
from fastapi import APIRouter, Query, Depends, Header, HTTPException
|
||||
from typing import Optional
|
||||
|
||||
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse, MessageOut
|
||||
from app.schemas.invoice import (
|
||||
InvoiceCreate,
|
||||
InvoiceUpdate,
|
||||
UpdateStatus,
|
||||
UpdateType,
|
||||
InvoiceHeaderCreate,
|
||||
InvoiceHeaderUpdate,
|
||||
PaymentReceiptCreate,
|
||||
AppCreateInvoiceWithReceipt,
|
||||
InvoiceOut,
|
||||
InvoiceList,
|
||||
InvoiceHeaderOut,
|
||||
PaymentReceiptOut,
|
||||
)
|
||||
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.get("/list", summary="获取发票列表", response_model=PageResponse[InvoiceOut], dependencies=[DependAuth, DependPermission])
|
||||
async def list_invoices(
|
||||
phone: Optional[str] = Query(None),
|
||||
company_name: Optional[str] = Query(None),
|
||||
tax_number: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
ticket_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_size: int = Query(10, ge=1, le=100),
|
||||
):
|
||||
"""
|
||||
发票列表查询
|
||||
参数支持按手机号、公司名称、税号、状态、发票类型进行筛选
|
||||
返回分页结构
|
||||
"""
|
||||
result = await invoice_controller.list(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
phone=phone,
|
||||
company_name=company_name,
|
||||
tax_number=tax_number,
|
||||
status=status,
|
||||
ticket_type=ticket_type,
|
||||
invoice_type=invoice_type,
|
||||
app_user_id=user_id,
|
||||
)
|
||||
return SuccessExtra(
|
||||
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])
|
||||
async def invoice_detail(id: int = Query(...)):
|
||||
"""
|
||||
根据ID获取发票详情
|
||||
"""
|
||||
out = await invoice_controller.get_out(id)
|
||||
if not out:
|
||||
return Success(data={}, msg="未找到")
|
||||
return Success(data=out, msg="获取成功")
|
||||
|
||||
|
||||
@invoice_router.post("/create", summary="创建发票", response_model=BasicResponse[InvoiceOut], dependencies=[DependAuth, DependPermission])
|
||||
async def create_invoice(data: InvoiceCreate):
|
||||
"""
|
||||
创建发票记录
|
||||
"""
|
||||
inv = await invoice_controller.create(data)
|
||||
out = await invoice_controller.get_out(inv.id)
|
||||
return Success(data=out, msg="创建成功")
|
||||
|
||||
|
||||
@invoice_router.post("/update", summary="更新发票", response_model=BasicResponse[InvoiceOut], dependencies=[DependAuth, DependPermission])
|
||||
async def update_invoice(data: InvoiceUpdate, id: int = Query(...)):
|
||||
"""
|
||||
更新发票记录
|
||||
"""
|
||||
updated = await invoice_controller.update(id, data)
|
||||
out = await invoice_controller.get_out(id) if updated else None
|
||||
return Success(data=out or {}, msg="更新成功" if updated else "未找到")
|
||||
|
||||
|
||||
@invoice_router.delete("/delete", summary="删除发票", response_model=BasicResponse[MessageOut], dependencies=[DependAuth, DependPermission])
|
||||
async def delete_invoice(id: int = Query(...)):
|
||||
"""
|
||||
删除发票记录
|
||||
"""
|
||||
try:
|
||||
await invoice_controller.remove(id)
|
||||
ok = True
|
||||
except Exception:
|
||||
ok = False
|
||||
return Success(data={"deleted": ok}, msg="删除成功" if ok else "未找到")
|
||||
|
||||
|
||||
@invoice_router.post("/update-status", summary="更新发票状态", response_model=BasicResponse[InvoiceOut], dependencies=[DependAuth, DependPermission])
|
||||
async def update_invoice_status(data: UpdateStatus):
|
||||
"""
|
||||
更新发票状态(pending|invoiced|rejected|refunded)
|
||||
"""
|
||||
out = await invoice_controller.update_status(data)
|
||||
return Success(data=out or {}, msg="更新成功" if out else "未找到")
|
||||
|
||||
|
||||
|
||||
|
||||
@invoice_router.post("/{id}/receipt", summary="上传付款凭证", response_model=BasicResponse[dict], dependencies=[DependAuth, DependPermission])
|
||||
async def upload_payment_receipt(id: int, data: PaymentReceiptCreate):
|
||||
"""
|
||||
上传对公转账付款凭证
|
||||
"""
|
||||
receipt = await invoice_controller.create_receipt(id, data)
|
||||
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||
return Success(data=detail, msg="上传成功")
|
||||
|
||||
|
||||
@invoice_router.get("/headers", summary="发票抬头列表", response_model=PageResponse[InvoiceHeaderOut], dependencies=[DependAuth, DependPermission])
|
||||
async def get_invoice_headers(
|
||||
app_user_id: Optional[int] = Query(None, description="按App用户ID过滤"),
|
||||
user_id: Optional[int] = Query(None, description="按App用户ID过滤(兼容参数)"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
):
|
||||
"""
|
||||
管理端抬头列表(管理员token):支持按 App 用户过滤与分页。
|
||||
"""
|
||||
uid = app_user_id if app_user_id is not None else user_id
|
||||
qs = InvoiceHeader.all()
|
||||
if uid is not None:
|
||||
qs = qs.filter(app_user_id=uid)
|
||||
total = await qs.count()
|
||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||
items = [InvoiceHeaderOut.model_validate(r) for r in rows]
|
||||
return SuccessExtra(data=[i.model_dump() for i in items], total=total, page=page, page_size=page_size, msg="获取成功")
|
||||
|
||||
|
||||
@invoice_router.get("/headers/{id}", summary="发票抬头详情", response_model=BasicResponse[InvoiceHeaderOut], dependencies=[DependAuth, DependPermission])
|
||||
async def get_invoice_header_by_id(id: int):
|
||||
"""
|
||||
获取发票抬头详情
|
||||
"""
|
||||
header = await invoice_controller.get_header_by_id(id)
|
||||
return Success(data=header or {}, msg="获取成功" if header else "未找到")
|
||||
|
||||
|
||||
@invoice_router.post("/headers", summary="新增发票抬头", response_model=BasicResponse[InvoiceHeaderOut], dependencies=[DependAuth, DependPermission])
|
||||
async def create_invoice_header(data: InvoiceHeaderCreate, app_user_id: Optional[int] = Query(None)):
|
||||
"""
|
||||
新增发票抬头
|
||||
"""
|
||||
header = await invoice_controller.create_header(user_id=app_user_id, data=data)
|
||||
return Success(data=header, msg="创建成功")
|
||||
|
||||
|
||||
@invoice_router.put("/{id}/type", summary="更新发票类型", response_model=BasicResponse[InvoiceOut], dependencies=[DependAuth, DependPermission])
|
||||
async def update_invoice_type(id: int, data: UpdateType):
|
||||
"""
|
||||
更新发票的电子/纸质与专票/普票类型
|
||||
"""
|
||||
out = await invoice_controller.update_type(id, data)
|
||||
return Success(data=out or {}, msg="更新成功" if out else "未找到")
|
||||
|
||||
|
||||
@invoice_router.delete("/headers/{id}", summary="删除发票抬头", response_model=BasicResponse[MessageOut], dependencies=[DependAuth, DependPermission])
|
||||
async def delete_invoice_header(id: int):
|
||||
ok = await invoice_controller.delete_header(id)
|
||||
return Success(msg="删除成功" if ok else "未找到")
|
||||
|
||||
|
||||
@invoice_router.put("/headers/{id}", summary="更新发票抬头", response_model=BasicResponse[InvoiceHeaderOut], dependencies=[DependAuth, DependPermission])
|
||||
async def update_invoice_header(id: int, data: InvoiceHeaderUpdate):
|
||||
header = await invoice_controller.update_header(id, data)
|
||||
return Success(data=header or {}, msg="更新成功" if header else "未找到")
|
||||
@ -3,7 +3,8 @@ import logging
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.controllers.menu import menu_controller
|
||||
from app.schemas.base import Fail, Success, SuccessExtra
|
||||
from app.schemas.base import Fail, Success, SuccessExtra, BasicResponse, PageResponse, MessageOut
|
||||
from app.schemas.menus import BaseMenu
|
||||
from app.schemas.menus import *
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -11,7 +12,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/list", summary="查看菜单列表")
|
||||
@router.get("/list", summary="查看菜单列表", response_model=PageResponse[BaseMenu])
|
||||
async def list_menu(
|
||||
page: int = Query(1, description="页码"),
|
||||
page_size: int = Query(10, description="每页数量"),
|
||||
@ -28,7 +29,7 @@ async def list_menu(
|
||||
return SuccessExtra(data=res_menu, total=len(res_menu), page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/get", summary="查看菜单")
|
||||
@router.get("/get", summary="查看菜单", response_model=BasicResponse[BaseMenu])
|
||||
async def get_menu(
|
||||
menu_id: int = Query(..., description="菜单id"),
|
||||
):
|
||||
@ -36,7 +37,7 @@ async def get_menu(
|
||||
return Success(data=result)
|
||||
|
||||
|
||||
@router.post("/create", summary="创建菜单")
|
||||
@router.post("/create", summary="创建菜单", response_model=BasicResponse[MessageOut])
|
||||
async def create_menu(
|
||||
menu_in: MenuCreate,
|
||||
):
|
||||
@ -44,7 +45,7 @@ async def create_menu(
|
||||
return Success(msg="Created Success")
|
||||
|
||||
|
||||
@router.post("/update", summary="更新菜单")
|
||||
@router.post("/update", summary="更新菜单", response_model=BasicResponse[MessageOut])
|
||||
async def update_menu(
|
||||
menu_in: MenuUpdate,
|
||||
):
|
||||
@ -52,7 +53,7 @@ async def update_menu(
|
||||
return Success(msg="Updated Success")
|
||||
|
||||
|
||||
@router.delete("/delete", summary="删除菜单")
|
||||
@router.delete("/delete", summary="删除菜单", response_model=BasicResponse[MessageOut])
|
||||
async def delete_menu(
|
||||
id: int = Query(..., description="菜单id"),
|
||||
):
|
||||
|
||||
@ -3,12 +3,14 @@ from tortoise.expressions import Q
|
||||
|
||||
from app.controllers.policy import policy_controller
|
||||
from app.schemas import Success, SuccessExtra
|
||||
from app.schemas.base import BasicResponse, PageResponse, MessageOut
|
||||
from app.schemas.policy import PolicyResponse
|
||||
from app.schemas.policy import PolicyCreate, PolicyUpdate, PolicyResponse
|
||||
|
||||
router = APIRouter(tags=["政策管理"])
|
||||
|
||||
|
||||
@router.get("/list", summary="查看政策列表")
|
||||
@router.get("/list", summary="查看政策列表", response_model=PageResponse[PolicyResponse])
|
||||
async def list_policy(
|
||||
page: int = Query(1, description="页码"),
|
||||
page_size: int = Query(10, description="每页数量"),
|
||||
@ -28,7 +30,7 @@ async def list_policy(
|
||||
return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/get", summary="查看政策详情")
|
||||
@router.get("/get", summary="查看政策详情", response_model=BasicResponse[PolicyResponse])
|
||||
async def get_policy(
|
||||
id: int = Query(..., description="政策 ID"),
|
||||
):
|
||||
@ -37,7 +39,7 @@ async def get_policy(
|
||||
return Success(data=data)
|
||||
|
||||
|
||||
@router.post("/create", summary="创建政策")
|
||||
@router.post("/create", summary="创建政策", response_model=BasicResponse[MessageOut])
|
||||
async def create_policy(
|
||||
policy_in: PolicyCreate,
|
||||
):
|
||||
@ -49,7 +51,7 @@ async def create_policy(
|
||||
return Success(msg="创建成功")
|
||||
|
||||
|
||||
@router.post("/update", summary="更新政策")
|
||||
@router.post("/update", summary="更新政策", response_model=BasicResponse[MessageOut])
|
||||
async def update_policy(
|
||||
policy_in: PolicyUpdate,
|
||||
):
|
||||
@ -63,7 +65,7 @@ async def update_policy(
|
||||
return Success(msg="更新成功")
|
||||
|
||||
|
||||
@router.delete("/delete", summary="删除政策")
|
||||
@router.delete("/delete", summary="删除政策", response_model=BasicResponse[MessageOut])
|
||||
async def delete_policy(
|
||||
policy_id: int = Query(..., description="政策 ID"),
|
||||
):
|
||||
|
||||
@ -5,14 +5,15 @@ from fastapi.exceptions import HTTPException
|
||||
from tortoise.expressions import Q
|
||||
|
||||
from app.controllers import role_controller
|
||||
from app.schemas.base import Success, SuccessExtra
|
||||
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse, MessageOut
|
||||
from app.schemas.roles import BaseRole
|
||||
from app.schemas.roles import *
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/list", summary="查看角色列表")
|
||||
@router.get("/list", summary="查看角色列表", response_model=PageResponse[BaseRole])
|
||||
async def list_role(
|
||||
page: int = Query(1, description="页码"),
|
||||
page_size: int = Query(10, description="每页数量"),
|
||||
@ -26,7 +27,7 @@ async def list_role(
|
||||
return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/get", summary="查看角色")
|
||||
@router.get("/get", summary="查看角色", response_model=BasicResponse[BaseRole])
|
||||
async def get_role(
|
||||
role_id: int = Query(..., description="角色ID"),
|
||||
):
|
||||
@ -34,7 +35,7 @@ async def get_role(
|
||||
return Success(data=await role_obj.to_dict())
|
||||
|
||||
|
||||
@router.post("/create", summary="创建角色")
|
||||
@router.post("/create", summary="创建角色", response_model=BasicResponse[MessageOut])
|
||||
async def create_role(role_in: RoleCreate):
|
||||
if await role_controller.is_exist(name=role_in.name):
|
||||
raise HTTPException(
|
||||
@ -45,13 +46,13 @@ async def create_role(role_in: RoleCreate):
|
||||
return Success(msg="Created Successfully")
|
||||
|
||||
|
||||
@router.post("/update", summary="更新角色")
|
||||
@router.post("/update", summary="更新角色", response_model=BasicResponse[MessageOut])
|
||||
async def update_role(role_in: RoleUpdate):
|
||||
await role_controller.update(id=role_in.id, obj_in=role_in)
|
||||
return Success(msg="Updated Successfully")
|
||||
|
||||
|
||||
@router.delete("/delete", summary="删除角色")
|
||||
@router.delete("/delete", summary="删除角色", response_model=BasicResponse[MessageOut])
|
||||
async def delete_role(
|
||||
role_id: int = Query(..., description="角色ID"),
|
||||
):
|
||||
@ -59,14 +60,14 @@ async def delete_role(
|
||||
return Success(msg="Deleted Success")
|
||||
|
||||
|
||||
@router.get("/authorized", summary="查看角色权限")
|
||||
@router.get("/authorized", summary="查看角色权限", response_model=BasicResponse[BaseRole])
|
||||
async def get_role_authorized(id: int = Query(..., description="角色ID")):
|
||||
role_obj = await role_controller.get(id=id)
|
||||
data = await role_obj.to_dict(m2m=True)
|
||||
return Success(data=data)
|
||||
|
||||
|
||||
@router.post("/authorized", summary="更新角色权限")
|
||||
@router.post("/authorized", summary="更新角色权限", response_model=BasicResponse[MessageOut])
|
||||
async def update_role_authorized(role_in: RoleUpdateMenusApis):
|
||||
role_obj = await role_controller.get(id=role_in.id)
|
||||
await role_controller.update_roles(role=role_obj, menu_ids=role_in.menu_ids, api_infos=role_in.api_infos)
|
||||
|
||||
224
app/api/v1/sms/sms.py
Normal file
@ -0,0 +1,224 @@
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
import time
|
||||
|
||||
from app.services.sms_client import sms_client
|
||||
from app.services.rate_limiter import PhoneRateLimiter
|
||||
from app.services.sms_store import store
|
||||
from app.core.dependency import DependAuth
|
||||
from app.log import logger
|
||||
from app.schemas.app_user import AppUserInfoOut, AppUserJWTOut
|
||||
from app.schemas.base import BasicResponse, Success
|
||||
|
||||
|
||||
class SendCodeRequest(BaseModel):
|
||||
phone: str = Field(...)
|
||||
|
||||
|
||||
class SendReportRequest(BaseModel):
|
||||
phone: str = Field(...)
|
||||
|
||||
|
||||
class VerifyCodeRequest(BaseModel):
|
||||
phone: str = Field(...)
|
||||
code: str = Field(...)
|
||||
|
||||
|
||||
class SendResponse(BaseModel):
|
||||
status: str = Field(..., description="发送状态")
|
||||
message: str = Field(..., description="说明")
|
||||
request_id: Optional[str] = Field(None, description="请求ID")
|
||||
|
||||
|
||||
class VerifyResponse(BaseModel):
|
||||
status: str = Field(..., description="验证状态")
|
||||
message: str = Field(..., description="说明")
|
||||
|
||||
|
||||
class SMSLoginResponse(BaseModel):
|
||||
user: AppUserInfoOut
|
||||
token: AppUserJWTOut
|
||||
|
||||
|
||||
rate_limiter = PhoneRateLimiter(60)
|
||||
router = APIRouter(tags=["短信服务"])
|
||||
|
||||
|
||||
@router.post("/send-code", response_model=BasicResponse[dict], summary="验证码发送")
|
||||
async def send_code(payload: SendCodeRequest) -> BasicResponse[dict]:
|
||||
"""发送验证码短信
|
||||
|
||||
Args:
|
||||
payload: 请求体,含手机号与验证码
|
||||
|
||||
Returns:
|
||||
发送结果响应
|
||||
"""
|
||||
ok, reason = store.allow_send(payload.phone)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(reason))
|
||||
try:
|
||||
otp = store.generate_code()
|
||||
store.set_code(payload.phone, otp)
|
||||
from app.settings import settings
|
||||
if settings.SMS_DEBUG_LOG_CODE:
|
||||
logger.info("sms.code generated phone={} code={}", payload.phone, otp)
|
||||
res = sms_client.send_code(payload.phone, otp)
|
||||
code = res.get("Code") or res.get("ResponseCode")
|
||||
rid = res.get("RequestId") or res.get("MessageId")
|
||||
if code == "OK":
|
||||
logger.info("sms.send_code success phone={} request_id={}", payload.phone, rid)
|
||||
return Success(
|
||||
data={
|
||||
"status": "OK",
|
||||
"message": "sent",
|
||||
"request_id": str(rid) if rid else None,
|
||||
}
|
||||
)
|
||||
msg = res.get("Message") or res.get("ResponseDescription") or "error"
|
||||
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))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("sms.send_code exception err={}", repr(e))
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="短信服务异常")
|
||||
|
||||
|
||||
@router.post("/send-report", response_model=BasicResponse[dict], summary="报告通知发送", dependencies=[DependAuth])
|
||||
async def send_report(payload: SendReportRequest) -> BasicResponse[dict]:
|
||||
"""发送报告通知短信
|
||||
|
||||
Args:
|
||||
payload: 请求体,含手机号
|
||||
|
||||
Returns:
|
||||
发送结果响应
|
||||
"""
|
||||
ok, reason = store.allow_send(payload.phone)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(reason))
|
||||
try:
|
||||
res = sms_client.send_report(payload.phone)
|
||||
code = res.get("Code") or res.get("ResponseCode")
|
||||
rid = res.get("RequestId") or res.get("MessageId")
|
||||
if code == "OK":
|
||||
logger.info("sms.send_report success phone={} request_id={}", payload.phone, rid)
|
||||
return Success(
|
||||
data={
|
||||
"status": "OK",
|
||||
"message": "sent",
|
||||
"request_id": str(rid) if rid else None,
|
||||
}
|
||||
)
|
||||
msg = res.get("Message") or res.get("ResponseDescription") or "error"
|
||||
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))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("sms.send_report exception err={}", repr(e))
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="短信服务异常")
|
||||
|
||||
|
||||
@router.post("/verify-code", summary="验证码验证", response_model=BasicResponse[dict])
|
||||
async def verify_code(payload: VerifyCodeRequest) -> BasicResponse[dict]:
|
||||
"""验证验证码
|
||||
|
||||
Args:
|
||||
payload: 请求体,含手机号与验证码
|
||||
|
||||
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)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=str(reason))
|
||||
record = store.get_code(payload.phone)
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已过期")
|
||||
code, expires_at = record
|
||||
if time.time() > expires_at:
|
||||
store.clear_code(payload.phone)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已过期")
|
||||
if payload.code != code:
|
||||
count, locked = store.record_verify_failure(payload.phone)
|
||||
if locked:
|
||||
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="尝试次数过多,已锁定")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误")
|
||||
store.clear_code(payload.phone)
|
||||
store.reset_failures(payload.phone)
|
||||
logger.info("sms.verify_code success phone={}", payload.phone)
|
||||
store.mark_verified(payload.phone)
|
||||
return Success(data={"status": "OK", "message": "verified"})
|
||||
|
||||
|
||||
class SMSLoginRequest(BaseModel):
|
||||
phone_number: str = Field(...)
|
||||
verification_code: str = Field(...)
|
||||
device_id: Optional[str] = Field(None)
|
||||
|
||||
|
||||
@router.post("/login", summary="短信验证码登录", response_model=BasicResponse[dict])
|
||||
async def sms_login(payload: SMSLoginRequest) -> BasicResponse[dict]:
|
||||
from app.settings import settings
|
||||
bypass = settings.SMS_BYPASS_CODE and payload.verification_code == settings.SMS_BYPASS_CODE
|
||||
if not bypass:
|
||||
ok, reason = store.can_verify(payload.phone_number)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=str(reason))
|
||||
record = store.get_code(payload.phone_number)
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="验证码过期")
|
||||
code, expires_at = record
|
||||
if time.time() > expires_at:
|
||||
store.clear_code(payload.phone_number)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="验证码过期")
|
||||
if payload.verification_code != code:
|
||||
count, locked = store.record_verify_failure(payload.phone_number)
|
||||
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.schemas.app_user import AppUserRegisterSchema, AppUserInfoOut, AppUserJWTOut
|
||||
from app.utils.app_user_jwt import create_app_user_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
|
||||
user = await app_user_controller.get_user_by_phone(payload.phone_number)
|
||||
if user is None:
|
||||
user = await app_user_controller.register(AppUserRegisterSchema(phone=payload.phone_number))
|
||||
await app_user_controller.update_last_login(user.id)
|
||||
|
||||
access_token = create_app_user_access_token(user_id=user.id, phone=user.phone)
|
||||
if not bypass:
|
||||
store.clear_code(payload.phone_number)
|
||||
store.reset_failures(payload.phone_number)
|
||||
logger.info("sms.login success phone={}", payload.phone_number)
|
||||
|
||||
user_info = AppUserInfoOut(
|
||||
id=user.id,
|
||||
phone=user.phone,
|
||||
nickname=getattr(user, "alias", None),
|
||||
avatar=None,
|
||||
company_name=user.company_name,
|
||||
company_address=user.company_address,
|
||||
company_contact=user.company_contact,
|
||||
company_phone=user.company_phone,
|
||||
company_email=user.company_email,
|
||||
is_active=user.is_active,
|
||||
last_login=user.last_login,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
remaining_quota=user.remaining_quota,
|
||||
)
|
||||
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()})
|
||||
class VerifyCodeRequest(BaseModel):
|
||||
phone: str = Field(...)
|
||||
code: str = Field(...)
|
||||
3
app/api/v1/transactions/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .transactions import transactions_router
|
||||
|
||||
__all__ = ["transactions_router"]
|
||||
173
app/api/v1/transactions/transactions.py
Normal file
@ -0,0 +1,173 @@
|
||||
from fastapi import APIRouter, Query, UploadFile, File, HTTPException
|
||||
from typing import Optional
|
||||
|
||||
from app.schemas.base import Success, SuccessExtra, PageResponse, BasicResponse
|
||||
from app.schemas.invoice import PaymentReceiptOut
|
||||
from app.controllers.invoice import invoice_controller
|
||||
from app.models.invoice import PaymentReceipt
|
||||
from fastapi import Body
|
||||
from app.schemas.transactions import SendEmailRequest, SendEmailResponse
|
||||
from app.services.email_client import email_client
|
||||
from app.models.invoice import EmailSendLog
|
||||
from app.settings.config import settings
|
||||
from app.log.log import logger
|
||||
import httpx
|
||||
|
||||
|
||||
transactions_router = APIRouter(tags=["交易管理"])
|
||||
|
||||
|
||||
@transactions_router.get("/receipts", summary="对公转账记录列表", response_model=PageResponse[PaymentReceiptOut])
|
||||
async def list_receipts(
|
||||
phone: Optional[str] = Query(None),
|
||||
wechat: Optional[str] = Query(None),
|
||||
company_name: Optional[str] = Query(None),
|
||||
tax_number: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
ticket_type: Optional[str] = Query(None),
|
||||
invoice_type: Optional[str] = Query(None),
|
||||
created_at: Optional[list[int]] = Query(None),
|
||||
submitted_start: Optional[str] = Query(None),
|
||||
submitted_end: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
):
|
||||
"""
|
||||
对公转账记录列表(含提交时间、凭证与关联企业信息)
|
||||
"""
|
||||
result = await invoice_controller.list_receipts(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
phone=phone,
|
||||
wechat=wechat,
|
||||
company_name=company_name,
|
||||
tax_number=tax_number,
|
||||
status=status,
|
||||
ticket_type=ticket_type,
|
||||
invoice_type=invoice_type,
|
||||
created_at=created_at,
|
||||
submitted_start=submitted_start,
|
||||
submitted_end=submitted_end,
|
||||
)
|
||||
return SuccessExtra(
|
||||
data=result["items"],
|
||||
total=result["total"],
|
||||
page=result["page"],
|
||||
page_size=result["page_size"],
|
||||
msg="获取成功",
|
||||
)
|
||||
|
||||
|
||||
@transactions_router.get("/receipts/{id}", summary="对公转账记录详情", response_model=BasicResponse[PaymentReceiptOut])
|
||||
async def get_receipt_detail(id: int):
|
||||
"""
|
||||
对公转账记录详情
|
||||
"""
|
||||
data = await invoice_controller.get_receipt_by_id(id)
|
||||
return Success(data=data or {}, msg="获取成功" if data else "未找到")
|
||||
|
||||
|
||||
|
||||
|
||||
@transactions_router.post("/send-email", summary="发送邮件", response_model=BasicResponse[SendEmailResponse])
|
||||
async def send_email(payload: SendEmailRequest = Body(...)):
|
||||
|
||||
attachments = []
|
||||
urls = []
|
||||
try:
|
||||
domain = payload.email.split("@")[-1]
|
||||
import dns.resolver
|
||||
try:
|
||||
dns.resolver.resolve(domain, "MX")
|
||||
except Exception:
|
||||
dns.resolver.resolve(domain, "A")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="收件方地址域名不可用或未正确解析")
|
||||
if payload.file_urls:
|
||||
urls.extend([u.strip().strip('`') for u in payload.file_urls if isinstance(u, str)])
|
||||
if payload.file_url:
|
||||
if isinstance(payload.file_url, str):
|
||||
urls.append(payload.file_url.strip().strip('`'))
|
||||
elif isinstance(payload.file_url, list):
|
||||
urls.extend([u.strip().strip('`') for u in payload.file_url if isinstance(u, str)])
|
||||
if urls:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for u in urls:
|
||||
r = await client.get(u)
|
||||
r.raise_for_status()
|
||||
attachments.append((r.content, u.split("/")[-1]))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"附件下载失败: {e}")
|
||||
|
||||
logger.info("transactions.email_send_start email={} subject={}", payload.email, payload.subject or "")
|
||||
try:
|
||||
result = email_client.send_many(payload.email, payload.subject, payload.body, attachments)
|
||||
except RuntimeError as e:
|
||||
result = {"status": "FAIL", "error": str(e)}
|
||||
except Exception as e:
|
||||
result = {"status": "FAIL", "error": str(e)}
|
||||
|
||||
body_summary = payload.body
|
||||
status = result.get("status")
|
||||
error = result.get("error")
|
||||
first_name = attachments[0][1] if attachments else None
|
||||
first_url = urls[0] if urls else None
|
||||
log = await EmailSendLog.create(
|
||||
email=payload.email,
|
||||
subject=payload.subject,
|
||||
body_summary=body_summary,
|
||||
file_name=first_name,
|
||||
file_url=first_url,
|
||||
status=status,
|
||||
error=error,
|
||||
)
|
||||
if status == "OK":
|
||||
logger.info("transactions.email_send_ok email={}", payload.email)
|
||||
else:
|
||||
logger.error("transactions.email_send_fail email={} err={}", payload.email, error)
|
||||
|
||||
if payload.receipt_id:
|
||||
try:
|
||||
r = await PaymentReceipt.filter(id=payload.receipt_id).first()
|
||||
if r:
|
||||
try:
|
||||
inv = await r.invoice
|
||||
if inv:
|
||||
s = str(payload.status or "").lower()
|
||||
if s in {"invoiced", "success"}:
|
||||
target = "invoiced"
|
||||
elif s in {"refunded", "rejected", "pending"}:
|
||||
target = s
|
||||
else:
|
||||
target = "invoiced"
|
||||
inv.status = target
|
||||
await inv.save()
|
||||
logger.info("transactions.invoice_status_updated receipt_id={} invoice_id={} status={}", payload.receipt_id, inv.id, target)
|
||||
except Exception as e2:
|
||||
logger.warning("transactions.invoice_status_update_fail receipt_id={} err={}", payload.receipt_id, str(e2))
|
||||
except Exception as e:
|
||||
logger.error("transactions.email_extra_save_fail id={} err={}", payload.receipt_id, str(e))
|
||||
|
||||
return Success(data={"status": status, "log_id": log.id, "error": error}, msg="发送成功" if status == "OK" else "发送失败")
|
||||
|
||||
|
||||
@transactions_router.get("/smtp-config", summary="SMTP配置状态", response_model=BasicResponse[dict])
|
||||
async def smtp_config_status():
|
||||
configured = all([
|
||||
settings.SMTP_HOST,
|
||||
settings.SMTP_PORT,
|
||||
settings.SMTP_FROM,
|
||||
settings.SMTP_USERNAME,
|
||||
settings.SMTP_PASSWORD,
|
||||
])
|
||||
data = {
|
||||
"host": bool(settings.SMTP_HOST),
|
||||
"port": bool(settings.SMTP_PORT),
|
||||
"from": bool(settings.SMTP_FROM),
|
||||
"username": bool(settings.SMTP_USERNAME),
|
||||
"password": bool(settings.SMTP_PASSWORD),
|
||||
"tls": settings.SMTP_TLS,
|
||||
"configured": configured,
|
||||
}
|
||||
return Success(data=data, msg="OK")
|
||||
@ -1,14 +1,11 @@
|
||||
from fastapi import APIRouter, UploadFile, File
|
||||
from app.controllers.upload import UploadController
|
||||
from app.schemas.upload import ImageUploadResponse
|
||||
from app.schemas.upload import ImageUploadResponse, FileUploadResponse
|
||||
from app.schemas.base import BasicResponse, Success
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/image", response_model=ImageUploadResponse, summary="上传图片")
|
||||
async def upload_image(file: UploadFile = File(...)) -> ImageUploadResponse:
|
||||
"""
|
||||
上传图片接口
|
||||
:param file: 图片文件
|
||||
:return: 图片URL和文件名
|
||||
"""
|
||||
return await UploadController.upload_image(file)
|
||||
@router.post("/file", response_model=BasicResponse[dict], summary="统一上传接口")
|
||||
async def upload(file: UploadFile = File(...)) -> BasicResponse[dict]:
|
||||
res = await UploadController.upload_any(file)
|
||||
return Success(data={"url": res.url, "filename": res.filename, "content_type": res.content_type})
|
||||
|
||||
@ -5,7 +5,8 @@ from tortoise.expressions import Q
|
||||
|
||||
from app.controllers.dept import dept_controller
|
||||
from app.controllers.user import user_controller
|
||||
from app.schemas.base import Fail, Success, SuccessExtra
|
||||
from app.schemas.base import Fail, Success, SuccessExtra, BasicResponse, PageResponse, MessageOut
|
||||
from app.schemas.users import BaseUser
|
||||
from app.schemas.users import *
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -13,7 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/list", summary="查看用户列表")
|
||||
@router.get("/list", summary="查看用户列表", response_model=PageResponse[BaseUser])
|
||||
async def list_user(
|
||||
page: int = Query(1, description="页码"),
|
||||
page_size: int = Query(10, description="每页数量"),
|
||||
@ -37,7 +38,7 @@ async def list_user(
|
||||
return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/get", summary="查看用户")
|
||||
@router.get("/get", summary="查看用户", response_model=BasicResponse[BaseUser])
|
||||
async def get_user(
|
||||
user_id: int = Query(..., description="用户ID"),
|
||||
):
|
||||
@ -46,7 +47,7 @@ async def get_user(
|
||||
return Success(data=user_dict)
|
||||
|
||||
|
||||
@router.post("/create", summary="创建用户")
|
||||
@router.post("/create", summary="创建用户", response_model=BasicResponse[MessageOut])
|
||||
async def create_user(
|
||||
user_in: UserCreate,
|
||||
):
|
||||
@ -58,7 +59,7 @@ async def create_user(
|
||||
return Success(msg="Created Successfully")
|
||||
|
||||
|
||||
@router.post("/update", summary="更新用户")
|
||||
@router.post("/update", summary="更新用户", response_model=BasicResponse[MessageOut])
|
||||
async def update_user(
|
||||
user_in: UserUpdate,
|
||||
):
|
||||
@ -67,7 +68,7 @@ async def update_user(
|
||||
return Success(msg="Updated Successfully")
|
||||
|
||||
|
||||
@router.delete("/delete", summary="删除用户")
|
||||
@router.delete("/delete", summary="删除用户", response_model=BasicResponse[MessageOut])
|
||||
async def delete_user(
|
||||
user_id: int = Query(..., description="用户ID"),
|
||||
):
|
||||
@ -75,7 +76,7 @@ async def delete_user(
|
||||
return Success(msg="Deleted Successfully")
|
||||
|
||||
|
||||
@router.post("/reset_password", summary="重置密码")
|
||||
@router.post("/reset_password", summary="重置密码", response_model=BasicResponse[MessageOut])
|
||||
async def reset_password(user_id: int = Body(..., description="用户ID", embed=True)):
|
||||
await user_controller.reset_password(user_id)
|
||||
return Success(msg="密码已重置为123456")
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
from app.controllers.valuation import valuation_controller
|
||||
from app.schemas.valuation import (
|
||||
@ -9,15 +9,16 @@ from app.schemas.valuation import (
|
||||
ValuationAssessmentList,
|
||||
ValuationAssessmentQuery,
|
||||
ValuationApprovalRequest,
|
||||
ValuationAdminNotesUpdate
|
||||
ValuationAdminNotesUpdate,
|
||||
ValuationCalculationStepOut
|
||||
)
|
||||
from app.schemas.base import Success, SuccessExtra
|
||||
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse
|
||||
from app.core.ctx import CTX_USER_ID
|
||||
|
||||
valuations_router = APIRouter(tags=["估值评估"])
|
||||
|
||||
|
||||
@valuations_router.post("/", summary="创建估值评估")
|
||||
@valuations_router.post("/", summary="创建估值评估", response_model=BasicResponse[ValuationAssessmentOut])
|
||||
async def create_valuation(data: ValuationAssessmentCreate):
|
||||
"""创建新的估值评估记录"""
|
||||
try:
|
||||
@ -25,37 +26,78 @@ async def create_valuation(data: ValuationAssessmentCreate):
|
||||
user_id = CTX_USER_ID.get()
|
||||
print(user_id)
|
||||
result = await valuation_controller.create(data, user_id)
|
||||
return Success(data=result, msg="创建成功")
|
||||
import json
|
||||
return Success(data=json.loads(result.model_dump_json()), msg="创建成功")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"创建失败: {str(e)}")
|
||||
|
||||
|
||||
@valuations_router.get("/statistics/overview", summary="获取统计信息")
|
||||
@valuations_router.get("/statistics/overview", summary="获取统计信息", response_model=BasicResponse[dict])
|
||||
async def get_statistics():
|
||||
"""获取估值评估统计信息"""
|
||||
result = await valuation_controller.get_statistics()
|
||||
return Success(data=result, msg="获取统计信息成功")
|
||||
|
||||
|
||||
@valuations_router.get("/{valuation_id}", summary="获取估值评估详情")
|
||||
@valuations_router.get("/{valuation_id}", summary="获取估值评估详情", response_model=BasicResponse[ValuationAssessmentOut])
|
||||
async def get_valuation(valuation_id: int):
|
||||
"""根据ID获取估值评估详情"""
|
||||
result = await valuation_controller.get_by_id(valuation_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="估值评估记录不存在")
|
||||
return Success(data=result, msg="获取成功")
|
||||
import json
|
||||
return Success(data=json.loads(result.model_dump_json()), msg="获取成功")
|
||||
|
||||
|
||||
@valuations_router.put("/{valuation_id}", summary="更新估值评估")
|
||||
@valuations_router.get("/{valuation_id}/steps", summary="获取估值计算步骤", response_model=BasicResponse[List[ValuationCalculationStepOut]])
|
||||
async def get_valuation_steps(valuation_id: int):
|
||||
"""根据估值ID获取所有计算步骤"""
|
||||
steps = await valuation_controller.get_calculation_steps(valuation_id)
|
||||
if not steps:
|
||||
raise HTTPException(status_code=404, detail="未找到该估值的计算步骤")
|
||||
import json
|
||||
steps_out = [json.loads(step.model_dump_json()) for step in steps]
|
||||
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])
|
||||
async def update_valuation(valuation_id: int, data: ValuationAssessmentUpdate):
|
||||
"""更新估值评估记录"""
|
||||
result = await valuation_controller.update(valuation_id, data)
|
||||
result = await valuation_controller.update1(valuation_id, data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="估值评估记录不存在")
|
||||
return Success(data=result, msg="更新成功")
|
||||
import json
|
||||
return Success(data=json.loads(result.model_dump_json()), msg="更新成功")
|
||||
|
||||
|
||||
@valuations_router.delete("/{valuation_id}", summary="删除估值评估")
|
||||
@valuations_router.delete("/{valuation_id}", summary="删除估值评估", response_model=BasicResponse[dict])
|
||||
async def delete_valuation(valuation_id: int):
|
||||
"""软删除估值评估记录"""
|
||||
result = await valuation_controller.delete(valuation_id)
|
||||
@ -64,7 +106,7 @@ async def delete_valuation(valuation_id: int):
|
||||
return Success(data={"deleted": True}, msg="删除成功")
|
||||
|
||||
|
||||
@valuations_router.get("/", summary="获取估值评估列表")
|
||||
@valuations_router.get("/", summary="获取估值评估列表", response_model=PageResponse[ValuationAssessmentOut])
|
||||
async def get_valuations(
|
||||
asset_name: Optional[str] = Query(None, description="资产名称"),
|
||||
institution: Optional[str] = Query(None, description="所属机构"),
|
||||
@ -72,8 +114,14 @@ async def get_valuations(
|
||||
heritage_level: Optional[str] = Query(None, description="非遗等级"),
|
||||
status: Optional[str] = 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="页码"),
|
||||
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(
|
||||
@ -83,12 +131,19 @@ async def get_valuations(
|
||||
heritage_level=heritage_level,
|
||||
status=status,
|
||||
is_active=is_active,
|
||||
phone=phone,
|
||||
submitted_start=submitted_start,
|
||||
submitted_end=submitted_end,
|
||||
audited_start=audited_start,
|
||||
audited_end=audited_end,
|
||||
page=page,
|
||||
size=size
|
||||
size=page_size if page_size is not None else size
|
||||
)
|
||||
result = await valuation_controller.get_list(query)
|
||||
import json
|
||||
items = [json.loads(item.model_dump_json()) for item in result.items]
|
||||
return SuccessExtra(
|
||||
data=result.items,
|
||||
data=items,
|
||||
total=result.total,
|
||||
page=result.page,
|
||||
page_size=result.size,
|
||||
@ -97,16 +152,19 @@ async def get_valuations(
|
||||
)
|
||||
|
||||
|
||||
@valuations_router.get("/search/keyword", summary="搜索估值评估")
|
||||
@valuations_router.get("/search/keyword", summary="搜索估值评估", response_model=PageResponse[ValuationAssessmentOut])
|
||||
async def search_valuations(
|
||||
keyword: str = Query(..., description="搜索关键词"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
size: int = Query(10, ge=1, le=100, description="每页数量")
|
||||
size: int = Query(10, ge=1, le=100, description="每页数量"),
|
||||
page_size: Optional[int] = Query(None, alias="page_size", ge=1, le=100, description="每页数量")
|
||||
):
|
||||
"""根据关键词搜索估值评估记录"""
|
||||
result = await valuation_controller.search(keyword, page, size)
|
||||
result = await valuation_controller.search(keyword, page, page_size if page_size is not None else size)
|
||||
import json
|
||||
items = [json.loads(item.model_dump_json()) for item in result.items]
|
||||
return SuccessExtra(
|
||||
data=result.items,
|
||||
data=items,
|
||||
total=result.total,
|
||||
page=result.page,
|
||||
page_size=result.size,
|
||||
@ -116,7 +174,7 @@ async def search_valuations(
|
||||
|
||||
|
||||
# 批量操作接口
|
||||
@valuations_router.post("/batch/delete", summary="批量删除估值评估")
|
||||
@valuations_router.post("/batch/delete", summary="批量删除估值评估", response_model=BasicResponse[dict])
|
||||
async def batch_delete_valuations(valuation_ids: list[int]):
|
||||
"""批量软删除估值评估记录"""
|
||||
success_count = 0
|
||||
@ -140,7 +198,7 @@ async def batch_delete_valuations(valuation_ids: list[int]):
|
||||
|
||||
|
||||
# 导出接口
|
||||
@valuations_router.get("/export/excel", summary="导出估值评估数据")
|
||||
@valuations_router.get("/export/excel", summary="导出估值评估数据", response_model=BasicResponse[dict])
|
||||
async def export_valuations(
|
||||
asset_name: Optional[str] = Query(None, description="资产名称"),
|
||||
institution: Optional[str] = Query(None, description="所属机构"),
|
||||
@ -154,28 +212,31 @@ async def export_valuations(
|
||||
|
||||
|
||||
# 审核管理接口
|
||||
@valuations_router.post("/{valuation_id}/approve", summary="审核通过估值评估")
|
||||
@valuations_router.post("/{valuation_id}/approve", summary="审核通过估值评估", response_model=BasicResponse[ValuationAssessmentOut])
|
||||
async def approve_valuation(valuation_id: int, data: ValuationApprovalRequest):
|
||||
"""审核通过估值评估"""
|
||||
result = await valuation_controller.approve_valuation(valuation_id, data.admin_notes)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="估值评估记录不存在")
|
||||
return Success(data=result, msg="审核通过成功")
|
||||
import json
|
||||
return Success(data=json.loads(result.model_dump_json()), msg="审核通过成功")
|
||||
|
||||
|
||||
@valuations_router.post("/{valuation_id}/reject", summary="审核拒绝估值评估")
|
||||
@valuations_router.post("/{valuation_id}/reject", summary="审核拒绝估值评估", response_model=BasicResponse[ValuationAssessmentOut])
|
||||
async def reject_valuation(valuation_id: int, data: ValuationApprovalRequest):
|
||||
"""审核拒绝估值评估"""
|
||||
result = await valuation_controller.reject_valuation(valuation_id, data.admin_notes)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="估值评估记录不存在")
|
||||
return Success(data=result, msg="审核拒绝成功")
|
||||
import json
|
||||
return Success(data=json.loads(result.model_dump_json()), msg="审核拒绝成功")
|
||||
|
||||
|
||||
@valuations_router.put("/{valuation_id}/admin-notes", summary="更新管理员备注")
|
||||
@valuations_router.put("/{valuation_id}/admin-notes", summary="更新管理员备注", response_model=BasicResponse[ValuationAssessmentOut])
|
||||
async def update_admin_notes(valuation_id: int, data: ValuationAdminNotesUpdate):
|
||||
"""更新管理员备注"""
|
||||
result = await valuation_controller.update_admin_notes(valuation_id, data.admin_notes)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="估值评估记录不存在")
|
||||
return Success(data=result, msg="管理员备注更新成功")
|
||||
import json
|
||||
return Success(data=json.loads(result.model_dump_json()), msg="管理员备注更新成功")
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from app.models.user import AppUser
|
||||
from app.models.user import AppUserQuotaLog
|
||||
from app.schemas.app_user import AppUserRegisterSchema, AppUserLoginSchema, AppUserUpdateSchema
|
||||
from app.utils.password import get_password_hash, verify_password
|
||||
from app.core.crud import CRUDBase
|
||||
@ -20,6 +21,15 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
||||
# 检查手机号是否已存在
|
||||
existing_user = await self.model.filter(phone=register_data.phone).first()
|
||||
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="手机号已存在")
|
||||
|
||||
# 生成默认密码:手机号后六位
|
||||
@ -41,7 +51,7 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
||||
用户认证
|
||||
"""
|
||||
user = await self.model.filter(
|
||||
phone=login_data.phone, is_active=True
|
||||
phone=login_data.phone, is_active=True, is_deleted=False
|
||||
).first()
|
||||
|
||||
if not user:
|
||||
@ -56,13 +66,13 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
||||
"""
|
||||
根据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]:
|
||||
"""
|
||||
根据手机号获取用户
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
@ -85,11 +95,40 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
||||
|
||||
# 更新字段
|
||||
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():
|
||||
setattr(user, field, value)
|
||||
|
||||
await user.save()
|
||||
return user
|
||||
|
||||
async def update_quota(self, operator_id: int, operator_name: str, user_id: int, target_count: Optional[int] = None, delta: Optional[int] = None, op_type: str = "调整", remark: Optional[str] = None) -> Optional[AppUser]:
|
||||
user = await self.model.filter(id=user_id).first()
|
||||
if not user:
|
||||
return None
|
||||
before = int(getattr(user, "remaining_quota", 0) or 0)
|
||||
after = before
|
||||
if target_count is not None:
|
||||
after = max(0, int(target_count))
|
||||
elif delta is not None:
|
||||
after = max(0, before + int(delta))
|
||||
user.remaining_quota = after
|
||||
await user.save()
|
||||
await AppUserQuotaLog.create(
|
||||
app_user_id=user_id,
|
||||
operator_id=operator_id,
|
||||
operator_name=operator_name,
|
||||
before_count=before,
|
||||
after_count=after,
|
||||
op_type=op_type,
|
||||
remark=remark,
|
||||
)
|
||||
# if remark is not None:
|
||||
# user.notes = remark
|
||||
# await user.save()
|
||||
return user
|
||||
|
||||
async def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||
"""
|
||||
@ -119,6 +158,27 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
||||
return True
|
||||
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()
|
||||
|
||||
381
app/controllers/invoice.py
Normal file
@ -0,0 +1,381 @@
|
||||
from typing import Optional, List
|
||||
from tortoise.queryset import QuerySet
|
||||
|
||||
from app.core.crud import CRUDBase
|
||||
from app.models.invoice import Invoice, InvoiceHeader, PaymentReceipt
|
||||
from app.schemas.invoice import (
|
||||
InvoiceCreate,
|
||||
InvoiceUpdate,
|
||||
InvoiceOut,
|
||||
InvoiceList,
|
||||
InvoiceHeaderCreate,
|
||||
InvoiceHeaderUpdate,
|
||||
InvoiceHeaderOut,
|
||||
UpdateStatus,
|
||||
UpdateType,
|
||||
PaymentReceiptCreate,
|
||||
PaymentReceiptOut,
|
||||
)
|
||||
|
||||
|
||||
class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
||||
"""发票控制器"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(model=Invoice)
|
||||
|
||||
async def create_header(self, user_id: Optional[int], data: InvoiceHeaderCreate) -> InvoiceHeaderOut:
|
||||
"""
|
||||
创建发票抬头
|
||||
参数:
|
||||
user_id: 关联的 AppUser ID(可选)
|
||||
data: 发票抬头创建数据
|
||||
返回:
|
||||
InvoiceHeaderOut: 抬头输出对象
|
||||
"""
|
||||
payload = data.model_dump()
|
||||
for k in ["register_address", "register_phone", "bank_name", "bank_account"]:
|
||||
if payload.get(k) is None:
|
||||
payload[k] = ""
|
||||
if payload.get("is_default"):
|
||||
if user_id is not None:
|
||||
await InvoiceHeader.filter(app_user_id=user_id).update(is_default=False)
|
||||
header = await InvoiceHeader.create(app_user_id=user_id, **payload)
|
||||
return InvoiceHeaderOut.model_validate(header)
|
||||
|
||||
async def get_headers(self, user_id: Optional[int] = None) -> List[InvoiceHeaderOut]:
|
||||
"""
|
||||
获取发票抬头列表
|
||||
参数:
|
||||
user_id: 可筛选 AppUser 的抬头
|
||||
返回:
|
||||
List[InvoiceHeaderOut]: 抬头列表
|
||||
"""
|
||||
qs = InvoiceHeader.all()
|
||||
if user_id is not None:
|
||||
qs = qs.filter(app_user_id=user_id)
|
||||
headers = await qs.order_by("-created_at")
|
||||
return [InvoiceHeaderOut.model_validate(h) for h in headers]
|
||||
|
||||
async def get_header_by_id(self, id_: int) -> Optional[InvoiceHeaderOut]:
|
||||
"""
|
||||
根据ID获取抬头
|
||||
参数:
|
||||
id_: 抬头ID
|
||||
返回:
|
||||
InvoiceHeaderOut 或 None
|
||||
"""
|
||||
header = await InvoiceHeader.filter(id=id_).first()
|
||||
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:
|
||||
"""
|
||||
获取发票列表(支持筛选与分页)
|
||||
参数:
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
**filters: phone、company_name、tax_number、status、ticket_type、invoice_type、时间范围等
|
||||
返回:
|
||||
InvoiceList: 分页结果
|
||||
"""
|
||||
qs: QuerySet = self.model.all()
|
||||
if filters.get("phone"):
|
||||
qs = qs.filter(phone__icontains=filters["phone"])
|
||||
if filters.get("company_name"):
|
||||
qs = qs.filter(company_name__icontains=filters["company_name"])
|
||||
if filters.get("tax_number"):
|
||||
qs = qs.filter(tax_number__icontains=filters["tax_number"])
|
||||
if filters.get("status"):
|
||||
qs = qs.filter(status=filters["status"])
|
||||
if filters.get("ticket_type"):
|
||||
qs = qs.filter(ticket_type=filters["ticket_type"])
|
||||
if filters.get("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()
|
||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
items = [
|
||||
InvoiceOut(
|
||||
id=row.id,
|
||||
created_at=row.created_at.isoformat() if row.created_at else "",
|
||||
ticket_type=row.ticket_type,
|
||||
invoice_type=row.invoice_type,
|
||||
phone=row.phone,
|
||||
email=row.email,
|
||||
company_name=row.company_name,
|
||||
tax_number=row.tax_number,
|
||||
register_address=row.register_address,
|
||||
register_phone=row.register_phone,
|
||||
bank_name=row.bank_name,
|
||||
bank_account=row.bank_account,
|
||||
status=row.status,
|
||||
app_user_id=row.app_user_id,
|
||||
header_id=row.header_id,
|
||||
wechat=row.wechat,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return InvoiceList(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
async def update_status(self, data: UpdateStatus) -> Optional[InvoiceOut]:
|
||||
"""
|
||||
更新发票状态
|
||||
参数:
|
||||
data: 包含 id 与目标状态
|
||||
返回:
|
||||
更新后的发票输出或 None
|
||||
"""
|
||||
inv = await self.model.filter(id=data.id).first()
|
||||
if not inv:
|
||||
return None
|
||||
inv.status = data.status
|
||||
await inv.save()
|
||||
return await self.get_out(inv.id)
|
||||
|
||||
async def update_type(self, id_: int, data: UpdateType) -> Optional[InvoiceOut]:
|
||||
"""
|
||||
更新发票类型(电子/纸质、专票/普票)
|
||||
参数:
|
||||
id_: 发票ID
|
||||
data: 类型更新数据
|
||||
返回:
|
||||
更新后的发票输出或 None
|
||||
"""
|
||||
inv = await self.model.filter(id=id_).first()
|
||||
if not inv:
|
||||
return None
|
||||
inv.ticket_type = data.ticket_type
|
||||
inv.invoice_type = data.invoice_type
|
||||
await inv.save()
|
||||
return await self.get_out(inv.id)
|
||||
|
||||
async def create_receipt(self, invoice_id: int, data: PaymentReceiptCreate) -> PaymentReceiptOut:
|
||||
"""
|
||||
上传付款凭证
|
||||
参数:
|
||||
invoice_id: 发票ID
|
||||
data: 凭证创建数据
|
||||
返回:
|
||||
PaymentReceiptOut
|
||||
"""
|
||||
receipt = await PaymentReceipt.create(invoice_id=invoice_id, **data.model_dump())
|
||||
return PaymentReceiptOut(
|
||||
id=receipt.id,
|
||||
url=receipt.url,
|
||||
note=receipt.note,
|
||||
verified=receipt.verified,
|
||||
created_at=receipt.created_at.isoformat() if receipt.created_at else "",
|
||||
extra=receipt.extra,
|
||||
)
|
||||
|
||||
async def list_receipts(self, page: int = 1, page_size: int = 10, **filters) -> dict:
|
||||
"""
|
||||
对公转账记录列表
|
||||
参数:
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
**filters: 提交时间范围、手机号、微信号、公司名称/税号、状态、开票类型等
|
||||
返回:
|
||||
dict: { items, total, page, page_size }
|
||||
"""
|
||||
qs = PaymentReceipt.all().prefetch_related("invoice")
|
||||
# 通过关联发票进行筛选
|
||||
if filters.get("phone"):
|
||||
qs = qs.filter(invoice__phone__icontains=filters["phone"])
|
||||
if filters.get("wechat"):
|
||||
qs = qs.filter(invoice__wechat__icontains=filters["wechat"])
|
||||
if filters.get("company_name"):
|
||||
qs = qs.filter(invoice__company_name__icontains=filters["company_name"])
|
||||
if filters.get("tax_number"):
|
||||
qs = qs.filter(invoice__tax_number__icontains=filters["tax_number"])
|
||||
if filters.get("status"):
|
||||
qs = qs.filter(invoice__status=filters["status"])
|
||||
if filters.get("ticket_type"):
|
||||
qs = qs.filter(invoice__ticket_type=filters["ticket_type"])
|
||||
if filters.get("invoice_type"):
|
||||
qs = qs.filter(invoice__invoice_type=filters["invoice_type"])
|
||||
|
||||
# 时间区间筛选(凭证提交时间)
|
||||
created_range = filters.get("created_at")
|
||||
submitted_start = filters.get("submitted_start")
|
||||
submitted_end = filters.get("submitted_end")
|
||||
if created_range and isinstance(created_range, (list, tuple)) and len(created_range) == 2:
|
||||
try:
|
||||
# 前端可能传毫秒时间戳
|
||||
start_ms = int(created_range[0])
|
||||
end_ms = int(created_range[1])
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromtimestamp(start_ms / 1000)
|
||||
end_dt = datetime.fromtimestamp(end_ms / 1000)
|
||||
qs = qs.filter(created_at__gte=start_dt, created_at__lte=end_dt)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
from datetime import datetime
|
||||
def parse_time(v):
|
||||
try:
|
||||
iv = int(v)
|
||||
return datetime.fromtimestamp(iv / 1000)
|
||||
except Exception:
|
||||
try:
|
||||
# ISO 字符串
|
||||
return datetime.fromisoformat(v)
|
||||
except Exception:
|
||||
return None
|
||||
if submitted_start:
|
||||
s_dt = parse_time(submitted_start)
|
||||
if s_dt:
|
||||
qs = qs.filter(created_at__gte=s_dt)
|
||||
if submitted_end:
|
||||
e_dt = parse_time(submitted_end)
|
||||
if e_dt:
|
||||
qs = qs.filter(created_at__lte=e_dt)
|
||||
|
||||
total = await qs.count()
|
||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
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({
|
||||
"id": r.id,
|
||||
"invoice_id": getattr(inv, "id", None),
|
||||
"submitted_at": r.created_at.isoformat() if r.created_at else "",
|
||||
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
|
||||
"extra": r.extra,
|
||||
"receipts": receipts,
|
||||
"phone": inv.phone,
|
||||
"wechat": inv.wechat,
|
||||
"company_name": inv.company_name,
|
||||
"tax_number": inv.tax_number,
|
||||
"register_address": inv.register_address,
|
||||
"register_phone": inv.register_phone,
|
||||
"bank_name": inv.bank_name,
|
||||
"bank_account": inv.bank_account,
|
||||
"email": inv.email,
|
||||
"ticket_type": inv.ticket_type,
|
||||
"invoice_type": inv.invoice_type,
|
||||
"status": inv.status,
|
||||
})
|
||||
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
async def get_receipt_by_id(self, id_: int) -> Optional[dict]:
|
||||
"""
|
||||
对公转账记录详情
|
||||
参数:
|
||||
id_: 付款凭证ID
|
||||
返回:
|
||||
dict 或 None
|
||||
"""
|
||||
r = await PaymentReceipt.filter(id=id_).first()
|
||||
if not r:
|
||||
return None
|
||||
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 {
|
||||
"id": r.id,
|
||||
"invoice_id": getattr(inv, "id", None),
|
||||
"submitted_at": r.created_at.isoformat() if r.created_at else "",
|
||||
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
|
||||
"extra": r.extra,
|
||||
"receipts": receipts,
|
||||
"phone": inv.phone,
|
||||
"wechat": inv.wechat,
|
||||
"company_name": inv.company_name,
|
||||
"tax_number": inv.tax_number,
|
||||
"register_address": inv.register_address,
|
||||
"register_phone": inv.register_phone,
|
||||
"bank_name": inv.bank_name,
|
||||
"bank_account": inv.bank_account,
|
||||
"email": inv.email,
|
||||
"ticket_type": inv.ticket_type,
|
||||
"invoice_type": inv.invoice_type,
|
||||
"status": inv.status,
|
||||
}
|
||||
|
||||
async def get_out(self, id_: int) -> Optional[InvoiceOut]:
|
||||
"""
|
||||
根据ID返回发票输出对象
|
||||
参数:
|
||||
id_: 发票ID
|
||||
返回:
|
||||
InvoiceOut 或 None
|
||||
"""
|
||||
inv = await self.model.filter(id=id_).first()
|
||||
if not inv:
|
||||
return None
|
||||
return InvoiceOut(
|
||||
id=inv.id,
|
||||
created_at=inv.created_at.isoformat() if inv.created_at else "",
|
||||
ticket_type=inv.ticket_type,
|
||||
invoice_type=inv.invoice_type,
|
||||
phone=inv.phone,
|
||||
email=inv.email,
|
||||
company_name=inv.company_name,
|
||||
tax_number=inv.tax_number,
|
||||
register_address=inv.register_address,
|
||||
register_phone=inv.register_phone,
|
||||
bank_name=inv.bank_name,
|
||||
bank_account=inv.bank_account,
|
||||
status=inv.status,
|
||||
app_user_id=inv.app_user_id,
|
||||
header_id=inv.header_id,
|
||||
wechat=inv.wechat,
|
||||
)
|
||||
|
||||
|
||||
invoice_controller = InvoiceController()
|
||||
@ -2,7 +2,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from fastapi import UploadFile
|
||||
from app.schemas.upload import ImageUploadResponse
|
||||
from app.schemas.upload import ImageUploadResponse, FileUploadResponse
|
||||
from app.settings.config import settings
|
||||
|
||||
class UploadController:
|
||||
@ -15,8 +15,9 @@ class UploadController:
|
||||
:param file: 上传的图片文件
|
||||
:return: 图片URL和文件名
|
||||
"""
|
||||
# 检查文件类型
|
||||
if not file.content_type.startswith('image/'):
|
||||
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/') or ext in image_exts):
|
||||
raise ValueError("只支持上传图片文件")
|
||||
|
||||
# 获取项目根目录
|
||||
@ -49,4 +50,80 @@ class UploadController:
|
||||
return ImageUploadResponse(
|
||||
url=f"{settings.BASE_URL}/static/images/{filename}",
|
||||
filename=filename
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def upload_file(file: UploadFile) -> FileUploadResponse:
|
||||
allowed = {
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
"application/zip",
|
||||
"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 = {
|
||||
".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("不支持的文件类型")
|
||||
|
||||
base_dir = Path(__file__).resolve().parent.parent
|
||||
upload_dir = base_dir / "static" / "files"
|
||||
if not upload_dir.exists():
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = file.filename
|
||||
file_path = upload_dir / filename
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
name, ext = os.path.splitext(filename)
|
||||
filename = f"{name}_{counter}{ext}"
|
||||
file_path = upload_dir / filename
|
||||
counter += 1
|
||||
|
||||
content = await file.read()
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
return FileUploadResponse(
|
||||
url=f"{settings.BASE_URL}/static/files/{filename}",
|
||||
filename=filename,
|
||||
content_type=file.content_type,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def upload_any(file: UploadFile) -> FileUploadResponse:
|
||||
"""
|
||||
统一上传入口,自动识别图片与非图片类型。
|
||||
返回统一结构:url, filename, content_type
|
||||
"""
|
||||
image_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
|
||||
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||
if (file.content_type and file.content_type.startswith("image/")) or (ext in image_exts):
|
||||
img = await UploadController.upload_image(file)
|
||||
return FileUploadResponse(url=img.url, filename=img.filename, content_type=file.content_type or "image")
|
||||
# 非图片类型复用原文件上传校验
|
||||
return await UploadController.upload_file(file)
|
||||
|
||||
@ -114,11 +114,7 @@ class UserValuationController:
|
||||
|
||||
async def _to_user_out(self, valuation: ValuationAssessment) -> UserValuationOut:
|
||||
"""转换为用户端输出模型"""
|
||||
return UserValuationOut.model_validate(valuation)
|
||||
|
||||
async def _to_user_detail(self, valuation: ValuationAssessment) -> UserValuationDetail:
|
||||
"""转换为用户端详细模型"""
|
||||
return UserValuationDetail(
|
||||
return UserValuationOut(
|
||||
id=valuation.id,
|
||||
asset_name=valuation.asset_name,
|
||||
institution=valuation.institution,
|
||||
@ -131,13 +127,15 @@ class UserValuationController:
|
||||
inheritor_ages=valuation.inheritor_ages,
|
||||
inheritor_age_count=valuation.inheritor_age_count,
|
||||
inheritor_certificates=valuation.inheritor_certificates,
|
||||
heritage_level=valuation.heritage_level,
|
||||
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,
|
||||
@ -159,6 +157,78 @@ class UserValuationController:
|
||||
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:
|
||||
"""转换为用户端详细模型"""
|
||||
return UserValuationDetail(
|
||||
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,
|
||||
@ -184,4 +254,4 @@ class UserValuationController:
|
||||
|
||||
|
||||
# 创建控制器实例
|
||||
user_valuation_controller = UserValuationController()
|
||||
user_valuation_controller = UserValuationController()
|
||||
|
||||
@ -1,22 +1,662 @@
|
||||
from typing import List, Optional
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from tortoise.expressions import Q
|
||||
from tortoise.queryset import QuerySet
|
||||
from tortoise.functions import Count
|
||||
|
||||
from app.models.valuation import ValuationAssessment
|
||||
from app.models.valuation import ValuationAssessment, ValuationCalculationStep
|
||||
from app.schemas.valuation import (
|
||||
ValuationAssessmentCreate,
|
||||
ValuationAssessmentUpdate,
|
||||
ValuationAssessmentQuery,
|
||||
ValuationAssessmentOut,
|
||||
ValuationAssessmentList
|
||||
ValuationAssessmentList,
|
||||
ValuationCalculationStepCreate,
|
||||
ValuationCalculationStepOut
|
||||
)
|
||||
from app.models.user import AppUser
|
||||
from app.utils.calculation_engine.formula_registry import get_formula_meta
|
||||
|
||||
|
||||
class ValuationController:
|
||||
"""估值评估控制器"""
|
||||
|
||||
model = ValuationAssessment
|
||||
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:
|
||||
"""
|
||||
创建估值计算步骤
|
||||
|
||||
Args:
|
||||
data (ValuationCalculationStepCreate): 估值计算步骤数据
|
||||
|
||||
Returns:
|
||||
ValuationCalculationStepOut: 创建的估值计算步骤
|
||||
"""
|
||||
step = await self.step_model.create(**data.model_dump())
|
||||
logger.info(
|
||||
"calcstep.create valuation_id={} order={} name={}",
|
||||
data.valuation_id,
|
||||
data.step_order,
|
||||
data.step_name,
|
||||
)
|
||||
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:
|
||||
step = await self.step_model.filter(id=step_id).first()
|
||||
if not step:
|
||||
raise ValueError(f"calculation_step not found: {step_id}")
|
||||
await step.update_from_dict(update).save()
|
||||
logger.info(
|
||||
"calcstep.update id={} fields={}",
|
||||
step_id,
|
||||
list(update.keys()),
|
||||
)
|
||||
return ValuationCalculationStepOut.model_validate(step)
|
||||
|
||||
async def get_calculation_steps(self, valuation_id: int) -> List[ValuationCalculationStepOut]:
|
||||
"""
|
||||
根据估值ID获取所有相关的计算步骤。
|
||||
|
||||
此方法从数据库中检索与特定估值ID关联的所有计算步骤记录,
|
||||
并按创建时间升序排序,确保步骤的顺序正确。
|
||||
|
||||
Args:
|
||||
valuation_id (int): 估值的唯一标识符。
|
||||
|
||||
Returns:
|
||||
List[ValuationCalculationStepOut]: 一个包含所有相关计算步骤的列表,
|
||||
如果找不到任何步骤,则返回空列表。
|
||||
"""
|
||||
steps = await self.step_model.filter(valuation_id=valuation_id).order_by('created_at')
|
||||
logger.info("calcstep.list valuation_id={} count={}", valuation_id, len(steps))
|
||||
return [ValuationCalculationStepOut.model_validate(step) for step in steps]
|
||||
|
||||
async def get_calculation_report_markdown(self, valuation_id: int) -> str:
|
||||
"""
|
||||
根据估值ID生成计算过程的 Markdown 报告。
|
||||
|
||||
此方法会查询所有相关的计算步骤,按照公式顺序组织,
|
||||
并生成格式化的 Markdown 文档,包含:
|
||||
- 公式名称
|
||||
- 输入参数
|
||||
- 公式文本
|
||||
- 输出结果
|
||||
|
||||
Args:
|
||||
valuation_id (int): 估值的唯一标识符。
|
||||
|
||||
Returns:
|
||||
str: Markdown 格式的计算报告。
|
||||
|
||||
Raises:
|
||||
ValueError: 如果找不到对应的估值记录。
|
||||
"""
|
||||
# 验证估值记录是否存在
|
||||
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
|
||||
if not valuation:
|
||||
raise ValueError(f"估值记录不存在: {valuation_id}")
|
||||
|
||||
# 获取所有计算步骤,按 step_order 排序
|
||||
steps = await self.step_model.filter(valuation_id=valuation_id).order_by('step_order')
|
||||
if not steps:
|
||||
return f"# 计算摘要\n\n**估值ID**: {valuation_id}\n\n**资产名称**: {valuation.asset_name}\n\n> 暂无计算步骤记录。\n"
|
||||
|
||||
# 转换为字典列表,便于处理
|
||||
steps_data = []
|
||||
for step in steps:
|
||||
step_dict = ValuationCalculationStepOut.model_validate(step).model_dump()
|
||||
steps_data.append(step_dict)
|
||||
|
||||
# 生成 Markdown
|
||||
markdown = self._generate_markdown(valuation, steps_data)
|
||||
|
||||
logger.info("calcstep.report_markdown generated valuation_id={} steps_count={}", valuation_id, len(steps_data))
|
||||
return markdown
|
||||
|
||||
def _build_formula_tree(self, steps: List[Dict]) -> Dict:
|
||||
"""
|
||||
构建公式的树形结构。
|
||||
|
||||
Args:
|
||||
steps: 计算步骤列表。
|
||||
|
||||
Returns:
|
||||
Dict: 树形结构的字典,key 为 formula_code,value 为步骤数据和子节点。
|
||||
"""
|
||||
# 按 formula_code 索引
|
||||
step_map = {}
|
||||
for step in steps:
|
||||
code = step.get('formula_code')
|
||||
if code:
|
||||
step_map[code] = step
|
||||
|
||||
# 构建树形结构
|
||||
tree = {}
|
||||
processed = set()
|
||||
|
||||
# 第一遍:创建所有节点
|
||||
for step in steps:
|
||||
code = step.get('formula_code')
|
||||
if not code or code in processed:
|
||||
continue
|
||||
|
||||
node = {
|
||||
'step': step,
|
||||
'children': []
|
||||
}
|
||||
tree[code] = node
|
||||
processed.add(code)
|
||||
|
||||
# 第二遍:建立父子关系
|
||||
root_nodes = []
|
||||
for step in steps:
|
||||
code = step.get('formula_code')
|
||||
if not code:
|
||||
continue
|
||||
|
||||
parent_code = step.get('parent_formula_code')
|
||||
node = tree[code]
|
||||
|
||||
if parent_code and parent_code in tree:
|
||||
# 有父节点,添加到父节点的 children
|
||||
tree[parent_code]['children'].append(node)
|
||||
else:
|
||||
# 根节点
|
||||
root_nodes.append(node)
|
||||
|
||||
# 按 step_order 排序
|
||||
def sort_nodes(nodes):
|
||||
nodes.sort(key=lambda n: float(n['step'].get('step_order', 0)))
|
||||
for node in nodes:
|
||||
if node['children']:
|
||||
sort_nodes(node['children'])
|
||||
|
||||
sort_nodes(root_nodes)
|
||||
|
||||
return {'roots': root_nodes, 'all': tree}
|
||||
|
||||
def _generate_markdown(self, valuation, steps_data: List[Dict]) -> str:
|
||||
"""
|
||||
生成 Markdown 格式的报告。
|
||||
|
||||
Args:
|
||||
valuation: 估值评估对象。
|
||||
steps_data: 计算步骤列表(已按 step_order 排序)。
|
||||
|
||||
Returns:
|
||||
str: Markdown 格式的字符串。
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# 标题
|
||||
lines.append("# 计算摘要")
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
|
||||
# 遍历所有步骤,按顺序生成
|
||||
for step in steps_data:
|
||||
name = step.get('formula_name', step.get('step_name', '未知'))
|
||||
formula_text = step.get('formula_text', step.get('step_description', ''))
|
||||
input_params = step.get('input_params')
|
||||
output_result = step.get('output_result')
|
||||
|
||||
# 公式标题(二级标题)
|
||||
lines.append(f"## {name}")
|
||||
lines.append("")
|
||||
|
||||
# 参数部分
|
||||
if input_params:
|
||||
lines.append("**参数:**")
|
||||
lines.append("")
|
||||
# 格式化参数显示
|
||||
param_lines = self._format_params(input_params)
|
||||
lines.extend(param_lines)
|
||||
lines.append("")
|
||||
|
||||
# 公式部分
|
||||
if formula_text:
|
||||
lines.append("**公式:**")
|
||||
lines.append("")
|
||||
lines.append("```")
|
||||
lines.append(formula_text)
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
# 计算过程部分(显示详细的计算步骤)
|
||||
if output_result and isinstance(output_result, dict):
|
||||
# 首先检查 calculation_detail 字段
|
||||
calculation_detail = output_result.get('calculation_detail')
|
||||
if calculation_detail and isinstance(calculation_detail, dict):
|
||||
lines.append("**计算过程:**")
|
||||
lines.append("")
|
||||
# 按步骤顺序显示
|
||||
steps = []
|
||||
for key in sorted(calculation_detail.keys()):
|
||||
if key.startswith('step'):
|
||||
steps.append(f"> {calculation_detail[key]}")
|
||||
if steps:
|
||||
lines.extend(steps)
|
||||
lines.append("")
|
||||
|
||||
# 然后检查旧的 calculation 字段
|
||||
calculation = output_result.get('calculation')
|
||||
if calculation and not calculation_detail:
|
||||
lines.append("**计算过程:**")
|
||||
lines.append("")
|
||||
lines.append(f"> {calculation}")
|
||||
lines.append("")
|
||||
|
||||
# 结果部分
|
||||
if output_result:
|
||||
# 提取主要结果值
|
||||
result_value = self._extract_main_result(output_result, name)
|
||||
if result_value is not None:
|
||||
lines.append("**结果:**")
|
||||
lines.append("")
|
||||
lines.append(f"`{result_value}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_params(self, params: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
格式化参数显示,优先使用列表格式(如果是数组),否则显示为列表项。
|
||||
参数名会附带中文说明(如果存在)。
|
||||
|
||||
Args:
|
||||
params: 参数字典
|
||||
|
||||
Returns:
|
||||
List[str]: 格式化后的参数行列表
|
||||
"""
|
||||
lines = []
|
||||
|
||||
def _get_param_label(key: str) -> str:
|
||||
"""获取参数标签,包含中文说明"""
|
||||
description = self.PARAM_DESCRIPTIONS.get(key)
|
||||
if description:
|
||||
return f"{key}({description})"
|
||||
return key
|
||||
|
||||
# 如果参数只有一个键,且值是数组,直接显示数组(不带参数名,符合示例格式)
|
||||
if len(params) == 1:
|
||||
key, value = next(iter(params.items()))
|
||||
if isinstance(value, (list, tuple)):
|
||||
# 格式化为列表:- [12.2, 13.2, 14.2]
|
||||
value_str = json.dumps(list(value), ensure_ascii=False)
|
||||
lines.append(f"- {value_str}")
|
||||
return lines
|
||||
|
||||
# 多个参数或非数组,显示为列表项(带说明)
|
||||
for key, value in params.items():
|
||||
param_label = _get_param_label(key)
|
||||
if isinstance(value, (list, tuple)):
|
||||
value_str = json.dumps(list(value), ensure_ascii=False)
|
||||
lines.append(f"- **{param_label}**: {value_str}")
|
||||
elif isinstance(value, dict):
|
||||
value_str = json.dumps(value, ensure_ascii=False)
|
||||
lines.append(f"- **{param_label}**: {value_str}")
|
||||
else:
|
||||
lines.append(f"- **{param_label}**: {value}")
|
||||
|
||||
return lines
|
||||
|
||||
def _extract_main_result(self, output_result: Dict[str, Any], formula_name: str) -> Optional[str]:
|
||||
"""
|
||||
从输出结果中提取主要结果值。
|
||||
|
||||
优先顺序:
|
||||
1. 如果结果中只有一个数值类型的值,返回该值
|
||||
2. 如果结果中包含与公式名称相关的字段(如 "财务价值 F" -> "financial_value_f"),返回该值
|
||||
3. 如果结果中包含常见的计算结果字段(如 "result", "value", "output"),返回该值
|
||||
4. 返回第一个数值类型的值
|
||||
|
||||
Args:
|
||||
output_result: 输出结果字典
|
||||
formula_name: 公式名称
|
||||
|
||||
Returns:
|
||||
Optional[str]: 主要结果值的字符串表示,如果找不到则返回 None
|
||||
"""
|
||||
if not output_result or not isinstance(output_result, dict):
|
||||
return None
|
||||
|
||||
# 调试:打印B3的output_result
|
||||
if 'risk_value_b3' in str(output_result) or 'legal_risk' in str(output_result):
|
||||
print(f"=== _extract_main_result 调试 ===")
|
||||
print(f"formula_name: {formula_name}")
|
||||
print(f"output_result keys: {list(output_result.keys())}")
|
||||
print(f"output_result: {output_result}")
|
||||
print(f"================================")
|
||||
|
||||
# 移除 duration_ms 等元数据字段
|
||||
filtered_result = {k: v for k, v in output_result.items()
|
||||
if k not in ['duration_ms', 'duration', 'timestamp', 'status']}
|
||||
|
||||
if not filtered_result:
|
||||
return None
|
||||
|
||||
# 如果只有一个值,直接返回
|
||||
if len(filtered_result) == 1:
|
||||
value = next(iter(filtered_result.values()))
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
elif isinstance(value, (list, tuple)) and len(value) == 1:
|
||||
return str(value[0])
|
||||
else:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
# 优先查找常见的结果字段(优先级从高到低)
|
||||
# 这个列表的顺序很重要,确保正确的结果字段优先被选中
|
||||
common_result_keys = [
|
||||
# 计算引擎实际使用的结果字段名
|
||||
'risk_value_b3', # 风险调整系数B3
|
||||
'risk_adjustment_b3', # 风险调整系数B3(备选)
|
||||
'economic_value_b1', # 经济价值B1
|
||||
'cultural_value_b2', # 文化价值B2
|
||||
'model_value_b', # 模型估值B
|
||||
'market_value_c', # 市场估值C
|
||||
'final_value_a', # 最终估值A
|
||||
'final_value_ab', # 最终估值AB
|
||||
'basic_value_b11', # 基础价值B11
|
||||
'traffic_factor_b12', # 流量因子B12
|
||||
'policy_multiplier_b13', # 政策乘数B13
|
||||
'living_heritage_b21', # 活态传承系数B21
|
||||
'pattern_gene_b22', # 纹样基因值B22
|
||||
# 通用结果字段
|
||||
'result', 'value', 'output', 'final_value', 'calculated_value',
|
||||
# 子计算结果字段
|
||||
'financial_value_f', 'legal_strength_l', 'development_potential_d',
|
||||
'social_media_spread_s3', 'interaction_index', 'coverage_index', 'conversion_efficiency',
|
||||
'market_bid_c1', 'heat_coefficient_c2', 'scarcity_multiplier_c3', 'timeliness_decay_c4',
|
||||
'teaching_frequency', 'inheritor_level_coefficient',
|
||||
'risk_score_sum', # 风险评分总和R
|
||||
'dynamic_pledge_rate', # 动态质押率
|
||||
]
|
||||
|
||||
# 首先检查常见结果字段(这个优先级最高,避免错误匹配子风险值)
|
||||
for key in common_result_keys:
|
||||
if key in filtered_result:
|
||||
value = filtered_result[key]
|
||||
if isinstance(value, (int, float)):
|
||||
if 'risk' in formula_name.lower() or 'b3' in formula_name.lower():
|
||||
print(f"=== 返回值调试 (common_keys) ===")
|
||||
print(f"formula_name: {formula_name}")
|
||||
print(f"matched key: {key}")
|
||||
print(f"返回值: {value}")
|
||||
print(f"================================")
|
||||
return str(value)
|
||||
|
||||
# 尝试根据公式名称匹配字段
|
||||
# 例如:"财务价值 F" -> 查找 "financial_value_f", "财务价值F" 等
|
||||
# 提取公式名称中的关键部分(通常是最后一个字母或单词)
|
||||
name_parts = formula_name.split()
|
||||
if name_parts:
|
||||
# 获取最后一个部分(通常是字母,如 "F", "L", "D")
|
||||
last_part = name_parts[-1].lower()
|
||||
# 构建可能的字段名:如 "financial_value_f", "legal_strength_l" 等
|
||||
# 将中文名称转换为可能的英文字段名模式
|
||||
possible_keys = []
|
||||
|
||||
# 1. 直接匹配包含最后部分的字段(如包含 "f", "l", "d")
|
||||
for key in filtered_result.keys():
|
||||
if last_part in key.lower() or key.lower().endswith(f"_{last_part}"):
|
||||
possible_keys.append(key)
|
||||
|
||||
# 2. 尝试匹配常见的命名模式
|
||||
# 例如:"财务价值 F" -> "financial_value_f"
|
||||
# 这里我们尝试匹配以最后部分结尾的字段
|
||||
suffix_patterns = [
|
||||
f"_{last_part}",
|
||||
f"_{last_part}_",
|
||||
last_part,
|
||||
]
|
||||
|
||||
for key in filtered_result.keys():
|
||||
key_lower = key.lower()
|
||||
for pattern in suffix_patterns:
|
||||
if key_lower.endswith(pattern) or pattern in key_lower:
|
||||
if key not in possible_keys:
|
||||
possible_keys.append(key)
|
||||
|
||||
# 按优先级匹配
|
||||
for key in possible_keys:
|
||||
if key in filtered_result:
|
||||
value = filtered_result[key]
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
|
||||
# 返回第一个数值类型的值
|
||||
for key, value in filtered_result.items():
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
|
||||
# 如果都没有,返回整个结果的 JSON(但简化显示)
|
||||
return json.dumps(filtered_result, ensure_ascii=False)
|
||||
|
||||
async def create(self, data: ValuationAssessmentCreate, user_id: int) -> ValuationAssessmentOut:
|
||||
"""创建估值评估"""
|
||||
@ -24,13 +664,15 @@ class ValuationController:
|
||||
create_data = data.model_dump()
|
||||
create_data['user_id'] = user_id
|
||||
valuation = await self.model.create(**create_data)
|
||||
return ValuationAssessmentOut.model_validate(valuation)
|
||||
out = ValuationAssessmentOut.model_validate(valuation)
|
||||
return await self._attach_user_phone(out)
|
||||
|
||||
async def get_by_id(self, valuation_id: int) -> Optional[ValuationAssessmentOut]:
|
||||
"""根据ID获取估值评估"""
|
||||
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
|
||||
if valuation:
|
||||
return ValuationAssessmentOut.model_validate(valuation)
|
||||
out = ValuationAssessmentOut.model_validate(valuation)
|
||||
return await self._attach_user_phone(out)
|
||||
return None
|
||||
|
||||
async def update(self, valuation_id: int, data: ValuationAssessmentUpdate) -> Optional[ValuationAssessmentOut]:
|
||||
@ -41,11 +683,52 @@ class ValuationController:
|
||||
|
||||
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 ="pending"
|
||||
if not getattr(valuation, "audited_at", None):
|
||||
valuation.audited_at = datetime.now()
|
||||
valuation.updated_at = datetime.now()
|
||||
await valuation.save()
|
||||
|
||||
return ValuationAssessmentOut.model_validate(valuation)
|
||||
out = ValuationAssessmentOut.model_validate(valuation)
|
||||
return await self._attach_user_phone(out)
|
||||
|
||||
async def update1(self, valuation_id: int, data: ValuationAssessmentUpdate) -> Optional[ValuationAssessmentOut]:
|
||||
"""更新估值评估"""
|
||||
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
|
||||
if not valuation:
|
||||
return None
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if update_data:
|
||||
if 'certificate_url' in update_data and update_data.get('certificate_url'):
|
||||
from datetime import datetime
|
||||
update_data['audited_at'] = datetime.now()
|
||||
update_data['updated_at'] = datetime.now()
|
||||
else:
|
||||
from datetime import datetime
|
||||
update_data['updated_at'] = datetime.now()
|
||||
await valuation.update_from_dict(update_data)
|
||||
await valuation.save()
|
||||
from datetime import datetime
|
||||
valuation.status ="success"
|
||||
if not getattr(valuation, "audited_at", None):
|
||||
valuation.audited_at = datetime.now()
|
||||
valuation.updated_at = datetime.now()
|
||||
await valuation.save()
|
||||
|
||||
out = ValuationAssessmentOut.model_validate(valuation)
|
||||
return await self._attach_user_phone(out)
|
||||
|
||||
async def delete(self, valuation_id: int) -> bool:
|
||||
"""软删除估值评估"""
|
||||
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
|
||||
@ -69,6 +752,7 @@ class ValuationController:
|
||||
|
||||
# 转换为输出模型
|
||||
items = [ValuationAssessmentOut.model_validate(v) for v in valuations]
|
||||
items = await self._attach_user_phone_bulk(items)
|
||||
# 计算总页数
|
||||
pages = (total + query.size - 1) // query.size
|
||||
|
||||
@ -102,7 +786,38 @@ class ValuationController:
|
||||
# 添加状态筛选
|
||||
if hasattr(query, 'status') and 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
|
||||
|
||||
async def get_statistics(self) -> dict:
|
||||
@ -143,6 +858,7 @@ class ValuationController:
|
||||
|
||||
# 转换为输出模型
|
||||
items = [ValuationAssessmentOut.model_validate(v) for v in valuations]
|
||||
items = await self._attach_user_phone_bulk(items)
|
||||
|
||||
# 计算总页数
|
||||
pages = (total + size - 1) // size
|
||||
@ -161,12 +877,14 @@ class ValuationController:
|
||||
if not valuation:
|
||||
return None
|
||||
|
||||
update_data = {"status": "approved"}
|
||||
from datetime import datetime
|
||||
update_data = {"status": "success", "audited_at": datetime.now(), "updated_at": datetime.now()}
|
||||
if admin_notes:
|
||||
update_data["admin_notes"] = admin_notes
|
||||
|
||||
await valuation.update_from_dict(update_data).save()
|
||||
return ValuationAssessmentOut.model_validate(valuation)
|
||||
out = ValuationAssessmentOut.model_validate(valuation)
|
||||
return await self._attach_user_phone(out)
|
||||
|
||||
async def reject_valuation(self, valuation_id: int, admin_notes: Optional[str] = None) -> Optional[ValuationAssessmentOut]:
|
||||
"""审核拒绝估值评估"""
|
||||
@ -174,22 +892,54 @@ class ValuationController:
|
||||
if not valuation:
|
||||
return None
|
||||
|
||||
update_data = {"status": "rejected"}
|
||||
from datetime import datetime
|
||||
update_data = {"status": "rejected", "audited_at": datetime.now(), "updated_at": datetime.now()}
|
||||
if admin_notes:
|
||||
update_data["admin_notes"] = admin_notes
|
||||
|
||||
await valuation.update_from_dict(update_data).save()
|
||||
return ValuationAssessmentOut.model_validate(valuation)
|
||||
out = ValuationAssessmentOut.model_validate(valuation)
|
||||
return await self._attach_user_phone(out)
|
||||
|
||||
async def update_admin_notes(self, valuation_id: int, admin_notes: str) -> Optional[ValuationAssessmentOut]:
|
||||
"""更新管理员备注"""
|
||||
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
|
||||
if not valuation:
|
||||
return None
|
||||
|
||||
await valuation.update_from_dict({"admin_notes": admin_notes}).save()
|
||||
return ValuationAssessmentOut.model_validate(valuation)
|
||||
|
||||
from datetime import datetime
|
||||
await valuation.update_from_dict({"admin_notes": admin_notes, "updated_at": datetime.now()}).save()
|
||||
out = ValuationAssessmentOut.model_validate(valuation)
|
||||
return await self._attach_user_phone(out)
|
||||
|
||||
async def update_calc(self, valuation_id: int, data: ValuationAssessmentUpdate) -> Optional[ValuationAssessmentOut]:
|
||||
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
|
||||
if not valuation:
|
||||
return None
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
valuation.status ="pending"
|
||||
if update_data:
|
||||
await valuation.update_from_dict(update_data)
|
||||
await valuation.save()
|
||||
out = ValuationAssessmentOut.model_validate(valuation)
|
||||
return await self._attach_user_phone(out)
|
||||
|
||||
async def _attach_user_phone(self, out: ValuationAssessmentOut) -> ValuationAssessmentOut:
|
||||
user = await AppUser.filter(id=out.user_id).first()
|
||||
out.user_phone = getattr(user, "phone", None) if user else None
|
||||
return out
|
||||
|
||||
async def _attach_user_phone_bulk(self, items: List[ValuationAssessmentOut]) -> List[ValuationAssessmentOut]:
|
||||
ids = list({item.user_id for item in items if item.user_id})
|
||||
if not ids:
|
||||
return items
|
||||
users = await AppUser.filter(id__in=ids).values("id", "phone")
|
||||
phone_map = {u["id"]: u["phone"] for u in users}
|
||||
for item in items:
|
||||
item.user_phone = phone_map.get(item.user_id)
|
||||
return items
|
||||
|
||||
|
||||
# 创建控制器实例
|
||||
valuation_controller = ValuationController()
|
||||
valuation_controller = ValuationController()
|
||||
from app.log import logger
|
||||
|
||||
@ -16,6 +16,7 @@ async def DoesNotExistHandle(req: Request, exc: DoesNotExist) -> JSONResponse:
|
||||
content = dict(
|
||||
code=404,
|
||||
msg=f"Object has not found, exc: {exc}, query_params: {req.query_params}",
|
||||
data={},
|
||||
)
|
||||
return JSONResponse(content=content, status_code=404)
|
||||
|
||||
@ -24,20 +25,21 @@ async def IntegrityHandle(_: Request, exc: IntegrityError) -> JSONResponse:
|
||||
content = dict(
|
||||
code=500,
|
||||
msg=f"IntegrityError,{exc}",
|
||||
data={},
|
||||
)
|
||||
return JSONResponse(content=content, status_code=500)
|
||||
|
||||
|
||||
async def HttpExcHandle(_: Request, exc: HTTPException) -> JSONResponse:
|
||||
content = dict(code=exc.status_code, msg=exc.detail, data=None)
|
||||
content = dict(code=exc.status_code, msg=exc.detail, data={})
|
||||
return JSONResponse(content=content, status_code=exc.status_code)
|
||||
|
||||
|
||||
async def RequestValidationHandle(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
content = dict(code=422, msg=f"RequestValidationError, {exc}")
|
||||
content = dict(code=422, msg=f"RequestValidationError, {exc}", data={})
|
||||
return JSONResponse(content=content, status_code=422)
|
||||
|
||||
|
||||
async def ResponseValidationHandle(_: Request, exc: ResponseValidationError) -> JSONResponse:
|
||||
content = dict(code=500, msg=f"ResponseValidationError, {exc}")
|
||||
content = dict(code=500, msg=f"ResponseValidationError, {exc}", data={})
|
||||
return JSONResponse(content=content, status_code=500)
|
||||
|
||||
@ -23,6 +23,7 @@ from app.core.exceptions import (
|
||||
)
|
||||
from app.log import logger
|
||||
from app.models.admin import Api, Menu, Role
|
||||
from app.models.invoice import Invoice, PaymentReceipt
|
||||
from app.schemas.menus import MenuType
|
||||
from app.settings.config import settings
|
||||
|
||||
@ -237,14 +238,158 @@ async def init_menus():
|
||||
redirect="",
|
||||
)
|
||||
|
||||
# 创建交易管理菜单
|
||||
transaction_menu = await Menu.create(
|
||||
menu_type=MenuType.CATALOG,
|
||||
name="交易管理",
|
||||
path="/transaction",
|
||||
order=3,
|
||||
parent_id=0,
|
||||
icon="carbon:wallet",
|
||||
is_hidden=False,
|
||||
component="Layout",
|
||||
keepalive=False,
|
||||
redirect="/transaction/invoice",
|
||||
)
|
||||
transaction_children = [
|
||||
Menu(
|
||||
menu_type=MenuType.MENU,
|
||||
name="发票管理",
|
||||
path="invoice",
|
||||
order=1,
|
||||
parent_id=transaction_menu.id,
|
||||
icon="mdi:file-document-outline",
|
||||
is_hidden=False,
|
||||
component="/transaction/invoice",
|
||||
keepalive=False,
|
||||
),
|
||||
Menu(
|
||||
menu_type=MenuType.MENU,
|
||||
name="交易记录",
|
||||
path="receipts",
|
||||
order=2,
|
||||
parent_id=transaction_menu.id,
|
||||
icon="mdi:receipt-text-outline",
|
||||
is_hidden=False,
|
||||
component="/transaction/receipts",
|
||||
keepalive=False,
|
||||
),
|
||||
]
|
||||
await Menu.bulk_create(transaction_children)
|
||||
|
||||
|
||||
async def init_apis():
|
||||
apis = await api_controller.model.exists()
|
||||
if not apis:
|
||||
await api_controller.refresh_api()
|
||||
await api_controller.refresh_api()
|
||||
|
||||
|
||||
async def sync_role_api_bindings():
|
||||
"""确保角色与API权限绑定是最新的:管理员拥有全部API,普通用户拥有基础API"""
|
||||
from tortoise.expressions import Q
|
||||
try:
|
||||
admin_role = await Role.filter(name="管理员").first()
|
||||
if admin_role:
|
||||
all_apis = await Api.all()
|
||||
current = await admin_role.apis.all()
|
||||
current_keys = {(a.method, a.path) for a in current}
|
||||
missing = [a for a in all_apis if (a.method, a.path) not in current_keys]
|
||||
if missing:
|
||||
await admin_role.apis.add(*missing)
|
||||
user_role = await Role.filter(name="普通用户").first()
|
||||
if user_role:
|
||||
basic_apis = await Api.filter(Q(method__in=["GET"]) | Q(tags="基础模块"))
|
||||
current_u = await user_role.apis.all()
|
||||
current_u_keys = {(a.method, a.path) for a in current_u}
|
||||
missing_u = [a for a in basic_apis if (a.method, a.path) not in current_u_keys]
|
||||
if missing_u:
|
||||
await user_role.apis.add(*missing_u)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _ensure_unique_index():
|
||||
"""确保 valuation_calculation_steps 表的唯一索引存在"""
|
||||
try:
|
||||
conn_alias = settings.TORTOISE_ORM["apps"]["models"]["default_connection"]
|
||||
from tortoise import connections
|
||||
conn = connections.get(conn_alias)
|
||||
|
||||
# 检查表是否存在
|
||||
result = await conn.execute_query(
|
||||
"SHOW TABLES LIKE 'valuation_calculation_steps'"
|
||||
)
|
||||
if not result or len(result[1]) == 0:
|
||||
logger.info("Table valuation_calculation_steps does not exist, skipping index check")
|
||||
return
|
||||
|
||||
# 检查唯一索引是否存在
|
||||
# 查找包含 valuation_id 和 formula_code 的唯一索引
|
||||
index_result = await conn.execute_query(
|
||||
"SHOW INDEX FROM `valuation_calculation_steps` WHERE Non_unique = 0 AND Column_name IN ('valuation_id', 'formula_code')"
|
||||
)
|
||||
|
||||
# 查找是否存在 (valuation_id, formula_code) 的唯一索引
|
||||
# 对于复合索引,SHOW INDEX 会返回多行,每行对应一个列
|
||||
# 需要检查是否有同一个 Key_name 包含两个列
|
||||
has_unique_index = False
|
||||
if index_result and len(index_result) > 1:
|
||||
# 按 Key_name 分组
|
||||
index_groups = {}
|
||||
for row in index_result[1]:
|
||||
if len(row) >= 5:
|
||||
key_name = row[2] if len(row) > 2 else ""
|
||||
non_unique = row[1] if len(row) > 1 else 1
|
||||
column_name = row[4] if len(row) > 4 else ""
|
||||
seq_in_index = row[3] if len(row) > 3 else 0
|
||||
if non_unique == 0 and column_name in ('valuation_id', 'formula_code'):
|
||||
if key_name not in index_groups:
|
||||
index_groups[key_name] = []
|
||||
index_groups[key_name].append(column_name)
|
||||
|
||||
# 检查是否有索引包含两个列
|
||||
for key_name, columns in index_groups.items():
|
||||
if 'valuation_id' in columns and 'formula_code' in columns:
|
||||
has_unique_index = True
|
||||
logger.debug(f"Found unique index: {key_name} on (valuation_id, formula_code)")
|
||||
break
|
||||
|
||||
if not has_unique_index:
|
||||
logger.warning("Unique index on (valuation_id, formula_code) not found, attempting to create...")
|
||||
try:
|
||||
# 先删除可能存在的重复记录
|
||||
await conn.execute_query("""
|
||||
DELETE t1 FROM `valuation_calculation_steps` t1
|
||||
INNER JOIN `valuation_calculation_steps` t2
|
||||
WHERE t1.id > t2.id
|
||||
AND t1.valuation_id = t2.valuation_id
|
||||
AND t1.formula_code = t2.formula_code
|
||||
AND t1.formula_code IS NOT NULL
|
||||
""")
|
||||
logger.info("Cleaned up duplicate records")
|
||||
|
||||
# 创建唯一索引
|
||||
await conn.execute_query("""
|
||||
CREATE UNIQUE INDEX `uidx_valuation_formula`
|
||||
ON `valuation_calculation_steps` (`valuation_id`, `formula_code`)
|
||||
""")
|
||||
logger.info("Created unique index on (valuation_id, formula_code)")
|
||||
except Exception as idx_err:
|
||||
error_str = str(idx_err).lower()
|
||||
if "duplicate key name" in error_str or "already exists" in error_str:
|
||||
logger.info("Unique index already exists (different name)")
|
||||
else:
|
||||
logger.warning(f"Failed to create unique index: {idx_err}")
|
||||
else:
|
||||
logger.debug("Unique index on (valuation_id, formula_code) already exists")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to ensure unique index: {e}")
|
||||
|
||||
|
||||
async def init_db():
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tortoise import Tortoise
|
||||
from tortoise.exceptions import OperationalError
|
||||
|
||||
command = Command(tortoise_config=settings.TORTOISE_ORM)
|
||||
try:
|
||||
await command.init_db(safe=True)
|
||||
@ -252,14 +397,84 @@ async def init_db():
|
||||
pass
|
||||
|
||||
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:
|
||||
await command.migrate()
|
||||
except AttributeError:
|
||||
logger.warning("unable to retrieve model history from database, model history will be created from scratch")
|
||||
shutil.rmtree("migrations")
|
||||
await command.init_db(safe=True)
|
||||
except Exception as e:
|
||||
# 如果 migrate 失败,记录警告但继续执行 upgrade
|
||||
logger.warning(f"Migrate failed: {e}, continuing with upgrade...")
|
||||
|
||||
await command.upgrade(run_in_transaction=True)
|
||||
# 在 upgrade 之前,先检查表是否存在,如果不存在则先创建表
|
||||
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():
|
||||
@ -287,9 +502,124 @@ async def init_roles():
|
||||
await user_role.apis.add(*basic_apis)
|
||||
|
||||
|
||||
async def init_demo_transactions():
|
||||
"""
|
||||
创建开发环境演示用的发票与交易记录(付款凭证)数据。
|
||||
|
||||
功能:
|
||||
- 在无现有付款凭证数据时,批量生成若干 `Invoice` 与关联的 `PaymentReceipt`。
|
||||
- 仅在调试模式下执行,避免污染生产环境。
|
||||
|
||||
参数: 无
|
||||
|
||||
返回: `None`,异步执行插入操作。
|
||||
"""
|
||||
if not settings.DEBUG:
|
||||
return
|
||||
|
||||
has_receipt = await PaymentReceipt.exists()
|
||||
if has_receipt:
|
||||
return
|
||||
|
||||
demo_invoices = []
|
||||
demo_payloads = [
|
||||
{
|
||||
"ticket_type": "electronic",
|
||||
"invoice_type": "normal",
|
||||
"phone": "13800000001",
|
||||
"email": "demo1@example.com",
|
||||
"company_name": "演示科技有限公司",
|
||||
"tax_number": "91310000MA1DEMO01",
|
||||
"register_address": "上海市浦东新区演示路 100 号",
|
||||
"register_phone": "021-88880001",
|
||||
"bank_name": "招商银行上海分行",
|
||||
"bank_account": "6214830000000001",
|
||||
"status": "pending",
|
||||
"wechat": "demo_wechat_01",
|
||||
},
|
||||
{
|
||||
"ticket_type": "paper",
|
||||
"invoice_type": "special",
|
||||
"phone": "13800000002",
|
||||
"email": "demo2@example.com",
|
||||
"company_name": "示例信息技术股份有限公司",
|
||||
"tax_number": "91310000MA1DEMO02",
|
||||
"register_address": "北京市海淀区知春路 66 号",
|
||||
"register_phone": "010-66660002",
|
||||
"bank_name": "中国银行北京分行",
|
||||
"bank_account": "6216610000000002",
|
||||
"status": "invoiced",
|
||||
"wechat": "demo_wechat_02",
|
||||
},
|
||||
{
|
||||
"ticket_type": "electronic",
|
||||
"invoice_type": "special",
|
||||
"phone": "13800000003",
|
||||
"email": "demo3@example.com",
|
||||
"company_name": "华夏制造有限公司",
|
||||
"tax_number": "91310000MA1DEMO03",
|
||||
"register_address": "广州市天河区高新大道 8 号",
|
||||
"register_phone": "020-77770003",
|
||||
"bank_name": "建设银行广州分行",
|
||||
"bank_account": "6227000000000003",
|
||||
"status": "rejected",
|
||||
"wechat": "demo_wechat_03",
|
||||
},
|
||||
{
|
||||
"ticket_type": "paper",
|
||||
"invoice_type": "normal",
|
||||
"phone": "13800000004",
|
||||
"email": "demo4@example.com",
|
||||
"company_name": "泰岳网络科技有限公司",
|
||||
"tax_number": "91310000MA1DEMO04",
|
||||
"register_address": "杭州市滨江区科技大道 1 号",
|
||||
"register_phone": "0571-55550004",
|
||||
"bank_name": "农业银行杭州分行",
|
||||
"bank_account": "6228480000000004",
|
||||
"status": "refunded",
|
||||
"wechat": "demo_wechat_04",
|
||||
},
|
||||
{
|
||||
"ticket_type": "electronic",
|
||||
"invoice_type": "normal",
|
||||
"phone": "13800000005",
|
||||
"email": "demo5@example.com",
|
||||
"company_name": "星云数据有限公司",
|
||||
"tax_number": "91310000MA1DEMO05",
|
||||
"register_address": "成都市高新区软件园 9 号楼",
|
||||
"register_phone": "028-33330005",
|
||||
"bank_name": "工商银行成都分行",
|
||||
"bank_account": "6222020000000005",
|
||||
"status": "pending",
|
||||
"wechat": "demo_wechat_05",
|
||||
},
|
||||
]
|
||||
|
||||
for payload in demo_payloads:
|
||||
inv = await Invoice.create(**payload)
|
||||
demo_invoices.append(inv)
|
||||
|
||||
for idx, inv in enumerate(demo_invoices, start=1):
|
||||
await PaymentReceipt.create(
|
||||
invoice=inv,
|
||||
url=f"https://example.com/demo-receipt-{idx}-a.png",
|
||||
note="DEMO 凭证 A",
|
||||
verified=(inv.status == "invoiced"),
|
||||
)
|
||||
if idx % 2 == 0:
|
||||
await PaymentReceipt.create(
|
||||
invoice=inv,
|
||||
url=f"https://example.com/demo-receipt-{idx}-b.png",
|
||||
note="DEMO 凭证 B",
|
||||
verified=False,
|
||||
)
|
||||
|
||||
|
||||
async def init_data():
|
||||
await init_db()
|
||||
await init_superuser()
|
||||
await init_menus()
|
||||
await init_apis()
|
||||
await init_roles()
|
||||
await sync_role_api_bindings()
|
||||
await init_demo_transactions()
|
||||
|
||||
@ -149,7 +149,8 @@ class HttpAuditLogMiddleware(BaseHTTPMiddleware):
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except (ValueError, TypeError):
|
||||
return stripped
|
||||
# 将非 JSON 字符串包装为字典,以便 JSONField 能够正确存储
|
||||
return {"text": stripped}
|
||||
|
||||
if isinstance(value, (dict, list, int, float, bool)):
|
||||
return value
|
||||
|
||||
13
app/core/token_blacklist.py
Normal file
@ -0,0 +1,13 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.models.token_blacklist import TokenBlacklist
|
||||
|
||||
|
||||
async def add_to_blacklist(token: str, user_id: int, exp: Optional[datetime] = None, jti: Optional[str] = None) -> None:
|
||||
await TokenBlacklist.create(token=token, user_id=user_id, exp=exp, jti=jti)
|
||||
|
||||
|
||||
async def is_blacklisted(token: str) -> bool:
|
||||
return await TokenBlacklist.filter(token=token).exists()
|
||||
|
||||
@ -5,4 +5,6 @@ from .index import *
|
||||
from .industry import *
|
||||
from .policy import *
|
||||
from .user import *
|
||||
from .valuation import *
|
||||
from .valuation import *
|
||||
from .invoice import *
|
||||
from .token_blacklist import *
|
||||
|
||||
66
app/models/invoice.py
Normal file
@ -0,0 +1,66 @@
|
||||
from tortoise import fields
|
||||
|
||||
from .base import BaseModel, TimestampMixin
|
||||
|
||||
|
||||
class InvoiceHeader(BaseModel, TimestampMixin):
|
||||
app_user_id = fields.IntField(null=True, description="App用户ID", index=True)
|
||||
company_name = fields.CharField(max_length=128, description="公司名称", index=True)
|
||||
tax_number = fields.CharField(max_length=32, description="公司税号", index=True)
|
||||
register_address = fields.CharField(max_length=256, description="注册地址")
|
||||
register_phone = fields.CharField(max_length=32, description="注册电话")
|
||||
bank_name = fields.CharField(max_length=128, description="开户银行")
|
||||
bank_account = fields.CharField(max_length=64, description="银行账号")
|
||||
email = fields.CharField(max_length=128, description="接收邮箱")
|
||||
is_default = fields.BooleanField(default=False, description="是否默认抬头", index=True)
|
||||
|
||||
class Meta:
|
||||
table = "invoice_header"
|
||||
table_description = "发票抬头"
|
||||
|
||||
|
||||
class Invoice(BaseModel, TimestampMixin):
|
||||
ticket_type = fields.CharField(max_length=16, description="票据类型: electronic|paper", index=True)
|
||||
invoice_type = fields.CharField(max_length=16, description="发票类型: special|normal", index=True)
|
||||
phone = fields.CharField(max_length=20, description="手机号", index=True)
|
||||
email = fields.CharField(max_length=128, description="接收邮箱")
|
||||
company_name = fields.CharField(max_length=128, description="公司名称", index=True)
|
||||
tax_number = fields.CharField(max_length=32, description="公司税号", index=True)
|
||||
register_address = fields.CharField(max_length=256, description="注册地址")
|
||||
register_phone = fields.CharField(max_length=32, description="注册电话")
|
||||
bank_name = fields.CharField(max_length=128, description="开户银行")
|
||||
bank_account = fields.CharField(max_length=64, description="银行账号")
|
||||
status = fields.CharField(max_length=16, description="状态: pending|invoiced|rejected|refunded", index=True, default="pending")
|
||||
app_user_id = fields.IntField(null=True, description="App用户ID", index=True)
|
||||
header = fields.ForeignKeyField("models.InvoiceHeader", related_name="invoices", null=True, description="抬头关联")
|
||||
wechat = fields.CharField(max_length=64, null=True, description="微信号", index=True)
|
||||
|
||||
class Meta:
|
||||
table = "invoice"
|
||||
table_description = "发票记录"
|
||||
|
||||
|
||||
class PaymentReceipt(BaseModel, TimestampMixin):
|
||||
invoice = fields.ForeignKeyField("models.Invoice", related_name="receipts", description="关联发票")
|
||||
url = fields.CharField(max_length=512, description="付款凭证图片地址")
|
||||
note = fields.CharField(max_length=256, null=True, description="备注")
|
||||
verified = fields.BooleanField(default=False, description="是否已核验")
|
||||
extra = fields.JSONField(null=True, description="额外信息:邮件发送相关")
|
||||
|
||||
class Meta:
|
||||
table = "payment_receipt"
|
||||
table_description = "对公转账付款凭证"
|
||||
|
||||
|
||||
class EmailSendLog(BaseModel, TimestampMixin):
|
||||
email = fields.CharField(max_length=255, description="目标邮箱", index=True)
|
||||
subject = fields.CharField(max_length=255, null=True, description="主题")
|
||||
body_summary = fields.CharField(max_length=512, null=True, description="正文摘要")
|
||||
file_name = fields.CharField(max_length=255, null=True, description="附件文件名")
|
||||
file_url = fields.CharField(max_length=512, null=True, description="附件URL")
|
||||
status = fields.CharField(max_length=16, description="状态: OK|FAIL", index=True)
|
||||
error = fields.TextField(null=True, description="错误信息")
|
||||
|
||||
class Meta:
|
||||
table = "email_send_log"
|
||||
table_description = "邮件发送日志"
|
||||
15
app/models/token_blacklist.py
Normal file
@ -0,0 +1,15 @@
|
||||
from tortoise import fields
|
||||
|
||||
from .base import BaseModel, TimestampMixin
|
||||
|
||||
|
||||
class TokenBlacklist(BaseModel, TimestampMixin):
|
||||
token = fields.TextField(description="JWT令牌")
|
||||
jti = fields.CharField(max_length=64, null=True, description="令牌唯一ID", index=True)
|
||||
user_id = fields.IntField(description="用户ID", index=True)
|
||||
exp = fields.DatetimeField(null=True, description="过期时间", index=True)
|
||||
|
||||
class Meta:
|
||||
table = "token_blacklist"
|
||||
table_description = "JWT令牌黑名单"
|
||||
|
||||
@ -19,7 +19,25 @@ class AppUser(BaseModel, TimestampMixin):
|
||||
company_email = fields.CharField(max_length=100, null=True, description="公司邮箱")
|
||||
is_active = fields.BooleanField(default=True, description="是否激活", index=True)
|
||||
last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True)
|
||||
remaining_quota = fields.IntField(default=0, description="剩余估值次数", index=True)
|
||||
notes = fields.CharField(max_length=256, null=True, description="备注")
|
||||
is_deleted = fields.BooleanField(default=False, description="是否已注销", index=True)
|
||||
deleted_at = fields.DatetimeField(null=True, description="注销时间", index=True)
|
||||
|
||||
class Meta:
|
||||
table = "app_user"
|
||||
table_description = "用户表"
|
||||
table_description = "用户表"
|
||||
|
||||
|
||||
class AppUserQuotaLog(BaseModel, TimestampMixin):
|
||||
app_user_id = fields.IntField(description="App用户ID", index=True)
|
||||
operator_id = fields.IntField(description="操作人ID", index=True)
|
||||
operator_name = fields.CharField(max_length=64, description="操作人")
|
||||
before_count = fields.IntField(description="变更前次数")
|
||||
after_count = fields.IntField(description="变更后次数")
|
||||
op_type = fields.CharField(max_length=32, description="操作类型")
|
||||
remark = fields.CharField(max_length=256, null=True, description="备注")
|
||||
|
||||
class Meta:
|
||||
table = "app_user_quota_log"
|
||||
table_description = "App用户估值次数操作日志"
|
||||
|
||||
@ -23,18 +23,26 @@ class ValuationAssessment(Model):
|
||||
inheritor_ages = fields.JSONField(null=True, description="传承人年龄列表")
|
||||
inheritor_age_count = fields.JSONField(null=True, description="非遗传承人年龄水平及数量")
|
||||
inheritor_certificates = fields.JSONField(null=True, description="非遗传承人等级证书")
|
||||
heritage_level = fields.CharField(max_length=50, null=True, description="非遗等级")
|
||||
heritage_asset_level = fields.CharField(max_length=50, null=True, description="非遗资产等级")
|
||||
patent_application_no = fields.CharField(max_length=100, null=True, description="非遗资产所用专利的申请号")
|
||||
patent_remaining_years = fields.CharField(max_length=50, null=True, description="专利剩余年限")
|
||||
historical_evidence = fields.JSONField(null=True, description="非遗资产历史证明证据及数量")
|
||||
patent_certificates = fields.JSONField(null=True, description="非遗资产所用专利的证书")
|
||||
pattern_images = fields.JSONField(null=True, description="非遗纹样图片")
|
||||
report_url = fields.CharField(max_length=512, null=True, description="管理员上传的评估报告URL")
|
||||
certificate_url = fields.CharField(max_length=512, null=True, description="管理员上传的证书URL")
|
||||
|
||||
# 非遗应用与推广
|
||||
implementation_stage = fields.CharField(max_length=100, null=True, description="非遗资产应用成熟度")
|
||||
application_maturity = fields.CharField(max_length=100, null=True, description="非遗资产应用成熟度")
|
||||
application_coverage = fields.CharField(max_length=100, null=True, description="非遗资产应用覆盖范围")
|
||||
coverage_area = fields.CharField(max_length=100, null=True, description="应用覆盖范围")
|
||||
cooperation_depth = fields.CharField(max_length=100, null=True, description="非遗资产跨界合作深度")
|
||||
collaboration_type = fields.CharField(max_length=100, null=True, description="跨界合作类型")
|
||||
offline_activities = fields.CharField(max_length=50, null=True, description="近12个月线下相关宣讲活动次数")
|
||||
offline_teaching_count = fields.IntField(null=True, description="近12个月线下相关演讲活动次数")
|
||||
online_accounts = fields.JSONField(null=True, description="线上相关宣传账号信息")
|
||||
platform_accounts = fields.JSONField(null=True, description="线上相关宣传账号信息")
|
||||
|
||||
# 非遗资产衍生商品信息
|
||||
@ -44,10 +52,13 @@ class ValuationAssessment(Model):
|
||||
scarcity_level = fields.CharField(max_length=50, null=True, description="稀缺等级")
|
||||
last_market_activity = fields.CharField(max_length=100, null=True, description="该商品最近一次市场活动时间")
|
||||
market_activity_time = fields.CharField(max_length=100, null=True, description="市场活动的时间")
|
||||
monthly_transaction = fields.CharField(max_length=50, null=True, description="月交易额")
|
||||
monthly_transaction_amount = fields.CharField(max_length=50, null=True, description="月交易额")
|
||||
price_fluctuation = fields.JSONField(null=True, description="该商品近30天价格波动区间")
|
||||
price_range = fields.JSONField(null=True, description="资产商品的价格波动率")
|
||||
market_price = fields.FloatField(null=True, description="市场价格(单位:万元)")
|
||||
credit_code_or_id = fields.CharField(max_length=64, null=True, description="统一社会信用代码或身份证号")
|
||||
biz_intro = fields.TextField(null=True, description="业务/传承介绍")
|
||||
|
||||
# 内置API计算字段
|
||||
infringement_record = fields.CharField(max_length=100, null=True, description="侵权记录")
|
||||
@ -71,10 +82,11 @@ class ValuationAssessment(Model):
|
||||
|
||||
# 系统字段
|
||||
user = fields.ForeignKeyField("models.AppUser", related_name="valuations", description="提交用户")
|
||||
status = fields.CharField(max_length=20, default="success", description="评估状态: pending(待审核), success(已通过), fail(已拒绝)")
|
||||
status = fields.CharField(max_length=20, default="pending", description="评估状态: pending(待审核), success(已通过), rejected(已拒绝)")
|
||||
admin_notes = fields.TextField(null=True, description="管理员备注")
|
||||
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
|
||||
updated_at = fields.DatetimeField(auto_now=True, description="更新时间")
|
||||
updated_at = fields.DatetimeField(null=True, description="更新时间")
|
||||
audited_at = fields.DatetimeField(null=True, description="审核时间")
|
||||
is_active = fields.BooleanField(default=True, description="是否激活")
|
||||
|
||||
class Meta:
|
||||
@ -82,4 +94,35 @@ class ValuationAssessment(Model):
|
||||
table_description = "估值评估表"
|
||||
|
||||
def __str__(self):
|
||||
return f"估值评估-{self.asset_name}"
|
||||
return f"估值评估-{self.asset_name}"
|
||||
|
||||
|
||||
class ValuationCalculationStep(Model):
|
||||
"""估值计算步骤模型"""
|
||||
id = fields.IntField(pk=True, description="主键ID")
|
||||
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_name = fields.CharField(max_length=255, description="步骤名称")
|
||||
step_description = fields.TextField(null=True, description="步骤描述")
|
||||
input_params = fields.JSONField(null=True, description="输入参数")
|
||||
output_result = fields.JSONField(null=True, description="输出结果")
|
||||
status = fields.CharField(max_length=20, default="processing", description="步骤状态: processing, completed, failed")
|
||||
error_message = fields.TextField(null=True, description="错误信息")
|
||||
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
|
||||
updated_at = fields.DatetimeField(null=True, description="更新时间")
|
||||
|
||||
class Meta:
|
||||
table = "valuation_calculation_steps"
|
||||
table_description = "估值计算步骤表"
|
||||
ordering = ["step_order"]
|
||||
# 唯一索引:同一估值ID下,同一公式编码只能有一条记录
|
||||
# 注意:formula_code 允许为 NULL,但新逻辑中 formula_code 总是有值
|
||||
unique_together = [("valuation", "formula_code")]
|
||||
|
||||
def __str__(self):
|
||||
return f"估值ID {self.valuation_id} - 步骤 {self.step_order}: {self.step_name}"
|
||||
|
||||
@ -50,6 +50,7 @@ class AppUserInfoOut(BaseModel):
|
||||
last_login: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
remaining_quota: int
|
||||
|
||||
|
||||
class AppUserUpdateSchema(BaseModel):
|
||||
@ -61,9 +62,56 @@ class AppUserUpdateSchema(BaseModel):
|
||||
company_contact: Optional[str] = Field(None, description="公司联系人")
|
||||
company_phone: Optional[str] = Field(None, description="公司电话")
|
||||
company_email: Optional[str] = Field(None, description="公司邮箱")
|
||||
notes: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class AppUserChangePasswordSchema(BaseModel):
|
||||
"""AppUser修改密码Schema"""
|
||||
old_password: str = Field(..., description="原密码")
|
||||
new_password: str = Field(..., description="新密码")
|
||||
new_password: str = Field(..., description="新密码")
|
||||
|
||||
|
||||
class AppUserDashboardOut(BaseModel):
|
||||
"""AppUser首页摘要输出"""
|
||||
remaining_quota: int
|
||||
latest_valuation: Optional[dict] = None
|
||||
pending_invoices: int
|
||||
|
||||
|
||||
class AppUserQuotaOut(BaseModel):
|
||||
"""AppUser剩余估值次数输出"""
|
||||
remaining_count: int
|
||||
user_type: Optional[str] = None
|
||||
|
||||
|
||||
class AppUserQuotaUpdateSchema(BaseModel):
|
||||
user_id: int = Field(..., description="用户ID")
|
||||
target_count: Optional[int] = Field(None, description="目标次数")
|
||||
delta: Optional[int] = Field(None, description="增减次数")
|
||||
op_type: str = Field(..., description="操作类型")
|
||||
remark: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class AppUserQuotaLogOut(BaseModel):
|
||||
id: int
|
||||
app_user_id: int
|
||||
operator_id: int
|
||||
operator_name: str
|
||||
before_count: int
|
||||
after_count: int
|
||||
op_type: str
|
||||
remark: Optional[str] = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class AppUserRegisterOut(BaseModel):
|
||||
"""App 用户注册结果"""
|
||||
user_id: int = Field(..., description="用户ID")
|
||||
phone: str = Field(..., description="手机号")
|
||||
default_password: str = Field(..., description="默认密码(手机号后六位)")
|
||||
|
||||
|
||||
class TokenValidateOut(BaseModel):
|
||||
"""Token 校验结果"""
|
||||
user_id: int = Field(..., description="用户ID")
|
||||
phone: str = Field(..., description="手机号")
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Optional, Generic, TypeVar, List
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic.generics import GenericModel
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
|
||||
class Success(JSONResponse):
|
||||
@ -11,9 +14,9 @@ class Success(JSONResponse):
|
||||
data: Optional[Any] = None,
|
||||
**kwargs,
|
||||
):
|
||||
content = {"code": code, "msg": msg, "data": data}
|
||||
content = {"code": code, "msg": msg, "data": ({} if data is None else data)}
|
||||
content.update(kwargs)
|
||||
super().__init__(content=content, status_code=code)
|
||||
super().__init__(content=jsonable_encoder(content), status_code=code)
|
||||
|
||||
|
||||
class Fail(JSONResponse):
|
||||
@ -24,9 +27,9 @@ class Fail(JSONResponse):
|
||||
data: Optional[Any] = None,
|
||||
**kwargs,
|
||||
):
|
||||
content = {"code": code, "msg": msg, "data": data}
|
||||
content = {"code": code, "msg": msg, "data": ({} if data is None else data)}
|
||||
content.update(kwargs)
|
||||
super().__init__(content=content, status_code=code)
|
||||
super().__init__(content=jsonable_encoder(content), status_code=code)
|
||||
|
||||
|
||||
class SuccessExtra(JSONResponse):
|
||||
@ -49,4 +52,27 @@ class SuccessExtra(JSONResponse):
|
||||
"page_size": page_size,
|
||||
}
|
||||
content.update(kwargs)
|
||||
super().__init__(content=content, status_code=code)
|
||||
super().__init__(content=jsonable_encoder(content), status_code=code)
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class BasicResponse(GenericModel, Generic[T]):
|
||||
code: int = Field(200, description="状态码")
|
||||
msg: Optional[str] = Field("OK", description="信息")
|
||||
data: Optional[T] = Field(None, description="数据载荷")
|
||||
|
||||
|
||||
class PageResponse(GenericModel, Generic[T]):
|
||||
code: int = Field(200, description="状态码")
|
||||
msg: Optional[str] = Field(None, description="信息")
|
||||
data: List[T] = Field(default_factory=list, description="数据列表")
|
||||
total: int = Field(0, description="总数量")
|
||||
page: int = Field(1, description="当前页码")
|
||||
page_size: int = Field(20, description="每页数量")
|
||||
pages: Optional[int] = Field(None, description="总页数")
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
message: str = Field(..., description="提示信息")
|
||||
|
||||
200
app/schemas/invoice.py
Normal file
@ -0,0 +1,200 @@
|
||||
from typing import Optional, List, Union, Dict, Any
|
||||
from pydantic import BaseModel, Field, EmailStr, field_validator, model_validator
|
||||
|
||||
|
||||
class InvoiceHeaderCreate(BaseModel):
|
||||
company_name: str = Field(..., min_length=1, max_length=128)
|
||||
tax_number: str = Field(..., min_length=1, max_length=32)
|
||||
register_address: Optional[str] = Field(None, min_length=1, max_length=256)
|
||||
register_phone: Optional[str] = Field(None, min_length=1, max_length=32)
|
||||
bank_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
bank_account: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
email: EmailStr
|
||||
is_default: Optional[bool] = False
|
||||
|
||||
@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):
|
||||
id: int
|
||||
app_user_id: Optional[int] = None
|
||||
company_name: str
|
||||
tax_number: str
|
||||
register_address: str
|
||||
register_phone: str
|
||||
bank_name: str
|
||||
bank_account: str
|
||||
email: Optional[str] = None
|
||||
class Config:
|
||||
from_attributes = True
|
||||
is_default: Optional[bool] = False
|
||||
|
||||
|
||||
class InvoiceHeaderUpdate(BaseModel):
|
||||
company_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
tax_number: Optional[str] = Field(None, min_length=1, max_length=32)
|
||||
register_address: Optional[str] = Field(None, max_length=256)
|
||||
register_phone: Optional[str] = Field(None, max_length=32)
|
||||
bank_name: Optional[str] = Field(None, max_length=128)
|
||||
bank_account: Optional[str] = Field(None, max_length=64)
|
||||
email: Optional[EmailStr] = None
|
||||
is_default: Optional[bool] = None
|
||||
|
||||
|
||||
class InvoiceCreate(BaseModel):
|
||||
ticket_type: str = Field(..., pattern=r"^(electronic|paper)$")
|
||||
invoice_type: str = Field(..., pattern=r"^(special|normal)$")
|
||||
phone: str = Field(..., min_length=5, max_length=20)
|
||||
email: EmailStr
|
||||
company_name: str = Field(..., min_length=1, max_length=128)
|
||||
tax_number: str = Field(..., min_length=1, max_length=32)
|
||||
register_address: str = Field(..., max_length=256)
|
||||
register_phone: str = Field(..., max_length=32)
|
||||
bank_name: str = Field(..., max_length=128)
|
||||
bank_account: str = Field(..., max_length=64)
|
||||
app_user_id: Optional[int] = None
|
||||
header_id: Optional[int] = None
|
||||
wechat: Optional[str] = None
|
||||
|
||||
|
||||
class InvoiceUpdate(BaseModel):
|
||||
ticket_type: Optional[str] = Field(None, pattern=r"^(electronic|paper)$")
|
||||
invoice_type: Optional[str] = Field(None, pattern=r"^(special|normal)$")
|
||||
phone: Optional[str] = Field(None, min_length=5, max_length=20)
|
||||
email: Optional[EmailStr] = None
|
||||
company_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
tax_number: Optional[str] = Field(None, min_length=1, max_length=32)
|
||||
register_address: Optional[str] = Field(None, min_length=1, max_length=256)
|
||||
register_phone: Optional[str] = Field(None, min_length=1, max_length=32)
|
||||
bank_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
bank_account: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
wechat: Optional[str] = None
|
||||
|
||||
|
||||
class InvoiceOut(BaseModel):
|
||||
id: int
|
||||
created_at: str
|
||||
ticket_type: str
|
||||
invoice_type: str
|
||||
phone: str
|
||||
email: EmailStr
|
||||
company_name: str
|
||||
tax_number: str
|
||||
register_address: str
|
||||
register_phone: str
|
||||
bank_name: str
|
||||
bank_account: str
|
||||
status: str
|
||||
app_user_id: Optional[int]
|
||||
header_id: Optional[int]
|
||||
wechat: Optional[str]
|
||||
|
||||
|
||||
class InvoiceList(BaseModel):
|
||||
items: List[InvoiceOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class UpdateStatus(BaseModel):
|
||||
id: int
|
||||
status: str = Field(..., pattern=r"^(pending|invoiced|rejected|refunded)$")
|
||||
|
||||
|
||||
class UpdateType(BaseModel):
|
||||
ticket_type: str = Field(..., pattern=r"^(electronic|paper)$")
|
||||
invoice_type: str = Field(..., pattern=r"^(special|normal)$")
|
||||
|
||||
|
||||
class PaymentReceiptCreate(BaseModel):
|
||||
url: str = Field(..., min_length=1, max_length=512)
|
||||
note: Optional[str] = Field(None, max_length=256)
|
||||
extra: Optional[Union[List[str], Dict[str, Any]]] = None
|
||||
|
||||
|
||||
class PaymentReceiptOut(BaseModel):
|
||||
id: int
|
||||
url: str
|
||||
note: Optional[str]
|
||||
verified: bool
|
||||
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
|
||||
36
app/schemas/transactions.py
Normal file
@ -0,0 +1,36 @@
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from typing import Optional, List, Union
|
||||
|
||||
|
||||
class SendEmailRequest(BaseModel):
|
||||
receipt_id: Optional[int] = Field(None, description="付款凭证ID")
|
||||
email: EmailStr = Field(..., description="邮箱地址")
|
||||
subject: Optional[str] = Field(None, description="邮件主题")
|
||||
body: str = Field(..., description="文案内容")
|
||||
file_urls: Optional[List[str]] = Field(None, description="附件URL列表")
|
||||
file_url: Optional[Union[str, List[str]]] = Field(None, description="附件URL或列表(兼容前端传参)")
|
||||
status: Optional[str] = Field(None, description="开票状态标记: success|invoiced|rejected|refunded")
|
||||
|
||||
|
||||
class SendEmailBody(BaseModel):
|
||||
data: SendEmailRequest
|
||||
|
||||
|
||||
class SendEmailResponse(BaseModel):
|
||||
status: str
|
||||
log_id: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class EmailSendLogOut(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
subject: Optional[str]
|
||||
body_summary: Optional[str]
|
||||
file_name: Optional[str]
|
||||
file_url: Optional[str]
|
||||
status: str
|
||||
|
||||
|
||||
class SendEmailBody(BaseModel):
|
||||
data: SendEmailRequest
|
||||
@ -3,4 +3,9 @@ from pydantic import BaseModel
|
||||
class ImageUploadResponse(BaseModel):
|
||||
"""图片上传响应模型"""
|
||||
url: str
|
||||
filename: str
|
||||
filename: str
|
||||
|
||||
class FileUploadResponse(BaseModel):
|
||||
url: str
|
||||
filename: str
|
||||
content_type: str
|
||||
@ -1,6 +1,7 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Any, Dict, Union
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class ValuationAssessmentBase(BaseModel):
|
||||
@ -28,6 +29,8 @@ class ValuationAssessmentBase(BaseModel):
|
||||
historical_evidence: Optional[Dict[str, int]] = Field(None, description="非遗资产历史证明证据及数量")
|
||||
patent_certificates: Optional[List[str]] = Field(None, description="非遗资产所用专利的证书")
|
||||
pattern_images: Optional[List[str]] = Field(None, description="非遗纹样图片")
|
||||
report_url: Optional[str] = Field(None, description="评估报告URL")
|
||||
certificate_url: Optional[str] = Field(None, description="证书URL")
|
||||
|
||||
# 非遗应用与推广
|
||||
application_maturity: Optional[str] = Field(None, description="非遗资产应用成熟度")
|
||||
@ -53,6 +56,8 @@ class ValuationAssessmentBase(BaseModel):
|
||||
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="统一社会信用代码或身份证号")
|
||||
biz_intro: Optional[str] = Field(None, description="业务/传承介绍")
|
||||
|
||||
# 内置API计算字段
|
||||
infringement_record: Optional[str] = Field(None, description="侵权记录")
|
||||
@ -95,40 +100,101 @@ class ValuationAssessmentUpdate(BaseModel):
|
||||
|
||||
# 非遗等级与技术
|
||||
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_certificates: Optional[List[Any]] = 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="非遗资产所用专利的申请号")
|
||||
historical_evidence: Optional[List[Any]] = Field(None, description="非遗资产历史证明证据及数量")
|
||||
patent_remaining_years: Optional[str] = Field(None, description="专利剩余年限")
|
||||
historical_evidence: Optional[Dict[str, int]] = Field(None, description="非遗资产历史证明证据及数量")
|
||||
patent_certificates: Optional[List[Any]] = Field(None, description="非遗资产所用专利的证书")
|
||||
pattern_images: Optional[List[Any]] = Field(None, description="非遗纹样图片")
|
||||
report_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_coverage: Optional[str] = Field(None, description="非遗资产应用覆盖范围")
|
||||
coverage_area: 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_teaching_count: Optional[int] = Field(None, description="近12个月线下相关演讲活动次数")
|
||||
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个月销售量")
|
||||
link_views: Optional[str] = Field(None, description="该商品近12个月的链接浏览量")
|
||||
circulation: Optional[str] = Field(None, description="该商品的发行量")
|
||||
scarcity_level: 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_amount: Optional[str] = Field(None, description="月交易额")
|
||||
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="统一社会信用代码或身份证号")
|
||||
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="是否激活")
|
||||
|
||||
@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):
|
||||
"""估值评估输出模型"""
|
||||
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="评估状态")
|
||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
updated_at: datetime = Field(..., description="更新时间")
|
||||
updated_at: Optional[datetime] = Field(None, description="更新时间")
|
||||
audited_at: Optional[datetime] = Field(None, description="审核时间")
|
||||
is_active: bool = Field(..., description="是否激活")
|
||||
|
||||
class Config:
|
||||
@ -139,6 +205,29 @@ class ValuationAssessmentOut(ValuationAssessmentBase):
|
||||
# 确保所有字段都被序列化,包括None值
|
||||
exclude_none = False
|
||||
|
||||
@field_validator('report_url', 'certificate_url', mode='before')
|
||||
@classmethod
|
||||
def _to_list(cls, v):
|
||||
def clean(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith('`') and s.endswith('`'):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
|
||||
if isinstance(v, str):
|
||||
s = clean(v)
|
||||
return [s] if s else []
|
||||
return []
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _fill_downloads(self):
|
||||
self.report_download_urls = list(self.report_url or [])
|
||||
self.certificate_download_urls = list(self.certificate_url or [])
|
||||
return self
|
||||
|
||||
|
||||
# 用户端专用模式
|
||||
class UserValuationCreate(ValuationAssessmentBase):
|
||||
@ -150,10 +239,14 @@ class UserValuationOut(ValuationAssessmentBase):
|
||||
"""用户端估值评估输出模型"""
|
||||
id: int = Field(..., 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="评估状态")
|
||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
updated_at: datetime = Field(..., description="更新时间")
|
||||
updated_at: Optional[datetime] = Field(None, description="更新时间")
|
||||
is_active: Optional[bool] = Field(None, description="是否激活")
|
||||
|
||||
class Config:
|
||||
@ -163,14 +256,41 @@ class UserValuationOut(ValuationAssessmentBase):
|
||||
}
|
||||
exclude_none = False
|
||||
|
||||
@field_validator('report_url', 'certificate_url', mode='before')
|
||||
@classmethod
|
||||
def _to_list(cls, v):
|
||||
def clean(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith('`') and s.endswith('`'):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
|
||||
if isinstance(v, str):
|
||||
s = clean(v)
|
||||
return [s] if s else []
|
||||
return []
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _fill_downloads(self):
|
||||
self.report_download_urls = list(self.report_url or [])
|
||||
self.certificate_download_urls = list(self.certificate_url or [])
|
||||
return self
|
||||
|
||||
|
||||
class UserValuationDetail(ValuationAssessmentBase):
|
||||
"""用户端详细估值评估模型"""
|
||||
id: int = Field(..., description="主键ID")
|
||||
report_url: List[str] = Field(default_factory=list, description="评估报告URL列表")
|
||||
certificate_url: List[str] = Field(default_factory=list, description="证书URL列表")
|
||||
report_download_urls: List[str] = Field(default_factory=list, description="评估报告下载地址列表")
|
||||
certificate_download_urls: List[str] = Field(default_factory=list, description="证书下载地址列表")
|
||||
status: str = Field(..., description="评估状态")
|
||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
updated_at: datetime = Field(..., description="更新时间")
|
||||
updated_at: Optional[datetime] = Field(None, description="更新时间")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@ -178,6 +298,29 @@ class UserValuationDetail(ValuationAssessmentBase):
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
|
||||
@field_validator('report_url', 'certificate_url', mode='before')
|
||||
@classmethod
|
||||
def _to_list(cls, v):
|
||||
def clean(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith('`') and s.endswith('`'):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
|
||||
if isinstance(v, str):
|
||||
s = clean(v)
|
||||
return [s] if s else []
|
||||
return []
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _fill_downloads(self):
|
||||
self.report_download_urls = list(self.report_url or [])
|
||||
self.certificate_download_urls = list(self.certificate_url or [])
|
||||
return self
|
||||
|
||||
|
||||
class UserValuationList(BaseModel):
|
||||
"""用户端估值评估列表模型"""
|
||||
@ -219,8 +362,13 @@ class ValuationAssessmentQuery(BaseModel):
|
||||
institution: Optional[str] = Field(None, description="所属机构")
|
||||
industry: Optional[str] = Field(None, description="所属行业")
|
||||
heritage_level: Optional[str] = Field(None, description="非遗等级")
|
||||
status: Optional[str] = Field(None, description="评估状态: pending(待审核), approved(已通过), rejected(已拒绝)")
|
||||
status: Optional[str] = Field(None, description="评估状态: pending(待审核), success(已通过), rejected(已拒绝)")
|
||||
is_active: Optional[bool] = Field(None, description="是否激活")
|
||||
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="页码")
|
||||
size: int = Field(10, ge=1, le=100, description="每页数量")
|
||||
|
||||
@ -233,4 +381,52 @@ class ValuationApprovalRequest(BaseModel):
|
||||
|
||||
class ValuationAdminNotesUpdate(BaseModel):
|
||||
"""管理员备注更新模型"""
|
||||
admin_notes: str = Field(..., description="管理员备注")
|
||||
admin_notes: str = Field(..., description="管理员备注")
|
||||
|
||||
|
||||
class ValuationCalculationStepBase(BaseModel):
|
||||
"""估值计算步骤基础模型"""
|
||||
step_order: Decimal = Field(..., description="步骤顺序")
|
||||
step_name: str = Field(..., description="步骤名称")
|
||||
step_description: Optional[str] = Field(None, description="步骤描述")
|
||||
input_params: Optional[Dict[str, Any]] = Field(None, description="输入参数")
|
||||
output_result: Optional[Dict[str, Any]] = Field(None, description="输出结果")
|
||||
status: str = Field(..., description="步骤状态: processing/completed/failed")
|
||||
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')
|
||||
@classmethod
|
||||
def _coerce_step_order(cls, v):
|
||||
if isinstance(v, Decimal):
|
||||
return v
|
||||
if isinstance(v, (int, float, str)):
|
||||
try:
|
||||
return Decimal(str(v))
|
||||
except Exception:
|
||||
raise ValueError('Invalid step_order')
|
||||
raise ValueError('Invalid step_order type')
|
||||
|
||||
|
||||
class ValuationCalculationStepCreate(ValuationCalculationStepBase):
|
||||
"""创建估值计算步骤模型"""
|
||||
valuation_id: int = Field(..., description="关联的估值评估ID")
|
||||
|
||||
|
||||
class ValuationCalculationStepOut(ValuationCalculationStepBase):
|
||||
"""估值计算步骤输出模型"""
|
||||
id: int = Field(..., description="主键ID")
|
||||
valuation_id: int = Field(..., description="关联的估值评估ID")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
updated_at: Optional[datetime] = Field(None, description="更新时间")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat(),
|
||||
Decimal: lambda v: float(v)
|
||||
}
|
||||
|
||||
66
app/services/email_client.py
Normal file
@ -0,0 +1,66 @@
|
||||
import smtplib
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email import encoders
|
||||
from typing import Optional, List, Tuple
|
||||
import httpx
|
||||
|
||||
from app.settings.config import settings
|
||||
|
||||
|
||||
class EmailClient:
|
||||
def send(self, to_email: str, subject: Optional[str], body: str, file_bytes: Optional[bytes], file_name: Optional[str], content_type: Optional[str]) -> dict:
|
||||
if not settings.SMTP_HOST or not settings.SMTP_PORT or not settings.SMTP_FROM:
|
||||
raise RuntimeError("SMTP 未配置")
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = settings.SMTP_FROM
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject or "估值服务通知"
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
if file_bytes and file_name:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(file_bytes)
|
||||
encoders.encode_base64(part)
|
||||
part.add_header("Content-Disposition", f"attachment; filename=\"{file_name}\"")
|
||||
msg.attach(part)
|
||||
|
||||
if hasattr(self, "_extra_attachments") and isinstance(self._extra_attachments, list):
|
||||
for fb, fn in self._extra_attachments:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(fb)
|
||||
encoders.encode_base64(part)
|
||||
part.add_header("Content-Disposition", f"attachment; filename=\"{fn}\"")
|
||||
msg.attach(part)
|
||||
|
||||
if settings.SMTP_TLS:
|
||||
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30)
|
||||
server.starttls()
|
||||
else:
|
||||
server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30)
|
||||
try:
|
||||
if settings.SMTP_USERNAME and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.SMTP_FROM, [to_email], msg.as_string())
|
||||
server.quit()
|
||||
return {"status": "OK"}
|
||||
except Exception as e:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(e, smtplib.SMTPRecipientsRefused):
|
||||
return {"status": "FAIL", "error": "收件方地址不存在或暂时不可用"}
|
||||
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()
|
||||
52
app/services/rate_limiter.py
Normal file
@ -0,0 +1,52 @@
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class PhoneRateLimiter:
|
||||
def __init__(self, window_seconds: int = 60) -> None:
|
||||
"""手机号限流器
|
||||
|
||||
Args:
|
||||
window_seconds: 限流窗口秒数
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.window = window_seconds
|
||||
self.last_sent: Dict[str, float] = {}
|
||||
|
||||
def allow(self, phone: str) -> bool:
|
||||
"""校验是否允许发送
|
||||
|
||||
Args:
|
||||
phone: 手机号
|
||||
|
||||
Returns:
|
||||
True 表示允许发送,False 表示命中限流
|
||||
"""
|
||||
now = time.time()
|
||||
ts = self.last_sent.get(phone, 0)
|
||||
if now - ts < self.window:
|
||||
return False
|
||||
self.last_sent[phone] = now
|
||||
return True
|
||||
|
||||
def next_allowed_at(self, phone: str) -> float:
|
||||
"""返回下一次允许发送的时间戳
|
||||
|
||||
Args:
|
||||
phone: 手机号
|
||||
|
||||
Returns:
|
||||
时间戳(秒)
|
||||
"""
|
||||
ts = self.last_sent.get(phone, 0)
|
||||
return ts + self.window
|
||||
|
||||
def reset(self) -> None:
|
||||
"""重置限流状态
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.last_sent.clear()
|
||||
107
app/services/sms_client.py
Normal file
@ -0,0 +1,107 @@
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.settings import settings
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class SMSClient:
|
||||
def __init__(self) -> None:
|
||||
"""初始化短信客户端
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.client = None
|
||||
|
||||
def _ensure_client(self) -> None:
|
||||
"""确保客户端初始化
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if self.client is not None:
|
||||
return
|
||||
from alibabacloud_tea_openapi import models as open_api_models # type: ignore
|
||||
from alibabacloud_dysmsapi20170525.client import Client as DysmsClient # type: ignore
|
||||
from alibabacloud_credentials.client import Client as CredentialClient # type: ignore
|
||||
use_chain = bool(settings.ALIYUN_USE_DEFAULT_CREDENTIALS) or (not settings.ALIBABA_CLOUD_ACCESS_KEY_ID or not settings.ALIBABA_CLOUD_ACCESS_KEY_SECRET)
|
||||
if not use_chain and (not settings.ALIBABA_CLOUD_ACCESS_KEY_ID or not settings.ALIBABA_CLOUD_ACCESS_KEY_SECRET):
|
||||
raise RuntimeError("短信凭证未配置:请设置 ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET 或启用默认凭据链")
|
||||
if not settings.ALIYUN_SMS_SIGN_NAME:
|
||||
raise RuntimeError("短信签名未配置:请设置 ALIYUN_SMS_SIGN_NAME")
|
||||
if str(settings.ALIYUN_SMS_SIGN_NAME).upper().startswith("SMS_"):
|
||||
raise RuntimeError("短信签名配置错误:签名不应为模板码")
|
||||
if settings.ALIYUN_SMS_TEMPLATE_CODE_VERIFY and settings.ALIYUN_SMS_TEMPLATE_CODE_REPORT and settings.ALIYUN_SMS_TEMPLATE_CODE_VERIFY == settings.ALIYUN_SMS_TEMPLATE_CODE_REPORT:
|
||||
raise RuntimeError("短信模板配置错误:验证码模板与报告模板重复")
|
||||
if use_chain:
|
||||
credential = CredentialClient()
|
||||
config = open_api_models.Config(credential=credential)
|
||||
else:
|
||||
config = open_api_models.Config(
|
||||
access_key_id=settings.ALIBABA_CLOUD_ACCESS_KEY_ID,
|
||||
access_key_secret=settings.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
|
||||
)
|
||||
config.endpoint = settings.ALIYUN_SMS_ENDPOINT
|
||||
self.client = DysmsClient(config)
|
||||
|
||||
def send_by_template(self, phone: str, template_code: str, template_param: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""按模板发送短信
|
||||
|
||||
Args:
|
||||
phone: 接收短信手机号
|
||||
template_code: 模板 Code
|
||||
template_param: 模板变量字典
|
||||
|
||||
Returns:
|
||||
返回体映射字典
|
||||
"""
|
||||
from alibabacloud_dysmsapi20170525 import models as sms_models # type: ignore
|
||||
self._ensure_client()
|
||||
req = sms_models.SendSmsRequest(
|
||||
phone_numbers=phone,
|
||||
sign_name=settings.ALIYUN_SMS_SIGN_NAME,
|
||||
template_code=template_code,
|
||||
template_param=json.dumps(template_param or {}),
|
||||
)
|
||||
|
||||
logger.info("sms.send start phone={} sign={} template={}", phone, settings.ALIYUN_SMS_SIGN_NAME, template_code)
|
||||
try:
|
||||
resp = self.client.send_sms(req)
|
||||
body = resp.body.to_map() if hasattr(resp, "body") else {}
|
||||
logger.info("sms.send response code={} request_id={} phone={}", body.get("Code"), body.get("RequestId"), phone)
|
||||
return body
|
||||
except Exception as e:
|
||||
logger.error("sms.provider_error err={}", repr(e))
|
||||
return {"Code": "ERROR", "Message": str(e)}
|
||||
|
||||
def send_code(self, phone: str, code: str) -> Dict[str, Any]:
|
||||
"""发送验证码短信
|
||||
|
||||
Args:
|
||||
phone: 接收短信手机号
|
||||
code: 验证码
|
||||
|
||||
Returns:
|
||||
返回体映射字典
|
||||
"""
|
||||
key = settings.ALIYUN_SMS_TEMPLATE_PARAM_CODE_KEY or "code"
|
||||
template = settings.ALIYUN_SMS_TEMPLATE_CODE_VERIFY
|
||||
logger.info("sms.send_code using key={} template={} phone={}", key, template, phone)
|
||||
return self.send_by_template(phone, template, {key: code})
|
||||
|
||||
def send_report(self, phone: str) -> Dict[str, Any]:
|
||||
"""发送报告通知短信
|
||||
|
||||
Args:
|
||||
phone: 接收短信手机号
|
||||
|
||||
Returns:
|
||||
返回体映射字典
|
||||
"""
|
||||
template = settings.ALIYUN_SMS_TEMPLATE_CODE_REPORT
|
||||
logger.info("sms.send_report using template={} phone={}", template, phone)
|
||||
return self.send_by_template(phone, template, {})
|
||||
|
||||
|
||||
sms_client = SMSClient()
|
||||
157
app/services/sms_store.py
Normal file
@ -0,0 +1,157 @@
|
||||
import random
|
||||
import time
|
||||
from datetime import date
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
class VerificationStore:
|
||||
def __init__(self, code_ttl_seconds: int = 300, minute_window: int = 60, daily_limit: int = 10, max_failures: int = 5, lock_seconds: int = 3600) -> None:
|
||||
"""验证码与限流存储
|
||||
|
||||
Args:
|
||||
code_ttl_seconds: 验证码有效期秒数
|
||||
minute_window: 同号分钟级限流窗口
|
||||
daily_limit: 每日发送上限次数
|
||||
max_failures: 最大失败次数后锁定
|
||||
lock_seconds: 锁定时长秒数
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.code_ttl = code_ttl_seconds
|
||||
self.minute_window = minute_window
|
||||
self.daily_limit = daily_limit
|
||||
self.max_failures = max_failures
|
||||
self.lock_seconds = lock_seconds
|
||||
self.codes: Dict[str, Tuple[str, float]] = {}
|
||||
self.sends: Dict[str, Dict[str, float]] = {}
|
||||
self.failures: Dict[str, Dict[str, float]] = {}
|
||||
self.verified: Dict[str, float] = {}
|
||||
|
||||
def generate_code(self) -> str:
|
||||
"""生成数字验证码
|
||||
|
||||
Returns:
|
||||
指定位数的数字字符串
|
||||
"""
|
||||
digits = int(getattr(settings, "SMS_CODE_DIGITS", 6) or 6)
|
||||
max_val = (10 ** digits) - 1
|
||||
return f"{random.randint(0, max_val):0{digits}d}"
|
||||
|
||||
def set_code(self, phone: str, code: str) -> None:
|
||||
"""设置验证码与过期时间
|
||||
|
||||
Args:
|
||||
phone: 手机号
|
||||
code: 验证码
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
expires_at = time.time() + self.code_ttl
|
||||
self.codes[phone] = (code, expires_at)
|
||||
|
||||
def get_code(self, phone: str) -> Optional[Tuple[str, float]]:
|
||||
"""获取存储的验证码与过期时间
|
||||
|
||||
Args:
|
||||
phone: 手机号
|
||||
|
||||
Returns:
|
||||
元组(code, expires_at)或None
|
||||
"""
|
||||
return self.codes.get(phone)
|
||||
|
||||
def clear_code(self, phone: str) -> None:
|
||||
"""清除验证码记录
|
||||
|
||||
Args:
|
||||
phone: 手机号
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.codes.pop(phone, None)
|
||||
|
||||
def allow_send(self, phone: str) -> Tuple[bool, Optional[str]]:
|
||||
"""校验是否允许发送验证码
|
||||
|
||||
Args:
|
||||
phone: 手机号
|
||||
|
||||
Returns:
|
||||
(允许, 拒绝原因)
|
||||
"""
|
||||
now = time.time()
|
||||
dkey = date.today().isoformat()
|
||||
info = self.sends.get(phone) or {"day": dkey, "count": 0.0, "last_ts": 0.0}
|
||||
if info["day"] != dkey:
|
||||
info = {"day": dkey, "count": 0.0, "last_ts": 0.0}
|
||||
if now - info["last_ts"] < self.minute_window:
|
||||
self.sends[phone] = info
|
||||
return False, "发送频率过高"
|
||||
if info["count"] >= float(self.daily_limit):
|
||||
self.sends[phone] = info
|
||||
return False, "今日发送次数已达上限"
|
||||
info["last_ts"] = now
|
||||
info["count"] = info["count"] + 1.0
|
||||
self.sends[phone] = info
|
||||
return True, None
|
||||
|
||||
def can_verify(self, phone: str) -> Tuple[bool, Optional[str]]:
|
||||
"""校验是否允许验证
|
||||
|
||||
Args:
|
||||
phone: 手机号
|
||||
|
||||
Returns:
|
||||
(允许, 拒绝原因)
|
||||
"""
|
||||
now = time.time()
|
||||
stat = self.failures.get(phone)
|
||||
if stat and stat.get("lock_until", 0.0) > now:
|
||||
return False, "尝试次数过多,已锁定"
|
||||
return True, None
|
||||
|
||||
def record_verify_failure(self, phone: str) -> Tuple[int, bool]:
|
||||
"""记录一次验证失败并判断是否触发锁定
|
||||
|
||||
Args:
|
||||
phone: 手机号
|
||||
|
||||
Returns:
|
||||
(失败次数, 是否锁定)
|
||||
"""
|
||||
now = time.time()
|
||||
stat = self.failures.get(phone) or {"count": 0.0, "lock_until": 0.0}
|
||||
if stat.get("lock_until", 0.0) > now:
|
||||
return int(stat["count"]), True
|
||||
stat["count"] = stat.get("count", 0.0) + 1.0
|
||||
if int(stat["count"]) >= self.max_failures:
|
||||
stat["lock_until"] = now + self.lock_seconds
|
||||
self.failures[phone] = stat
|
||||
return int(stat["count"]), stat["lock_until"] > now
|
||||
|
||||
def reset_failures(self, phone: str) -> None:
|
||||
"""重置失败计数
|
||||
|
||||
Args:
|
||||
phone: 手机号
|
||||
|
||||
Returns:
|
||||
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()
|
||||
@ -31,22 +31,22 @@ class Settings(BaseSettings):
|
||||
TORTOISE_ORM: dict = {
|
||||
"connections": {
|
||||
# SQLite configuration
|
||||
"sqlite": {
|
||||
"engine": "tortoise.backends.sqlite",
|
||||
"credentials": {"file_path": f"{BASE_DIR}/db.sqlite3"}, # Path to SQLite database file
|
||||
},
|
||||
# "sqlite": {
|
||||
# "engine": "tortoise.backends.sqlite",
|
||||
# "credentials": {"file_path": f"{BASE_DIR}/db.sqlite3"}, # Path to SQLite database file
|
||||
# },
|
||||
# MySQL/MariaDB configuration
|
||||
# Install with: tortoise-orm[asyncmy]
|
||||
# "mysql": {
|
||||
# "engine": "tortoise.backends.mysql",
|
||||
# "credentials": {
|
||||
# "host": "localhost", # Database host address
|
||||
# "port": 3306, # Database port
|
||||
# "user": "yourusername", # Database username
|
||||
# "password": "yourpassword", # Database password
|
||||
# "database": "yourdatabase", # Database name
|
||||
# },
|
||||
# },
|
||||
"mysql": {
|
||||
"engine": "tortoise.backends.mysql",
|
||||
"credentials": {
|
||||
"host": "sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com", # Database host address
|
||||
"port": 28555, # Database port
|
||||
"user": "root", # Database username
|
||||
"password": "api2api..", # Database password
|
||||
"database": "valuation_service", # Database name
|
||||
},
|
||||
},
|
||||
# PostgreSQL configuration
|
||||
# Install with: tortoise-orm[asyncpg]
|
||||
# "postgres": {
|
||||
@ -87,7 +87,7 @@ class Settings(BaseSettings):
|
||||
"apps": {
|
||||
"models": {
|
||||
"models": ["app.models", "aerich.models"],
|
||||
"default_connection": "sqlite",
|
||||
"default_connection": "mysql",
|
||||
},
|
||||
},
|
||||
"use_tz": False, # Whether to use timezone-aware datetimes
|
||||
@ -95,5 +95,24 @@ class Settings(BaseSettings):
|
||||
}
|
||||
DATETIME_FORMAT: str = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: typing.Optional[str] = "LTAI5tA8gcgM8Qc7K9qCtmXg"
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: typing.Optional[str] = "eWZIWi6xILGtmPSGyJEAhILX5fQx0h"
|
||||
ALIYUN_SMS_SIGN_NAME: typing.Optional[str] = "成都文化产权交易所"
|
||||
ALIYUN_SMS_ENDPOINT: str = "dysmsapi.aliyuncs.com"
|
||||
ALIYUN_SMS_TEMPLATE_CODE_VERIFY: typing.Optional[str] = "SMS_498140213"
|
||||
ALIYUN_SMS_TEMPLATE_CODE_REPORT: typing.Optional[str] = "SMS_498190229"
|
||||
SMS_CODE_DIGITS: int = 6
|
||||
SMS_DEBUG_LOG_CODE: bool = True
|
||||
ALIYUN_USE_DEFAULT_CREDENTIALS: bool = False
|
||||
ALIYUN_SMS_TEMPLATE_PARAM_CODE_KEY: typing.Optional[str] = "code"
|
||||
SMS_BYPASS_CODE: typing.Optional[str] = "202511"
|
||||
|
||||
SMTP_HOST: typing.Optional[str] = "smtp.qiye.aliyun.com"
|
||||
SMTP_PORT: typing.Optional[int] = 465
|
||||
SMTP_USERNAME: typing.Optional[str] = "value@cdcee.net"
|
||||
SMTP_PASSWORD: typing.Optional[str] = "PPXbILdGlRCn2VOx"
|
||||
SMTP_TLS: bool = False
|
||||
SMTP_FROM: typing.Optional[str] = "value@cdcee.net"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
2
app/static/files/valuation_assessments.txt
Normal file
@ -0,0 +1,2 @@
|
||||
"id" "asset_name" "institution" "industry" "annual_revenue" "rd_investment" "three_year_income" "funding_status" "inheritor_level" "inheritor_ages" "inheritor_age_count" "inheritor_certificates" "heritage_asset_level" "patent_application_no" "patent_remaining_years" "historical_evidence" "patent_certificates" "pattern_images" "implementation_stage" "application_coverage" "cooperation_depth" "offline_activities" "platform_accounts" "sales_volume" "link_views" "circulation" "scarcity_level" "last_market_activity" "market_activity_time" "monthly_transaction_amount" "price_fluctuation" "price_range" "market_price" "infringement_record" "patent_count" "esg_value" "policy_matching" "online_course_views" "pattern_complexity" "normalized_entropy" "legal_risk" "base_pledge_rate" "flow_correction" "model_value_b" "market_value_c" "final_value_ab" "dynamic_pledge_rate" "calculation_result" "calculation_input" "status" "admin_notes" "created_at" "updated_at" "is_active" "user_id"
|
||||
"19" "蜀锦" "成都古蜀蜀锦研究所" "纺织业" "169" "32" "[169,169,169]" "无资助" "省级传承人" "[0,0,2]" "[0,0,2]" "[]" "国家级非遗" "" "{""artifacts"":2,""ancient_literature"":5,""inheritor_testimony"":5,""modern_research"":6}" "[]" "[]" "成熟应用" "1" "1" "50" "{""douyin"":{""account"":""huguangjing3691"",""likes"":""67000"",""comments"":""800"",""shares"":""500""}}" "5000" "296000" "限量:总发行份数 ≤100份" "限量:总发行份数 ≤100份" "0" "近一周" "月交易额>100万<500万" "[1580,3980]" "success" "2025-11-17 18:13:17.435287+08:00" "2025-11-17 18:13:17.435322+08:00" "1" "30"
|
||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
BIN
app/static/images/7-icon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
@ -3,6 +3,7 @@ from typing import Optional
|
||||
import jwt
|
||||
from fastapi import HTTPException, status, Depends, Header
|
||||
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.settings import settings
|
||||
|
||||
@ -48,18 +49,24 @@ def verify_app_user_token(token: str) -> Optional[AppUserJWTPayload]:
|
||||
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
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭据",
|
||||
detail="未登录,请重新登录",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not token:
|
||||
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)
|
||||
if payload is None:
|
||||
@ -80,4 +87,4 @@ async def get_current_app_user(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在或已被停用"
|
||||
)
|
||||
return user
|
||||
return user
|
||||
|
||||
@ -1,53 +1,53 @@
|
||||
'''
|
||||
这是非物质文化遗产IP知识产权评估系统的核心计算引擎包。
|
||||
'''
|
||||
from app.utils.calculation_engine.economic_value_b1 import EconomicValueB1Calculator
|
||||
from app.utils.calculation_engine.economic_value_b1.sub_formulas import (
|
||||
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
|
||||
"""
|
||||
非遗资产估值计算引擎包。
|
||||
提供各类计算器,并通过懒加载避免循环依赖。
|
||||
"""
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "Assessment Team"
|
||||
|
||||
__all__ = [
|
||||
# 经济价值B1模块
|
||||
"EconomicValueB1Calculator",
|
||||
"BasicValueB11Calculator",
|
||||
"TrafficFactorB12Calculator",
|
||||
"PolicyMultiplierB13Calculator",
|
||||
|
||||
# 文化价值B2模块
|
||||
"CulturalValueB2Calculator",
|
||||
"LivingHeritageB21Calculator",
|
||||
"PatternGeneB22Calculator",
|
||||
|
||||
# 风险调整系数B3模块
|
||||
"RiskAdjustmentB3Calculator",
|
||||
|
||||
# 市场估值C模块
|
||||
"MarketValueCCalculator",
|
||||
"MarketBiddingC1Calculator",
|
||||
"HeatCoefficientC2Calculator",
|
||||
"ScarcityMultiplierC3Calculator",
|
||||
"TemporalDecayC4Calculator",
|
||||
|
||||
|
||||
# 最终估值A模块
|
||||
"FinalValueACalculator"
|
||||
"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
|
||||
|
||||
@ -6,14 +6,24 @@
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(os.path.join(current_dir, '..', '..', '..'))
|
||||
|
||||
try:
|
||||
# 相对导入(当作为包使用时)
|
||||
from .sub_formulas.living_heritage_b21 import LivingHeritageB21Calculator
|
||||
from .sub_formulas.pattern_gene_b22 import PatternGeneB22Calculator
|
||||
from app.controllers.valuation import ValuationController
|
||||
except ImportError:
|
||||
# 绝对导入(当直接运行时)
|
||||
from sub_formulas.living_heritage_b21 import LivingHeritageB21Calculator
|
||||
from sub_formulas.pattern_gene_b22 import PatternGeneB22Calculator
|
||||
from app.controllers.valuation import ValuationController
|
||||
from app.controllers.valuation import ValuationController
|
||||
|
||||
|
||||
class CulturalValueB2Calculator:
|
||||
@ -23,6 +33,7 @@ class CulturalValueB2Calculator:
|
||||
"""初始化计算器"""
|
||||
self.living_heritage_calculator = LivingHeritageB21Calculator()
|
||||
self.pattern_gene_calculator = PatternGeneB22Calculator()
|
||||
self.valuation_controller = ValuationController()
|
||||
|
||||
def calculate_cultural_value_b2(self,
|
||||
living_heritage_b21: float,
|
||||
@ -42,48 +53,124 @@ class CulturalValueB2Calculator:
|
||||
|
||||
return cultural_value
|
||||
|
||||
def calculate_complete_cultural_value_b2(self, input_data: Dict) -> Dict:
|
||||
async def calculate_complete_cultural_value_b2(self, valuation_id: int, input_data: Dict) -> Dict[str, float]:
|
||||
"""
|
||||
计算完整的文化价值B2,包含所有子公式
|
||||
|
||||
args:
|
||||
input_data: 输入数据字典,包含所有必要的参数
|
||||
|
||||
return:
|
||||
Dict: 包含所有中间计算结果和最终结果的字典
|
||||
"""
|
||||
# 计算活态传承系数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"]
|
||||
)
|
||||
living_heritage_b21 = self.living_heritage_calculator.calculate_living_heritage_b21(
|
||||
input_data['inheritor_level_coefficient'],
|
||||
teaching_frequency,
|
||||
input_data['cross_border_depth']
|
||||
)
|
||||
|
||||
# 计算纹样基因值B22
|
||||
pattern_gene_b22 = self.pattern_gene_calculator.calculate_pattern_gene_b22(
|
||||
input_data['structure_complexity'],
|
||||
input_data['normalized_entropy'],
|
||||
input_data['historical_inheritance']
|
||||
)
|
||||
|
||||
# 计算文化价值B2
|
||||
cultural_value_b2 = self.calculate_cultural_value_b2(
|
||||
living_heritage_b21,
|
||||
pattern_gene_b22
|
||||
)
|
||||
|
||||
return {
|
||||
'living_heritage_b21': living_heritage_b21,
|
||||
'pattern_gene_b22': pattern_gene_b22,
|
||||
'cultural_value_b2': cultural_value_b2
|
||||
}
|
||||
计算完整的文化价值B2,并记录所有计算步骤。
|
||||
|
||||
该函数通过整合活态传承系数B21和纹样基因值B22的计算,
|
||||
最终得出文化价值B2。每一步的计算过程都会被记录下来,
|
||||
以确保计算的透明度和可追溯性。
|
||||
|
||||
Args:
|
||||
valuation_id (int): 估值的唯一标识符,用于关联所有计算步骤。
|
||||
input_data (Dict): 包含所有计算所需参数的字典,例如:
|
||||
{
|
||||
'inheritor_level_coefficient': 10.0, # B21
|
||||
'offline_sessions': 1, # B21
|
||||
'structure_complexity': 0.75, # B22
|
||||
...
|
||||
}
|
||||
|
||||
Returns:
|
||||
Dict[str, float]: 包含文化价值B2及子公式结果的字典。
|
||||
|
||||
Raises:
|
||||
Exception: 在计算过程中遇到的任何异常都会被捕获、记录,并重新抛出。
|
||||
"""
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_CULTURAL_B2",
|
||||
status="processing",
|
||||
input_params=input_data,
|
||||
)
|
||||
try:
|
||||
# 计算活态传承系数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(
|
||||
input_data['inheritor_level_coefficient'],
|
||||
teaching_frequency,
|
||||
input_data['cross_border_depth']
|
||||
)
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_CULTURAL_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
|
||||
pattern_gene_b22 = self.pattern_gene_calculator.calculate_pattern_gene_b22(
|
||||
input_data['structure_complexity'],
|
||||
input_data['normalized_entropy'],
|
||||
input_data['historical_inheritance']
|
||||
)
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_CULTURAL_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
|
||||
cultural_value_b2 = self.calculate_cultural_value_b2(
|
||||
living_heritage_b21,
|
||||
pattern_gene_b22
|
||||
)
|
||||
|
||||
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
|
||||
except Exception as e:
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_CULTURAL_B2",
|
||||
status="failed",
|
||||
error_message=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
# 示例使用
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
2. pattern_gene_b22: 纹样基因值B22计算
|
||||
- 结构复杂度SC = Σ(元素权重 × 复杂度系数) / 总元素数
|
||||
- 归一化信息熵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
|
||||
- 文化价值B2 = 活态传承系数B21 × 0.6 + (纹样基因值B22 / 10) × 0.4
|
||||
|
||||
|
||||
@ -8,12 +8,26 @@
|
||||
|
||||
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(os.path.join(current_dir, '..', '..', '..', '..'))
|
||||
|
||||
try:
|
||||
from app.controllers.valuation import ValuationController
|
||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
||||
except ImportError:
|
||||
# 处理可能的导入错误
|
||||
pass
|
||||
|
||||
class LivingHeritageB21Calculator:
|
||||
"""活态传承系数B21计算器"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化计算器"""
|
||||
pass
|
||||
self.valuation_controller = ValuationController()
|
||||
|
||||
def calculate_living_heritage_b21(self,
|
||||
inheritor_level_coefficient: float,
|
||||
@ -22,7 +36,6 @@ class LivingHeritageB21Calculator:
|
||||
"""
|
||||
计算活态传承系数B21
|
||||
|
||||
|
||||
活态传承系数B21 = 传承人等级系数 × 0.4 + 教学传播频次 × 0.3 + 跨界合作深度 × 0.3
|
||||
|
||||
args:
|
||||
@ -30,14 +43,12 @@ class LivingHeritageB21Calculator:
|
||||
teaching_frequency: 教学传播频次 (用户填写)
|
||||
cross_border_depth: 跨界合作深度 (用户填写)
|
||||
|
||||
return:
|
||||
return:
|
||||
float: 活态传承系数B21
|
||||
"""
|
||||
#
|
||||
living_heritage = (inheritor_level_coefficient * 0.4 +
|
||||
teaching_frequency * 0.3 +
|
||||
cross_border_depth * 0.3)
|
||||
|
||||
return living_heritage
|
||||
|
||||
def calculate_inheritor_level_coefficient(self, inheritor_level: str) -> float:
|
||||
@ -47,13 +58,12 @@ class LivingHeritageB21Calculator:
|
||||
传承人等级评分标准:
|
||||
- 国家级传承人: 1分
|
||||
- 省级传承人: 0.7分
|
||||
- 市级传承人: .44分
|
||||
|
||||
- 市级传承人: 0.4分
|
||||
|
||||
args:
|
||||
inheritor_level: 传承人等级 (用户填写)
|
||||
|
||||
return:
|
||||
return:
|
||||
float: 传承人等级系数
|
||||
"""
|
||||
level_scores = {
|
||||
@ -61,7 +71,6 @@ class LivingHeritageB21Calculator:
|
||||
"省级传承人": 0.7,
|
||||
"市级传承人": 0.4,
|
||||
}
|
||||
|
||||
return level_scores.get(inheritor_level, 0.4)
|
||||
|
||||
def calculate_teaching_frequency(self,
|
||||
@ -74,16 +83,8 @@ class LivingHeritageB21Calculator:
|
||||
|
||||
教学传播频次 = 线下传习次数 × 0.6 + 线上课程点击量(万) × 0.4
|
||||
|
||||
线下传习次数统计规范:
|
||||
1) 单次活动标准:传承人主导、时长≥2小时、参与人数≥5人
|
||||
2) 频次计算:按自然年度累计,同一内容重复培训不计入
|
||||
|
||||
线上课程折算:
|
||||
- 抖音/快手播放量按100:1折算为学习人次
|
||||
- B站课程按50:1折算
|
||||
|
||||
args:
|
||||
offline_sessions: 线下传习次数(符合标准的活动次数)
|
||||
offline_sessions: 线下传习次数
|
||||
douyin_views: 抖音播放量
|
||||
kuaishou_views: 快手播放量
|
||||
bilibili_views: B站播放量
|
||||
@ -91,71 +92,55 @@ class LivingHeritageB21Calculator:
|
||||
returns:
|
||||
float: 教学传播频次评分
|
||||
"""
|
||||
# 线下传习次数权重计算
|
||||
offline_score = offline_sessions * 0.6
|
||||
|
||||
# 线上课程点击量折算
|
||||
# 抖音/快手按100:1折算
|
||||
douyin_kuaishou_learning_sessions = (douyin_views + kuaishou_views) / 100
|
||||
# B站按50:1折算
|
||||
bilibili_learning_sessions = bilibili_views / 50
|
||||
online_views_in_ten_thousands = (douyin_kuaishou_learning_sessions + bilibili_learning_sessions) / 10000
|
||||
online_score = online_views_in_ten_thousands * 0.4
|
||||
teaching_frequency_score = offline_score + online_score
|
||||
return teaching_frequency_score
|
||||
|
||||
def calculate_cross_border_depth(self, cross_border_projects: int) -> float:
|
||||
"""
|
||||
计算跨界合作深度
|
||||
|
||||
# 线上总学习人次(万)
|
||||
online_learning_sessions_10k = (douyin_kuaishou_learning_sessions + bilibili_learning_sessions) / 10000
|
||||
每参与1个跨界合作项目+1分,最高10分
|
||||
|
||||
# 线上课程权重计算
|
||||
online_score = online_learning_sessions_10k * 0.4
|
||||
|
||||
# 总教学传播频次
|
||||
teaching_frequency = offline_score + online_score
|
||||
|
||||
return teaching_frequency
|
||||
def cross_border_depth_dict(border_depth: str) -> float:
|
||||
cross_border_depth_scores = {
|
||||
"品牌联名": 0.3,
|
||||
"科技载体": 0.5,
|
||||
"国家外交礼品": 1,
|
||||
}
|
||||
return cross_border_depth_scores.get(border_depth, 0.3)
|
||||
|
||||
args:
|
||||
cross_border_projects: 跨界合作项目数
|
||||
|
||||
returns:
|
||||
float: 跨界合作深度评分
|
||||
"""
|
||||
return min(cross_border_projects, 10.0)
|
||||
|
||||
# 示例使用
|
||||
if __name__ == "__main__":
|
||||
calculator = LivingHeritageB21Calculator()
|
||||
|
||||
# 示例数据
|
||||
inheritor_level = "国家级传承人" # 传承人等级 (用户填写)
|
||||
cross_border_depth = 50.0
|
||||
# 教学传播频次数据
|
||||
offline_sessions = 20 # 线下传习次数(符合标准:传承人主导、时长≥2小时、参与人数≥5人)
|
||||
douyin_views = 10000000 # 抖音播放量
|
||||
kuaishou_views = 0 # 快手播放量
|
||||
bilibili_views = 0 # B站播放量
|
||||
|
||||
async def calculate_complete_living_heritage_b21(self, valuation_id: int, input_data: dict) -> float:
|
||||
step = await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
valuation_id=valuation_id,
|
||||
step_order=2.21,
|
||||
step_name="活态传承系数B21计算",
|
||||
step_description="开始计算活态传承系数B21",
|
||||
input_params=input_data,
|
||||
status="in_progress"
|
||||
)
|
||||
)
|
||||
try:
|
||||
inheritor_level_coefficient = self.calculate_inheritor_level_coefficient(input_data['inheritor_level'])
|
||||
teaching_frequency = self.calculate_teaching_frequency(input_data['offline_sessions'], input_data.get('douyin_views', 0), input_data.get('kuaishou_views', 0), input_data.get('bilibili_views', 0))
|
||||
cross_border_depth = self.calculate_cross_border_depth(input_data['cross_border_projects'])
|
||||
|
||||
|
||||
# 计算各项指标
|
||||
inheritor_level_coefficient = calculator.calculate_inheritor_level_coefficient(inheritor_level)
|
||||
teaching_frequency = calculator.calculate_teaching_frequency(
|
||||
offline_sessions=offline_sessions,
|
||||
douyin_views=douyin_views,
|
||||
kuaishou_views=kuaishou_views,
|
||||
bilibili_views=bilibili_views
|
||||
)
|
||||
print(teaching_frequency)
|
||||
living_heritage_b21 = self.calculate_living_heritage_b21(inheritor_level_coefficient, teaching_frequency, cross_border_depth)
|
||||
|
||||
# 计算活态传承系数B21
|
||||
living_heritage_b21 = calculator.calculate_living_heritage_b21(
|
||||
1, teaching_frequency, 0.3
|
||||
)
|
||||
|
||||
print(f"传承人等级系数: {inheritor_level_coefficient:.2f}")
|
||||
print(f"教学传播频次: {teaching_frequency:.2f}")
|
||||
print(f" - 线下传习次数: {offline_sessions}次")
|
||||
print(f" - 抖音播放量: {douyin_views:,}次")
|
||||
print(f" - 快手播放量: {kuaishou_views:,}次")
|
||||
print(f" - B站播放量: {bilibili_views:,}次")
|
||||
print(f"跨界合作深度: {cross_border_depth:.2f}")
|
||||
print(f"活态传承系数B21: {living_heritage_b21:.4f}")
|
||||
await self.valuation_controller.update_calculation_step(
|
||||
step.id, {"status": "completed", "output_result": {"living_heritage_b21": living_heritage_b21}}
|
||||
)
|
||||
return living_heritage_b21
|
||||
except Exception as e:
|
||||
error_message = f"活态传承系数B21计算失败: {e}"
|
||||
await self.valuation_controller.update_calculation_step(
|
||||
step.id, {"status": "failed", "error_message": error_message}
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
@ -1,21 +1,26 @@
|
||||
|
||||
"""
|
||||
纹样基因值B22计算模块
|
||||
|
||||
纹样基因值B22 = (结构复杂度SC × 0.6 + 归一化信息熵H × 0.4) × 历史传承度HI × 10
|
||||
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import math
|
||||
from typing import Dict, List
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(os.path.join(current_dir, '..', '..', '..', '..'))
|
||||
|
||||
try:
|
||||
from app.controllers.valuation import ValuationController
|
||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
||||
except ImportError:
|
||||
# 处理可能的导入错误
|
||||
pass
|
||||
|
||||
class PatternGeneB22Calculator:
|
||||
"""纹样基因值B22计算器"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化计算器"""
|
||||
pass
|
||||
self.valuation_controller = ValuationController()
|
||||
|
||||
def calculate_pattern_gene_b22(self,
|
||||
structure_complexity: float,
|
||||
@ -24,7 +29,6 @@ class PatternGeneB22Calculator:
|
||||
"""
|
||||
计算纹样基因值B22
|
||||
|
||||
|
||||
纹样基因值B22 = (结构复杂度SC × 0.6 + 归一化信息熵H × 0.4) × 历史传承度HI × 10
|
||||
|
||||
args:
|
||||
@ -35,11 +39,9 @@ class PatternGeneB22Calculator:
|
||||
return:
|
||||
float: 纹样基因值B22
|
||||
"""
|
||||
|
||||
pattern_gene = ((structure_complexity * 0.6 +
|
||||
normalized_entropy * 0.4) *
|
||||
historical_inheritance * 10)
|
||||
|
||||
return pattern_gene
|
||||
|
||||
def calculate_structure_complexity(self, pattern_elements: List[Dict]) -> float:
|
||||
@ -87,57 +89,49 @@ class PatternGeneB22Calculator:
|
||||
if not pattern_data or len(pattern_data) <= 1:
|
||||
return 0.0
|
||||
|
||||
# 计算概率分布
|
||||
total = sum(pattern_data)
|
||||
if total == 0:
|
||||
return 0.0
|
||||
|
||||
probabilities = [x / total for x in pattern_data if x > 0]
|
||||
|
||||
# 计算信息熵
|
||||
entropy = 0.0
|
||||
for p in probabilities:
|
||||
if p > 0:
|
||||
entropy -= p * math.log2(p)
|
||||
|
||||
# 归一化
|
||||
n = len(probabilities)
|
||||
if n <= 1:
|
||||
return 0.0
|
||||
|
||||
normalized_entropy = entropy / math.log2(n)
|
||||
return normalized_entropy
|
||||
|
||||
|
||||
async def calculate_complete_pattern_gene_b22(self, valuation_id: int, input_data: dict) -> float:
|
||||
step = await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
valuation_id=valuation_id,
|
||||
step_order=2.22,
|
||||
step_name="纹样基因值B22计算",
|
||||
step_description="开始计算纹样基因值B22",
|
||||
input_params=input_data,
|
||||
status="in_progress"
|
||||
)
|
||||
)
|
||||
try:
|
||||
structure_complexity = self.calculate_structure_complexity(input_data['pattern_elements'])
|
||||
normalized_entropy = self.calculate_normalized_entropy(input_data['entropy_data'])
|
||||
historical_inheritance = input_data['historical_inheritance']
|
||||
|
||||
# 示例使用
|
||||
if __name__ == "__main__":
|
||||
pattern_gene_b22 = self.calculate_pattern_gene_b22(structure_complexity, normalized_entropy, historical_inheritance)
|
||||
|
||||
calculator = PatternGeneB22Calculator()
|
||||
|
||||
# 示例数据
|
||||
pattern_elements = [
|
||||
{'type': '几何图形', 'weight': 0.3, 'complexity': 0.7},
|
||||
{'type': '植物纹样', 'weight': 0.4, 'complexity': 0.8},
|
||||
{'type': '动物纹样', 'weight': 0.3, 'complexity': 0.6}
|
||||
]
|
||||
entropy_data = [0.3, 0.4, 0.3]
|
||||
inheritance_years = 500 # 传承年数 (用户填写)
|
||||
cultural_significance = "国家级" # 文化意义等级 (用户填写)
|
||||
preservation_status = "良好" # 保护状况 (用户填写)
|
||||
historical_inheritance = 100.0
|
||||
|
||||
# 计算各项指标
|
||||
structure_complexity = calculator.calculate_structure_complexity(pattern_elements)
|
||||
normalized_entropy = calculator.calculate_normalized_entropy(entropy_data)
|
||||
|
||||
|
||||
# 计算纹样基因值B22
|
||||
pattern_gene_b22 = calculator.calculate_pattern_gene_b22(
|
||||
1.5, 9, historical_inheritance
|
||||
)
|
||||
|
||||
print(f"结构复杂度SC: {structure_complexity:.4f}")
|
||||
print(f"归一化信息熵H: {normalized_entropy:.4f}")
|
||||
print(f"历史传承度HI: {historical_inheritance:.4f}")
|
||||
print(f"纹样基因值B22: {pattern_gene_b22:.4f}")
|
||||
await self.valuation_controller.update_calculation_step(
|
||||
step.id, {"status": "completed", "output_result": {"pattern_gene_b22": pattern_gene_b22}}
|
||||
)
|
||||
return pattern_gene_b22
|
||||
except Exception as e:
|
||||
error_message = f"纹样基因值B22计算失败: {e}"
|
||||
await self.valuation_controller.update_calculation_step(
|
||||
step.id, {"status": "failed", "error_message": error_message}
|
||||
)
|
||||
raise
|
||||
|
||||
@ -6,17 +6,11 @@
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
from app.controllers.valuation import ValuationController
|
||||
|
||||
try:
|
||||
# 相对导入(当作为包使用时)
|
||||
from .sub_formulas.basic_value_b11 import BasicValueB11Calculator, calculate_popularity_score
|
||||
from .sub_formulas.traffic_factor_b12 import TrafficFactorB12Calculator
|
||||
from .sub_formulas.policy_multiplier_b13 import PolicyMultiplierB13Calculator
|
||||
except ImportError:
|
||||
# 绝对导入(当直接运行时)
|
||||
from sub_formulas.basic_value_b11 import BasicValueB11Calculator
|
||||
from sub_formulas.traffic_factor_b12 import TrafficFactorB12Calculator
|
||||
from sub_formulas.policy_multiplier_b13 import PolicyMultiplierB13Calculator
|
||||
from .sub_formulas.basic_value_b11 import BasicValueB11Calculator
|
||||
from .sub_formulas.traffic_factor_b12 import TrafficFactorB12Calculator
|
||||
from .sub_formulas.policy_multiplier_b13 import PolicyMultiplierB13Calculator
|
||||
|
||||
|
||||
class EconomicValueB1Calculator:
|
||||
@ -27,6 +21,7 @@ class EconomicValueB1Calculator:
|
||||
self.basic_value_calculator = BasicValueB11Calculator()
|
||||
self.traffic_factor_calculator = TrafficFactorB12Calculator()
|
||||
self.policy_multiplier_calculator = PolicyMultiplierB13Calculator()
|
||||
self.valuation_controller = ValuationController()
|
||||
|
||||
def calculate_economic_value_b1(self,
|
||||
basic_value_b11: float,
|
||||
@ -50,95 +45,251 @@ class EconomicValueB1Calculator:
|
||||
|
||||
return economic_value
|
||||
|
||||
def calculate_complete_economic_value_b1(self, input_data: Dict) -> Dict:
|
||||
async def calculate_complete_economic_value_b1(self, valuation_id: int, input_data: Dict) -> Dict[str, float]:
|
||||
"""
|
||||
计算完整的经济价值B1,包含所有子公式
|
||||
|
||||
args:
|
||||
input_data: 输入数据字典,包含所有必要的参数
|
||||
|
||||
returns:
|
||||
Dict: 包含所有中间计算结果和最终结果的字典
|
||||
计算完整的经济价值B1,并记录所有计算步骤。
|
||||
|
||||
此函数集成了基础价值B11、流量因子B12和政策乘数B13的计算,
|
||||
通过调用相应的子计算器来完成。每一步的计算结果都会被记录下来,
|
||||
以支持后续的审计和分析。
|
||||
|
||||
Args:
|
||||
valuation_id (int): 估值的唯一标识符,用于关联所有计算步骤。
|
||||
input_data (Dict): 包含所有计算所需参数的字典,例如:
|
||||
{
|
||||
'three_year_income': [2000, 2400, 2600], # B11
|
||||
'patent_score': 1, # B11
|
||||
'search_index_s1': 4500.0, # B12
|
||||
'policy_match_score': 10.0, # B13
|
||||
...
|
||||
}
|
||||
|
||||
Returns:
|
||||
Dict[str, float]: 包含经济价值B1及各子公式结果的字典。
|
||||
|
||||
Raises:
|
||||
Exception: 在计算过程中发生的任何异常都会被捕获、记录,并重新抛出。
|
||||
"""
|
||||
# 财务价值F 近三年年均收益列表 [1,2,3]
|
||||
financial_value = self.basic_value_calculator.calculate_financial_value_f(input_data["three_year_income"])
|
||||
# 计算法律强度L patent_score: 专利分 (0-10分) (用户填写)
|
||||
# popularity_score: 普及地域分 (0-10分) (用户填写)
|
||||
# infringement_score: 侵权分 (0-10分) (用户填写)
|
||||
|
||||
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_B1",
|
||||
status="processing",
|
||||
input_params=input_data,
|
||||
)
|
||||
try:
|
||||
financial_value = self.basic_value_calculator.calculate_financial_value_f(input_data["three_year_income"])
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_ECON_B11_FINANCIAL_VALUE",
|
||||
status="completed",
|
||||
input_params={"three_year_income": input_data.get("three_year_income")},
|
||||
output_result={"financial_value_f": financial_value},
|
||||
)
|
||||
|
||||
# 发展潜力 patent_count: 专利分 (0-10分) (用户填写)
|
||||
# esg_score: ESG分 (0-10分) (用户填写)
|
||||
# innovation_ratio: 创新投入比 (研发费用/营收) * 100 (用户填写)
|
||||
development_potential = self.basic_value_calculator.calculate_development_potential_d(
|
||||
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},
|
||||
)
|
||||
|
||||
input_data["patent_count"],
|
||||
input_data["esg_score"],
|
||||
input_data["innovation_ratio"],
|
||||
)
|
||||
# 计算行业系数I target_industry_roe: 目标行业平均ROE (系统配置)
|
||||
# benchmark_industry_roe: 基准行业ROE (系统配置)
|
||||
# industry_coefficient = self.basic_value_calculator.calculate_industry_coefficient_i(
|
||||
#
|
||||
# )
|
||||
# 计算基础价值B11
|
||||
basic_value_b11 = self.basic_value_calculator.calculate_basic_value_b11(
|
||||
financial_value, # 财务价值F
|
||||
legal_strength, # 法律强度L
|
||||
development_potential,
|
||||
input_data["industry_coefficient"]
|
||||
)
|
||||
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},
|
||||
)
|
||||
|
||||
# 计算互动量指数
|
||||
interaction_index = self.traffic_factor_calculator.calculate_interaction_index(
|
||||
input_data["likes"],
|
||||
input_data["comments"],
|
||||
input_data["shares"],
|
||||
)
|
||||
# 计算覆盖人群指数
|
||||
coverage_index = self.traffic_factor_calculator.calculate_coverage_index(0)
|
||||
# 计算转化率
|
||||
conversion_efficiency = self.traffic_factor_calculator.calculate_conversion_efficiency(
|
||||
input_data["sales_volume"], input_data["link_views"])
|
||||
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},
|
||||
)
|
||||
|
||||
social_media_spread_s3 = self.traffic_factor_calculator.calculate_social_media_spread_s3(interaction_index,
|
||||
coverage_index,
|
||||
conversion_efficiency)
|
||||
basic_value_b11 = self.basic_value_calculator.calculate_basic_value_b11(
|
||||
financial_value,
|
||||
legal_strength,
|
||||
development_potential,
|
||||
industry_coefficient,
|
||||
)
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_ECON_B11",
|
||||
status="completed",
|
||||
input_params={
|
||||
"financial_value_f": financial_value,
|
||||
"legal_strength_l": legal_strength,
|
||||
"development_potential_d": development_potential,
|
||||
"industry_coefficient": industry_coefficient,
|
||||
},
|
||||
output_result={
|
||||
"basic_value_b11": basic_value_b11,
|
||||
"financial_value_f": financial_value,
|
||||
"legal_strength_l": legal_strength,
|
||||
"development_potential_d": development_potential,
|
||||
"industry_coefficient": industry_coefficient,
|
||||
},
|
||||
)
|
||||
|
||||
traffic_factor_b12 = self.traffic_factor_calculator.calculate_traffic_factor_b12(
|
||||
input_data['search_index_s1'],
|
||||
input_data['industry_average_s2'],
|
||||
social_media_spread_s3
|
||||
)
|
||||
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},
|
||||
)
|
||||
|
||||
# 计算政策乘数B13
|
||||
policy_compatibility_score = self.policy_multiplier_calculator.calculate_policy_compatibility_score(
|
||||
input_data["policy_match_score"],
|
||||
input_data["implementation_stage"],
|
||||
input_data["funding_support"])
|
||||
policy_multiplier_b13 = self.policy_multiplier_calculator.calculate_policy_multiplier_b13(
|
||||
policy_compatibility_score
|
||||
)
|
||||
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},
|
||||
)
|
||||
|
||||
# 计算经济价值B1
|
||||
economic_value_b1 = self.calculate_economic_value_b1(
|
||||
basic_value_b11,
|
||||
traffic_factor_b12,
|
||||
policy_multiplier_b13
|
||||
)
|
||||
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},
|
||||
)
|
||||
|
||||
return {
|
||||
'basic_value_b11': basic_value_b11,
|
||||
'traffic_factor_b12': traffic_factor_b12,
|
||||
'policy_multiplier_b13': policy_multiplier_b13,
|
||||
'economic_value_b1': economic_value_b1
|
||||
}
|
||||
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},
|
||||
)
|
||||
|
||||
traffic_factor_b12 = self.traffic_factor_calculator.calculate_traffic_factor_b12(
|
||||
input_data["search_index_s1"],
|
||||
input_data["industry_average_s2"],
|
||||
social_media_spread_s3,
|
||||
)
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_FLOW_B12",
|
||||
status="completed",
|
||||
input_params={
|
||||
"search_index_s1": input_data.get("search_index_s1"),
|
||||
"industry_average_s2": input_data.get("industry_average_s2"),
|
||||
},
|
||||
output_result={
|
||||
"traffic_factor_b12": traffic_factor_b12,
|
||||
"social_media_spread_s3": social_media_spread_s3,
|
||||
},
|
||||
)
|
||||
|
||||
policy_compatibility = self.policy_multiplier_calculator.calculate_policy_compatibility_score(
|
||||
input_data["policy_match_score"],
|
||||
input_data["implementation_stage"],
|
||||
input_data["funding_support"],
|
||||
)
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_POLICY_B13",
|
||||
status="processing",
|
||||
input_params={
|
||||
"policy_match_score": input_data.get("policy_match_score"),
|
||||
"implementation_stage": input_data.get("implementation_stage"),
|
||||
"funding_support": input_data.get("funding_support"),
|
||||
},
|
||||
output_result={"policy_compatibility_score": policy_compatibility},
|
||||
)
|
||||
|
||||
policy_multiplier_b13 = self.policy_multiplier_calculator.calculate_policy_multiplier_b13(
|
||||
policy_compatibility,
|
||||
)
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_POLICY_B13",
|
||||
status="completed",
|
||||
output_result={
|
||||
"policy_multiplier_b13": policy_multiplier_b13,
|
||||
"policy_compatibility_score": policy_compatibility,
|
||||
},
|
||||
)
|
||||
|
||||
economic_value_b1 = self.calculate_economic_value_b1(
|
||||
basic_value_b11,
|
||||
traffic_factor_b12,
|
||||
policy_multiplier_b13,
|
||||
)
|
||||
result = {
|
||||
"economic_value_b1": economic_value_b1,
|
||||
"basic_value_b11": basic_value_b11,
|
||||
"traffic_factor_b12": traffic_factor_b12,
|
||||
"policy_multiplier_b13": policy_multiplier_b13,
|
||||
"financial_value_f": financial_value,
|
||||
"legal_strength_l": legal_strength,
|
||||
"development_potential_d": development_potential,
|
||||
}
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_ECON_B1",
|
||||
status="completed",
|
||||
output_result=result,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_ECON_B1",
|
||||
status="failed",
|
||||
error_message=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
# 示例使用
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -1,13 +1,25 @@
|
||||
import math
|
||||
from typing import List, Optional
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(os.path.join(current_dir, '..', '..', '..', '..'))
|
||||
|
||||
try:
|
||||
from app.controllers.valuation import ValuationController
|
||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
||||
except ImportError:
|
||||
# 处理可能的导入错误
|
||||
pass
|
||||
|
||||
class BasicValueB11Calculator:
|
||||
"""基础价值B11计算器"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化计算器"""
|
||||
pass
|
||||
self.valuation_controller = ValuationController()
|
||||
|
||||
def calculate_basic_value_b11(self,
|
||||
financial_value: float,
|
||||
@ -94,7 +106,7 @@ class BasicValueB11Calculator:
|
||||
# 使用两个增长率的平均值
|
||||
avg_growth_rate = (growth_rate_1 + growth_rate_2) / 2
|
||||
|
||||
return avg_growth_rate
|
||||
return max(avg_growth_rate, 0.0)
|
||||
|
||||
def calculate_legal_strength_l(self,
|
||||
patent_score: float,
|
||||
@ -168,94 +180,146 @@ class BasicValueB11Calculator:
|
||||
|
||||
return industry_coefficient
|
||||
|
||||
def _calculate_patent_score(self, patent_remaining_years: int) -> float:
|
||||
"""
|
||||
计算专利分
|
||||
|
||||
# 专利相关计算函数
|
||||
def calculate_patent_score(patent_remaining_years: int) -> float:
|
||||
"""
|
||||
计算专利分
|
||||
专利剩余保护期评分标准:
|
||||
- >10年: 10分
|
||||
- 5-10年: 7分
|
||||
- <5年: 3分
|
||||
|
||||
专利剩余保护期评分标准:
|
||||
- >10年: 10分
|
||||
- 5-10年: 7分
|
||||
- <5年: 3分
|
||||
args:
|
||||
patent_remaining_years: 专利剩余保护期(年) (用户填写)
|
||||
|
||||
args:
|
||||
patent_remaining_years: 专利剩余保护期(年) (用户填写)
|
||||
returns:
|
||||
float: 专利分
|
||||
"""
|
||||
if patent_remaining_years > 10:
|
||||
return 10.0
|
||||
elif patent_remaining_years >= 5:
|
||||
return 7.0
|
||||
else:
|
||||
return 3.0
|
||||
|
||||
returns:
|
||||
float: 专利分
|
||||
"""
|
||||
if patent_remaining_years > 10:
|
||||
return 10.0
|
||||
elif patent_remaining_years >= 5:
|
||||
return 7.0
|
||||
else:
|
||||
return 3.0
|
||||
def _calculate_patent_usage_score(self, patent_count: int) -> float:
|
||||
"""
|
||||
计算专利使用量分
|
||||
|
||||
专利使用量评分标准:
|
||||
- 未引用: 0分
|
||||
- 每引用一项: +2.5分
|
||||
- 10分封顶
|
||||
|
||||
# 识别用户所上传的图像中的专利号,通过API验证专利是否存在,按所用专利数量赋分,未引用0分,每引用一项+2.5分,10分封顶(0-10分)
|
||||
def calculate_patent_usage_score(patent_count: int) -> float:
|
||||
"""
|
||||
计算专利使用量分
|
||||
args:
|
||||
patent_count: 专利数量 (用户填写)
|
||||
|
||||
专利使用量评分标准:
|
||||
- 未引用: 0分
|
||||
- 每引用一项: +2.5分
|
||||
- 10分封顶
|
||||
returns:
|
||||
float: 专利使用量分
|
||||
"""
|
||||
score = min(patent_count * 2.5, 10.0)
|
||||
return score
|
||||
|
||||
args:
|
||||
patent_count: 专利数量 (用户填写)
|
||||
def _calculate_popularity_score(self, region_coverage: str) -> float:
|
||||
"""
|
||||
计算普及地域分
|
||||
|
||||
returns:
|
||||
float: 专利使用量分
|
||||
"""
|
||||
score = min(patent_count * 2.5, 10.0)
|
||||
return score
|
||||
全球覆盖(10分),全国覆盖(7分),区域覆盖(4分)
|
||||
|
||||
args:
|
||||
region_coverage: 普及地域类型 (用户填写)
|
||||
|
||||
# 普及地域评分
|
||||
def calculate_popularity_score(region_coverage: str) -> float:
|
||||
"""
|
||||
计算普及地域分
|
||||
returns:
|
||||
float: 普及地域分
|
||||
"""
|
||||
coverage_scores = {
|
||||
"全球覆盖": 10.0,
|
||||
"全国覆盖": 7.0,
|
||||
"区域覆盖": 4.0
|
||||
}
|
||||
|
||||
全球覆盖(10分),全国覆盖(7分),区域覆盖(4分)
|
||||
return coverage_scores.get(region_coverage, 7.0)
|
||||
|
||||
args:
|
||||
region_coverage: 普及地域类型 (用户填写)
|
||||
def _calculate_infringement_score(self, infringement_status: str) -> float:
|
||||
"""
|
||||
计算侵权记录分
|
||||
|
||||
returns:
|
||||
float: 普及地域分
|
||||
"""
|
||||
coverage_scores = {
|
||||
"全球覆盖": 10.0,
|
||||
"全国覆盖": 7.0,
|
||||
"区域覆盖": 4.0
|
||||
}
|
||||
无侵权记录(10分),历史侵权已解决(6分),现存纠纷(2分)
|
||||
|
||||
return coverage_scores.get(region_coverage, 7.0)
|
||||
args:
|
||||
infringement_status: 侵权记录状态 (用户填写)
|
||||
|
||||
returns:
|
||||
float: 侵权记录分
|
||||
"""
|
||||
infringement_scores = {
|
||||
"无侵权记录": 10.0,
|
||||
"历史侵权已解决": 6.0,
|
||||
"现存纠纷": 2.0
|
||||
}
|
||||
|
||||
# 侵权记录评分
|
||||
def calculate_infringement_score(infringement_status: str) -> float:
|
||||
"""
|
||||
计算侵权记录分
|
||||
return infringement_scores.get(infringement_status, 6.0)
|
||||
|
||||
无侵权记录(10分),历史侵权已解决(6分),现存纠纷(2分)
|
||||
async def calculate_complete_basic_value_b11(self, valuation_id: int, input_data: dict) -> float:
|
||||
step = await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
valuation_id=valuation_id,
|
||||
step_order=2.11,
|
||||
step_name="基础价值B11计算",
|
||||
step_description="开始计算基础价值B11",
|
||||
input_params=input_data,
|
||||
status="in_progress"
|
||||
)
|
||||
)
|
||||
try:
|
||||
financial_value = self.calculate_financial_value_f(input_data['annual_revenue_3_years'])
|
||||
await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
valuation_id=valuation_id, step_order=2.111, step_name="财务价值F",
|
||||
output_result={'financial_value': financial_value}, status="completed"
|
||||
)
|
||||
)
|
||||
|
||||
args:
|
||||
infringement_status: 侵权记录状态 (用户填写)
|
||||
patent_score = self._calculate_patent_score(input_data['patent_remaining_years'])
|
||||
popularity_score = self._calculate_popularity_score(input_data['region_coverage'])
|
||||
infringement_score = self._calculate_infringement_score(input_data['infringement_status'])
|
||||
legal_strength = self.calculate_legal_strength_l(patent_score, popularity_score, infringement_score)
|
||||
await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
valuation_id=valuation_id, step_order=2.112, step_name="法律强度L",
|
||||
output_result={'legal_strength': legal_strength}, status="completed"
|
||||
)
|
||||
)
|
||||
|
||||
returns:
|
||||
float: 侵权记录分
|
||||
"""
|
||||
infringement_scores = {
|
||||
"无侵权记录": 10.0,
|
||||
"历史侵权已解决": 6.0,
|
||||
"现存纠纷": 2.0
|
||||
}
|
||||
patent_usage_score = self._calculate_patent_usage_score(input_data['patent_count'])
|
||||
development_potential = self.calculate_development_potential_d(patent_usage_score, input_data['esg_score'], input_data['innovation_ratio'])
|
||||
await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
valuation_id=valuation_id, step_order=2.113, step_name="发展潜力D",
|
||||
output_result={'development_potential': development_potential}, status="completed"
|
||||
)
|
||||
)
|
||||
|
||||
return infringement_scores.get(infringement_status, 6.0)
|
||||
industry_coefficient = self.calculate_industry_coefficient_i(input_data['target_industry_roe'], input_data['benchmark_industry_roe'])
|
||||
await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
valuation_id=valuation_id, step_order=2.114, step_name="行业系数I",
|
||||
output_result={'industry_coefficient': industry_coefficient}, status="completed"
|
||||
)
|
||||
)
|
||||
|
||||
basic_value_b11 = self.calculate_basic_value_b11(financial_value, legal_strength, development_potential, industry_coefficient)
|
||||
|
||||
await self.valuation_controller.update_calculation_step(
|
||||
step.id, {"status": "completed", "output_result": {"basic_value_b11": basic_value_b11}}
|
||||
)
|
||||
return basic_value_b11
|
||||
except Exception as e:
|
||||
error_message = f"基础价值B11计算失败: {e}"
|
||||
await self.valuation_controller.update_calculation_step(
|
||||
step.id, {"status": "failed", "error_message": error_message}
|
||||
)
|
||||
raise
|
||||
|
||||
# 示例使用
|
||||
if __name__ == "__main__":
|
||||
@ -315,4 +379,4 @@ if __name__ == "__main__":
|
||||
# print(f"增长率: {growth_rate*100}%")
|
||||
# print(f"(1+14%)^5 = {growth_factor:.4f}")
|
||||
# print(f"2333 × {growth_factor:.4f} = {initial_value * growth_factor:.2f}")
|
||||
# print(f"再除以5: {initial_value * growth_factor:.2f} ÷ 5 = {result:.2f}")
|
||||
# print(f"再除以5: {initial_value * growth_factor:.2f} ÷ 5 = {result:.2f}")
|
||||
|
||||
@ -1,15 +1,28 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(os.path.join(current_dir, '..', '..', '..', '..'))
|
||||
|
||||
try:
|
||||
from app.controllers.valuation import ValuationController
|
||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
||||
except ImportError:
|
||||
# 处理可能的导入错误
|
||||
pass
|
||||
|
||||
class PolicyMultiplierB13Calculator:
|
||||
"""政策乘数B13计算器"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化计算器"""
|
||||
pass
|
||||
self.valuation_controller = ValuationController()
|
||||
|
||||
def calculate_policy_multiplier_b13(self, policy_compatibility_score: float) -> float:
|
||||
"""
|
||||
计算政策乘数B13
|
||||
|
||||
|
||||
政策乘数B13 = 1 + (政策契合度评分P × 0.15)
|
||||
|
||||
Args:
|
||||
@ -18,9 +31,7 @@ class PolicyMultiplierB13Calculator:
|
||||
returns:
|
||||
float: 政策乘数B13
|
||||
"""
|
||||
#
|
||||
policy_multiplier = 1 + (policy_compatibility_score * 0.15)
|
||||
|
||||
return policy_multiplier
|
||||
|
||||
def calculate_policy_compatibility_score(self,
|
||||
@ -30,7 +41,6 @@ class PolicyMultiplierB13Calculator:
|
||||
"""
|
||||
计算政策契合度评分P
|
||||
|
||||
|
||||
政策契合度P = 政策匹配度 × 0.4 + 实施阶段评分 × 0.3 + 资金支持度 × 0.3
|
||||
|
||||
Args:
|
||||
@ -41,11 +51,9 @@ class PolicyMultiplierB13Calculator:
|
||||
returns:
|
||||
float: 政策契合度评分P
|
||||
"""
|
||||
#
|
||||
policy_compatibility = (policy_match_score * 0.4 +
|
||||
implementation_stage_score * 0.3 +
|
||||
funding_support_score * 0.3)
|
||||
|
||||
return policy_compatibility
|
||||
|
||||
def calculate_policy_match_score(self, industry: str) -> float:
|
||||
@ -60,8 +68,8 @@ class PolicyMultiplierB13Calculator:
|
||||
returns:
|
||||
float: 政策匹配度
|
||||
"""
|
||||
|
||||
return 5
|
||||
# 此处应有更复杂的逻辑根据行业匹配政策,暂时返回固定值
|
||||
return 5.0
|
||||
|
||||
def calculate_implementation_stage_score(self, implementation_stage: str) -> float:
|
||||
"""
|
||||
@ -80,8 +88,7 @@ class PolicyMultiplierB13Calculator:
|
||||
"推广阶段": 7.0,
|
||||
"试点阶段": 4.0
|
||||
}
|
||||
|
||||
return stage_scores.get(implementation_stage, 10.0)
|
||||
return stage_scores.get(implementation_stage, 7.0)
|
||||
|
||||
def calculate_funding_support_score(self, funding_support: str) -> float:
|
||||
"""
|
||||
@ -100,8 +107,44 @@ class PolicyMultiplierB13Calculator:
|
||||
"省级资助": 7.0,
|
||||
"无资助": 0.0
|
||||
}
|
||||
return funding_scores.get(funding_support, 7.0)
|
||||
|
||||
return funding_scores.get(funding_support, 0.0)
|
||||
async def calculate_complete_policy_multiplier_b13(self, valuation_id: int, input_data: dict) -> float:
|
||||
step = await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
valuation_id=valuation_id,
|
||||
step_order=2.13,
|
||||
step_name="政策乘数B13计算",
|
||||
step_description="开始计算政策乘数B13",
|
||||
input_params=input_data,
|
||||
status="in_progress"
|
||||
)
|
||||
)
|
||||
try:
|
||||
policy_match_score = self.calculate_policy_match_score(input_data['industry'])
|
||||
implementation_stage_score = self.calculate_implementation_stage_score(input_data['implementation_stage'])
|
||||
funding_support_score = self.calculate_funding_support_score(input_data['funding_support'])
|
||||
|
||||
policy_compatibility_score = self.calculate_policy_compatibility_score(policy_match_score, implementation_stage_score, funding_support_score)
|
||||
await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
valuation_id=valuation_id, step_order=2.131, step_name="政策契合度评分P",
|
||||
output_result={'policy_compatibility_score': policy_compatibility_score}, status="completed"
|
||||
)
|
||||
)
|
||||
|
||||
policy_multiplier_b13 = self.calculate_policy_multiplier_b13(policy_compatibility_score)
|
||||
|
||||
await self.valuation_controller.update_calculation_step(
|
||||
step.id, {"status": "completed", "output_result": {"policy_multiplier_b13": policy_multiplier_b13}}
|
||||
)
|
||||
return policy_multiplier_b13
|
||||
except Exception as e:
|
||||
error_message = f"政策乘数B13计算失败: {e}"
|
||||
await self.valuation_controller.update_calculation_step(
|
||||
step.id, {"status": "failed", "error_message": error_message}
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
# 示例使用
|
||||
|
||||
@ -1,14 +1,25 @@
|
||||
import math
|
||||
from typing import Dict, Tuple
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(os.path.join(current_dir, '..', '..', '..', '..'))
|
||||
|
||||
try:
|
||||
from app.controllers.valuation import ValuationController
|
||||
from app.schemas.valuation import ValuationCalculationStepCreate
|
||||
except ImportError:
|
||||
# 处理可能的导入错误
|
||||
pass
|
||||
|
||||
class TrafficFactorB12Calculator:
|
||||
"""流量因子B12计算器"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化计算器"""
|
||||
pass
|
||||
self.valuation_controller = ValuationController()
|
||||
|
||||
def calculate_traffic_factor_b12(self,
|
||||
search_index_s1: float,
|
||||
@ -31,18 +42,29 @@ class TrafficFactorB12Calculator:
|
||||
if industry_average_s2 == 0:
|
||||
raise ValueError("行业均值S2必须大于0")
|
||||
|
||||
if search_index_s1 == 0:
|
||||
if search_index_s1 <= 0:
|
||||
# 如果搜索指数为0或负数,使用最小值避免对数计算错误
|
||||
search_index_s1 = 1.0
|
||||
|
||||
# ,不进行任何拆分
|
||||
traffic_factor = (math.log(search_index_s1 / industry_average_s2) * 0.3 +
|
||||
social_media_spread_s3 * 0.7)
|
||||
|
||||
"""
|
||||
为什么需要
|
||||
|
||||
- 经济价值 B1 的公式是 B1 = B11 × (1 + B12) × B13 (app/utils/calculation_engine/economic_value_b1/economic_value_b1.py:34-45)。
|
||||
- 如果 B12 < -1 ,则 (1 + B12) 会变成负数,导致 B1 翻成负值并把模型估值 B(final_value_ab/model_value_b.py:48-50)拉到巨负。
|
||||
- 通过设置 B12 ≥ -0.9 ,确保 (1 + B12) ≥ 0.1 ,即乘数始终为正且不至于过小。
|
||||
直观示例
|
||||
|
||||
- 原始计算得到 B12 = -1.8 (例如 ln(S1/S2) 很大负、社交传播度 S3 又很低),则 (1 + B12) = -0.8 ,会让 B1 变负。
|
||||
- 裁剪后 B12 = -0.9 ,则 (1 + B12) = 0.1 , B1 保持为正,避免最终估值出现大幅负值。
|
||||
"""
|
||||
if traffic_factor < -0.9:
|
||||
traffic_factor = -0.9
|
||||
|
||||
return traffic_factor
|
||||
|
||||
|
||||
|
||||
def calculate_social_media_spread_s3(self,
|
||||
interaction_index: float,
|
||||
coverage_index: float,
|
||||
@ -60,7 +82,6 @@ class TrafficFactorB12Calculator:
|
||||
returns:
|
||||
float: 社交媒体传播度S3
|
||||
"""
|
||||
#
|
||||
social_media_spread = (interaction_index * 0.4 +
|
||||
coverage_index * 0.3 +
|
||||
conversion_efficiency * 0.3)
|
||||
@ -84,7 +105,6 @@ class TrafficFactorB12Calculator:
|
||||
returns:
|
||||
float: 互动量指数
|
||||
"""
|
||||
#
|
||||
interaction_index = (likes + comments + shares) / 1000.0
|
||||
|
||||
return interaction_index
|
||||
@ -101,11 +121,45 @@ class TrafficFactorB12Calculator:
|
||||
returns:
|
||||
float: 覆盖人群指数
|
||||
"""
|
||||
#
|
||||
if followers == 0:
|
||||
return 0
|
||||
return 0.0
|
||||
coverage_index = followers / 10000.0
|
||||
return coverage_index
|
||||
|
||||
async def calculate_complete_traffic_factor_b12(self, valuation_id: int, input_data: dict) -> float:
|
||||
step = await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
valuation_id=valuation_id,
|
||||
step_order=2.12,
|
||||
step_name="流量因子B12计算",
|
||||
step_description="开始计算流量因子B12",
|
||||
input_params=input_data,
|
||||
status="in_progress"
|
||||
)
|
||||
)
|
||||
try:
|
||||
interaction_index = self.calculate_interaction_index(input_data['likes'], input_data['comments'], input_data['shares'])
|
||||
coverage_index = self.calculate_coverage_index(input_data['followers'])
|
||||
social_media_spread_s3 = self.calculate_social_media_spread_s3(interaction_index, coverage_index, input_data['conversion_efficiency'])
|
||||
await self.valuation_controller.create_calculation_step(
|
||||
ValuationCalculationStepCreate(
|
||||
valuation_id=valuation_id, step_order=2.121, step_name="社交媒体传播度S3",
|
||||
output_result={'social_media_spread_s3': social_media_spread_s3}, status="completed"
|
||||
)
|
||||
)
|
||||
|
||||
traffic_factor_b12 = self.calculate_traffic_factor_b12(input_data['search_index_s1'], input_data['industry_average_s2'], social_media_spread_s3)
|
||||
|
||||
await self.valuation_controller.update_calculation_step(
|
||||
step.id, {"status": "completed", "output_result": {"traffic_factor_b12": traffic_factor_b12}}
|
||||
)
|
||||
return traffic_factor_b12
|
||||
except Exception as e:
|
||||
error_message = f"流量因子B12计算失败: {e}"
|
||||
await self.valuation_controller.update_calculation_step(
|
||||
step.id, {"status": "failed", "error_message": error_message}
|
||||
)
|
||||
raise
|
||||
|
||||
def calculate_conversion_efficiency(self,
|
||||
click_count: int,
|
||||
@ -223,26 +277,19 @@ def calculate_heat_score(daily_views: float, favorites: int) -> float:
|
||||
return 0.0
|
||||
|
||||
# 30天搜索指数S1
|
||||
def calculate_search_index_s1(baidu_index: float,
|
||||
wechat_index: float,
|
||||
weibo_index: float) -> float:
|
||||
def calculate_search_index_s1(wechat_index: float) -> float:
|
||||
"""
|
||||
计算近30天搜索指数S1
|
||||
|
||||
近30天搜索指数S1 = 百度搜索指数 × 0.4 + 微信搜索指数 × 0.3 + 微博搜索指数 × 0.3
|
||||
近30天搜索指数S1 = 微信指数 / 10
|
||||
|
||||
args:
|
||||
baidu_index: 百度搜索指数 (API获取)
|
||||
wechat_index: 微信搜索指数 (API获取)
|
||||
weibo_index: 微博搜索指数 (API获取)
|
||||
|
||||
returns:
|
||||
float: 近30天搜索指数S1
|
||||
"""
|
||||
#
|
||||
search_index = (baidu_index * 0.4 +
|
||||
wechat_index * 0.3 +
|
||||
weibo_index * 0.3)
|
||||
search_index = wechat_index / 10.0
|
||||
|
||||
return search_index
|
||||
# 示例使用
|
||||
@ -252,10 +299,8 @@ if __name__ == "__main__":
|
||||
processor = PlatformDataProcessor()
|
||||
|
||||
# 示例数据
|
||||
# 搜索指数数据 (API获取)
|
||||
baidu_index = 6000.0
|
||||
# 微信指数数据 (API获取)
|
||||
wechat_index = 4500.0
|
||||
weibo_index = 3000.0
|
||||
|
||||
# 行业均值 (系统配置)
|
||||
industry_average = 5000.0
|
||||
@ -285,7 +330,7 @@ if __name__ == "__main__":
|
||||
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)
|
||||
conversion_efficiency = calculator.calculate_conversion_efficiency(click_count, view_count)
|
||||
# 互动量指数 × 0.4 + 覆盖人群指数 × 0.3 + 转化效率 × 0.3
|
||||
@ -306,4 +351,4 @@ if __name__ == "__main__":
|
||||
print(f"覆盖人群指数: {coverage_index:.4f}")
|
||||
print(f"转化效率: {conversion_efficiency:.4f}")
|
||||
print(f"社交媒体传播度S3: {social_media_spread_s3:.4f}")
|
||||
print(f"流量因子B12: {traffic_factor:.4f}")
|
||||
print(f"流量因子B12: {traffic_factor:.4f}")
|
||||
|
||||
@ -19,10 +19,12 @@ try:
|
||||
# 包内相对导入
|
||||
from .model_value_b import ModelValueBCalculator
|
||||
from ..market_value_c import MarketValueCCalculator
|
||||
from app.controllers.valuation import ValuationController
|
||||
except ImportError:
|
||||
# 直接运行时的绝对导入
|
||||
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.controllers.valuation import ValuationController
|
||||
|
||||
|
||||
class FinalValueACalculator:
|
||||
@ -32,6 +34,7 @@ class FinalValueACalculator:
|
||||
"""初始化计算器"""
|
||||
self.model_value_calculator = ModelValueBCalculator()
|
||||
self.market_value_calculator = MarketValueCCalculator()
|
||||
self.valuation_controller = ValuationController()
|
||||
|
||||
def calculate_final_value_a(self,
|
||||
model_value_b: float,
|
||||
@ -64,79 +67,100 @@ class FinalValueACalculator:
|
||||
|
||||
return final_value
|
||||
|
||||
async def calculate_complete_final_value_a(self, input_data: Dict) -> Dict:
|
||||
async def calculate_complete_final_value_a(self, valuation_id: int, input_data: Dict) -> float:
|
||||
"""
|
||||
计算完整的最终估值A,包含所有子模块
|
||||
计算完整的最终估值A,并记录每一步的计算过程。
|
||||
|
||||
input_data: 输入数据字典,包含所有必要的参数
|
||||
|
||||
该函数作为最终估值计算的入口,协调调用模型估值B和市场估值C的计算,
|
||||
并将计算过程中的关键步骤(如子模块的调用、输入、输出)持久化,
|
||||
以便于后续的审计和追溯。
|
||||
|
||||
包含所有中间计算结果和最终结果的字典
|
||||
Args:
|
||||
valuation_id (int): 本次估值的唯一标识符,用于关联所有计算步骤。
|
||||
input_data (Dict): 包含所有计算所需参数的字典,结构如下:
|
||||
{
|
||||
'model_data': { ... }, # 模型估值B所需数据
|
||||
'market_data': { ... } # 市场估值C所需数据
|
||||
}
|
||||
|
||||
Returns:
|
||||
float: 计算得出的最终估值A。
|
||||
|
||||
Raises:
|
||||
Exception: 在计算过程中遇到的任何异常都会被重新抛出,
|
||||
并在记录最后一步为“计算失败”后终止。
|
||||
"""
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
# 记录输入参数
|
||||
logger.info("final_value_a.calculation_start input_data_keys={} model_data_keys={} market_data_keys={}",
|
||||
list(input_data.keys()),
|
||||
list(input_data.get('model_data', {}).keys()),
|
||||
list(input_data.get('market_data', {}).keys()))
|
||||
|
||||
# 详细记录模型数据参数
|
||||
model_data = input_data.get('model_data', {})
|
||||
if 'economic_data' in model_data:
|
||||
economic_data = model_data['economic_data']
|
||||
logger.info("final_value_a.economic_data 经济价值B1参数: 近三年机构收益={} 专利分={} 普及地域分={} 侵权分={} 创新投入比={} ESG分={} 专利使用量={} 行业修正系数={}",
|
||||
economic_data.get('three_year_income'),
|
||||
economic_data.get('patent_score'),
|
||||
economic_data.get('popularity_score'),
|
||||
economic_data.get('infringement_score'),
|
||||
economic_data.get('innovation_ratio'),
|
||||
economic_data.get('esg_score'),
|
||||
economic_data.get('patent_count'),
|
||||
economic_data.get('industry_coefficient'))
|
||||
|
||||
if 'cultural_data' in model_data:
|
||||
cultural_data = model_data['cultural_data']
|
||||
logger.info("final_value_a.cultural_data 文化价值B2参数: 传承人等级系数={} 跨境深度={} 线下教学次数={} 抖音浏览量={} 快手浏览量={} 哔哩哔哩浏览量={} 结构复杂度={} 归一化信息熵={} 历史传承度={}",
|
||||
cultural_data.get('inheritor_level_coefficient'),
|
||||
cultural_data.get('cross_border_depth'),
|
||||
cultural_data.get('offline_sessions'),
|
||||
cultural_data.get('douyin_views'),
|
||||
cultural_data.get('kuaishou_views'),
|
||||
cultural_data.get('bilibili_views'),
|
||||
cultural_data.get('structure_complexity'),
|
||||
cultural_data.get('normalized_entropy'),
|
||||
cultural_data.get('historical_inheritance'))
|
||||
|
||||
if 'risky_data' in model_data:
|
||||
risky_data = model_data['risky_data']
|
||||
logger.info("final_value_a.risky_data 风险调整B3参数: 最高价={} 最低价={} 诉讼状态={} 传承人年龄={}",
|
||||
risky_data.get('highest_price'),
|
||||
risky_data.get('lowest_price'),
|
||||
risky_data.get('lawsuit_status'),
|
||||
risky_data.get('inheritor_ages'))
|
||||
|
||||
# 详细记录市场数据参数
|
||||
market_data = input_data.get('market_data', {})
|
||||
logger.info("final_value_a.market_data 市场估值C参数: 平均交易价={} 手动出价={} 专家估值={} 日浏览量={} 收藏数量={} 发行等级={} 最近市场活动={}",
|
||||
market_data.get('average_transaction_price'),
|
||||
market_data.get('manual_bids'),
|
||||
market_data.get('expert_valuations'),
|
||||
market_data.get('daily_browse_volume'),
|
||||
market_data.get('collection_count'),
|
||||
market_data.get('issuance_level'),
|
||||
market_data.get('recent_market_activity'))
|
||||
|
||||
# 计算模型估值B
|
||||
logger.info("final_value_a.calculating_model_value_b 开始计算模型估值B")
|
||||
model_start_time = time.time()
|
||||
|
||||
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"FINAL_A",
|
||||
status="processing",
|
||||
input_params=input_data,
|
||||
)
|
||||
|
||||
try:
|
||||
model_result = self.model_value_calculator.calculate_complete_model_value_b(
|
||||
# 详细记录模型数据参数
|
||||
model_data = input_data.get('model_data', {})
|
||||
if 'economic_data' in model_data:
|
||||
economic_data = model_data['economic_data']
|
||||
logger.info("final_value_a.economic_data 经济价值B1参数: 近三年机构收益={} 专利分={} 普及地域分={} 侵权分={} 创新投入比={} ESG分={} 专利使用量={} 行业修正系数={}",
|
||||
economic_data.get('three_year_income'),
|
||||
economic_data.get('patent_score'),
|
||||
economic_data.get('popularity_score'),
|
||||
economic_data.get('infringement_score'),
|
||||
economic_data.get('innovation_ratio'),
|
||||
economic_data.get('esg_score'),
|
||||
economic_data.get('patent_count'),
|
||||
economic_data.get('industry_coefficient'))
|
||||
|
||||
if 'cultural_data' in model_data:
|
||||
cultural_data = model_data['cultural_data']
|
||||
logger.info("final_value_a.cultural_data 文化价值B2参数: 传承人等级系数={} 跨境深度={} 线下教学次数={} 抖音浏览量={} 快手浏览量={} 哔哩哔哩浏览量={} 结构复杂度={} 归一化信息熵={} 历史传承度={}",
|
||||
cultural_data.get('inheritor_level_coefficient'),
|
||||
cultural_data.get('cross_border_depth'),
|
||||
cultural_data.get('offline_sessions'),
|
||||
cultural_data.get('douyin_views'),
|
||||
cultural_data.get('kuaishou_views'),
|
||||
cultural_data.get('bilibili_views'),
|
||||
cultural_data.get('structure_complexity'),
|
||||
cultural_data.get('normalized_entropy'),
|
||||
cultural_data.get('historical_inheritance'))
|
||||
|
||||
if 'risky_data' in model_data:
|
||||
risky_data = model_data['risky_data']
|
||||
logger.info("final_value_a.risky_data 风险调整B3参数: 最高价={} 最低价={} 诉讼状态={} 传承人年龄={}",
|
||||
risky_data.get('highest_price'),
|
||||
risky_data.get('lowest_price'),
|
||||
risky_data.get('lawsuit_status'),
|
||||
risky_data.get('inheritor_ages'))
|
||||
|
||||
# 详细记录市场数据参数
|
||||
market_data = input_data.get('market_data', {})
|
||||
logger.info("final_value_a.market_data 市场估值C参数: 平均交易价={} 手动出价={} 专家估值={} 日浏览量={} 收藏数量={} 发行等级={} 最近市场活动={}",
|
||||
market_data.get('average_transaction_price'),
|
||||
market_data.get('manual_bids'),
|
||||
market_data.get('expert_valuations'),
|
||||
market_data.get('daily_browse_volume'),
|
||||
market_data.get('collection_count'),
|
||||
market_data.get('issuance_level'),
|
||||
market_data.get('recent_market_activity'))
|
||||
|
||||
# 计算模型估值B
|
||||
logger.info("final_value_a.calculating_model_value_b 开始计算模型估值B")
|
||||
model_start_time = time.time()
|
||||
|
||||
model_result_raw = await self.model_value_calculator.calculate_complete_model_value_b(
|
||||
valuation_id,
|
||||
input_data['model_data']
|
||||
)
|
||||
model_value_b = model_result['model_value_b']
|
||||
model_result = model_result_raw if isinstance(model_result_raw, dict) else {"model_value_b": model_result_raw}
|
||||
model_value_b = model_result.get('model_value_b')
|
||||
model_duration = time.time() - model_start_time
|
||||
|
||||
logger.info("final_value_a.model_value_b_calculated 模型估值B计算完成: 模型估值B={}万元 耗时={}ms 返回字段={}",
|
||||
@ -144,35 +168,27 @@ class FinalValueACalculator:
|
||||
int(model_duration * 1000),
|
||||
list(model_result.keys()))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("final_value_a.model_value_b_calculation_failed 模型估值B计算失败: 错误={} 输入数据={}", str(e), input_data.get('model_data', {}))
|
||||
raise
|
||||
|
||||
# 计算市场估值C
|
||||
logger.info("final_value_a.calculating_market_value_c 开始计算市场估值C")
|
||||
market_start_time = time.time()
|
||||
|
||||
try:
|
||||
market_result = await self.market_value_calculator.calculate_complete_market_value_c(
|
||||
# 计算市场估值C
|
||||
logger.info("final_value_a.calculating_market_value_c 开始计算市场估值C")
|
||||
market_start_time = time.time()
|
||||
|
||||
market_result_raw = await self.market_value_calculator.calculate_complete_market_value_c(
|
||||
valuation_id,
|
||||
input_data['market_data']
|
||||
)
|
||||
market_value_c = market_result['market_value_c']
|
||||
market_result = market_result_raw if isinstance(market_result_raw, dict) else {"market_value_c": market_result_raw}
|
||||
market_value_c = market_result.get('market_value_c')
|
||||
market_duration = time.time() - market_start_time
|
||||
|
||||
logger.info("final_value_a.market_value_c_calculated 市场估值C计算完成: 市场估值C={}万元 耗时={}ms 返回字段={}",
|
||||
logger.info("final_value_a.market_value_c_calculated 市场估值C计算完成: 市场估值C={}万元 耗时={}ms 请求字段={}",
|
||||
market_value_c,
|
||||
int(market_duration * 1000),
|
||||
list(market_result.keys()))
|
||||
input_data['market_data'])
|
||||
|
||||
# 计算最终估值A
|
||||
logger.info("final_value_a.calculating_final_value_a 开始计算最终估值A: 模型估值B={}万元 市场估值C={}万元",
|
||||
model_value_b, market_value_c)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("final_value_a.market_value_c_calculation_failed 市场估值C计算失败: 错误={} 输入数据={}", str(e), input_data.get('market_data', {}))
|
||||
raise
|
||||
|
||||
# 计算最终估值A
|
||||
logger.info("final_value_a.calculating_final_value_a 开始计算最终估值A: 模型估值B={}万元 市场估值C={}万元",
|
||||
model_value_b, market_value_c)
|
||||
|
||||
try:
|
||||
final_value_a = self.calculate_final_value_a(
|
||||
model_value_b,
|
||||
market_value_c
|
||||
@ -188,16 +204,35 @@ class FinalValueACalculator:
|
||||
int(model_duration * 1000),
|
||||
int(market_duration * 1000))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("final_value_a.final_value_calculation_failed 最终估值A计算失败: 错误={} 模型估值B={}万元 市场估值C={}万元",
|
||||
str(e), model_value_b, market_value_c)
|
||||
raise
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"FINAL_A",
|
||||
status="completed",
|
||||
output_result={
|
||||
"model_value_b": model_value_b,
|
||||
"market_value_c": market_value_c,
|
||||
"final_value_ab": final_value_a,
|
||||
"model_duration_ms": int(model_duration * 1000),
|
||||
"market_duration_ms": int(market_duration * 1000),
|
||||
"total_duration_ms": int(total_duration * 1000),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"model_value_b": model_value_b,
|
||||
"market_value_c": market_value_c,
|
||||
"final_value_ab": final_value_a,
|
||||
}
|
||||
|
||||
return {
|
||||
'model_value_b': model_value_b,
|
||||
'market_value_c': market_value_c,
|
||||
'final_value_ab': final_value_a,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("final_value_a.calculation_failed 计算失败: 错误={}", str(e))
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"FINAL_A",
|
||||
status="failed",
|
||||
error_message=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -12,10 +12,12 @@ try:
|
||||
# 相对导入(当作为包使用时)
|
||||
from ..economic_value_b1.economic_value_b1 import EconomicValueB1Calculator
|
||||
from ..cultural_value_b2.cultural_value_b2 import CulturalValueB2Calculator
|
||||
from app.controllers.valuation import ValuationController
|
||||
except ImportError:
|
||||
# 绝对导入(当直接运行时)
|
||||
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.controllers.valuation import ValuationController
|
||||
|
||||
|
||||
class ModelValueBCalculator:
|
||||
@ -26,6 +28,7 @@ class ModelValueBCalculator:
|
||||
self.economic_value_calculator = EconomicValueB1Calculator()
|
||||
self.cultural_value_calculator = CulturalValueB2Calculator()
|
||||
self.risk_adjustment_calculator = RiskAdjustmentB3Calculator()
|
||||
self.valuation_controller = ValuationController()
|
||||
|
||||
def calculate_model_value_b(self,
|
||||
economic_value_b1: float,
|
||||
@ -46,45 +49,104 @@ class ModelValueBCalculator:
|
||||
|
||||
return model_value
|
||||
|
||||
def calculate_complete_model_value_b(self, input_data: Dict) -> Dict:
|
||||
async def calculate_complete_model_value_b(self, valuation_id: int, input_data: Dict) -> Dict[str, float]:
|
||||
"""
|
||||
计算完整的模型估值B,包含所有子公式
|
||||
|
||||
计算完整的模型估值B,并记录详细的计算步骤。
|
||||
|
||||
此函数通过依次调用经济价值B1、文化价值B2和风险调整系数B3的计算器,
|
||||
完成模型估值B的全面计算。每一步的计算(包括子模块的调用、输入、输出)
|
||||
都会被记录下来,用于后续的分析和审计。
|
||||
|
||||
Args:
|
||||
input_data: 输入数据字典,包含所有必要的参数
|
||||
|
||||
valuation_id (int): 估值的唯一标识符,用于关联所有计算步骤。
|
||||
input_data (Dict): 包含所有计算所需参数的字典,结构应包含:
|
||||
{
|
||||
'economic_data': { ... }, # 经济价值B1所需数据
|
||||
'cultural_data': { ... }, # 文化价值B2所需数据
|
||||
'risky_data': { ... } # 风险调整系数B3所需数据
|
||||
}
|
||||
|
||||
Returns:
|
||||
Dict: 包含所有中间计算结果和最终结果的字典
|
||||
Dict[str, float]: 包含中间结果和最终模型估值B的字典。
|
||||
|
||||
Raises:
|
||||
Exception: 在计算过程中遇到的任何异常都会被捕获、记录,然后重新抛出。
|
||||
"""
|
||||
# 计算经济价值B1
|
||||
economic_result = self.economic_value_calculator.calculate_complete_economic_value_b1(
|
||||
input_data['economic_data']
|
||||
)
|
||||
economic_value_b1 = economic_result['economic_value_b1']
|
||||
|
||||
# 计算文化价值B2
|
||||
cultural_result = self.cultural_value_calculator.calculate_complete_cultural_value_b2(
|
||||
input_data['cultural_data']
|
||||
)
|
||||
cultural_value_b2 = cultural_result['cultural_value_b2']
|
||||
|
||||
risk_value_result = self.risk_adjustment_calculator.calculate_complete_risky_value_b3(
|
||||
input_data['risky_data']
|
||||
)
|
||||
risk_value_b3 = risk_value_result['risk_adjustment_b3']
|
||||
# 计算模型估值B
|
||||
model_value_b = self.calculate_model_value_b(
|
||||
economic_value_b1,
|
||||
cultural_value_b2,
|
||||
risk_value_b3
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B",
|
||||
status="processing",
|
||||
input_params=input_data,
|
||||
)
|
||||
|
||||
return {
|
||||
'economic_value_b1': economic_value_b1,
|
||||
'cultural_value_b2': cultural_value_b2,
|
||||
'risk_value_b3': risk_value_b3,
|
||||
'model_value_b': model_value_b,
|
||||
}
|
||||
current_stage = "初始化模型估值B参数"
|
||||
try:
|
||||
if not isinstance(input_data, dict):
|
||||
raise TypeError(f"model_data必须为字典,当前类型为{type(input_data).__name__}")
|
||||
|
||||
required_sections = ("economic_data", "cultural_data", "risky_data")
|
||||
missing_sections = [
|
||||
section for section in required_sections
|
||||
if not isinstance(input_data.get(section), dict)
|
||||
]
|
||||
if missing_sections:
|
||||
raise ValueError(f"model_data缺少必要字段: {', '.join(missing_sections)}")
|
||||
|
||||
# 计算经济价值B1(传入估值ID并等待异步完成)
|
||||
current_stage = "经济价值B1计算"
|
||||
economic_result = await self.economic_value_calculator.calculate_complete_economic_value_b1(
|
||||
valuation_id,
|
||||
input_data['economic_data']
|
||||
)
|
||||
economic_value_b1 = economic_result["economic_value_b1"]
|
||||
|
||||
# 计算文化价值B2(传入估值ID并等待异步完成)
|
||||
current_stage = "文化价值B2计算"
|
||||
cultural_result = await self.cultural_value_calculator.calculate_complete_cultural_value_b2(
|
||||
valuation_id,
|
||||
input_data['cultural_data']
|
||||
)
|
||||
cultural_value_b2 = cultural_result["cultural_value_b2"]
|
||||
|
||||
# 计算风险调整系数B3(传入估值ID并等待异步完成)
|
||||
current_stage = "风险调整系数B3计算"
|
||||
risk_result = await self.risk_adjustment_calculator.calculate_complete_risky_value_b3(
|
||||
valuation_id,
|
||||
input_data['risky_data']
|
||||
)
|
||||
risk_value_b3 = risk_result["risk_value_b3"]
|
||||
|
||||
# 计算模型估值B
|
||||
current_stage = "模型估值B汇总"
|
||||
model_value_b = self.calculate_model_value_b(
|
||||
economic_value_b1,
|
||||
cultural_value_b2,
|
||||
risk_value_b3
|
||||
)
|
||||
result = {
|
||||
"economic_value_b1": economic_value_b1,
|
||||
"cultural_value_b2": cultural_value_b2,
|
||||
"risk_value_b3": risk_value_b3,
|
||||
"model_value_b": model_value_b,
|
||||
"economic_details": economic_result,
|
||||
"cultural_details": cultural_result,
|
||||
"risk_details": risk_result,
|
||||
}
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B",
|
||||
status="completed",
|
||||
output_result=result,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B",
|
||||
status="failed",
|
||||
error_message=f"{current_stage}失败: {e}",
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
# 示例使用
|
||||
|
||||
358
app/utils/calculation_engine/formula_registry.py
Normal file
@ -0,0 +1,358 @@
|
||||
"""
|
||||
公式元数据注册表
|
||||
|
||||
用于将计算引擎中的每个公式节点(含子公式)映射到唯一的 code、名称、公式说明以及排序,
|
||||
以便在 valuation_calculation_steps 表中进行结构化记录,并最终生成可读的计算报告。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormulaMeta:
|
||||
code: str
|
||||
name: str
|
||||
formula: str
|
||||
order: Decimal
|
||||
parent_code: Optional[str]
|
||||
group_code: str
|
||||
|
||||
|
||||
FormulaTreeNode = Dict[str, object]
|
||||
|
||||
|
||||
def _node(
|
||||
code: str,
|
||||
name: str,
|
||||
formula: str,
|
||||
order: str,
|
||||
children: Optional[List[FormulaTreeNode]] = None,
|
||||
group: Optional[str] = None,
|
||||
) -> FormulaTreeNode:
|
||||
return {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"formula": formula,
|
||||
"order": order,
|
||||
"group_code": group,
|
||||
"children": children or [],
|
||||
}
|
||||
|
||||
|
||||
FORMULA_TREE: List[FormulaTreeNode] = [
|
||||
_node(
|
||||
"FINAL_A",
|
||||
"最终估值A",
|
||||
"最终估值A = 模型估值B × 0.7 + 市场估值C × 0.3",
|
||||
"10",
|
||||
children=[
|
||||
_node(
|
||||
"MODEL_B",
|
||||
"模型估值B",
|
||||
"模型估值B = (经济价值B1 × 0.7 + 文化价值B2 × 0.3) × 风险调整系数B3",
|
||||
"20",
|
||||
group="MODEL_B",
|
||||
children=[
|
||||
_node(
|
||||
"MODEL_B_ECON_B1",
|
||||
"经济价值B1",
|
||||
"经济价值B1 = 基础价值B11 × (1 + 流量因子B12) × 政策乘数B13",
|
||||
"21",
|
||||
children=[
|
||||
_node(
|
||||
"MODEL_B_ECON_B11",
|
||||
"基础价值B11",
|
||||
"基础价值B11 = 财务价值F × (0.45 + 0.05 × 行业系数I) + 法律强度L × (0.35 + 0.05 × 行业系数I) + 发展潜力D × 0.2",
|
||||
"21.1",
|
||||
children=[
|
||||
_node(
|
||||
"MODEL_B_ECON_B11_FINANCIAL_VALUE",
|
||||
"财务价值F",
|
||||
"财务价值F = [3年内年均收益 × (1 + 增长率)^5] ÷ 5",
|
||||
"21.11",
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_ECON_B11_LEGAL_STRENGTH",
|
||||
"法律强度L",
|
||||
"法律强度L = 专利分 × 0.4 + 普及分 × 0.3 + 侵权分 × 0.3",
|
||||
"21.12",
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_ECON_B11_DEVELOPMENT_POTENTIAL",
|
||||
"发展潜力D",
|
||||
"发展潜力D = 专利分 × 0.5 + ESG分 × 0.2 + 创新投入比 × 0.3",
|
||||
"21.13",
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_ECON_B11_INDUSTRY_COEFFICIENT",
|
||||
"行业系数I",
|
||||
"行业系数I = (目标行业平均ROE - 基准行业ROE) ÷ 基准行业ROE",
|
||||
"21.14",
|
||||
),
|
||||
],
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_FLOW_B12",
|
||||
"流量因子B12",
|
||||
"流量因子B12 = ln(S1 ÷ S2) × 0.3 + 社交媒体传播度S3 × 0.7",
|
||||
"21.2",
|
||||
children=[
|
||||
_node(
|
||||
"MODEL_B_FLOW_B12_INTERACTION_INDEX",
|
||||
"互动量指数",
|
||||
"互动量指数 = (点赞 + 评论 + 分享) ÷ 1000",
|
||||
"21.21",
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_FLOW_B12_COVERAGE_INDEX",
|
||||
"覆盖人群指数",
|
||||
"覆盖人群指数 = 粉丝数 ÷ 10000",
|
||||
"21.22",
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_FLOW_B12_CONVERSION_EFFICIENCY",
|
||||
"转化效率",
|
||||
"转化效率 = 商品链接点击量 ÷ 内容浏览量",
|
||||
"21.23",
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_FLOW_B12_SOCIAL_SPREAD",
|
||||
"社交媒体传播度S3",
|
||||
"社交媒体传播度S3 = 互动量指数 × 0.4 + 覆盖人群指数 × 0.3 + 转化效率 × 0.3",
|
||||
"21.24",
|
||||
),
|
||||
],
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_POLICY_B13",
|
||||
"政策乘数B13",
|
||||
"政策乘数B13 = 1 + 政策契合度评分P × 0.15,其中 P = 政策匹配度 × 0.4 + 实施阶段评分 × 0.3 + 资金支持度 × 0.3",
|
||||
"21.3",
|
||||
),
|
||||
],
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_CULTURAL_B2",
|
||||
"文化价值B2",
|
||||
"文化价值B2 = 活态传承系数B21 × 0.6 + (纹样基因值B22 ÷ 10) × 0.4",
|
||||
"22",
|
||||
children=[
|
||||
_node(
|
||||
"MODEL_B_CULTURAL_B21",
|
||||
"活态传承系数B21",
|
||||
"活态传承系数B21 = 传承人等级系数 × 0.4 + 教学传播频次 × 0.3 + 跨界合作深度 × 0.3",
|
||||
"22.1",
|
||||
children=[
|
||||
_node(
|
||||
"MODEL_B_CULTURAL_B21_TEACHING_FREQ",
|
||||
"教学传播频次",
|
||||
"教学传播频次 = 线下传习次数 × 0.6 + 线上课程点击量(万) × 0.4",
|
||||
"22.11",
|
||||
),
|
||||
],
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_CULTURAL_B22",
|
||||
"纹样基因值B22",
|
||||
"纹样基因值B22 = (结构复杂度SC × 0.6 + 归一化信息熵H × 0.4) × 历史传承度HI × 10",
|
||||
"22.2",
|
||||
),
|
||||
],
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_RISK_B3",
|
||||
"风险调整系数B3",
|
||||
"风险调整系数B3 = 0.8 + 风险评分总和R × 0.4,其中 R = 市场风险 × 0.3 + 法律风险 × 0.4 + 传承风险 × 0.3",
|
||||
"23",
|
||||
children=[
|
||||
_node(
|
||||
"MODEL_B_RISK_B3_MARKET",
|
||||
"市场风险",
|
||||
"市场风险依据价格波动率:波动率 ≤5% 计10分,5-15%计5分,>15%计0分",
|
||||
"23.1",
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_RISK_B3_LEGAL",
|
||||
"法律风险",
|
||||
"法律风险根据诉讼状态评分(无诉讼/已解决/未解决)",
|
||||
"23.2",
|
||||
),
|
||||
_node(
|
||||
"MODEL_B_RISK_B3_INHERITANCE",
|
||||
"传承风险",
|
||||
"传承风险依据传承人年龄:≤50岁10分,50-70岁5分,>70岁0分,取最高分",
|
||||
"23.3",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
_node(
|
||||
"MARKET_C",
|
||||
"市场估值C",
|
||||
"市场估值C = 市场竞价C1 × 热度系数C2 × 稀缺性乘数C3 × 时效性衰减C4",
|
||||
"30",
|
||||
group="MARKET_C",
|
||||
children=[
|
||||
_node(
|
||||
"MARKET_C_C1",
|
||||
"市场竞价C1",
|
||||
"市场竞价C1 结合历史交易价格、人工竞价与专家估值的加权结果",
|
||||
"30.1",
|
||||
),
|
||||
_node(
|
||||
"MARKET_C_C2",
|
||||
"热度系数C2",
|
||||
"热度系数C2 = 1 + 浏览热度分(依据日均浏览量与收藏数量)",
|
||||
"30.2",
|
||||
),
|
||||
_node(
|
||||
"MARKET_C_C3",
|
||||
"稀缺性乘数C3",
|
||||
"稀缺性乘数C3 = 1 + 稀缺等级分",
|
||||
"30.3",
|
||||
),
|
||||
_node(
|
||||
"MARKET_C_C4",
|
||||
"时效性衰减C4",
|
||||
"时效性衰减C4 依据距最近市场活动天数的衰减系数",
|
||||
"30.4",
|
||||
),
|
||||
],
|
||||
),
|
||||
_node(
|
||||
"DYNAMIC_PLEDGE_RATE",
|
||||
"动态质押率DPR",
|
||||
"动态质押率DPR = 基础质押率 × (1 + 流量修正系数) + 政策加成系数 - 流动性调节因子",
|
||||
"40",
|
||||
group="DYNAMIC_PLEDGE",
|
||||
),
|
||||
# API查询结果记录
|
||||
_node(
|
||||
"API_ESG_QUERY",
|
||||
"ESG评分查询",
|
||||
"根据行业名称查询ESG基准分",
|
||||
"50.1",
|
||||
group="API_QUERY",
|
||||
),
|
||||
_node(
|
||||
"API_INDUSTRY_QUERY",
|
||||
"行业系数查询",
|
||||
"根据行业名称查询行业修正系数I",
|
||||
"50.2",
|
||||
group="API_QUERY",
|
||||
),
|
||||
_node(
|
||||
"API_POLICY_QUERY",
|
||||
"政策匹配度查询",
|
||||
"根据行业名称查询政策匹配度评分",
|
||||
"50.3",
|
||||
group="API_QUERY",
|
||||
),
|
||||
_node(
|
||||
"API_JUDICIAL_QUERY",
|
||||
"司法诉讼查询",
|
||||
"根据机构名称查询诉讼状态,映射为法律风险评分(无诉讼:10分, 已解决:7分, 未解决:0分)",
|
||||
"50.4",
|
||||
group="API_QUERY",
|
||||
),
|
||||
_node(
|
||||
"API_PATENT_QUERY",
|
||||
"专利信息查询",
|
||||
"根据专利申请号查询专利数量和剩余年限,计算专利评分",
|
||||
"50.5",
|
||||
group="API_QUERY",
|
||||
),
|
||||
_node(
|
||||
"API_WECHAT_INDEX",
|
||||
"微信指数查询",
|
||||
"根据资产名称查询微信指数,计算搜索指数S1 = 微信指数 / 10",
|
||||
"50.6",
|
||||
group="API_QUERY",
|
||||
),
|
||||
# 参数映射记录
|
||||
_node(
|
||||
"MAPPING_CROSS_BORDER_DEPTH",
|
||||
"跨界合作深度映射",
|
||||
"用户选项映射为评分:无(0分), 品牌联名(3分), 科技载体(5分), 国家外交礼品(10分)",
|
||||
"51.1",
|
||||
group="PARAM_MAPPING",
|
||||
),
|
||||
_node(
|
||||
"MAPPING_INHERITOR_LEVEL",
|
||||
"传承人等级映射",
|
||||
"用户选项映射为系数:国家级(10分), 省级(7分), 市级及以下(4分)",
|
||||
"51.2",
|
||||
group="PARAM_MAPPING",
|
||||
),
|
||||
# 权重计算记录
|
||||
_node(
|
||||
"CALC_HISTORICAL_INHERITANCE",
|
||||
"历史传承度计算",
|
||||
"HI = 出土实物×1.0 + 古代文献×0.8 + 传承人佐证×0.6 + 现代研究×0.4",
|
||||
"52.1",
|
||||
group="PARAM_CALC",
|
||||
),
|
||||
_node(
|
||||
"CALC_INHERITANCE_RISK",
|
||||
"传承风险年龄转换",
|
||||
"根据各年龄段传承人数量计算传承风险评分:≤50岁(10分), 50-70岁(5分), >70岁(0分), 取最高分",
|
||||
"52.2",
|
||||
group="PARAM_CALC",
|
||||
),
|
||||
_node(
|
||||
"CALC_MARKET_RISK",
|
||||
"市场风险价格波动",
|
||||
"根据30天价格波动计算市场风险评分:波动率≤5%(10分), 5-15%(5分), >15%(0分)",
|
||||
"52.3",
|
||||
group="PARAM_CALC",
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _build_index() -> Dict[str, FormulaMeta]:
|
||||
index: Dict[str, FormulaMeta] = {}
|
||||
|
||||
def dfs(nodes: List[FormulaTreeNode], parent_code: Optional[str], group_code: Optional[str]):
|
||||
for node in nodes:
|
||||
code = node["code"]
|
||||
name = node["name"]
|
||||
formula = node["formula"]
|
||||
order = Decimal(str(node["order"]))
|
||||
explicit_group = node.get("group_code")
|
||||
if explicit_group:
|
||||
current_group = explicit_group
|
||||
elif parent_code is None:
|
||||
current_group = code
|
||||
else:
|
||||
current_group = group_code or parent_code
|
||||
meta = FormulaMeta(
|
||||
code=code,
|
||||
name=name,
|
||||
formula=formula,
|
||||
order=order,
|
||||
parent_code=parent_code,
|
||||
group_code=current_group,
|
||||
)
|
||||
index[code] = meta
|
||||
dfs(node.get("children", []), code, current_group)
|
||||
|
||||
dfs(FORMULA_TREE, None, None)
|
||||
return index
|
||||
|
||||
|
||||
FORMULA_INDEX: Dict[str, FormulaMeta] = _build_index()
|
||||
|
||||
|
||||
def get_formula_meta(code: str) -> FormulaMeta:
|
||||
meta = FORMULA_INDEX.get(code)
|
||||
if not meta:
|
||||
raise KeyError(f"公式编码未注册: {code}")
|
||||
return meta
|
||||
|
||||
@ -8,6 +8,9 @@ import logging
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.append(current_dir)
|
||||
# 添加项目根目录
|
||||
sys.path.append(os.path.join(current_dir, '..', '..', '..'))
|
||||
|
||||
|
||||
try:
|
||||
# 相对导入(当作为包使用时)
|
||||
@ -16,6 +19,7 @@ try:
|
||||
from .sub_formulas.scarcity_multiplier_c3 import ScarcityMultiplierC3Calculator
|
||||
from .sub_formulas.temporal_decay_c4 import TemporalDecayC4Calculator
|
||||
from .market_data_analyzer import market_data_analyzer
|
||||
from app.controllers.valuation import ValuationController
|
||||
except ImportError:
|
||||
# 绝对导入(当直接运行时)
|
||||
from sub_formulas.market_bidding_c1 import MarketBiddingC1Calculator
|
||||
@ -23,6 +27,7 @@ except ImportError:
|
||||
from sub_formulas.scarcity_multiplier_c3 import ScarcityMultiplierC3Calculator
|
||||
from sub_formulas.temporal_decay_c4 import TemporalDecayC4Calculator
|
||||
from market_data_analyzer import market_data_analyzer
|
||||
from app.controllers.valuation import ValuationController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -36,6 +41,7 @@ class MarketValueCCalculator:
|
||||
self.heat_coefficient_calculator = HeatCoefficientC2Calculator()
|
||||
self.scarcity_multiplier_calculator = ScarcityMultiplierC3Calculator()
|
||||
self.temporal_decay_calculator = TemporalDecayC4Calculator()
|
||||
self.valuation_controller = ValuationController()
|
||||
|
||||
async def _get_dynamic_default_price(self, input_data: Dict) -> float:
|
||||
"""
|
||||
@ -93,64 +99,131 @@ class MarketValueCCalculator:
|
||||
market_value = (market_bidding_c1 * heat_coefficient_c2 *
|
||||
scarcity_multiplier_c3 * temporal_decay_c4)
|
||||
|
||||
return market_value
|
||||
return market_value / 10000.0
|
||||
|
||||
async def calculate_complete_market_value_c(self, input_data: Dict) -> Dict:
|
||||
async def calculate_complete_market_value_c(self, valuation_id: int, input_data: Dict) -> Dict[str, float]:
|
||||
"""
|
||||
计算完整的市场估值C,包含所有子公式
|
||||
|
||||
args:
|
||||
input_data: 输入数据字典,包含所有必要的参数
|
||||
参数来源标记(用户填写/系统配置/API获取/系统计算):
|
||||
- average_transaction_price: 系统计算(基于用户填写/API获取)
|
||||
- market_activity_coefficient: 系统计算(基于用户填写)
|
||||
- daily_browse_volume: API获取/系统估算
|
||||
- collection_count: API获取/系统估算
|
||||
- issuance_level: 用户填写
|
||||
- recent_market_activity: 用户填写
|
||||
- issuance_scarcity/circulation_scarcity/uniqueness_scarcity: 系统配置/系统计算(保留向后兼容)
|
||||
|
||||
return:
|
||||
Dict: 包含所有中间计算结果和最终结果的字典
|
||||
"""
|
||||
# 计算市场竞价C1
|
||||
market_bidding_c1 = self.market_bidding_calculator.calculate_market_bidding_c1(
|
||||
transaction_data={'weighted_average_price': input_data.get('weighted_average_price', 0)},
|
||||
manual_bids=input_data.get('manual_bids', []),
|
||||
expert_valuations=input_data.get('expert_valuations', [])
|
||||
)
|
||||
计算完整的市场估值C,并记录每一步的计算过程。
|
||||
|
||||
# 计算热度系数C2
|
||||
heat_coefficient_c2 = self.heat_coefficient_calculator.calculate_heat_coefficient_c2(
|
||||
input_data.get('daily_browse_volume', 500.0),
|
||||
input_data.get('collection_count', 50)
|
||||
该函数通过顺序调用市场竞价C1、热度系数C2、稀缺性乘数C3和时效性衰减C4的计算器,
|
||||
最终得出市场估值C。计算过程中的每个子步骤都会被详细记录,以便于审计和跟踪。
|
||||
|
||||
Args:
|
||||
valuation_id (int): 估值的唯一标识符,用于关联所有计算步骤。
|
||||
input_data (Dict): 包含所有计算所需参数的字典,例如:
|
||||
{
|
||||
'weighted_average_price': 50000.0, # C1
|
||||
'manual_bids': [48000.0, 52000.0], # C1
|
||||
'expert_valuations': [49000.0, 51000.0], # C1
|
||||
'daily_browse_volume': 500.0, # C2
|
||||
'collection_count': 50, # C2
|
||||
'issuance_level': '限量', # C3
|
||||
'recent_market_activity': '2024-01-15' # C4
|
||||
}
|
||||
|
||||
Returns:
|
||||
Dict[str, float]: 包含市场估值C及子公式结果的字典。
|
||||
|
||||
Raises:
|
||||
Exception: 如果在计算过程中发生任何错误,将记录失败状态并重新抛出异常。
|
||||
"""
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MARKET_C",
|
||||
status="processing",
|
||||
input_params=input_data,
|
||||
)
|
||||
|
||||
# 计算稀缺性乘数C3
|
||||
scarcity_multiplier_c3 = self.scarcity_multiplier_calculator.calculate_scarcity_multiplier_c3(
|
||||
input_data.get('issuance_level', '限量')
|
||||
)
|
||||
|
||||
# 计算时效性衰减C4
|
||||
temporal_decay_c4 = self.temporal_decay_calculator.calculate_temporal_decay_c4(
|
||||
input_data.get('recent_market_activity', '2024-01-15')
|
||||
)
|
||||
|
||||
# 计算市场估值C
|
||||
market_value_c = self.calculate_market_value_c(
|
||||
market_bidding_c1,
|
||||
heat_coefficient_c2,
|
||||
scarcity_multiplier_c3,
|
||||
temporal_decay_c4
|
||||
)
|
||||
|
||||
return {
|
||||
'market_bidding_c1': market_bidding_c1,
|
||||
'heat_coefficient_c2': heat_coefficient_c2,
|
||||
'scarcity_multiplier_c3': scarcity_multiplier_c3,
|
||||
'temporal_decay_c4': temporal_decay_c4,
|
||||
'market_value_c': market_value_c
|
||||
}
|
||||
try:
|
||||
# 计算市场竞价C1
|
||||
market_bidding_c1 = self.market_bidding_calculator.calculate_market_bidding_c1(
|
||||
transaction_data={'weighted_average_price': input_data.get('weighted_average_price', 0)},
|
||||
manual_bids=input_data.get('manual_bids', []),
|
||||
expert_valuations=input_data.get('expert_valuations', [])
|
||||
)
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MARKET_C_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
|
||||
heat_coefficient_c2 = self.heat_coefficient_calculator.calculate_heat_coefficient_c2(
|
||||
input_data.get('daily_browse_volume', 500.0),
|
||||
input_data.get('collection_count', 50)
|
||||
)
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MARKET_C_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
|
||||
scarcity_multiplier_c3 = self.scarcity_multiplier_calculator.calculate_scarcity_multiplier_c3(
|
||||
input_data.get('issuance_level', '限量')
|
||||
)
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MARKET_C_C3",
|
||||
status="completed",
|
||||
input_params={'issuance_level': input_data.get('issuance_level')},
|
||||
output_result={'scarcity_multiplier_c3': scarcity_multiplier_c3},
|
||||
)
|
||||
|
||||
# 计算时效性衰减C4
|
||||
temporal_decay_c4 = self.temporal_decay_calculator.calculate_temporal_decay_c4(
|
||||
input_data.get('recent_market_activity', '2024-01-15')
|
||||
)
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MARKET_C_C4",
|
||||
status="completed",
|
||||
input_params={'recent_market_activity': input_data.get('recent_market_activity')},
|
||||
output_result={'temporal_decay_c4': temporal_decay_c4},
|
||||
)
|
||||
|
||||
# 计算市场估值C
|
||||
market_value_c = self.calculate_market_value_c(
|
||||
market_bidding_c1,
|
||||
heat_coefficient_c2,
|
||||
scarcity_multiplier_c3,
|
||||
temporal_decay_c4
|
||||
)
|
||||
|
||||
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
|
||||
except Exception as e:
|
||||
error_message = f"市场估值C计算失败: {e}"
|
||||
logger.error(error_message, exc_info=True)
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MARKET_C",
|
||||
status="failed",
|
||||
error_message=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
# 示例使用
|
||||
|
||||
@ -6,13 +6,23 @@
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(os.path.join(current_dir, '..', '..', '..', '..'))
|
||||
|
||||
try:
|
||||
from app.controllers.valuation import ValuationController
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
class RiskAdjustmentB3Calculator:
|
||||
"""风险调整系数B3计算器"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
self.valuation_controller = ValuationController()
|
||||
|
||||
def calculate_risk_adjustment_b3(self, risk_score_sum: float) -> float:
|
||||
"""
|
||||
@ -155,22 +165,116 @@ class RiskAdjustmentB3Calculator:
|
||||
|
||||
return max_score
|
||||
|
||||
def calculate_complete_risky_value_b3(self, input_data: Dict) -> Dict:
|
||||
# 计算各项风险评分
|
||||
market_risk = self.calculate_market_risk(input_data["highest_price"], input_data["lowest_price"])
|
||||
legal_risk = self.calculate_legal_risk(input_data["lawsuit_status"])
|
||||
inheritance_risk = self.calculate_inheritance_risk(input_data["inheritor_ages"])
|
||||
async def calculate_complete_risky_value_b3(self, valuation_id: int, input_data: Dict) -> Dict[str, float]:
|
||||
"""
|
||||
计算完整的风险调整系数B3,并记录所有计算步骤。
|
||||
|
||||
# 计算风险评分总和R
|
||||
risk_score_sum = self.calculate_risk_score_sum(market_risk, legal_risk, inheritance_risk)
|
||||
该函数通过整合市场风险、法律风险和传承风险的评估,
|
||||
计算出风险评分总和R,并最终得出风险调整系数B3。
|
||||
每一步的计算过程都会被记录下来,以确保计算的透明度和可追溯性。
|
||||
|
||||
# 计算风险调整系数B3
|
||||
risk_adjustment_b3 = self.calculate_risk_adjustment_b3(risk_score_sum)
|
||||
return {
|
||||
'risk_score_sum': risk_score_sum,
|
||||
'risk_adjustment_b3': risk_adjustment_b3
|
||||
}
|
||||
Args:
|
||||
valuation_id (int): 估值的唯一标识符,用于关联所有计算步骤。
|
||||
input_data (Dict): 包含所有计算所需参数的字典,例如:
|
||||
{
|
||||
'highest_price': 340.0, # 市场风险
|
||||
'lowest_price': 300.0, # 市场风险
|
||||
'lawsuit_status': 10.0, # 法律风险
|
||||
'inheritor_ages': [100, 20, 5], # 传承风险
|
||||
...
|
||||
}
|
||||
|
||||
Returns:
|
||||
Dict[str, float]: 包含各项风险评分和风险调整系数的字典。
|
||||
|
||||
Raises:
|
||||
Exception: 在计算过程中遇到的任何异常都会被捕获、记录,并重新抛出。
|
||||
"""
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_RISK_B3",
|
||||
status="processing",
|
||||
input_params=input_data,
|
||||
)
|
||||
try:
|
||||
# 计算各项风险评分
|
||||
market_risk = self.calculate_market_risk(input_data["highest_price"], input_data["lowest_price"])
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_RISK_B3_MARKET",
|
||||
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"])
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_RISK_B3_LEGAL",
|
||||
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"])
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_RISK_B3_INHERITANCE",
|
||||
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
|
||||
risk_score_sum = self.calculate_risk_score_sum(market_risk, legal_risk, inheritance_risk)
|
||||
|
||||
# 计算风险调整系数B3
|
||||
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 = {
|
||||
"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
|
||||
except Exception as e:
|
||||
await self.valuation_controller.log_formula_step(
|
||||
valuation_id,
|
||||
"MODEL_B_RISK_B3",
|
||||
status="failed",
|
||||
error_message=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
# 示例使用
|
||||
if __name__ == "__main__":
|
||||
|
||||
4551
app/utils/专利.json
384
demo_api.py
@ -1,384 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import requests
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
|
||||
# API基础URL
|
||||
BASE_URL = "http://127.0.0.1:9999/api/v1"
|
||||
|
||||
# 测试数据
|
||||
test_phone = f"1380000{random.randint(1000, 9999)}"
|
||||
test_password = test_phone[-6:] # 默认密码是手机号后6位
|
||||
access_token = None
|
||||
user_id = None
|
||||
valuation_id = None
|
||||
|
||||
def test_register():
|
||||
"""测试用户注册功能"""
|
||||
print("\n===== 测试用户注册 =====")
|
||||
url = f"{BASE_URL}/app-user/register"
|
||||
data = {
|
||||
"phone": test_phone
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data)
|
||||
print(f"请求URL: {url}")
|
||||
print(f"请求数据: {data}")
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
|
||||
assert response.status_code == 200, "注册请求失败"
|
||||
result = response.json()
|
||||
assert result["code"] == 200, "注册失败"
|
||||
assert result["data"]["phone"] == test_phone, "返回的手机号不匹配"
|
||||
assert result["data"]["default_password"] == test_phone[-6:], "默认密码不正确"
|
||||
|
||||
print("✅ 用户注册测试通过")
|
||||
return result
|
||||
|
||||
def test_login():
|
||||
"""测试用户登录功能"""
|
||||
global access_token
|
||||
|
||||
print("\n===== 测试用户登录 =====")
|
||||
url = f"{BASE_URL}/app-user/login"
|
||||
data = {
|
||||
"phone": test_phone,
|
||||
"password": test_password
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data)
|
||||
print(f"请求URL: {url}")
|
||||
print(f"请求数据: {data}")
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
|
||||
assert response.status_code == 200, "登录请求失败"
|
||||
result = response.json()
|
||||
assert "access_token" in result, "登录失败,未返回token"
|
||||
|
||||
# 保存token供后续请求使用
|
||||
access_token = result["access_token"]
|
||||
|
||||
print("✅ 用户登录测试通过")
|
||||
return result
|
||||
|
||||
def test_create_valuation():
|
||||
"""测试创建估值评估申请"""
|
||||
global access_token
|
||||
|
||||
print("\n===== 测试创建估值评估申请 =====")
|
||||
url = f"{BASE_URL}/app-valuations/"
|
||||
|
||||
# 准备请求头,包含授权token
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 估值评估申请数据 - 根据估值字段.txt更新
|
||||
data = {
|
||||
# 02 - 基础信息 - 非遗IP资产的基本信息
|
||||
"asset_name": f"蜀绣-{random.randint(1000, 9999)}", # 资产名称:必须是企业全名称
|
||||
"institution": "有数", # 所属机构:拥有或管理该非遗IP资产的机构名称
|
||||
"industry": "文化艺术", # 所属行业:非遗IP资产所属的行业分类
|
||||
|
||||
# 03 - 财务状况
|
||||
"rd_investment": "5", # :近12个月的研发费用(单位:万元)
|
||||
"annual_revenue": "100", # 近12个月的营收额(单位:万元),用于计算创新投入比
|
||||
"three_year_income": [100, 120, 150], # 近三年的每年收益:资产近3年的年收益数据(单位:万元),用于计算年均收益和增长率
|
||||
"funding_status": "国家级资助", # 资金支持: 国家级资助(10分)、省级资助(7分)、无资助(0分)
|
||||
|
||||
# 04 - 非遗等级与技术
|
||||
"inheritor_level": "国家级", # 传承人等级
|
||||
"inheritor_ages": [60, 42, 35], # 传承人年龄
|
||||
"inheritor_certificates": ["http://example.com/国家级非遗传承人证书.jpg"], # 传承人证书:传承人资质证明材料
|
||||
"heritage_asset_level": "国家级非遗", # 非遗资产等级
|
||||
"patent_remaining_years": "8", # [实际上就是专利号 通过API查询到的 ]专利剩余年限:资产相关专利的剩余保护期,8年对应7分
|
||||
"historical_evidence": { # 资产历史证据类型+数量:历史传承的证据材料
|
||||
"artifacts": 1, # 出土实物数量
|
||||
"ancient_literature": 2, # 古代文献数量
|
||||
"inheritor_testimony": 3, # 传承人佐证数量
|
||||
"modern_research": 1 # 现代研究数量
|
||||
},
|
||||
# 专利证书:
|
||||
"patent_certificates": ["http://example.com/专利证书1.jpg", "http://example.com/专利证书2.jpg"],
|
||||
"pattern_images": ["pattern1.jpg"], # 纹样图片:资产相关的纹样图片文件
|
||||
|
||||
# 04 - 非遗应用与推广
|
||||
"implementation_stage": "成熟应用", # 非遗资产应用成熟度
|
||||
"coverage_area": "区域覆盖", # 非遗资产应用覆盖范围
|
||||
"collaboration_type": "品牌联名", # 非遗资产跨界合作深度
|
||||
"offline_teaching_count": 12, # 近12个月线下相关演讲活动次数
|
||||
"platform_accounts": { # 线上相关宣传账号信息
|
||||
"bilibili": {
|
||||
"followers_count": 8000, # 粉丝数量
|
||||
"likes": 1000, # 点赞数
|
||||
"comments": 500, # 评论数
|
||||
"shares": 500 # 转发数
|
||||
}, # B站账号
|
||||
"douyin": {
|
||||
"followers_count": 8000, # 粉丝数量
|
||||
"likes": 1000, # 点赞数
|
||||
"comments": 500, # 评论数
|
||||
"shares": 500 # 转发数
|
||||
} # 抖音账号
|
||||
},
|
||||
|
||||
# 06 - 非遗资产衍生商品信息
|
||||
#该商品近12个月销售量
|
||||
"sales_volume": "1000", # 近12个月销售量:资产衍生商品的近12个月销售量(单位:件) 链接购买量
|
||||
# 该商品近12个月的链接浏览量
|
||||
"link_views": "10000", # 近12个月链接浏览量:资产衍生商品相关链接的近12个月浏览量(单位:次) 浏览量
|
||||
"scarcity_level": "流通", # 稀缺等级:资产的稀缺程度,流通(发行量>1000份)对应0.1分
|
||||
"market_activity_time": "近一月", # 市场活动的时间
|
||||
"monthly_transaction_amount": "<100万元", # 月交易额:资产衍生商品的月交易额水平,<100万元对应-0.1
|
||||
"price_range": { # 资产商品的价格波动率:近30天商品价格的波动情况
|
||||
"highest": 239, # 最高价(单位:元)
|
||||
"lowest": 189 # 最低价(单位:元)
|
||||
},
|
||||
"market_price": 0, # 直接提供的市场价格(单位:万元) 用户输入: 专家审核 或者 系统默认 专家审核
|
||||
|
||||
# 内置API 计算字段
|
||||
"infringement_record": "无侵权记录", # 侵权记录:资产的侵权历史情况,无侵权记录对应10分
|
||||
"patent_count": "1", # 专利使用量:资产相关的专利数量,每引用一项专利+2.5分
|
||||
"esg_value": "10", # ESG关联价值:根据行业匹配的ESG(环境、社会、治理)关联价值
|
||||
"policy_matching": "10", # 政策匹配度:根据行业自动匹配的政策匹配度分值
|
||||
"online_course_views": 2000, # 线上课程点击量:抖音/快手播放量按100:1折算为学习人次,B站课程按50:1折算
|
||||
"pattern_complexity": "1.459", # 结构复杂度:纹样的结构复杂度值 搞一个默认值: 0.0
|
||||
"normalized_entropy": "9.01", # 归一化信息熵:纹样的归一化信息熵值 搞一个默认值: 0.0
|
||||
"legal_risk": "无诉讼", # 法律风险-侵权诉讼历史:资产所属机构的诉讼历史,无诉讼对应10分
|
||||
# 动态质押率DPR:计算公式=基础质押率*(1+流量修正系数)+政策加成系数-流动性调节因子
|
||||
"base_pledge_rate": "50%", # 基础质押率:基础质押率固定值50%
|
||||
"flow_correction": "0.3", # 流量修正系数:固定值0.3
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
print(f"请求URL: {url}")
|
||||
print(f"请求头: {headers}")
|
||||
print(f"请求数据: {json.dumps(data, ensure_ascii=False, indent=2)}")
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
|
||||
assert response.status_code == 200, "创建估值评估申请请求失败"
|
||||
result = response.json()
|
||||
assert result["code"] == 200, "创建估值评估申请失败"
|
||||
|
||||
print("✅ 创建估值评估申请测试通过")
|
||||
return result
|
||||
|
||||
def test_get_profile():
|
||||
"""测试获取用户个人信息"""
|
||||
global access_token, user_id
|
||||
|
||||
print("\n===== 测试获取用户个人信息 =====")
|
||||
url = f"{BASE_URL}/app-user/profile"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"请求URL: {url}")
|
||||
print(f"请求头: {headers}")
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
|
||||
assert response.status_code == 200, "获取用户信息请求失败"
|
||||
result = response.json()
|
||||
user_id = result["id"] # 保存用户ID供后续使用
|
||||
|
||||
print("✅ 获取用户个人信息测试通过")
|
||||
return result
|
||||
|
||||
def test_change_password():
|
||||
"""测试修改密码"""
|
||||
global access_token
|
||||
|
||||
print("\n===== 测试修改密码 =====")
|
||||
url = f"{BASE_URL}/app-user/change-password"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
new_password = "new" + test_password
|
||||
data = {
|
||||
"old_password": test_password,
|
||||
"new_password": new_password
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
print(f"请求URL: {url}")
|
||||
print(f"请求头: {headers}")
|
||||
print(f"请求数据: {data}")
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
|
||||
assert response.status_code == 200, "修改密码请求失败"
|
||||
result = response.json()
|
||||
assert result["code"] == 200, "修改密码失败"
|
||||
|
||||
print("✅ 修改密码测试通过")
|
||||
return result
|
||||
|
||||
def test_update_profile():
|
||||
"""测试更新用户信息"""
|
||||
global access_token
|
||||
|
||||
print("\n===== 测试更新用户信息 =====")
|
||||
url = f"{BASE_URL}/app-user/profile"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
data = {
|
||||
"nickname": f"测试用户{random.randint(100, 999)}",
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
"gender": "male",
|
||||
"email": f"test{random.randint(100, 999)}@example.com"
|
||||
}
|
||||
|
||||
response = requests.put(url, headers=headers, json=data)
|
||||
print(f"请求URL: {url}")
|
||||
print(f"请求头: {headers}")
|
||||
print(f"请求数据: {data}")
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
|
||||
assert response.status_code == 200, "更新用户信息请求失败"
|
||||
result = response.json()
|
||||
# 更新用户信息接口直接返回用户对象,不包含code字段
|
||||
assert "id" in result, "更新用户信息失败"
|
||||
|
||||
print("✅ 更新用户信息测试通过")
|
||||
return result
|
||||
|
||||
def test_logout():
|
||||
"""测试用户登出"""
|
||||
global access_token
|
||||
|
||||
print("\n===== 测试用户登出 =====")
|
||||
url = f"{BASE_URL}/app-user/logout"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers)
|
||||
print(f"请求URL: {url}")
|
||||
print(f"请求头: {headers}")
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
|
||||
assert response.status_code == 200, "登出请求失败"
|
||||
result = response.json()
|
||||
assert result["code"] == 200, "登出失败"
|
||||
|
||||
print("✅ 用户登出测试通过")
|
||||
return result
|
||||
|
||||
def test_get_valuation_list():
|
||||
"""测试获取用户估值列表"""
|
||||
global access_token
|
||||
|
||||
print("\n===== 测试获取用户估值列表 =====")
|
||||
url = f"{BASE_URL}/app-valuations/"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"请求URL: {url}")
|
||||
print(f"请求头: {headers}")
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
|
||||
assert response.status_code == 200, "获取估值列表请求失败"
|
||||
result = response.json()
|
||||
assert result["code"] == 200, "获取估值列表失败"
|
||||
|
||||
print("✅ 获取用户估值列表测试通过")
|
||||
return result
|
||||
|
||||
def test_get_valuation_detail():
|
||||
"""测试获取估值详情"""
|
||||
global access_token, valuation_id
|
||||
|
||||
# 先获取估值列表,获取第一个估值ID
|
||||
if not valuation_id:
|
||||
list_result = test_get_valuation_list()
|
||||
if list_result["data"] and len(list_result["data"]) > 0:
|
||||
valuation_id = list_result["data"][0]["id"]
|
||||
else:
|
||||
print("⚠️ 没有可用的估值记录,跳过估值详情测试")
|
||||
return None
|
||||
|
||||
print("\n===== 测试获取估值详情 =====")
|
||||
url = f"{BASE_URL}/app-valuations/{valuation_id}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"请求URL: {url}")
|
||||
print(f"请求头: {headers}")
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
|
||||
assert response.status_code == 200, "获取估值详情请求失败"
|
||||
result = response.json()
|
||||
assert result["code"] == 200, "获取估值详情失败"
|
||||
|
||||
print("✅ 获取估值详情测试通过")
|
||||
return result
|
||||
|
||||
def run_tests():
|
||||
"""运行所有测试"""
|
||||
try:
|
||||
# 测试注册
|
||||
test_register()
|
||||
|
||||
# 等待一秒,确保数据已保存
|
||||
time.sleep(1)
|
||||
|
||||
# 测试登录
|
||||
test_login()
|
||||
|
||||
# 测试获取用户个人信息
|
||||
test_get_profile()
|
||||
|
||||
# 测试更新用户信息
|
||||
test_update_profile()
|
||||
|
||||
# 测试创建估值评估申请
|
||||
test_create_valuation()
|
||||
|
||||
# 测试获取估值列表
|
||||
test_get_valuation_list()
|
||||
|
||||
# 测试获取估值详情
|
||||
test_get_valuation_detail()
|
||||
|
||||
# 测试修改密码
|
||||
test_change_password()
|
||||
|
||||
# 测试登出
|
||||
# test_logout()
|
||||
|
||||
print("\n===== 所有测试通过 =====")
|
||||
except AssertionError as e:
|
||||
print(f"\n❌ 测试失败: {e}")
|
||||
except Exception as e:
|
||||
print(f"\n❌ 发生错误: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_tests()
|
||||
@ -1,5 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
nginx
|
||||
# nginx
|
||||
|
||||
python run.py
|
||||
@ -1,13 +1,31 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
location = /docs {
|
||||
proxy_pass http://127.0.0.1:9999/docs;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location = /redoc {
|
||||
proxy_pass http://127.0.0.1:9999/redoc;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location = /openapi.json {
|
||||
proxy_pass http://127.0.0.1:9999/openapi.json;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location / {
|
||||
root /opt/vue-fastapi-admin/web/dist;
|
||||
index index.html index.htm;
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
-- 新增交易管理菜单
|
||||
-- 创建时间: 2025-11-13
|
||||
|
||||
-- 插入一级目录:交易管理
|
||||
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'));
|
||||
|
||||
-- 为管理员角色分配菜单权限
|
||||
INSERT INTO role_menu (role_id, menu_id)
|
||||
VALUES
|
||||
(1, 16),
|
||||
(1, 17);
|
||||
|
||||
-- 为普通用户角色分配菜单权限
|
||||
INSERT INTO role_menu (role_id, menu_id)
|
||||
VALUES
|
||||
(2, 16),
|
||||
(2, 17);
|
||||
@ -1,24 +0,0 @@
|
||||
-- 新增估值管理菜单
|
||||
-- 创建时间: 2025-11-13
|
||||
|
||||
-- 插入一级目录:估值管理
|
||||
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'));
|
||||
|
||||
-- 为管理员角色分配菜单权限
|
||||
INSERT INTO role_menu (role_id, menu_id)
|
||||
VALUES
|
||||
(1, 18),
|
||||
(1, 19);
|
||||
|
||||
-- 为普通用户角色分配菜单权限
|
||||
INSERT INTO role_menu (role_id, menu_id)
|
||||
VALUES
|
||||
(2, 18),
|
||||
(2, 19);
|
||||
@ -1,86 +0,0 @@
|
||||
-- 完整菜单初始化SQL
|
||||
-- 创建时间: 2025-11-17
|
||||
-- 说明: 包含所有新增的菜单项和权限分配
|
||||
|
||||
-- ========================================
|
||||
-- 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); -- 审核列表
|
||||
-- 注意:普通用户不分配用户管理权限
|
||||
|
||||
-- ========================================
|
||||
-- 验证SQL
|
||||
-- ========================================
|
||||
-- 查询所有新增的菜单
|
||||
-- SELECT * FROM menu WHERE id >= 16 ORDER BY parent_id, 'order';
|
||||
|
||||
-- 查询管理员角色的菜单权限
|
||||
-- SELECT m.name, m.path, m.menu_type FROM menu m
|
||||
-- JOIN role_menu rm ON m.id = rm.menu_id
|
||||
-- WHERE rm.role_id = 1 AND m.id >= 16
|
||||
-- ORDER BY m.parent_id, m.'order';
|
||||
3
node_modules/.vite/deps_temp_e96670e1/package.json
generated
vendored
@ -1,3 +0,0 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
34
package-lock.json
generated
@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "guzhi",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"echarts": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"echarts": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@ -69,6 +69,11 @@ dependencies = [
|
||||
"websockets==14.1",
|
||||
"pyproject-toml>=0.1.0",
|
||||
"uvloop==0.21.0 ; sys_platform != 'win32'",
|
||||
"alibabacloud_dysmsapi20170525==4.1.2",
|
||||
"alibabacloud_tea_openapi==0.4.1",
|
||||
"alibabacloud_tea_util==0.3.14",
|
||||
"pytest==8.3.3",
|
||||
"pytest-html==4.1.1",
|
||||
]
|
||||
|
||||
[tool.black]
|
||||
|
||||
BIN
requirements.txt
213
scripts/admin_flow_test.py
Normal file
@ -0,0 +1,213 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import random
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def make_url(base_url: str, path: str) -> str:
|
||||
if base_url.endswith("/"):
|
||||
base_url = base_url[:-1]
|
||||
return f"{base_url}{path}"
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def ensure_dict(obj: Any) -> Dict[str, Any]:
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
return {"raw": str(obj)}
|
||||
|
||||
|
||||
async def api_get(client: httpx.AsyncClient, url: str, headers: Optional[Dict[str, str]] = None, params: Optional[Dict[str, Any]] = None) -> Tuple[int, Dict[str, Any]]:
|
||||
r = await client.get(url, headers=headers or {}, params=params or {})
|
||||
try:
|
||||
parsed = r.json()
|
||||
except Exception:
|
||||
parsed = {"raw": r.text}
|
||||
return r.status_code, ensure_dict(parsed)
|
||||
|
||||
|
||||
async def api_post_json(client: httpx.AsyncClient, url: str, payload: Dict[str, Any], headers: Optional[Dict[str, str]] = None) -> Tuple[int, Dict[str, Any]]:
|
||||
r = await client.post(url, json=payload, headers=headers or {})
|
||||
try:
|
||||
parsed = r.json()
|
||||
except Exception:
|
||||
parsed = {"raw": r.text}
|
||||
return r.status_code, ensure_dict(parsed)
|
||||
|
||||
|
||||
async def api_put_json(client: httpx.AsyncClient, url: str, payload: Dict[str, Any], headers: Optional[Dict[str, str]] = None) -> Tuple[int, Dict[str, Any]]:
|
||||
r = await client.put(url, json=payload, headers=headers or {})
|
||||
try:
|
||||
parsed = r.json()
|
||||
except Exception:
|
||||
parsed = {"raw": r.text}
|
||||
return r.status_code, ensure_dict(parsed)
|
||||
|
||||
|
||||
async def api_delete(client: httpx.AsyncClient, url: str, headers: Optional[Dict[str, str]] = None, params: Optional[Dict[str, Any]] = None) -> Tuple[int, Dict[str, Any]]:
|
||||
r = await client.delete(url, headers=headers or {}, params=params or {})
|
||||
try:
|
||||
parsed = r.json()
|
||||
except Exception:
|
||||
parsed = {"raw": r.text}
|
||||
return r.status_code, ensure_dict(parsed)
|
||||
|
||||
|
||||
def write_html_report(filepath: str, title: str, results: List[Dict[str, Any]]) -> None:
|
||||
rows = []
|
||||
for r in results:
|
||||
color = {"PASS": "#4caf50", "FAIL": "#f44336"}.get(r.get("status"), "#9e9e9e")
|
||||
rows.append(
|
||||
f"<tr><td>{r.get('name')}</td><td style='color:{color};font-weight:600'>{r.get('status')}</td><td>{r.get('message','')}</td><td><pre>{json.dumps(r.get('detail', {}), ensure_ascii=False, indent=2)}</pre></td></tr>"
|
||||
)
|
||||
html = f"""
|
||||
<!doctype html>
|
||||
<html><head><meta charset='utf-8'><title>{title}</title>
|
||||
<style>body{{font-family:Arial;padding:12px}} table{{border-collapse:collapse;width:100%}} td,th{{border:1px solid #ddd;padding:8px}}</style>
|
||||
</head><body>
|
||||
<h2>{title}</h2>
|
||||
<p>生成时间: {time.strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
<table><thead><tr><th>用例</th><th>结果</th><th>说明</th><th>详情</th></tr></thead><tbody>
|
||||
{''.join(rows)}
|
||||
</tbody></table>
|
||||
</body></html>
|
||||
"""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
|
||||
|
||||
async def test_base(client: httpx.AsyncClient, base: str, token: str, results: List[Dict[str, Any]]):
|
||||
for path, name in [("/base/userinfo", "admin用户信息"), ("/base/userapi", "admin接口权限"), ("/base/usermenu", "admin菜单")]:
|
||||
code, data = await api_get(client, make_url(base, path), headers={"token": token})
|
||||
ok = (code == 200)
|
||||
results.append({"name": name, "status": "PASS" if ok else "FAIL", "message": "获取成功" if ok else "获取失败", "detail": {"http": code, "body": data}})
|
||||
|
||||
|
||||
async def test_users_crud(client: httpx.AsyncClient, base: str, token: str, results: List[Dict[str, Any]]):
|
||||
email = f"admin_{uuid.uuid4().hex[:6]}@test.com"
|
||||
username = "adm_" + uuid.uuid4().hex[:6]
|
||||
code, data = await api_post_json(client, make_url(base, "/user/create"), {"email": email, "username": username, "password": "123456", "is_active": True, "is_superuser": False, "role_ids": [], "dept_id": 0}, headers={"token": token})
|
||||
results.append({"name": "创建用户", "status": "PASS" if code == 200 and data.get("code") == 200 else "FAIL", "message": data.get("msg"), "detail": {"http": code, "body": data}})
|
||||
code, data = await api_get(client, make_url(base, "/user/list"), headers={"token": token}, params={"page": 1, "page_size": 10, "email": email})
|
||||
ok = (code == 200 and isinstance(data.get("data"), list))
|
||||
uid = None
|
||||
if ok and data["data"]:
|
||||
uid = data["data"][0].get("id")
|
||||
results.append({"name": "查询用户", "status": "PASS" if ok else "FAIL", "message": "获取成功" if ok else "获取失败", "detail": {"http": code, "body": data}})
|
||||
if uid:
|
||||
code, data = await api_post_json(client, make_url(base, "/user/update"), {"id": uid, "email": email, "username": username + "_u", "is_active": True, "is_superuser": False, "role_ids": [], "dept_id": 0}, headers={"token": token})
|
||||
results.append({"name": "更新用户", "status": "PASS" if code == 200 and data.get("code") == 200 else "FAIL", "message": data.get("msg"), "detail": {"http": code, "body": data}})
|
||||
code, data = await api_delete(client, make_url(base, "/user/delete"), headers={"token": token}, params={"user_id": uid})
|
||||
results.append({"name": "删除用户", "status": "PASS" if code == 200 and data.get("code") == 200 else "FAIL", "message": data.get("msg"), "detail": {"http": code, "body": data}})
|
||||
|
||||
|
||||
async def test_roles_menus_apis(client: httpx.AsyncClient, base: str, token: str, results: List[Dict[str, Any]]):
|
||||
rname = "role_" + uuid.uuid4().hex[:6]
|
||||
code, data = await api_post_json(client, make_url(base, "/role/create"), {"name": rname, "desc": "测试角色"}, headers={"token": token})
|
||||
results.append({"name": "创建角色", "status": "PASS" if code == 200 and data.get("code") == 200 else "FAIL", "message": data.get("msg"), "detail": {"http": code, "body": data}})
|
||||
code, data = await api_get(client, make_url(base, "/role/list"), headers={"token": token}, params={"page": 1, "page_size": 10, "role_name": rname})
|
||||
ok = (code == 200 and isinstance(data.get("data"), list))
|
||||
rid = None
|
||||
if ok and data["data"]:
|
||||
rid = data["data"][0].get("id")
|
||||
results.append({"name": "查询角色", "status": "PASS" if ok else "FAIL", "message": "获取成功" if ok else "获取失败", "detail": {"http": code, "body": data}})
|
||||
code, data = await api_post_json(client, make_url(base, "/api/refresh"), {}, headers={"token": token})
|
||||
results.append({"name": "刷新API权限表", "status": "PASS" if code == 200 and data.get("code") == 200 else "FAIL", "message": data.get("msg"), "detail": {"http": code, "body": data}})
|
||||
code, data = await api_get(client, make_url(base, "/api/list"), headers={"token": token}, params={"page": 1, "page_size": 10})
|
||||
ok_apis = (code == 200 and isinstance(data.get("data"), list))
|
||||
results.append({"name": "API列表", "status": "PASS" if ok_apis else "FAIL", "message": "获取成功" if ok_apis else "获取失败", "detail": {"http": code, "body": data}})
|
||||
if rid and ok_apis:
|
||||
api_infos = []
|
||||
if data["data"]:
|
||||
first = data["data"][0]
|
||||
api_infos = [{"path": first.get("path"), "method": first.get("method")}] if first.get("path") and first.get("method") else []
|
||||
code, data = await api_post_json(client, make_url(base, "/role/authorized"), {"id": rid, "menu_ids": [], "api_infos": api_infos}, headers={"token": token})
|
||||
results.append({"name": "角色授权", "status": "PASS" if code == 200 and data.get("code") == 200 else "FAIL", "message": data.get("msg"), "detail": {"http": code, "body": data}})
|
||||
code, data = await api_delete(client, make_url(base, "/role/delete"), headers={"token": token}, params={"role_id": rid})
|
||||
results.append({"name": "删除角色", "status": "PASS" if code == 200 and data.get("code") == 200 else "FAIL", "message": data.get("msg"), "detail": {"http": code, "body": data}})
|
||||
|
||||
|
||||
async def test_dept_crud(client: httpx.AsyncClient, base: str, token: str, results: List[Dict[str, Any]]):
|
||||
dname = "dept_" + uuid.uuid4().hex[:6]
|
||||
code, data = await api_post_json(client, make_url(base, "/dept/create"), {"name": dname, "desc": "测试部门"}, headers={"token": token})
|
||||
results.append({"name": "创建部门", "status": "PASS" if code == 200 and data.get("code") == 200 else "FAIL", "message": data.get("msg"), "detail": {"http": code, "body": data}})
|
||||
code, data = await api_get(client, make_url(base, "/dept/list"), headers={"token": token}, params={"page": 1, "page_size": 10})
|
||||
ok = (code == 200 and isinstance(data.get("data"), list))
|
||||
results.append({"name": "查询部门", "status": "PASS" if ok else "FAIL", "message": "获取成功" if ok else "获取失败", "detail": {"http": code, "body": data}})
|
||||
|
||||
|
||||
async def test_valuations_admin(client: httpx.AsyncClient, base: str, token: str, results: List[Dict[str, Any]]):
|
||||
payload = {"asset_name": "Admin资产", "institution": "Admin机构", "industry": "行业", "three_year_income": [10, 20, 30]}
|
||||
code, data = await api_post_json(client, make_url(base, "/valuations/"), payload, headers={"token": token})
|
||||
results.append({"name": "创建估值(管理员)", "status": "PASS" if code == 200 and data.get("code") == 200 else "FAIL", "message": data.get("msg"), "detail": {"http": code, "body": data}})
|
||||
code, data = await api_get(client, make_url(base, "/valuations/"), headers={"token": token}, params={"page": 1, "size": 5})
|
||||
ok = (code == 200 and isinstance(data.get("data"), list))
|
||||
results.append({"name": "估值列表(管理员)", "status": "PASS" if ok else "FAIL", "message": "获取成功" if ok else "获取失败", "detail": {"http": code, "body": data}})
|
||||
|
||||
|
||||
async def test_invoice_transactions(client: httpx.AsyncClient, base: str, token: str, results: List[Dict[str, Any]]):
|
||||
code, data = await api_get(client, make_url(base, "/invoice/list"), headers={"token": token}, params={"page": 1, "page_size": 10})
|
||||
ok = (code == 200 and isinstance(data.get("data"), list))
|
||||
results.append({"name": "发票列表", "status": "PASS" if ok else "FAIL", "message": "获取成功" if ok else "获取失败", "detail": {"http": code, "body": data}})
|
||||
code, data = await api_get(client, make_url(base, "/transactions/receipts"), headers={"token": token}, params={"page": 1, "page_size": 10})
|
||||
ok = (code == 200 and isinstance(data.get("data"), list))
|
||||
results.append({"name": "对公转账列表", "status": "PASS" if ok else "FAIL", "message": "获取成功" if ok else "获取失败", "detail": {"http": code, "body": data}})
|
||||
|
||||
|
||||
async def perf_benchmark(client: httpx.AsyncClient, base: str, token: str, results: List[Dict[str, Any]]):
|
||||
endpoints = ["/user/list", "/valuations/", "/invoice/list"]
|
||||
conc = 20
|
||||
metrics = []
|
||||
for ep in endpoints:
|
||||
start = now_ms()
|
||||
tasks = [api_get(client, make_url(base, ep), headers={"token": token}, params={"page": 1, "page_size": 10}) for _ in range(conc)]
|
||||
rets = await httpx.AsyncClient.gather(*tasks) if hasattr(httpx.AsyncClient, "gather") else None
|
||||
# 兼容:无 gather 则顺序执行
|
||||
if rets is None:
|
||||
rets = []
|
||||
for _ in range(conc):
|
||||
rets.append(await api_get(client, make_url(base, ep), headers={"token": token}, params={"page": 1, "page_size": 10}))
|
||||
dur = now_ms() - start
|
||||
ok = sum(1 for (code, _) in rets if code == 200)
|
||||
metrics.append({"endpoint": ep, "concurrency": conc, "duration_ms": dur, "success": ok, "total": conc})
|
||||
results.append({"name": "性能基准", "status": "PASS", "message": "并发测试完成", "detail": {"metrics": metrics}})
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
base = os.getenv("ADMIN_BASE_URL", "http://localhost:9999/api/v1")
|
||||
token = os.getenv("ADMIN_TOKEN", "dev")
|
||||
results: List[Dict[str, Any]] = []
|
||||
endpoint_list = [
|
||||
{"path": "/base/userinfo", "desc": "管理员信息"},
|
||||
{"path": "/user/*", "desc": "用户管理"},
|
||||
{"path": "/role/*", "desc": "角色管理与授权"},
|
||||
{"path": "/api/*", "desc": "API权限管理与刷新"},
|
||||
{"path": "/dept/*", "desc": "部门管理"},
|
||||
{"path": "/valuations/*", "desc": "估值评估管理"},
|
||||
{"path": "/invoice/*", "desc": "发票与抬头"},
|
||||
{"path": "/transactions/*", "desc": "对公转账记录"},
|
||||
]
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
await test_base(client, base, token, results)
|
||||
await test_users_crud(client, base, token, results)
|
||||
await test_roles_menus_apis(client, base, token, results)
|
||||
await test_dept_crud(client, base, token, results)
|
||||
await test_valuations_admin(client, base, token, results)
|
||||
await test_invoice_transactions(client, base, token, results)
|
||||
await perf_benchmark(client, base, token, results)
|
||||
passes = sum(1 for r in results if r.get("status") == "PASS")
|
||||
print(json.dumps({"total": len(results), "passes": passes, "results": results, "endpoints": endpoint_list}, ensure_ascii=False, indent=2))
|
||||
write_html_report("reports/admin_flow_script_report.html", "后台管理员维度接口全流程测试报告", results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(main())
|
||||
270
scripts/api_smoke_test.py
Normal file
@ -0,0 +1,270 @@
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def _print(title: str, payload: Any) -> None:
|
||||
print(f"\n[{title}]\n{json.dumps(payload, ensure_ascii=False, indent=2)}")
|
||||
|
||||
|
||||
def _url(base: str, path: str) -> str:
|
||||
return f"{base}{path}"
|
||||
|
||||
|
||||
class AppClient:
|
||||
"""
|
||||
用户端客户端,会话维持与常用接口封装
|
||||
|
||||
参数:
|
||||
base: API 基础地址,如 http://127.0.0.1:9991/api/v1
|
||||
|
||||
属性:
|
||||
session: requests.Session 会话对象,携带 token
|
||||
"""
|
||||
|
||||
def __init__(self, base: str) -> None:
|
||||
self.base = base.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
|
||||
def set_token(self, token: str) -> None:
|
||||
"""
|
||||
设置用户端 token 到请求头
|
||||
|
||||
参数:
|
||||
token: 登录接口返回的 access_token
|
||||
返回:
|
||||
None
|
||||
"""
|
||||
self.session.headers.update({"token": token})
|
||||
|
||||
def register(self, phone: str) -> Dict[str, Any]:
|
||||
"""
|
||||
用户注册
|
||||
|
||||
参数:
|
||||
phone: 手机号
|
||||
返回:
|
||||
注册响应 dict
|
||||
"""
|
||||
resp = self.session.post(_url(self.base, "/app-user/register"), json={"phone": phone})
|
||||
return _safe_json(resp)
|
||||
|
||||
def login(self, phone: str, password: str) -> Optional[str]:
|
||||
"""
|
||||
用户登录
|
||||
|
||||
参数:
|
||||
phone: 手机号
|
||||
password: 密码
|
||||
返回:
|
||||
access_token 或 None
|
||||
"""
|
||||
resp = self.session.post(_url(self.base, "/app-user/login"), json={"phone": phone, "password": password})
|
||||
data = _safe_json(resp)
|
||||
token = data.get("access_token") if isinstance(data, dict) else None
|
||||
if token:
|
||||
self.set_token(token)
|
||||
return token
|
||||
|
||||
def profile(self) -> Dict[str, Any]:
|
||||
resp = self.session.get(_url(self.base, "/app-user/profile"))
|
||||
return _safe_json(resp)
|
||||
|
||||
def dashboard(self) -> Dict[str, Any]:
|
||||
resp = self.session.get(_url(self.base, "/app-user/dashboard"))
|
||||
return _safe_json(resp)
|
||||
|
||||
def quota(self) -> Dict[str, Any]:
|
||||
resp = self.session.get(_url(self.base, "/app-user/quota"))
|
||||
return _safe_json(resp)
|
||||
|
||||
def submit_valuation(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
提交估值评估
|
||||
|
||||
参数:
|
||||
payload: 估值评估输入数据
|
||||
返回:
|
||||
提交响应 dict
|
||||
"""
|
||||
resp = self.session.post(_url(self.base, "/app-valuations/"), json=payload)
|
||||
return _safe_json(resp)
|
||||
|
||||
def list_valuations(self) -> Dict[str, Any]:
|
||||
resp = self.session.get(_url(self.base, "/app-valuations/"))
|
||||
return _safe_json(resp)
|
||||
|
||||
def valuation_detail(self, valuation_id: int) -> Dict[str, Any]:
|
||||
resp = self.session.get(_url(self.base, f"/app-valuations/{valuation_id}"))
|
||||
return _safe_json(resp)
|
||||
|
||||
|
||||
class AdminClient:
|
||||
"""
|
||||
后台客户端,会话维持与接口封装
|
||||
|
||||
参数:
|
||||
base: API 基础地址
|
||||
"""
|
||||
|
||||
def __init__(self, base: str) -> None:
|
||||
self.base = base.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
|
||||
def set_token(self, token: str) -> None:
|
||||
self.session.headers.update({"token": token})
|
||||
|
||||
def login(self, username: str, password: str) -> Optional[str]:
|
||||
resp = self.session.post(_url(self.base, "/base/access_token"), json={"username": username, "password": password})
|
||||
data = _safe_json(resp)
|
||||
token = data.get("data", {}).get("access_token") if isinstance(data, dict) else None
|
||||
if token:
|
||||
self.set_token(token)
|
||||
return token
|
||||
|
||||
def list_valuations(self) -> Dict[str, Any]:
|
||||
resp = self.session.get(_url(self.base, "/valuations/"))
|
||||
return _safe_json(resp)
|
||||
|
||||
def valuation_detail(self, valuation_id: int) -> Dict[str, Any]:
|
||||
resp = self.session.get(_url(self.base, f"/valuations/{valuation_id}"))
|
||||
return _safe_json(resp)
|
||||
|
||||
def valuation_steps(self, valuation_id: int) -> Dict[str, Any]:
|
||||
resp = self.session.get(_url(self.base, f"/valuations/{valuation_id}/steps"))
|
||||
return _safe_json(resp)
|
||||
|
||||
|
||||
def _safe_json(resp: requests.Response) -> Dict[str, Any]:
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {"status_code": resp.status_code, "text": resp.text}
|
||||
|
||||
|
||||
def build_sample_payload() -> Dict[str, Any]:
|
||||
"""
|
||||
构建估值评估示例输入(精简版)
|
||||
|
||||
返回:
|
||||
dict: 估值评估输入
|
||||
"""
|
||||
# 使用你提供的参数,保持后端计算逻辑不变
|
||||
payload = {
|
||||
"asset_name": "马王堆",
|
||||
"institution": "成都文化产权交易所",
|
||||
"industry": "文化艺术业",
|
||||
"annual_revenue": "10000",
|
||||
"rd_investment": "6000",
|
||||
"three_year_income": ["8000", "9000", "9500"],
|
||||
"funding_status": "省级资助",
|
||||
"sales_volume": "60000",
|
||||
"link_views": "350000",
|
||||
"circulation": "3",
|
||||
"last_market_activity": "0",
|
||||
"monthly_transaction": "1",
|
||||
"price_fluctuation": [402, 445],
|
||||
"application_maturity": "0",
|
||||
"application_coverage": "0",
|
||||
"cooperation_depth": "0",
|
||||
"offline_activities": "20",
|
||||
"online_accounts": ["1", "成都文交所", "500000", "89222", "97412"],
|
||||
"inheritor_level": "省级传承人",
|
||||
"inheritor_age_count": [200, 68, 20],
|
||||
"inheritor_certificates": [],
|
||||
"heritage_level": "2",
|
||||
"historical_evidence": {"artifacts": "58", "ancient_literature": "789", "inheritor_testimony": "100"},
|
||||
"patent_certificates": [],
|
||||
"pattern_images": [],
|
||||
"patent_application_no": "",
|
||||
"heritage_asset_level": "纳入《国家文化数字化战略清单》",
|
||||
"inheritor_ages": [200, 68, 20],
|
||||
"implementation_stage": "成熟应用",
|
||||
"coverage_area": "全球覆盖",
|
||||
"collaboration_type": "无",
|
||||
"scarcity_level": "流通:总发行份数 >1000份,或二级市场流通率 ≥ 5%",
|
||||
"market_activity_time": "近一周",
|
||||
"monthly_transaction_amount": "月交易额>100万<500万",
|
||||
"platform_accounts": {
|
||||
"douyin": {"account": "成都文交所", "likes": "500000", "comments": "89222", "shares": "97412", "views": "100000"}
|
||||
}
|
||||
}
|
||||
# 若 application_coverage 为占位,则用 coverage_area 回填
|
||||
if payload.get("application_coverage") in (None, "0", "") and payload.get("coverage_area"):
|
||||
payload["application_coverage"] = payload["coverage_area"]
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="估值二期 API 冒烟测试")
|
||||
parser.add_argument("--base", default="http://127.0.0.1:9991/api/v1", help="API基础地址")
|
||||
parser.add_argument("--phone", default="13800138001", help="测试手机号")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.base.rstrip("/")
|
||||
phone = args.phone
|
||||
default_pwd = phone[-6:]
|
||||
|
||||
app = AppClient(base)
|
||||
admin = AdminClient(base)
|
||||
|
||||
# 用户注册
|
||||
reg = app.register(phone)
|
||||
_print("用户注册", reg)
|
||||
|
||||
# 用户登录
|
||||
token = app.login(phone, default_pwd)
|
||||
_print("用户登录token", {"access_token": token})
|
||||
if not token:
|
||||
print("登录失败,终止测试")
|
||||
return
|
||||
|
||||
# 用户相关接口
|
||||
_print("用户信息", app.profile())
|
||||
_print("首页摘要", app.dashboard())
|
||||
_print("剩余估值次数", app.quota())
|
||||
|
||||
# 提交估值
|
||||
payload = build_sample_payload()
|
||||
submit = app.submit_valuation(payload)
|
||||
_print("提交估值", submit)
|
||||
|
||||
# 轮询估值列表抓取最新记录
|
||||
valuation_id = None
|
||||
for _ in range(10):
|
||||
lst = app.list_valuations()
|
||||
_print("我的估值列表", lst)
|
||||
try:
|
||||
items = lst.get("data", []) if isinstance(lst, dict) else []
|
||||
if items:
|
||||
valuation_id = items[0].get("id") or items[-1].get("id")
|
||||
if valuation_id:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.8)
|
||||
|
||||
if valuation_id:
|
||||
detail = app.valuation_detail(valuation_id)
|
||||
_print("估值详情", detail)
|
||||
else:
|
||||
print("未获得估值ID,跳过详情")
|
||||
|
||||
# 后台登录
|
||||
admin_token = admin.login("admin", "123456")
|
||||
_print("后台登录token", {"access_token": admin_token})
|
||||
if admin_token:
|
||||
vlist = admin.list_valuations()
|
||||
_print("后台估值列表", vlist)
|
||||
if valuation_id:
|
||||
vdetail = admin.valuation_detail(valuation_id)
|
||||
_print("后台估值详情", vdetail)
|
||||
vsteps = admin.valuation_steps(valuation_id)
|
||||
_print("后台估值计算步骤", vsteps)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
104
scripts/response_format_check.py
Normal file
@ -0,0 +1,104 @@
|
||||
import json
|
||||
from typing import Dict, Any, List, Tuple
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
def load_openapi(app: FastAPI) -> Dict[str, Any]:
|
||||
return app.openapi()
|
||||
|
||||
|
||||
def is_object_schema(schema: Dict[str, Any]) -> bool:
|
||||
return schema.get("type") == "object"
|
||||
|
||||
|
||||
def get_schema_props(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return schema.get("properties", {}) if schema else {}
|
||||
|
||||
|
||||
def check_success_schema(props: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
||||
issues: List[str] = []
|
||||
code_prop = props.get("code")
|
||||
msg_prop = props.get("msg")
|
||||
data_prop = props.get("data")
|
||||
if code_prop is None:
|
||||
issues.append("缺少字段: code")
|
||||
elif code_prop.get("type") != "integer":
|
||||
issues.append(f"code类型错误: {code_prop.get('type')}")
|
||||
if msg_prop is None:
|
||||
issues.append("缺少字段: msg")
|
||||
elif msg_prop.get("type") != "string":
|
||||
issues.append(f"msg类型错误: {msg_prop.get('type')}")
|
||||
if data_prop is None:
|
||||
issues.append("缺少字段: data")
|
||||
else:
|
||||
tp = data_prop.get("type")
|
||||
if tp != "object":
|
||||
issues.append(f"data类型错误: {tp}")
|
||||
return (len(issues) == 0, issues)
|
||||
|
||||
|
||||
def check_paths(openapi: Dict[str, Any]) -> Dict[str, Any]:
|
||||
paths = openapi.get("paths", {})
|
||||
compliant: List[Dict[str, Any]] = []
|
||||
non_compliant: List[Dict[str, Any]] = []
|
||||
for path, ops in paths.items():
|
||||
for method, meta in ops.items():
|
||||
op_id = meta.get("operationId")
|
||||
tags = meta.get("tags", [])
|
||||
responses = meta.get("responses", {})
|
||||
success = responses.get("200") or responses.get("201")
|
||||
if not success:
|
||||
non_compliant.append({
|
||||
"path": path,
|
||||
"method": method.upper(),
|
||||
"operationId": op_id,
|
||||
"tags": tags,
|
||||
"issues": ["无成功响应模型(200/201)"],
|
||||
})
|
||||
continue
|
||||
content = success.get("content", {}).get("application/json", {})
|
||||
schema = content.get("schema")
|
||||
if not schema:
|
||||
non_compliant.append({
|
||||
"path": path,
|
||||
"method": method.upper(),
|
||||
"operationId": op_id,
|
||||
"tags": tags,
|
||||
"issues": ["成功响应未声明JSON Schema"],
|
||||
})
|
||||
continue
|
||||
props = get_schema_props(schema)
|
||||
ok, issues = check_success_schema(props)
|
||||
rec = {
|
||||
"path": path,
|
||||
"method": method.upper(),
|
||||
"operationId": op_id,
|
||||
"tags": tags,
|
||||
}
|
||||
if ok:
|
||||
compliant.append(rec)
|
||||
else:
|
||||
non_compliant.append({**rec, "issues": issues})
|
||||
total = len(compliant) + len(non_compliant)
|
||||
rate = 0 if total == 0 else round(len(compliant) / total * 100, 2)
|
||||
return {
|
||||
"compliant": compliant,
|
||||
"non_compliant": non_compliant,
|
||||
"stats": {"total": total, "compliant": len(compliant), "non_compliant": len(non_compliant), "rate": rate},
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app = create_app()
|
||||
openapi = load_openapi(app)
|
||||
result = check_paths(openapi)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
with open("scripts/response_format_report.json", "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
103
scripts/send_email_test.py
Normal file
@ -0,0 +1,103 @@
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
def _parse_bool(value: Optional[str]) -> bool:
|
||||
"""
|
||||
功能: 将环境变量中的布尔字符串解析为布尔值
|
||||
参数: value (Optional[str]) - 环境变量字符串值, 如 "true", "1", "yes"
|
||||
返回: bool - 解析后的布尔值
|
||||
"""
|
||||
if value is None:
|
||||
return False
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "y"}
|
||||
|
||||
|
||||
def get_smtp_config() -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
功能: 从环境变量读取SMTP配置并返回配置字典
|
||||
参数: 无
|
||||
返回: Dict[str, Optional[str]] - 包含host、port、from、username、password、tls等键的配置
|
||||
"""
|
||||
host = os.environ.get("SMTP_HOST", "smtp.qiye.aliyun.com")
|
||||
port_str = os.environ.get("SMTP_PORT", "465")
|
||||
from_addr = os.environ.get("SMTP_FROM","value@cdcee.net")
|
||||
username = os.environ.get("SMTP_USERNAME","value@cdcee.net")
|
||||
password = os.environ.get("SMTP_PASSWORD","PPXbILdGlRCn2VOx")
|
||||
tls = _parse_bool(os.environ.get("SMTP_TLS"))
|
||||
|
||||
port = None
|
||||
if port_str:
|
||||
try:
|
||||
port = int(port_str)
|
||||
except Exception:
|
||||
port = None
|
||||
|
||||
return {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"from": from_addr,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"tls": tls,
|
||||
}
|
||||
|
||||
|
||||
def send_test_email(to_email: str, subject: Optional[str], body: str) -> Dict[str, str]:
|
||||
"""
|
||||
功能: 使用SMTP配置发送测试邮件到指定邮箱
|
||||
参数: to_email (str) - 收件人邮箱; subject (Optional[str]) - 邮件主题; body (str) - 邮件正文内容
|
||||
返回: Dict[str, str] - 发送结果字典, 包含status("OK"/"FAIL")与error(失败信息)
|
||||
"""
|
||||
cfg = get_smtp_config()
|
||||
if not cfg["host"] or not cfg["port"] or not cfg["from"]:
|
||||
return {"status": "FAIL", "error": "SMTP 未配置: 需设置 SMTP_HOST/SMTP_PORT/SMTP_FROM"}
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = cfg["from"]
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject or "估值服务通知"
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
server = None
|
||||
try:
|
||||
if cfg["tls"]:
|
||||
server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=30)
|
||||
server.starttls()
|
||||
else:
|
||||
server = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=30)
|
||||
|
||||
if cfg["username"] and cfg["password"]:
|
||||
server.login(cfg["username"], cfg["password"])
|
||||
|
||||
server.sendmail(cfg["from"], [to_email], msg.as_string())
|
||||
server.quit()
|
||||
return {"status": "OK"}
|
||||
except Exception as e:
|
||||
try:
|
||||
if server:
|
||||
server.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "FAIL", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
to = "zfc9393@163.com"
|
||||
subject = "测试邮件"
|
||||
body = "这是一封测试邮件,用于验证SMTP配置。"
|
||||
|
||||
cfg = get_smtp_config()
|
||||
print({
|
||||
"host": cfg["host"],
|
||||
"port": cfg["port"],
|
||||
"from": cfg["from"],
|
||||
"username_set": bool(cfg["username"]),
|
||||
"password_set": bool(cfg["password"]),
|
||||
"tls": cfg["tls"],
|
||||
})
|
||||
result = send_test_email(to, subject, body)
|
||||
print(result)
|
||||
54
scripts/send_email_test.sh
Normal file
@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TOKEN=""
|
||||
EMAIL=""
|
||||
SUBJECT=""
|
||||
BODY=""
|
||||
FILE_PATH=""
|
||||
FILE_URL=""
|
||||
BASE_API="http://127.0.0.1:9999/api/v1"
|
||||
|
||||
while getopts ":t:e:s:b:f:u:a:" opt; do
|
||||
case "$opt" in
|
||||
t) TOKEN="$OPTARG" ;;
|
||||
e) EMAIL="$OPTARG" ;;
|
||||
s) SUBJECT="$OPTARG" ;;
|
||||
b) BODY="$OPTARG" ;;
|
||||
f) FILE_PATH="$OPTARG" ;;
|
||||
u) FILE_URL="$OPTARG" ;;
|
||||
a) BASE_API="$OPTARG" ;;
|
||||
*) echo "Invalid option: -$OPTARG"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$TOKEN" || -z "$EMAIL" || -z "$BODY" ]]; then
|
||||
echo "Usage: $0 -t <token> -e <email> -b <body> [-s <subject>] [-f <file_path> | -u <file_url>] [-a <base_api>]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
URL="$BASE_API/transactions/send-email"
|
||||
|
||||
if [[ -n "$FILE_PATH" ]]; then
|
||||
if [[ ! -f "$FILE_PATH" ]]; then
|
||||
echo "File not found: $FILE_PATH"
|
||||
exit 1
|
||||
fi
|
||||
curl -s -X POST "$URL" \
|
||||
-H "accept: application/json" \
|
||||
-H "token: $TOKEN" \
|
||||
-F "email=$EMAIL" \
|
||||
-F "subject=$SUBJECT" \
|
||||
-F "body=$BODY" \
|
||||
-F "file=@$FILE_PATH" | jq -r '.' 2>/dev/null || true
|
||||
else
|
||||
PAYLOAD="{\"email\":\"$EMAIL\",\"subject\":\"$SUBJECT\",\"body\":\"$BODY\"}"
|
||||
if [[ -n "$FILE_URL" ]]; then
|
||||
PAYLOAD="{\"email\":\"$EMAIL\",\"subject\":\"$SUBJECT\",\"body\":\"$BODY\",\"file_url\":\"$FILE_URL\"}"
|
||||
fi
|
||||
curl -s -X POST "$URL" \
|
||||
-H "accept: application/json" \
|
||||
-H "token: $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" | jq -r '.' 2>/dev/null || true
|
||||
fi
|
||||
393
scripts/user_flow_test.py
Normal file
@ -0,0 +1,393 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import random
|
||||
from typing import Dict, Any, List, Tuple, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def make_url(base_url: str, path: str) -> str:
|
||||
if base_url.endswith("/"):
|
||||
base_url = base_url[:-1]
|
||||
return f"{base_url}{path}"
|
||||
|
||||
|
||||
def write_html_report(filepath: str, title: str, results: List[Dict[str, Any]]) -> None:
|
||||
"""
|
||||
生成HTML测试报告
|
||||
参数:
|
||||
filepath: 报告输出文件路径
|
||||
title: 报告标题
|
||||
results: 测试结果列表,包含 name/status/message/detail
|
||||
返回:
|
||||
None
|
||||
"""
|
||||
rows = []
|
||||
for r in results:
|
||||
color = {"PASS": "#4caf50", "FAIL": "#f44336"}.get(r.get("status"), "#9e9e9e")
|
||||
rows.append(
|
||||
f"<tr><td>{r.get('name')}</td><td style='color:{color};font-weight:600'>{r.get('status')}</td><td>{r.get('message','')}</td><td><pre>{json.dumps(r.get('detail', {}), ensure_ascii=False, indent=2)}</pre></td></tr>"
|
||||
)
|
||||
html = f"""
|
||||
<!doctype html>
|
||||
<html><head><meta charset='utf-8'><title>{title}</title>
|
||||
<style>body{{font-family:Arial;padding:12px}} table{{border-collapse:collapse;width:100%}} td,th{{border:1px solid #ddd;padding:8px}}</style>
|
||||
</head><body>
|
||||
<h2>{title}</h2>
|
||||
<p>生成时间: {time.strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
<table><thead><tr><th>用例</th><th>结果</th><th>说明</th><th>详情</th></tr></thead><tbody>
|
||||
{''.join(rows)}
|
||||
</tbody></table>
|
||||
</body></html>
|
||||
"""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
|
||||
|
||||
def _ensure_dict(obj: Any) -> Dict[str, Any]:
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
return {"raw": str(obj)}
|
||||
|
||||
|
||||
async def api_post_json(client: httpx.AsyncClient, url: str, payload: Dict[str, Any], headers: Optional[Dict[str, str]] = None) -> Tuple[int, Dict[str, Any]]:
|
||||
"""
|
||||
发送POST JSON请求
|
||||
参数:
|
||||
client: httpx异步客户端
|
||||
url: 完整URL
|
||||
payload: 请求体JSON
|
||||
headers: 请求头
|
||||
返回:
|
||||
(状态码, 响应JSON)
|
||||
"""
|
||||
r = await client.post(url, json=payload, headers=headers or {})
|
||||
try:
|
||||
parsed = r.json()
|
||||
except Exception:
|
||||
parsed = {"raw": r.text}
|
||||
if parsed is None:
|
||||
parsed = {"raw": r.text}
|
||||
return r.status_code, _ensure_dict(parsed)
|
||||
|
||||
|
||||
async def api_get(client: httpx.AsyncClient, url: str, headers: Optional[Dict[str, str]] = None, params: Optional[Dict[str, Any]] = None) -> Tuple[int, Dict[str, Any]]:
|
||||
"""
|
||||
发送GET请求
|
||||
参数:
|
||||
client: httpx异步客户端
|
||||
url: 完整URL
|
||||
headers: 请求头
|
||||
params: 查询参数
|
||||
返回:
|
||||
(状态码, 响应JSON)
|
||||
"""
|
||||
r = await client.get(url, headers=headers or {}, params=params or {})
|
||||
try:
|
||||
parsed = r.json()
|
||||
except Exception:
|
||||
parsed = {"raw": r.text}
|
||||
if parsed is None:
|
||||
parsed = {"raw": r.text}
|
||||
return r.status_code, _ensure_dict(parsed)
|
||||
|
||||
|
||||
async def api_put_json(client: httpx.AsyncClient, url: str, payload: Dict[str, Any], headers: Optional[Dict[str, str]] = None) -> Tuple[int, Dict[str, Any]]:
|
||||
"""
|
||||
发送PUT JSON请求
|
||||
参数:
|
||||
client: httpx异步客户端
|
||||
url: 完整URL
|
||||
payload: 请求体JSON
|
||||
headers: 请求头
|
||||
返回:
|
||||
(状态码, 响应JSON)
|
||||
"""
|
||||
r = await client.put(url, json=payload, headers=headers or {})
|
||||
try:
|
||||
parsed = r.json()
|
||||
except Exception:
|
||||
parsed = {"raw": r.text}
|
||||
if parsed is None:
|
||||
parsed = {"raw": r.text}
|
||||
return r.status_code, _ensure_dict(parsed)
|
||||
|
||||
|
||||
async def user_register_flow(base_url: str, client: httpx.AsyncClient, phone: str, expect_success: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
用户注册流程
|
||||
参数:
|
||||
base_url: 基础URL(含 /api/v1)
|
||||
client: httpx客户端
|
||||
phone: 手机号
|
||||
返回:
|
||||
测试结果字典
|
||||
"""
|
||||
url = make_url(base_url, "/app-user/register")
|
||||
code, data = await api_post_json(client, url, {"phone": phone})
|
||||
rs = {"name": f"注册-{phone}", "status": "FAIL", "message": "", "detail": {"http": code, "body": _ensure_dict(data)}}
|
||||
body = _ensure_dict(data)
|
||||
payload = _ensure_dict(body.get("data"))
|
||||
ok = (body.get("code") == 200 and payload.get("phone") == phone)
|
||||
# 期望失败场景:重复注册或无效格式
|
||||
if not expect_success:
|
||||
ok = (body.get("code") in (400, 422) or (isinstance(body.get("msg"), str) and "已存在" in body.get("msg")))
|
||||
rs["message"] = "注册失败(符合预期)" if ok else "注册失败(不符合预期)"
|
||||
else:
|
||||
rs["message"] = "注册成功" if ok else "注册失败"
|
||||
rs["status"] = "PASS" if ok else "FAIL"
|
||||
return rs
|
||||
|
||||
|
||||
async def user_login_flow(base_url: str, client: httpx.AsyncClient, phone: str, password: str, expect_success: bool = True) -> Tuple[Dict[str, Any], str]:
|
||||
"""
|
||||
用户登录流程
|
||||
参数:
|
||||
base_url: 基础URL(含 /api/v1)
|
||||
client: httpx客户端
|
||||
phone: 手机号
|
||||
password: 密码
|
||||
返回:
|
||||
(测试结果字典, access_token字符串或空)
|
||||
"""
|
||||
url = make_url(base_url, "/app-user/login")
|
||||
code, data = await api_post_json(client, url, {"phone": phone, "password": password})
|
||||
token = ""
|
||||
is_ok = (code == 200 and isinstance(data, dict) and data.get("access_token"))
|
||||
if is_ok:
|
||||
token = data.get("access_token", "")
|
||||
if not expect_success:
|
||||
ok = (code in (401, 403))
|
||||
rs = {"name": f"登录-{phone}", "status": "PASS" if ok else "FAIL", "message": "登录失败(符合预期)" if ok else "登录失败(不符合预期)", "detail": {"http": code, "body": data}}
|
||||
else:
|
||||
rs = {"name": f"登录-{phone}", "status": "PASS" if is_ok else "FAIL", "message": "登录成功" if is_ok else "登录失败", "detail": {"http": code, "body": data}}
|
||||
return rs, token
|
||||
|
||||
|
||||
async def user_profile_flow(base_url: str, client: httpx.AsyncClient, token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
用户资料查看与编辑
|
||||
参数:
|
||||
base_url: 基础URL(含 /api/v1)
|
||||
client: httpx客户端
|
||||
token: 用户JWT
|
||||
返回:
|
||||
测试结果字典
|
||||
"""
|
||||
headers = {"token": token}
|
||||
view_url = make_url(base_url, "/app-user/profile")
|
||||
v_code, v_data = await api_get(client, view_url, headers=headers)
|
||||
ok_view = (v_code == 200 and isinstance(v_data, dict) and v_data.get("id"))
|
||||
upd_url = make_url(base_url, "/app-user/profile")
|
||||
nickname = "tester-" + uuid.uuid4().hex[:6]
|
||||
u_code, u_data = await api_put_json(client, upd_url, {"nickname": nickname}, headers=headers)
|
||||
ok_upd = (u_code == 200 and isinstance(u_data, dict) and u_data.get("nickname") == nickname)
|
||||
is_ok = ok_view and ok_upd
|
||||
return {"name": "资料查看与编辑", "status": "PASS" if is_ok else "FAIL", "message": "个人资料操作成功" if is_ok else "个人资料操作失败", "detail": {"view": {"http": v_code, "body": v_data}, "update": {"http": u_code, "body": u_data}}}
|
||||
|
||||
|
||||
async def permission_flow(base_url: str, client: httpx.AsyncClient, admin_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
权限控制验证
|
||||
参数:
|
||||
base_url: 基础URL(含 /api/v1)
|
||||
client: httpx客户端
|
||||
admin_token: 管理端token头值
|
||||
返回:
|
||||
测试结果字典
|
||||
"""
|
||||
protected_url = make_url(base_url, "/user/list")
|
||||
c1, d1 = await api_get(client, protected_url)
|
||||
c2, d2 = await api_get(client, protected_url, headers={"token": admin_token})
|
||||
ok1 = (c1 in (401, 403, 422))
|
||||
ok2 = (c2 in (200, 403))
|
||||
is_ok = ok1 and ok2
|
||||
return {"name": "权限控制", "status": "PASS" if is_ok else "FAIL", "message": "权限校验完成", "detail": {"no_token": {"http": c1, "body": d1}, "with_token": {"http": c2, "body": d2}}}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""
|
||||
主流程
|
||||
参数:
|
||||
无
|
||||
返回:
|
||||
None
|
||||
"""
|
||||
base = os.getenv("TEST_BASE_URL", "http://localhost:9991/api/v1")
|
||||
admin_token = os.getenv("ADMIN_TOKEN", "dev")
|
||||
results: List[Dict[str, Any]] = []
|
||||
endpoint_list = [
|
||||
{"path": "/app-user/register", "desc": "用户注册"},
|
||||
{"path": "/app-user/login", "desc": "用户登录"},
|
||||
{"path": "/app-user/profile", "desc": "获取用户信息(需token)"},
|
||||
{"path": "/app-user/profile", "desc": "更新用户信息(需token) PUT"},
|
||||
{"path": "/app-user/dashboard", "desc": "用户首页摘要(需token)"},
|
||||
{"path": "/app-user/quota", "desc": "剩余估值次数(需token)"},
|
||||
{"path": "/app-user/change-password", "desc": "修改密码(需token)"},
|
||||
{"path": "/app-user/validate-token", "desc": "验证token(需token)"},
|
||||
{"path": "/app-user/logout", "desc": "登出(需token)"},
|
||||
{"path": "/upload/file", "desc": "上传文件"},
|
||||
{"path": "/app-valuations/", "desc": "创建估值评估(需token)"},
|
||||
{"path": "/app-valuations/", "desc": "获取我的估值评估列表(需token)"},
|
||||
{"path": "/app-valuations/{id}", "desc": "获取估值评估详情(需token)"},
|
||||
{"path": "/app-valuations/statistics/overview", "desc": "获取我的估值统计(需token)"},
|
||||
{"path": "/app-valuations/{id}", "desc": "删除估值评估(需token) DELETE"},
|
||||
]
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
def gen_cn_phone() -> str:
|
||||
second = str(random.choice([3,4,5,6,7,8,9]))
|
||||
rest = "".join(random.choice("0123456789") for _ in range(9))
|
||||
return "1" + second + rest
|
||||
phone_ok = gen_cn_phone()
|
||||
r1 = await user_register_flow(base, client, phone_ok, expect_success=True)
|
||||
results.append(r1)
|
||||
r2 = await user_register_flow(base, client, phone_ok, expect_success=False)
|
||||
results.append(r2)
|
||||
r3 = await user_register_flow(base, client, "abc", expect_success=False)
|
||||
results.append(r3)
|
||||
lr_ok, token = await user_login_flow(base, client, phone_ok, phone_ok[-6:], expect_success=True)
|
||||
results.append(lr_ok)
|
||||
lr_bad, _ = await user_login_flow(base, client, phone_ok, "wrong", expect_success=False)
|
||||
results.append(lr_bad)
|
||||
# token 场景:验证、资料、首页、配额
|
||||
if token:
|
||||
# 验证token
|
||||
vt_code, vt_data = await api_get(client, make_url(base, "/app-user/validate-token"), headers={"token": token})
|
||||
vt_ok = (vt_code == 200 and isinstance(vt_data, dict) and vt_data.get("data", {}).get("user_id"))
|
||||
results.append({"name": "验证token", "status": "PASS" if vt_ok else "FAIL", "message": "token有效" if vt_ok else "token无效", "detail": {"http": vt_code, "body": vt_data}})
|
||||
|
||||
# 资料查看与编辑
|
||||
pr = await user_profile_flow(base, client, token)
|
||||
results.append(pr)
|
||||
|
||||
# 首页摘要
|
||||
db_code, db_data = await api_get(client, make_url(base, "/app-user/dashboard"), headers={"token": token})
|
||||
db_ok = (db_code == 200 and isinstance(db_data, dict))
|
||||
results.append({"name": "用户首页摘要", "status": "PASS" if db_ok else "FAIL", "message": "获取成功" if db_ok else "获取失败", "detail": {"http": db_code, "body": db_data}})
|
||||
|
||||
# 剩余估值次数
|
||||
qt_code, qt_data = await api_get(client, make_url(base, "/app-user/quota"), headers={"token": token})
|
||||
qt_ok = (qt_code == 200 and isinstance(qt_data, dict))
|
||||
results.append({"name": "剩余估值次数", "status": "PASS" if qt_ok else "FAIL", "message": "获取成功" if qt_ok else "获取失败", "detail": {"http": qt_code, "body": qt_data}})
|
||||
|
||||
# 修改密码并验证新旧密码
|
||||
cp_code, cp_data = await api_post_json(client, make_url(base, "/app-user/change-password"), {"old_password": phone_ok[-6:], "new_password": "Npw" + phone_ok[-6:]}, headers={"token": token})
|
||||
cp_ok = (cp_code == 200 and isinstance(cp_data, dict) and cp_data.get("code") == 200)
|
||||
results.append({"name": "修改密码", "status": "PASS" if cp_ok else "FAIL", "message": "修改成功" if cp_ok else "修改失败", "detail": {"http": cp_code, "body": cp_data}})
|
||||
|
||||
# 旧密码登录应失败
|
||||
lr_old, _ = await user_login_flow(base, client, phone_ok, phone_ok[-6:], expect_success=False)
|
||||
results.append(lr_old)
|
||||
# 新密码登录成功
|
||||
lr_new, token2 = await user_login_flow(base, client, phone_ok, "Npw" + phone_ok[-6:], expect_success=True)
|
||||
results.append(lr_new)
|
||||
use_token = token2 or token
|
||||
|
||||
# 上传文件(pdf)
|
||||
file_url = ""
|
||||
try:
|
||||
up_resp = await client.post(make_url(base, "/upload/file"), files={"file": ("demo.pdf", b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n", "application/pdf")})
|
||||
u_code = up_resp.status_code
|
||||
u_data = _ensure_dict(up_resp.json() if up_resp.headers.get("content-type", "").startswith("application/json") else {"raw": up_resp.text})
|
||||
file_url = u_data.get("url", "")
|
||||
u_ok = (u_code == 200 and file_url)
|
||||
results.append({"name": "上传文件", "status": "PASS" if u_ok else "FAIL", "message": "上传成功" if u_ok else "上传失败", "detail": {"http": u_code, "body": u_data}})
|
||||
except Exception as e:
|
||||
results.append({"name": "上传文件", "status": "FAIL", "message": "上传异常", "detail": {"error": repr(e)}})
|
||||
|
||||
# 创建估值评估
|
||||
create_payload = {
|
||||
"asset_name": "测试资产",
|
||||
"institution": "测试机构",
|
||||
"industry": "测试行业",
|
||||
"three_year_income": [100, 120, 140],
|
||||
"application_coverage": "全国覆盖",
|
||||
"rd_investment": "10",
|
||||
"annual_revenue": "100",
|
||||
"price_fluctuation": [10, 20],
|
||||
"platform_accounts": {"douyin": {"likes": 1, "comments": 1, "shares": 1}},
|
||||
"pattern_images": [],
|
||||
"report_url": file_url or None,
|
||||
"certificate_url": file_url or None,
|
||||
}
|
||||
cv_code, cv_data = await api_post_json(client, make_url(base, "/app-valuations/"), create_payload, headers={"token": use_token})
|
||||
cv_ok = (cv_code == 200 and isinstance(cv_data, dict) and cv_data.get("data", {}).get("task_status") == "queued")
|
||||
results.append({"name": "创建估值评估", "status": "PASS" if cv_ok else "FAIL", "message": "任务已提交" if cv_ok else "提交失败", "detail": {"http": cv_code, "body": cv_data}})
|
||||
|
||||
# 等待片刻后获取列表与详情
|
||||
import asyncio
|
||||
await asyncio.sleep(0.3)
|
||||
gl_code, gl_data = await api_get(client, make_url(base, "/app-valuations/"), headers={"token": use_token}, params={"page": 1, "size": 10})
|
||||
gl_ok = (gl_code == 200 and isinstance(gl_data, dict) and isinstance(gl_data.get("data"), list))
|
||||
results.append({"name": "估值列表", "status": "PASS" if gl_ok else "FAIL", "message": "获取成功" if gl_ok else "获取失败", "detail": {"http": gl_code, "body": gl_data}})
|
||||
vid = None
|
||||
if gl_ok and gl_data.get("data"):
|
||||
vid = gl_data["data"][0].get("id")
|
||||
if vid:
|
||||
gd_code, gd_data = await api_get(client, make_url(base, f"/app-valuations/{vid}"), headers={"token": use_token})
|
||||
gd_ok = (gd_code == 200 and isinstance(gd_data, dict) and gd_data.get("data", {}).get("id") == vid)
|
||||
results.append({"name": "估值详情", "status": "PASS" if gd_ok else "FAIL", "message": "获取成功" if gd_ok else "获取失败", "detail": {"http": gd_code, "body": gd_data}})
|
||||
# 统计
|
||||
st_code, st_data = await api_get(client, make_url(base, "/app-valuations/statistics/overview"), headers={"token": use_token})
|
||||
st_ok = (st_code == 200 and isinstance(st_data, dict))
|
||||
results.append({"name": "估值统计", "status": "PASS" if st_ok else "FAIL", "message": "获取成功" if st_ok else "获取失败", "detail": {"http": st_code, "body": st_data}})
|
||||
# 删除
|
||||
del_resp = await client.delete(make_url(base, f"/app-valuations/{vid}"), headers={"token": use_token})
|
||||
d_code = del_resp.status_code
|
||||
d_data = _ensure_dict(del_resp.json() if del_resp.headers.get("content-type", "").startswith("application/json") else {"raw": del_resp.text})
|
||||
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}})
|
||||
|
||||
# 注销账号
|
||||
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_ok = (lo_code == 200)
|
||||
results.append({"name": "登出", "status": "PASS" if lo_ok else "FAIL", "message": "登出成功" if lo_ok else "登出失败", "detail": {"http": lo_code, "body": lo_data}})
|
||||
perm = await permission_flow(base, client, admin_token)
|
||||
results.append(perm)
|
||||
passes = sum(1 for r in results if r.get("status") == "PASS")
|
||||
total = len(results)
|
||||
print(json.dumps({"total": total, "passes": passes, "results": results, "endpoints": endpoint_list}, ensure_ascii=False, indent=2))
|
||||
write_html_report("reports/user_flow_script_report.html", "用户维度功能测试报告(脚本)", results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(main())
|
||||
async def api_put_json(client: httpx.AsyncClient, url: str, payload: Dict[str, Any], headers: Optional[Dict[str, str]] = None) -> Tuple[int, Dict[str, Any]]:
|
||||
"""
|
||||
发送PUT JSON请求
|
||||
参数:
|
||||
client: httpx异步客户端
|
||||
url: 完整URL
|
||||
payload: 请求体JSON
|
||||
headers: 请求头
|
||||
返回:
|
||||
(状态码, 响应JSON)
|
||||
"""
|
||||
r = await client.put(url, json=payload, headers=headers or {})
|
||||
data = {}
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception:
|
||||
data = {"raw": r.text}
|
||||
return r.status_code, data
|
||||