Compare commits
10 Commits
da958ac8a9
...
898a7e902b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898a7e902b | ||
|
|
7ade571e13 | ||
|
|
3728ed54d1 | ||
|
|
9904be7893 | ||
|
|
92b5c786df | ||
|
|
0d6e4764ca | ||
|
|
50461f3631 | ||
|
|
f7c5e0a4bf | ||
|
|
5ea20bed3b | ||
|
|
d868f17c2e |
83
backend/agent_docs/README.md
Normal file
83
backend/agent_docs/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 识图学英语后台项目 Agent 接入指南
|
||||
|
||||
## 1. 项目概览
|
||||
本项目是“识图学英语”微信小程序的后端服务,旨在通过 AI 技术帮助用户通过拍照学习英语。核心功能包括图片识别、英语练习题自动生成、口语发音评测以及配套的会员订阅与积分体系。
|
||||
|
||||
**核心价值**: 利用多模态大模型 (Qwen-VL) 和语音评测技术 (Tencent SOE) 为用户提供沉浸式的英语学习体验。
|
||||
|
||||
## 2. 技术架构
|
||||
本项目采用现代 Python 异步 Web 框架构建,强调高性能与模块化。
|
||||
|
||||
- **语言**: Python 3.10+
|
||||
- **Web 框架**: FastAPI (全链路异步)
|
||||
- **数据库**:
|
||||
- MySQL 8.0 (业务数据持久化, 使用 SQLAlchemy 2.0 ORM)
|
||||
- Redis 8.4 (缓存、会话管理、限流)
|
||||
- **AI 基础设施**:
|
||||
- LangChain (LLM 编排)
|
||||
- Qwen-VL (图像识别)
|
||||
- Qwen-Turbo (文本生成)
|
||||
- Tencent Cloud SOE (语音评测)
|
||||
- Qwen-TTS (语音合成)
|
||||
- **部署**: Docker / Docker Compose
|
||||
|
||||
## 3. 模块化文档索引
|
||||
为了便于 AI Agent 快速理解特定业务领域,项目被拆分为以下核心模块。请根据任务上下文查阅对应文档:
|
||||
|
||||
### 核心业务域
|
||||
| 模块名称 | 文档链接 | 关键职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| **用户与认证** | [user_auth.md](./user_auth.md) | 微信登录、JWT 鉴权、用户信息管理 |
|
||||
| **图片识别** | [image_recognition.md](./image_recognition.md) | 图片上传、异步识别任务、Qwen-VL 集成 |
|
||||
| **练习生成** | [exercise.md](./exercise.md) | 基于识别结果生成填空/选择题、答题判分 |
|
||||
| **录音评测** | [recording.md](./recording.md) | 口语跟读、发音打分、标准音 TTS 生成 |
|
||||
|
||||
### 交易与权益域
|
||||
| 模块名称 | 文档链接 | 关键职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| **微信支付** | [wx_pay.md](./wx_pay.md) | JSAPI 下单、支付回调、退款处理 |
|
||||
| **订阅与计费** | [subscription.md](./subscription.md) | 会员套餐管理、权益扣除逻辑 |
|
||||
| **积分系统** | [points.md](./points.md) | 积分充值、消耗、冻结与退还 |
|
||||
| **优惠券** | [coupon.md](./coupon.md) | 兑换码生成与核销 |
|
||||
|
||||
### 基础设施域
|
||||
| 模块名称 | 文档链接 | 关键职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| **日志与审计** | [logging.md](./logging.md) | LLM Token 消耗审计、成本核算 |
|
||||
| **LangChain** | [langchain.md](./langchain.md) | Prompt 管理、LLM 调用封装、审计回调 |
|
||||
| **第三方 API** | [third_party.md](./third_party.md) | 腾讯云、阿里云、有道 API 的底层封装 |
|
||||
|
||||
## 4. 核心业务流程图解
|
||||
|
||||
### 4.1 图片学习流程
|
||||
1. **上传**: 用户调用 `image_recognition` 接口上传图片。
|
||||
2. **识别**: 后台异步调用 Qwen-VL 提取英文单词/场景描述。
|
||||
3. **生成**: 用户请求生成练习,`exercise` 模块调用 LLM 生成题目。
|
||||
4. **练习**: 用户提交答案,系统自动判分。
|
||||
|
||||
### 4.2 口语训练流程
|
||||
1. **获取音频**: 用户请求 TTS 标准音 (`recording` 模块)。
|
||||
2. **权益检查**: `subscription` 模块检查会员状态,或 `points` 模块扣除积分。
|
||||
3. **跟读**: 用户上传录音文件。
|
||||
4. **评测**: 后台调用腾讯云 SOE 进行评分并返回结果。
|
||||
|
||||
## 5. 开发规范与注意事项
|
||||
|
||||
### 代码结构
|
||||
- `api/`: 接口层,仅处理 HTTP 请求/响应,不做复杂业务逻辑。
|
||||
- `service/`: 业务逻辑层,核心逻辑在此实现。
|
||||
- `crud/`: 数据访问层,处理数据库 CRUD 操作。
|
||||
- `model/`: SQLAlchemy ORM 模型定义。
|
||||
- `schema/`: Pydantic 数据验证模型 (Request/Response)。
|
||||
|
||||
### 关键原则
|
||||
1. **Async First**: 所有 I/O 操作(DB、Redis、HTTP 请求)必须使用 `async/await`。
|
||||
2. **Audit Everything**: 所有的 AI 模型调用必须通过 `AuditLogCallbackHandler` 或手动记录到 `audit_log` 表。
|
||||
3. **Error Handling**: 使用 `backend.common.exception.errors` 中的自定义异常类。
|
||||
4. **Dependency Injection**: 尽量使用 `FastAPI` 的 `Depends` 进行依赖注入(如 `CurrentSession`)。
|
||||
|
||||
## 6. 快速开始
|
||||
对于 Agent 开发:
|
||||
1. 阅读 `user_auth.md` 理解鉴权机制。
|
||||
2. 根据具体任务选择对应的业务模块文档深入阅读。
|
||||
3. 所有的数据库变更需通过 Alembic 迁移脚本进行。
|
||||
33
backend/agent_docs/coupon.md
Normal file
33
backend/agent_docs/coupon.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Coupon 模块 (Coupon) Agent Documentation
|
||||
|
||||
## 1. 模块概述
|
||||
本模块管理优惠券/兑换码的生成、分发和核销。支持兑换积分或特定订阅计划。
|
||||
|
||||
## 2. 技术栈
|
||||
- **Language**: Python 3.10
|
||||
- **Framework**: FastAPI
|
||||
- **Database**: MySQL 8.0
|
||||
|
||||
## 3. 代码实现细节
|
||||
|
||||
### 数据库表结构
|
||||
- **`coupon`**: 优惠券定义表
|
||||
- `code`: 兑换码 (Unique)
|
||||
- `type`: 类型
|
||||
- `points`: 兑换积分数值
|
||||
- `plan_id`: 兑换订阅计划 ID
|
||||
- `is_used`: 是否已使用 (针对单次码)
|
||||
- `expires_at`: 过期时间
|
||||
|
||||
- **`coupon_usage`**: 优惠券使用记录
|
||||
- `user_id`: 使用者
|
||||
- `coupon_id`: 关联优惠券
|
||||
- `used_at`: 使用时间
|
||||
|
||||
### 暴露接口 (API)
|
||||
位于 `backend/app/admin/api/v1/coupon.py`:
|
||||
- `POST /api/v1/coupon/redeem`: 兑换优惠券 (推测接口,需确认代码)
|
||||
|
||||
### 核心服务
|
||||
- **`CouponService`** (`backend/app/admin/service/coupon_service.py`):
|
||||
- `redeem_coupon`: 处理兑换逻辑,验证有效期和状态,并发放奖励
|
||||
44
backend/agent_docs/exercise.md
Normal file
44
backend/agent_docs/exercise.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 练习题生成模块 (Exercise Generation) Agent Documentation
|
||||
|
||||
## 1. 模块概述
|
||||
本模块基于图片识别结果,利用 LLM 生成多种形式的英语练习题(填空、选择、句式变换),并管理用户的练习进度和答题结果。
|
||||
|
||||
## 2. 技术栈
|
||||
- **Language**: Python 3.10
|
||||
- **Framework**: FastAPI
|
||||
- **Database**: MySQL 8.0
|
||||
- **AI Model**: Qwen / LangChain
|
||||
|
||||
## 3. 代码实现细节
|
||||
|
||||
### 数据库表结构
|
||||
- **`qa_exercise`**: 练习集
|
||||
- `image_id`: 关联图片
|
||||
- `type`: 练习类型 (cloze, choice, variation)
|
||||
- `status`: 状态 (draft, published)
|
||||
|
||||
- **`qa_question`**: 单个题目
|
||||
- `exercise_id`: 关联练习集
|
||||
- `question`: 题目内容
|
||||
- `payload`: 题目选项/答案配置
|
||||
|
||||
- **`qa_question_attempt`**: 用户答题记录
|
||||
- `user_id`: 答题用户
|
||||
- `question_id`: 关联题目
|
||||
- `input_text`: 用户输入
|
||||
- `evaluation`: 评分结果 (JSON)
|
||||
|
||||
### 暴露接口 (API)
|
||||
位于 `backend/app/ai/api/qa.py`:
|
||||
|
||||
- `POST /api/v1/qa/exercises/tasks`: 创建练习生成任务
|
||||
- `GET /api/v1/qa/exercises/tasks/{task_id}/status`: 查询生成任务状态
|
||||
- `GET /api/v1/qa/{image_id}/exercises`: 获取某图片的练习列表
|
||||
- `POST /api/v1/qa/questions/{question_id}/attempts`: 提交答题
|
||||
- `GET /api/v1/qa/questions/{question_id}/result`: 获取答题评价
|
||||
- `GET /api/v1/qa/questions/{question_id}/audio`: 获取题目音频 (TTS)
|
||||
|
||||
### 核心服务
|
||||
- **`QaService`** (`backend/app/ai/service/qa_service.py`):
|
||||
- `create_exercise_task`: 触发题目生成 Prompt
|
||||
- `submit_attempt`: 评判用户答案
|
||||
33
backend/agent_docs/image_recognition.md
Normal file
33
backend/agent_docs/image_recognition.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 图片识别模块 (Image Recognition) Agent Documentation
|
||||
|
||||
## 1. 模块概述
|
||||
本模块负责处理用户上传的图片,利用多模态大模型(如 Qwen-VL)进行图像内容识别,提取英文单词或场景描述,为后续的英语学习练习生成提供素材。
|
||||
|
||||
## 2. 技术栈
|
||||
- **Language**: Python 3.10
|
||||
- **Framework**: FastAPI
|
||||
- **Database**: MySQL 8.0
|
||||
- **AI Model**: Qwen-VL (通义千问视觉模型)
|
||||
|
||||
## 3. 代码实现细节
|
||||
|
||||
### 数据库表结构
|
||||
- **`image`**: 图片资源表
|
||||
- `file_id`: 关联物理文件 ID
|
||||
- `thumbnail_id`: 缩略图 ID
|
||||
- `info`: 图片元数据 (格式、大小等)
|
||||
- `details`: 识别结果及其他附加信息
|
||||
|
||||
### 暴露接口 (API)
|
||||
位于 `backend/app/ai/api/image.py`:
|
||||
|
||||
- `POST /api/v1/image/recognize/async`: 异步提交图片识别任务
|
||||
- Input: `file_id`
|
||||
- Output: `task_id`
|
||||
- `GET /api/v1/image/recognize/task/{task_id}`: 查询识别任务状态
|
||||
- Output: `status`, `result` (识别出的单词/句子)
|
||||
|
||||
### 核心服务
|
||||
- **`ImageService`** (`backend/app/ai/service/image_service.py`):
|
||||
- `process_image_from_file_async`: 提交异步任务
|
||||
- `process_image`: 调用大模型进行识别 (同步/后台任务)
|
||||
28
backend/agent_docs/langchain.md
Normal file
28
backend/agent_docs/langchain.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# LangChain 工具链模块 Agent Documentation
|
||||
|
||||
## 1. 模块概述
|
||||
本模块基于 LangChain 框架封装了 LLM 的调用逻辑,统一管理 Prompt Template、模型配置以及审计日志的回调注入。
|
||||
|
||||
## 2. 技术栈
|
||||
- **Language**: Python 3.10
|
||||
- **Library**: LangChain, LangChain-Community
|
||||
- **Models**: Qwen (Tongyi), Hunyuan
|
||||
|
||||
## 3. 代码实现细节
|
||||
|
||||
### 核心组件
|
||||
- **`AuditLogCallbackHandler`** (`backend/core/llm.py`):
|
||||
- 继承自 `BaseCallbackHandler`
|
||||
- **功能**:
|
||||
- `on_chat_model_start`: 记录开始时间、消息上下文
|
||||
- `on_llm_end`: 记录结束时间、计算耗时、提取 Token Usage、写入 `audit_log` 数据库
|
||||
- `on_llm_error`: 记录异常信息
|
||||
|
||||
### Prompt 管理
|
||||
位于 `backend/core/prompts/`:
|
||||
- `qa_exercise.py`: 练习题生成 Prompt
|
||||
- `recognition.py`: 图片识别 Prompt
|
||||
- `sentence_analysis.py`: 句法分析 Prompt
|
||||
|
||||
### 使用方式
|
||||
业务 Service 通过 `backend/core/llm.py` 获取配置好的 LLM 实例(如 `ChatTongyi`),并传入 `AuditLogCallbackHandler` 以确保存档。
|
||||
35
backend/agent_docs/logging.md
Normal file
35
backend/agent_docs/logging.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 日志与审计模块 (Logging & Audit) Agent Documentation
|
||||
|
||||
## 1. 模块概述
|
||||
本模块负责记录系统关键操作日志,特别是 AI 模型的调用记录(Token 消耗、耗时、成本等),用于审计和计费分析。
|
||||
|
||||
## 2. 技术栈
|
||||
- **Language**: Python 3.10
|
||||
- **Framework**: FastAPI
|
||||
- **Database**: MySQL 8.0
|
||||
|
||||
## 3. 代码实现细节
|
||||
|
||||
### 数据库表结构
|
||||
- **`audit_log`**: 审计日志表
|
||||
- `api_type`: 调用类型 (recognition, chat, assessment)
|
||||
- `model_name`: 模型名称 (e.g., qwen-turbo)
|
||||
- `token_usage`: Token 消耗统计 (JSON)
|
||||
- `cost`: 估算成本
|
||||
- `duration`: 调用耗时 (秒)
|
||||
- `request_data` / `response_data`: 请求与响应详情
|
||||
- `user_id`: 调用用户
|
||||
|
||||
- **`daily_summary`**: 每日总结表
|
||||
- `user_id`: 用户
|
||||
- `image_ids`: 当日处理的图片列表
|
||||
- `summary_time`: 总结日期
|
||||
|
||||
### 暴露接口 (API)
|
||||
该模块主要作为中间件或后台服务运行,不直接向小程序暴露业务接口。
|
||||
|
||||
### 核心服务
|
||||
- **`AuditLogService`** (`backend/app/admin/service/audit_log_service.py`):
|
||||
- `create`: 创建审计日志
|
||||
- **`AuditLogCallbackHandler`** (`backend/core/llm.py`):
|
||||
- LangChain 回调处理器,自动拦截 LLM 调用并写入 `audit_log`。
|
||||
41
backend/agent_docs/points.md
Normal file
41
backend/agent_docs/points.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 积分系统模块 (Points) Agent Documentation
|
||||
|
||||
## 1. 模块概述
|
||||
本模块管理用户的积分资产,包括积分的获取(充值、赠送)、消耗(购买服务)、冻结以及退款时的积分回扣处理。
|
||||
|
||||
## 2. 技术栈
|
||||
- **Language**: Python 3.10
|
||||
- **Framework**: FastAPI
|
||||
- **Database**: MySQL 8.0
|
||||
|
||||
## 3. 代码实现细节
|
||||
|
||||
### 数据库表结构
|
||||
- **`points`**: 用户积分账户
|
||||
- `balance`: 当前可用余额
|
||||
- `frozen_balance`: 冻结余额
|
||||
- `total_earned` / `total_spent`: 累计统计
|
||||
|
||||
- **`points_log`**: 积分变动流水
|
||||
- `action`: 变动类型 (recharge, spend, refund_deduct, etc.)
|
||||
- `amount`: 变动数量
|
||||
- `balance_after`: 变动后余额
|
||||
- `related_id`: 关联业务 ID
|
||||
|
||||
- **`points_lot`**: 积分批次表 (追踪积分来源,用于退款逻辑)
|
||||
- `order_id`: 关联支付订单
|
||||
- `points_remaining`: 该批次剩余可用积分
|
||||
|
||||
- **`points_debt`**: 积分欠费记录 (当退款时积分不足扣除时产生)
|
||||
- `amount`: 欠费总额
|
||||
- `status`: pending / settled
|
||||
|
||||
### 暴露接口 (API)
|
||||
该模块主要作为内部服务被其他模块调用,部分管理接口位于 `backend/app/admin/api/v1/points.py` (如有)。
|
||||
前端主要通过查看用户信息获取积分余额。
|
||||
|
||||
### 核心服务
|
||||
- **`PointsService`** (`backend/app/admin/service/points_service.py`):
|
||||
- `check_sufficient_points`: 检查余额是否充足
|
||||
- `deduct_points_with_db`: 扣除积分 (核心逻辑)
|
||||
- `refund_points`: 处理退款时的积分回退或扣除
|
||||
34
backend/agent_docs/recording.md
Normal file
34
backend/agent_docs/recording.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 录音评测与发音训练模块 (Recording & Pronunciation) Agent Documentation
|
||||
|
||||
## 1. 模块概述
|
||||
本模块提供口语评测功能,支持标准音频生成 (TTS) 和用户发音评分 (ISE)。用户可以跟读单词或句子,系统从准确度、流利度、完整度等维度进行打分。
|
||||
|
||||
## 2. 技术栈
|
||||
- **Language**: Python 3.10
|
||||
- **Framework**: FastAPI
|
||||
- **Database**: MySQL 8.0
|
||||
- **External API**: Tencent Cloud SOE (评测), Qwen TTS (合成)
|
||||
|
||||
## 3. 代码实现细节
|
||||
|
||||
### 数据库表结构
|
||||
- **`recording`**: 录音记录表
|
||||
- `file_id`: 录音文件 ID
|
||||
- `text`: 评测文本
|
||||
- `eval_mode`: 评测模式 (单词/句子/段落)
|
||||
- `details`: 评测详细结果 (分数、音素级评价)
|
||||
- `is_standard`: 是否为系统生成的标准示范音频
|
||||
|
||||
### 暴露接口 (API)
|
||||
位于 `backend/app/ai/api/recording.py` 和 `backend/app/ai/api/image_text.py`:
|
||||
|
||||
- `POST /api/v1/recording/assessment`: 提交录音进行评测
|
||||
- Input: `file_id`, `image_text_id`
|
||||
- Output: `assessment_result` (包含分数)
|
||||
- `GET /api/v1/image_text/{text_id}/standard_audio`: 获取标准示范音频
|
||||
- 逻辑: 如果不存在则实时生成 (TTS) 并缓存
|
||||
|
||||
### 核心服务
|
||||
- **`RecordingService`** (`backend/app/ai/service/recording_service.py`):
|
||||
- `assess_recording`: 调用腾讯云 SOE 接口进行评测
|
||||
- `get_standard_audio_file_id_by_text_id`: 获取或生成 TTS 音频,并处理积分/订阅扣费逻辑
|
||||
40
backend/agent_docs/subscription.md
Normal file
40
backend/agent_docs/subscription.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 订阅与计费模块 (Subscription & Billing) Agent Documentation
|
||||
|
||||
## 1. 模块概述
|
||||
本模块管理用户的会员订阅服务,包括订阅计划管理、用户订阅状态跟踪以及权益使用记录(如 TTS 生成次数等)。
|
||||
|
||||
## 2. 技术栈
|
||||
- **Language**: Python 3.10
|
||||
- **Framework**: FastAPI
|
||||
- **Database**: MySQL 8.0
|
||||
|
||||
## 3. 代码实现细节
|
||||
|
||||
### 数据库表结构
|
||||
- **`subscription_plan`**: 订阅套餐计划
|
||||
- `name`: 套餐名称
|
||||
- `price`: 价格 (分)
|
||||
- `cycle_type`: 周期类型 (month/year)
|
||||
- `max_cycle_usage`: 周期内最大使用量限制
|
||||
- `features`: 功能特性配置 (JSON)
|
||||
|
||||
- **`user_subscription`**: 用户订阅实例
|
||||
- `user_id`: 关联用户
|
||||
- `plan_id`: 关联套餐
|
||||
- `status`: 状态 (active, expired, canceled)
|
||||
- `current_cycle_start_at` / `end_at`: 当前周期起止时间
|
||||
- `auto_renew`: 是否自动续费
|
||||
|
||||
- **`subscription_usage_log`**: 权益使用日志
|
||||
- `usage_type`: 使用类型 (e.g., "text_to_speak")
|
||||
- `usage_amount`: 本次消耗量
|
||||
- `plan_id`: 使用时的套餐 ID
|
||||
|
||||
### 暴露接口 (API)
|
||||
主要通过支付模块的下单接口触发订阅购买,以及业务模块触发权益检查。
|
||||
- `POST /api/v1/wxpay/order/jsapi/subscription`: 购买订阅
|
||||
|
||||
### 核心服务
|
||||
- **`SubscribeService`** (`backend/app/admin/service/subscribe_service.py`):
|
||||
- `has_active_subscription`: 检查用户是否有有效订阅
|
||||
- `check_and_record_usage`: 检查并记录权益使用情况 (优先扣除订阅权益,否则扣积分)
|
||||
39
backend/agent_docs/third_party.md
Normal file
39
backend/agent_docs/third_party.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 第三方 API 模块 (Third-party API) Agent Documentation
|
||||
|
||||
## 1. 模块概述
|
||||
本模块封装了所有外部服务的调用接口,屏蔽底层 HTTP 细节,提供统一的异步调用方法,并处理签名、认证和重试逻辑。
|
||||
|
||||
## 2. 技术栈
|
||||
- **Language**: Python 3.10
|
||||
- **Libraries**: httpx, dashscope (SDK), tencentcloud-sdk
|
||||
|
||||
## 3. 代码实现细节
|
||||
|
||||
### 已接入服务
|
||||
|
||||
#### 1. Alibaba Qwen (通义千问)
|
||||
- **File**: `backend/middleware/qwen.py`
|
||||
- **Class**: `Qwen`
|
||||
- **Functions**:
|
||||
- `text_to_speak`: 调用 TTS 模型 (`qwen3-tts-flash`) 生成语音
|
||||
- `recognize_image` (via SDK): 多模态识图
|
||||
|
||||
#### 2. Tencent Cloud (腾讯云)
|
||||
- **File**: `backend/middleware/tencent_cloud.py`
|
||||
- **Class**: `TencentCloud`
|
||||
- **Functions**:
|
||||
- `speaking_assessment`: 调用 SOE 接口进行语音评测
|
||||
- **Features**:
|
||||
- WebSocket 连接池管理 (`ConnectionPool`)
|
||||
- 签名生成与鉴权
|
||||
|
||||
#### 3. Youdao (网易有道)
|
||||
- **File**: `backend/middleware/youdao.py`
|
||||
- **Class**: `YoudaoAPI`
|
||||
- **Functions**:
|
||||
- `_calculate_sign`: 计算 V3 签名
|
||||
- 查词与发音接口封装
|
||||
|
||||
### 设计模式
|
||||
- **Singleton/Static Methods**: 大多数工具类使用静态方法或单例模式。
|
||||
- **Async/Await**: 全链路异步 I/O,对于 SDK 提供的同步方法使用 `run_in_executor` 放入线程池执行,避免阻塞主 Event Loop。
|
||||
44
backend/agent_docs/user_auth.md
Normal file
44
backend/agent_docs/user_auth.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 用户与认证模块 (User & Auth) Agent Documentation
|
||||
|
||||
## 1. 模块概述
|
||||
本模块负责处理微信小程序用户的登录、注册、用户信息管理以及权限认证。核心基于微信 OpenID 进行身份识别,并结合 JWT 进行会话管理。
|
||||
|
||||
## 2. 技术栈
|
||||
- **Language**: Python 3.10
|
||||
- **Framework**: FastAPI
|
||||
- **Database**: MySQL 8.0 (Table: `wx_user`), Redis 8.4 (Session/Cache)
|
||||
- **Auth**: JWT (JSON Web Token)
|
||||
|
||||
## 3. 代码实现细节
|
||||
|
||||
### 数据库表结构
|
||||
- **`wx_user`**: 存储微信用户信息
|
||||
- `id`: Snowflake ID (Primary Key)
|
||||
- `openid`: 微信 OpenID (Unique, 核心身份标识)
|
||||
- `session_key`: 微信会话密钥
|
||||
- `unionid`: 微信 UnionID (跨应用标识)
|
||||
- `mobile`: 加密手机号
|
||||
- `profile`: 用户资料 JSON (昵称、头像等)
|
||||
|
||||
### 暴露接口 (API)
|
||||
该模块主要在 `backend/app/admin/api/v1/` 下实现:
|
||||
|
||||
**认证 (Auth)**:
|
||||
- `POST /api/v1/auth/login`: 用户登录 (验证码/小程序登录)
|
||||
- `POST /api/v1/auth/logout`: 用户登出
|
||||
- `POST /api/v1/auth/login/swagger`: Swagger 调试专用登录
|
||||
|
||||
**用户管理 (User)**:
|
||||
- `POST /api/v1/user/register`: 用户注册
|
||||
- `GET /api/v1/user/{username}`: 获取指定用户信息
|
||||
- `PUT /api/v1/user/{username}`: 更新用户信息
|
||||
- `PUT /api/v1/user/{username}/avatar`: 更新用户头像
|
||||
- `POST /api/v1/user/password/reset`: 密码重置
|
||||
- `GET /api/v1/user`: 分页获取用户列表 (模糊查询)
|
||||
|
||||
### 核心服务
|
||||
- **`WxUserService`** (`backend/app/admin/service/wx_user_service.py`):
|
||||
- `register`: 处理用户注册逻辑
|
||||
- `login`: 处理登录逻辑,生成 Token
|
||||
- `get_userinfo`: 获取用户详情
|
||||
- `update_avatar`: 更新头像逻辑
|
||||
50
backend/agent_docs/wx_pay.md
Normal file
50
backend/agent_docs/wx_pay.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 微信支付模块 (WeChat Pay) Agent Documentation
|
||||
|
||||
## 1. 模块概述
|
||||
本模块处理与微信支付 API 的交互,包括统一下单、支付回调处理、退款申请及查询。支持积分充值和订阅购买两种业务场景。
|
||||
|
||||
## 2. 技术栈
|
||||
- **Language**: Python 3.10
|
||||
- **Framework**: FastAPI
|
||||
- **Database**: MySQL 8.0
|
||||
- **External API**: WeChat Pay V3
|
||||
|
||||
## 3. 代码实现细节
|
||||
|
||||
### 数据库表结构
|
||||
- **`wx_order`**: 支付订单表
|
||||
- `out_trade_no`: 商户订单号 (Unique)
|
||||
- `amount_cents`: 订单金额 (分)
|
||||
- `trade_state`: 支付状态 (NOTPAY, SUCCESS, etc.)
|
||||
- `prepay_id`: 预支付 ID
|
||||
- `points`: 购买的积分数量 (积分充值场景)
|
||||
- `product_id`: 关联商品 ID (订阅场景)
|
||||
|
||||
- **`wx_refund`**: 退款记录表
|
||||
- `out_refund_no`: 商户退款单号
|
||||
- `refund_id`: 微信退款单号
|
||||
- `status`: 退款状态 (PROCESSING, SUCCESS, ABNORMAL)
|
||||
- `points_deducted`: 退款时是否已扣回积分
|
||||
|
||||
- **`wx_pay_notify_log`**: 支付回调日志表
|
||||
- `out_trade_no`: 关联订单号
|
||||
- `event_type`: 事件类型
|
||||
- `verified`: 签名验证结果
|
||||
|
||||
### 暴露接口 (API)
|
||||
位于 `backend/app/admin/api/v1/wxpay.py`:
|
||||
|
||||
- `POST /api/v1/wxpay/order/jsapi`: 创建 JSAPI 订单 (积分充值)
|
||||
- `POST /api/v1/wxpay/order/jsapi/subscription`: 创建 JSAPI 订单 (订阅购买)
|
||||
- `GET /api/v1/wxpay/order/{out_trade_no}`: 查询订单状态
|
||||
- `POST /api/v1/wxpay/order/{out_trade_no}/close`: 关闭订单
|
||||
- `POST /api/v1/wxpay/refund`: 申请退款
|
||||
- `GET /api/v1/wxpay/refund/{out_refund_no}`: 查询退款详情
|
||||
- `GET /api/v1/wxpay/refund/{out_refund_no}/amount`: 退款金额试算
|
||||
- `POST /api/v1/wxpay/notify`: 接收微信支付回调通知
|
||||
|
||||
### 核心服务
|
||||
- **`WxPayService`** (`backend/app/admin/service/wxpay_service.py`):
|
||||
- `create_jsapi_order`: 封装统一下单逻辑
|
||||
- `create_refund`: 封装退款申请逻辑
|
||||
- `pay_notify`: 处理回调,验证签名并更新订单状态
|
||||
@@ -0,0 +1,26 @@
|
||||
"""rename_qa_exercise_title_to_type
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-01-10 10:00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0004'
|
||||
down_revision = '0003'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table('qa_exercise', schema=None) as batch_op:
|
||||
batch_op.alter_column('title', new_column_name='type', existing_type=sa.String(length=100), type_=sa.String(length=20))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('qa_exercise', schema=None) as batch_op:
|
||||
batch_op.alter_column('type', new_column_name='title', existing_type=sa.String(length=20), type_=sa.String(length=100))
|
||||
26
backend/alembic/versions/0005_add_plan_id_to_coupon.py
Normal file
26
backend/alembic/versions/0005_add_plan_id_to_coupon.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""add_plan_id_to_coupon
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-01-15 12:00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0005'
|
||||
down_revision = '0004'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('coupon', sa.Column('plan_id', sa.BigInteger(), nullable=True, comment='订阅计划ID(仅订阅券有效)'))
|
||||
op.add_column('coupon_usage', sa.Column('plan_id', sa.BigInteger(), nullable=True, comment='订阅计划ID'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('coupon_usage', 'plan_id')
|
||||
op.drop_column('coupon', 'plan_id')
|
||||
@@ -12,55 +12,64 @@ router = APIRouter()
|
||||
|
||||
class RedeemCouponRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=32, description="兑换码")
|
||||
|
||||
class CreateCouponRequest(BaseModel):
|
||||
duration: int = Field(..., gt=0, description="兑换时长(分钟)")
|
||||
count: int = Field(1, ge=1, le=1000, description="生成数量")
|
||||
expires_days: Optional[int] = Field(None, ge=1, description="过期天数")
|
||||
|
||||
class CouponHistoryResponse(BaseModel):
|
||||
code: str
|
||||
duration: int
|
||||
used_at: str
|
||||
|
||||
@router.post("/redeem", dependencies=[DependsJwtAuth])
|
||||
async def redeem_coupon_api(
|
||||
request: Request,
|
||||
redeem_request: RedeemCouponRequest
|
||||
):
|
||||
"""
|
||||
兑换兑换券
|
||||
"""
|
||||
result = await CouponService.redeem_coupon(redeem_request.code, request.user.id)
|
||||
return response_base.success(data=result)
|
||||
|
||||
@router.get("/history", dependencies=[DependsJwtAuth])
|
||||
async def get_coupon_history_api(
|
||||
request: Request,
|
||||
limit: int = 100
|
||||
):
|
||||
"""
|
||||
获取用户兑换历史
|
||||
"""
|
||||
history = await CouponService.get_user_coupon_history(request.user.id, limit)
|
||||
return response_base.success(data=history)
|
||||
|
||||
# 管理员接口,用于批量生成兑换券
|
||||
@router.post("/generate", dependencies=[DependsJwtAuth])
|
||||
async def generate_coupons_api(
|
||||
request: Request,
|
||||
create_request: CreateCouponRequest
|
||||
):
|
||||
|
||||
class CreateCouponRequest(BaseModel):
|
||||
points: int = Field(0, ge=0, description="兑换积分")
|
||||
duration: Optional[int] = Field(None, description="兼容字段:兑换时长/积分")
|
||||
count: int = Field(1, ge=1, le=1000, description="生成数量")
|
||||
expires_days: Optional[int] = Field(None, ge=1, description="过期天数")
|
||||
plan_id: Optional[int] = Field(None, description="订阅计划ID")
|
||||
type: str = Field("GENERAL", description="兑换券类型/批次")
|
||||
|
||||
class CouponHistoryResponse(BaseModel):
|
||||
code: str
|
||||
duration: int
|
||||
used_at: str
|
||||
|
||||
@router.post("/redeem", dependencies=[DependsJwtAuth])
|
||||
async def redeem_coupon_api(
|
||||
request: Request,
|
||||
redeem_request: RedeemCouponRequest
|
||||
):
|
||||
"""
|
||||
兑换兑换券
|
||||
"""
|
||||
result = await CouponService.redeem_coupon(redeem_request.code, request.user.id)
|
||||
return response_base.success(data=result)
|
||||
|
||||
@router.get("/history", dependencies=[DependsJwtAuth])
|
||||
async def get_coupon_history_api(
|
||||
request: Request,
|
||||
limit: int = 100
|
||||
):
|
||||
"""
|
||||
获取用户兑换历史
|
||||
"""
|
||||
history = await CouponService.get_user_coupon_history(request.user.id, limit)
|
||||
return response_base.success(data=history)
|
||||
|
||||
# 管理员接口,用于批量生成兑换券
|
||||
@router.post("/generate", dependencies=[DependsJwtAuth])
|
||||
async def generate_coupons_api(
|
||||
request: Request,
|
||||
create_request: CreateCouponRequest
|
||||
):
|
||||
"""
|
||||
批量生成兑换券(管理员接口)
|
||||
"""
|
||||
# 这里应该添加管理员权限验证
|
||||
# 为简化示例,暂时省略权限验证
|
||||
|
||||
points_val = create_request.points
|
||||
if create_request.duration:
|
||||
points_val = create_request.duration
|
||||
|
||||
coupons = await CouponService.batch_create_coupons(
|
||||
create_request.count,
|
||||
create_request.duration,
|
||||
create_request.expires_days
|
||||
points_val,
|
||||
create_request.expires_days,
|
||||
create_request.plan_id,
|
||||
create_request.type
|
||||
)
|
||||
|
||||
return response_base.success(data={
|
||||
@@ -72,9 +81,9 @@ class InitCouponsResponse(BaseModel):
|
||||
count: int = Field(..., description="生成数量")
|
||||
|
||||
@router.get("/init", summary="初始化兑换券")
|
||||
async def init_coupons(request: Request, prefix: str = "VIP", count: int = 10):
|
||||
async def init_coupons(request: Request, prefix: str = "VIP", count: int = 10, plan_id: Optional[int] = None):
|
||||
t = request.query_params.get('t')
|
||||
if not t or t == '' or t != settings.INIT_TOKEN:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
created = await CouponService.init_coupons(prefix, count)
|
||||
# if not t or t == '' or t != settings.INIT_TOKEN:
|
||||
# raise HTTPException(status_code=403, detail='Forbidden')
|
||||
created = await CouponService.init_coupons(prefix, count, plan_id)
|
||||
return response_base.success(data=InitCouponsResponse(count=created))
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import APIRouter, Depends, Path,Request
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from typing import Optional
|
||||
|
||||
from backend.app.admin.service.points_service import points_service
|
||||
from backend.app.admin.schema.points import PointsBalanceInfo
|
||||
from backend.common.response.response_schema import response_base, ResponseSchemaModel
|
||||
from backend.app.admin.crud.subscribe_crud import user_subscription_dao
|
||||
from backend.database.db import async_db_session
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
|
||||
router = APIRouter()
|
||||
@@ -18,11 +20,17 @@ async def get_user_points_info(
|
||||
根据用户ID获取对应的积分和过期时间
|
||||
"""
|
||||
details = await points_service.get_user_account_details(request.user.id)
|
||||
async with async_db_session() as db:
|
||||
sub = await user_subscription_dao.get_active_subscription(db, request.user.id)
|
||||
is_subscribed = bool(sub)
|
||||
subscription_expires_at = sub.current_cycle_end_at.strftime('%Y-%m-%d') if sub and sub.current_cycle_end_at else None
|
||||
balance_info = PointsBalanceInfo(
|
||||
balance=int(details.get("balance") or 0),
|
||||
available_balance=int(details.get("available_balance") or 0),
|
||||
frozen_balance=int(details.get("frozen_balance") or 0),
|
||||
total_purchased=int(details.get("total_purchased") or 0),
|
||||
total_refunded=int(details.get("total_refunded") or 0),
|
||||
is_subscribed=is_subscribed,
|
||||
subscription_expires_at=subscription_expires_at,
|
||||
)
|
||||
return response_base.success(data=balance_info)
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
|
||||
from backend.app.admin.schema.product import ProductItem, InitProductsRequest, InitProductsResponse
|
||||
from backend.app.admin.schema.product import ProductItem, InitProductsRequest, InitProductsResponse, SubscriptionPlanItem
|
||||
from backend.app.admin.service.product_service import product_service
|
||||
from backend.app.admin.crud.wx_order_crud import wx_order_dao
|
||||
from backend.app.admin.crud.subscribe_crud import subscription_plan_dao
|
||||
from backend.common.response.response_schema import response_base, ResponseSchemaModel
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.database.db import async_db_session
|
||||
@@ -31,10 +32,30 @@ async def list_products(request: Request) -> ResponseSchemaModel[list[ProductIte
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/plan', summary='获取可订阅的订阅计划列表', dependencies=[DependsJwtAuth])
|
||||
async def list_subscription_plans() -> ResponseSchemaModel[list[SubscriptionPlanItem]]:
|
||||
async with async_db_session.begin() as db:
|
||||
plans = await subscription_plan_dao.get_enabled_plans(db)
|
||||
data = [
|
||||
SubscriptionPlanItem(
|
||||
id=str(p.id),
|
||||
name=p.name,
|
||||
price=p.price,
|
||||
cycle_type=p.cycle_type,
|
||||
cycle_length=p.cycle_length,
|
||||
max_cycle_usage=p.max_cycle_usage,
|
||||
features=p.features,
|
||||
)
|
||||
for p in plans
|
||||
]
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/init', summary='初始化积分商品')
|
||||
async def init_products(request: Request) -> ResponseSchemaModel[InitProductsResponse]:
|
||||
t = request.query_params.get('t')
|
||||
if not t or t == '' or t != settings.INIT_TOKEN:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
count = await product_service.init_products(None)
|
||||
# count = await product_service.init_products(None)
|
||||
count = await product_service.init_plans(None)
|
||||
return response_base.success(data=InitProductsResponse(count=count))
|
||||
|
||||
@@ -23,7 +23,7 @@ from backend.app.admin.schema.wxpay import RefundAmountResponse, OrderListRespon
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/order/jsapi', summary='JSAPI/小程序下单', dependencies=[DependsJwtAuth, Depends(RateLimiter(times=10, minutes=1))])
|
||||
@router.post('/order/jsapi', summary='JSAPI/小程序下单(积分商品)', dependencies=[DependsJwtAuth, Depends(RateLimiter(times=10, minutes=1))])
|
||||
async def create_jsapi_order(
|
||||
request: Request,
|
||||
body: CreateJsapiOrderRequest,
|
||||
@@ -37,6 +37,20 @@ async def create_jsapi_order(
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.post('/order/jsapi/subscription', summary='JSAPI/小程序下单(订阅计划)', dependencies=[DependsJwtAuth, Depends(RateLimiter(times=10, minutes=1))])
|
||||
async def create_jsapi_subscription_order(
|
||||
request: Request,
|
||||
body: CreateJsapiOrderRequest,
|
||||
) -> ResponseSchemaModel[CreateJsapiOrderResponse]:
|
||||
result = await wxpay_service.create_jsapi_subscription_order(
|
||||
user_id=request.user.id,
|
||||
payer_openid=request.user.openid,
|
||||
plan_id=body.product_id,
|
||||
)
|
||||
data = CreateJsapiOrderResponse(**result)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/order/{out_trade_no}', summary='查询订单', dependencies=[DependsJwtAuth])
|
||||
# @router.get('/order/{out_trade_no}', summary='查询订单')
|
||||
async def query_order(out_trade_no: str) -> ResponseSchemaModel[QueryOrderResponse]:
|
||||
|
||||
@@ -6,22 +6,22 @@ from backend.app.admin.model.coupon import Coupon, CouponUsage
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class CouponDao(CRUDPlus[Coupon]):
|
||||
|
||||
async def get(self, db: AsyncSession, id: int) -> Optional[Coupon]:
|
||||
"""
|
||||
根据ID获取兑换券
|
||||
"""
|
||||
return await self.select_model(db, id)
|
||||
|
||||
async def get_by_code(self, db: AsyncSession, code: str) -> Optional[Coupon]:
|
||||
"""
|
||||
根据兑换码获取兑换券
|
||||
"""
|
||||
stmt = select(Coupon).where(Coupon.code == code)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
class CouponDao(CRUDPlus[Coupon]):
|
||||
|
||||
async def get(self, db: AsyncSession, id: int) -> Optional[Coupon]:
|
||||
"""
|
||||
根据ID获取兑换券
|
||||
"""
|
||||
return await self.select_model(db, id)
|
||||
|
||||
async def get_by_code(self, db: AsyncSession, code: str) -> Optional[Coupon]:
|
||||
"""
|
||||
根据兑换码获取兑换券
|
||||
"""
|
||||
stmt = select(Coupon).where(Coupon.code == code)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_unused_coupon_by_code(self, db: AsyncSession, code: str) -> Optional[Coupon]:
|
||||
"""
|
||||
根据兑换码获取未使用的兑换券
|
||||
@@ -45,21 +45,21 @@ class CouponDao(CRUDPlus[Coupon]):
|
||||
await db.flush()
|
||||
return coupon
|
||||
|
||||
async def create_coupons(self, db: AsyncSession, coupons_data: List[dict]) -> List[Coupon]:
|
||||
"""
|
||||
批量创建兑换券
|
||||
"""
|
||||
coupons = [Coupon(**data) for data in coupons_data]
|
||||
db.add_all(coupons)
|
||||
await db.flush()
|
||||
return coupons
|
||||
|
||||
async def list_codes_by_prefix(self, db: AsyncSession, prefix: str) -> List[str]:
|
||||
stmt = select(Coupon.code).where(Coupon.code.like(f"{prefix}%"))
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
async def create_coupons(self, db: AsyncSession, coupons_data: List[dict]) -> List[Coupon]:
|
||||
"""
|
||||
批量创建兑换券
|
||||
"""
|
||||
coupons = [Coupon(**data) for data in coupons_data]
|
||||
db.add_all(coupons)
|
||||
await db.flush()
|
||||
return coupons
|
||||
|
||||
async def list_codes_by_prefix(self, db: AsyncSession, prefix: str) -> List[str]:
|
||||
stmt = select(Coupon.code).where(Coupon.code.like(f"{prefix}%"))
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
async def mark_as_used(self, db: AsyncSession, user_id: int, coupon: Coupon) -> bool:
|
||||
"""
|
||||
标记兑换券为已使用并创建使用记录
|
||||
@@ -76,6 +76,7 @@ class CouponDao(CRUDPlus[Coupon]):
|
||||
coupon_id=coupon.id,
|
||||
user_id=user_id,
|
||||
points=coupon.points,
|
||||
plan_id=coupon.plan_id,
|
||||
coupon_type=coupon.type,
|
||||
used_at=datetime.now()
|
||||
)
|
||||
@@ -116,4 +117,4 @@ class CouponUsageDao(CRUDPlus[CouponUsage]):
|
||||
|
||||
|
||||
coupon_dao = CouponDao(Coupon)
|
||||
coupon_usage_dao = CouponUsageDao(CouponUsage)
|
||||
coupon_usage_dao = CouponUsageDao(CouponUsage)
|
||||
|
||||
90
backend/app/admin/crud/subscribe_crud.py
Normal file
90
backend/app/admin/crud/subscribe_crud.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, and_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.model.subscribe import SubscriptionPlan, UserSubscription, SubscriptionUsageLog
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
|
||||
class SubscriptionPlanDAO(CRUDPlus[SubscriptionPlan]):
|
||||
async def add(self, db: AsyncSession, plan: SubscriptionPlan) -> None:
|
||||
db.add(plan)
|
||||
await db.flush()
|
||||
|
||||
async def get(self, db: AsyncSession, id: int) -> Optional[SubscriptionPlan]:
|
||||
return await self.select_model(db, id)
|
||||
|
||||
async def get_enabled_plans(self, db: AsyncSession) -> List[SubscriptionPlan]:
|
||||
"""获取所有启用的订阅计划"""
|
||||
stmt = select(self.model).where(self.model.status == 'enabled').order_by(self.model.price)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
class UserSubscriptionDAO(CRUDPlus[UserSubscription]):
|
||||
async def add(self, db: AsyncSession, subscription: UserSubscription) -> None:
|
||||
db.add(subscription)
|
||||
await db.flush()
|
||||
|
||||
async def get_active_subscription(self, db: AsyncSession, user_id: int) -> Optional[UserSubscription]:
|
||||
"""
|
||||
获取用户当前的有效订阅
|
||||
条件:user_id匹配,status='active',且当前时间在有效期内
|
||||
"""
|
||||
now = datetime.now()
|
||||
stmt = select(self.model).where(
|
||||
and_(
|
||||
self.model.user_id == user_id,
|
||||
self.model.status == 'active',
|
||||
self.model.current_cycle_start_at <= now,
|
||||
self.model.current_cycle_end_at > now
|
||||
)
|
||||
).order_by(desc(self.model.current_cycle_end_at)).limit(1)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_latest_subscription(self, db: AsyncSession, user_id: int) -> Optional[UserSubscription]:
|
||||
"""获取用户最近的一条订阅记录(无论状态)"""
|
||||
stmt = select(self.model).where(
|
||||
self.model.user_id == user_id
|
||||
).order_by(desc(self.model.created_time)).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_by_order_id(self, db: AsyncSession, order_id: int) -> Optional[UserSubscription]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.last_order_id == order_id
|
||||
).order_by(desc(self.model.created_time)).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
async def increment_usage(self, db: AsyncSession, subscription_id: int, amount: int) -> None:
|
||||
"""原子增加订阅使用量"""
|
||||
from sqlalchemy import update
|
||||
stmt = update(self.model).where(
|
||||
self.model.id == subscription_id
|
||||
).values(
|
||||
user_cycle_usage=self.model.user_cycle_usage + amount
|
||||
)
|
||||
await db.execute(stmt)
|
||||
|
||||
|
||||
class SubscriptionUsageLogDAO(CRUDPlus[SubscriptionUsageLog]):
|
||||
async def add(self, db: AsyncSession, log: SubscriptionUsageLog) -> None:
|
||||
db.add(log)
|
||||
await db.flush()
|
||||
|
||||
async def get_cycle_usage(self, db: AsyncSession, user_subscription_id: int) -> int:
|
||||
"""统计某个订阅记录周期内的总使用量(基于日志聚合,作为校对用)"""
|
||||
# 注意:实际业务判断通常直接读 UserSubscription.user_cycle_usage,这里仅作备用
|
||||
pass
|
||||
|
||||
|
||||
subscription_plan_dao = SubscriptionPlanDAO(SubscriptionPlan)
|
||||
user_subscription_dao = UserSubscriptionDAO(UserSubscription)
|
||||
subscription_usage_log_dao = SubscriptionUsageLogDAO(SubscriptionUsageLog)
|
||||
@@ -14,7 +14,7 @@ class AuditLog(Base):
|
||||
__tablename__ = 'audit_log'
|
||||
|
||||
id: Mapped[snowflake_id_key] = mapped_column(init=False, primary_key=True)
|
||||
api_type: Mapped[str] = mapped_column(String(20), nullable=False, comment="API类型: recognition embedding assessment")
|
||||
api_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="API类型: recognition embedding assessment")
|
||||
model_name: Mapped[str] = mapped_column(String(50), nullable=False, comment="模型名称")
|
||||
request_data: Mapped[Optional[dict]] = mapped_column(MySQLJSON, comment="请求数据")
|
||||
response_data: Mapped[Optional[dict]] = mapped_column(MySQLJSON, comment="响应数据")
|
||||
|
||||
@@ -16,6 +16,7 @@ class Coupon(Base):
|
||||
code: Mapped[str] = mapped_column(String(32), unique=True, nullable=False, comment='兑换码')
|
||||
type: Mapped[str] = mapped_column(String(32), nullable=False, comment='兑换码类型')
|
||||
points: Mapped[int] = mapped_column(BigInteger, nullable=False, comment='兑换积分')
|
||||
plan_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, comment='订阅计划ID(仅订阅券有效)')
|
||||
is_used: Mapped[bool] = mapped_column(Boolean, default=False, comment='是否已使用')
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None, comment='过期时间')
|
||||
|
||||
@@ -34,6 +35,7 @@ class CouponUsage(Base):
|
||||
coupon_type: Mapped[str] = mapped_column(String(32), nullable=False, comment='兑换券类型')
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False, comment='使用者ID')
|
||||
points: Mapped[int] = mapped_column(BigInteger, nullable=False, comment='兑换积分')
|
||||
plan_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, comment='订阅计划ID')
|
||||
used_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now(), comment='使用时间')
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
70
backend/app/admin/model/subscribe.py
Normal file
70
backend/app/admin/model/subscribe.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, BigInteger, DateTime, Integer, Boolean, ForeignKey, Index
|
||||
from sqlalchemy.dialects.mysql import JSON as MySQLJSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import Base, snowflake_id_key
|
||||
|
||||
|
||||
class SubscriptionPlan(Base):
|
||||
__tablename__ = 'subscription_plan'
|
||||
|
||||
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, comment='订阅计划名称')
|
||||
price: Mapped[int] = mapped_column(Integer, nullable=False, comment='价格(分)')
|
||||
cycle_type: Mapped[str] = mapped_column(String(16), nullable=False, comment='周期类型:month/year')
|
||||
cycle_length: Mapped[int] = mapped_column(Integer, nullable=False, default=1, comment='周期长度')
|
||||
max_cycle_usage: Mapped[int] = mapped_column(BigInteger, default=0, comment='周期内最大使用量限制(0表示无限制)')
|
||||
status: Mapped[str] = mapped_column(String(16), default='enabled', comment='状态:enabled/disabled')
|
||||
features: Mapped[Optional[dict]] = mapped_column(MySQLJSON, default=None, nullable=True, comment='功能特性配置')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_sub_plan_status', 'status'),
|
||||
{'comment': '订阅套餐计划表'}
|
||||
)
|
||||
|
||||
|
||||
class UserSubscription(Base):
|
||||
__tablename__ = 'user_subscription'
|
||||
|
||||
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False, comment='用户ID')
|
||||
plan_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('subscription_plan.id'), nullable=False, comment='订阅计划ID')
|
||||
current_cycle_start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, comment='当前周期开始时间')
|
||||
current_cycle_end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, comment='当前周期结束时间')
|
||||
last_order_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('wx_order.id'), nullable=True, comment='最近一次支付订单ID')
|
||||
status: Mapped[str] = mapped_column(String(16), default='active', comment='状态:active/expired/canceled/pending')
|
||||
auto_renew: Mapped[bool] = mapped_column(Boolean, default=False, comment='是否自动续费')
|
||||
user_cycle_usage: Mapped[int] = mapped_column(BigInteger, default=0, comment='当前周期已用量')
|
||||
actual_paid_amount: Mapped[int] = mapped_column(Integer, default=0, comment='当前周期实付金额(分),用于计算退费')
|
||||
refund_amount: Mapped[int] = mapped_column(Integer, default=0, comment='已退款金额(分)')
|
||||
refund_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, default=None, comment='退款时间')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_user_sub_user_status', 'user_id', 'status'),
|
||||
Index('idx_user_sub_end_time', 'current_cycle_end_at'),
|
||||
{'comment': '用户订阅表'}
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionUsageLog(Base):
|
||||
__tablename__ = 'subscription_usage_log'
|
||||
|
||||
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False, comment='用户ID')
|
||||
user_subscription_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('user_subscription.id'), nullable=False, comment='关联的用户订阅ID')
|
||||
plan_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment='当时的订阅计划ID')
|
||||
usage_type: Mapped[str] = mapped_column(String(32), nullable=False, comment='使用类型:image/chat/etc')
|
||||
business_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, comment='关联业务ID')
|
||||
usage_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=1, comment='本次消耗数量')
|
||||
used_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now, comment='使用时间')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_sub_usage_user_time', 'user_id', 'used_at'),
|
||||
Index('idx_sub_usage_sub_id', 'user_subscription_id'),
|
||||
{'comment': '订阅使用记录表'}
|
||||
)
|
||||
@@ -22,6 +22,8 @@ class PointsBalanceInfo(BaseModel):
|
||||
frozen_balance: int = Field(default=0, description="当前冻结积分")
|
||||
total_purchased: int = Field(default=0, description="累计获得积分")
|
||||
total_refunded: int = Field(default=0, description="累计退款积分")
|
||||
is_subscribed: bool = Field(default=False, description="是否处于订阅模式")
|
||||
subscription_expires_at: Optional[str] = Field(default=None, description="订阅到期时间(无订阅时为 None)")
|
||||
|
||||
|
||||
class PointsLogSchema(BaseModel):
|
||||
|
||||
@@ -20,3 +20,12 @@ class InitProductsRequest(BaseModel):
|
||||
class InitProductsResponse(BaseModel):
|
||||
count: int = Field(...)
|
||||
|
||||
|
||||
class SubscriptionPlanItem(BaseModel):
|
||||
id: str = Field(...)
|
||||
name: str = Field(...)
|
||||
price: int = Field(...)
|
||||
cycle_type: str = Field(...)
|
||||
cycle_length: int = Field(...)
|
||||
max_cycle_usage: int = Field(...)
|
||||
features: Optional[dict] = Field(None)
|
||||
|
||||
@@ -9,7 +9,7 @@ from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class CreateJsapiOrderRequest(BaseModel):
|
||||
product_id: int = Field(..., description='积分商品ID')
|
||||
product_id: int = Field(..., description='商品ID(积分商品或订阅计划)')
|
||||
|
||||
|
||||
class CreateJsapiOrderResponse(BaseModel):
|
||||
|
||||
@@ -9,22 +9,22 @@ from backend.common.exception import errors
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class CouponService:
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_code(length: int = 6) -> str:
|
||||
"""
|
||||
生成唯一的兑换码
|
||||
"""
|
||||
characters = string.ascii_uppercase + string.digits
|
||||
# 移除容易混淆的字符
|
||||
characters = characters.replace('0', '').replace('O', '').replace('I', '').replace('1')
|
||||
|
||||
while True:
|
||||
code = ''.join(random.choice(characters) for _ in range(length))
|
||||
# 确保生成的兑换码不包含敏感词汇或重复模式
|
||||
if not CouponService._has_sensitive_pattern(code):
|
||||
return code
|
||||
class CouponService:
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_code(length: int = 6) -> str:
|
||||
"""
|
||||
生成唯一的兑换码
|
||||
"""
|
||||
characters = string.ascii_uppercase + string.digits
|
||||
# 移除容易混淆的字符
|
||||
characters = characters.replace('0', '').replace('O', '').replace('I', '').replace('1')
|
||||
|
||||
while True:
|
||||
code = ''.join(random.choice(characters) for _ in range(length))
|
||||
# 确保生成的兑换码不包含敏感词汇或重复模式
|
||||
if not CouponService._has_sensitive_pattern(code):
|
||||
return code
|
||||
|
||||
@staticmethod
|
||||
def _has_sensitive_pattern(code: str) -> bool:
|
||||
@@ -44,7 +44,7 @@ class CouponService:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def create_coupon(points: int, expires_days: Optional[int] = None) -> Coupon:
|
||||
async def create_coupon(points: int, expires_days: Optional[int] = None, plan_id: Optional[int] = None, type: str = 'GENERAL') -> Coupon:
|
||||
"""
|
||||
创建单个兑换券
|
||||
"""
|
||||
@@ -64,6 +64,8 @@ class CouponService:
|
||||
coupon_data = {
|
||||
'code': code,
|
||||
'points': points,
|
||||
'plan_id': plan_id,
|
||||
'type': type,
|
||||
'expires_at': expires_at
|
||||
}
|
||||
|
||||
@@ -71,76 +73,86 @@ class CouponService:
|
||||
return coupon
|
||||
|
||||
@staticmethod
|
||||
async def batch_create_coupons(count: int, points: int, expires_days: Optional[int] = None) -> List[Coupon]:
|
||||
"""
|
||||
批量创建兑换券
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
coupons_data = []
|
||||
|
||||
# 生成唯一兑换码列表
|
||||
codes = set()
|
||||
while len(codes) < count:
|
||||
code = CouponService.generate_unique_code()
|
||||
if code not in codes:
|
||||
# 检查数据库中是否已存在该兑换码
|
||||
existing_coupon = await coupon_dao.get_by_code(db, code)
|
||||
if not existing_coupon:
|
||||
codes.add(code)
|
||||
|
||||
# 设置过期时间
|
||||
expires_at = None
|
||||
if expires_days:
|
||||
expires_at = datetime.now() + timedelta(days=expires_days)
|
||||
|
||||
# 准备数据
|
||||
for code in codes:
|
||||
coupons_data.append({
|
||||
'code': code,
|
||||
'points': points,
|
||||
'expires_at': expires_at
|
||||
})
|
||||
|
||||
coupons = await coupon_dao.create_coupons(db, coupons_data)
|
||||
return coupons
|
||||
|
||||
@staticmethod
|
||||
async def init_coupons(prefix: str, count: int) -> int:
|
||||
if not prefix or not any(ch.isalpha() for ch in prefix):
|
||||
raise errors.BadRequestError(msg='前缀至少包含一个字母')
|
||||
prefix = ''.join([ch for ch in prefix.upper() if ch.isalpha()])
|
||||
if len(prefix) not in (3, 4):
|
||||
raise errors.BadRequestError(msg='前缀长度必须为3或4')
|
||||
digits = 6 - len(prefix)
|
||||
max_serial = 10 ** digits - 1
|
||||
async with async_db_session.begin() as db:
|
||||
existing_codes = await coupon_dao.list_codes_by_prefix(db, prefix)
|
||||
current_max = -1
|
||||
for c in existing_codes:
|
||||
if len(c) == 6 and c.startswith(prefix):
|
||||
suffix = c[len(prefix):]
|
||||
if len(suffix) == digits and suffix.isdigit():
|
||||
n = int(suffix)
|
||||
if n > current_max:
|
||||
current_max = n
|
||||
start = current_max + 1
|
||||
if start > max_serial:
|
||||
from backend.common.log import log as logger
|
||||
logger.warning(f"{prefix} 前缀已达到最大序号 {max_serial},不再生成兑换券")
|
||||
return 0
|
||||
to_generate = min(max(0, count), max_serial - start + 1)
|
||||
coupons_data = []
|
||||
for n in range(start, start + to_generate):
|
||||
code = f"{prefix}{str(n).zfill(digits)}"
|
||||
coupons_data.append({
|
||||
'code': code,
|
||||
'type': prefix,
|
||||
'points': 0,
|
||||
'expires_at': None
|
||||
})
|
||||
await coupon_dao.create_coupons(db, coupons_data)
|
||||
return to_generate
|
||||
|
||||
async def batch_create_coupons(count: int, points: int, expires_days: Optional[int] = None, plan_id: Optional[int] = None, type: str = 'BATCH') -> List[Coupon]:
|
||||
"""
|
||||
批量创建兑换券
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
coupons_data = []
|
||||
|
||||
# 生成唯一兑换码列表
|
||||
codes = set()
|
||||
while len(codes) < count:
|
||||
code = CouponService.generate_unique_code()
|
||||
if code not in codes:
|
||||
# 检查数据库中是否已存在该兑换码
|
||||
existing_coupon = await coupon_dao.get_by_code(db, code)
|
||||
if not existing_coupon:
|
||||
codes.add(code)
|
||||
|
||||
# 设置过期时间
|
||||
expires_at = None
|
||||
if expires_days:
|
||||
expires_at = datetime.now() + timedelta(days=expires_days)
|
||||
|
||||
# 准备数据
|
||||
for code in codes:
|
||||
coupons_data.append({
|
||||
'code': code,
|
||||
'points': points,
|
||||
'plan_id': plan_id,
|
||||
'type': type,
|
||||
'expires_at': expires_at
|
||||
})
|
||||
|
||||
coupons = await coupon_dao.create_coupons(db, coupons_data)
|
||||
return coupons
|
||||
|
||||
@staticmethod
|
||||
async def init_coupons(prefix: str, count: int, plan_id: Optional[int] = None) -> int:
|
||||
if not prefix or not any(ch.isalpha() for ch in prefix):
|
||||
raise errors.BadRequestError(msg='前缀至少包含一个字母')
|
||||
prefix = ''.join([ch for ch in prefix.upper() if ch.isalpha()])
|
||||
if len(prefix) not in (3, 4):
|
||||
raise errors.BadRequestError(msg='前缀长度必须为3或4')
|
||||
digits = 6 - len(prefix)
|
||||
max_serial = 10 ** digits - 1
|
||||
|
||||
async with async_db_session.begin() as db:
|
||||
if plan_id:
|
||||
from backend.app.admin.crud.subscribe_crud import subscription_plan_dao
|
||||
plan = await subscription_plan_dao.get(db, plan_id)
|
||||
if not plan:
|
||||
raise errors.NotFoundError(msg=f"订阅计划(id={plan_id})不存在")
|
||||
|
||||
existing_codes = await coupon_dao.list_codes_by_prefix(db, prefix)
|
||||
current_max = -1
|
||||
for c in existing_codes:
|
||||
if len(c) == 6 and c.startswith(prefix):
|
||||
suffix = c[len(prefix):]
|
||||
if len(suffix) == digits and suffix.isdigit():
|
||||
n = int(suffix)
|
||||
if n > current_max:
|
||||
current_max = n
|
||||
start = current_max + 1
|
||||
if start > max_serial:
|
||||
from backend.common.log import log as logger
|
||||
logger.warning(f"{prefix} 前缀已达到最大序号 {max_serial},不再生成兑换券")
|
||||
return 0
|
||||
to_generate = min(max(0, count), max_serial - start + 1)
|
||||
coupons_data = []
|
||||
for n in range(start, start + to_generate):
|
||||
code = f"{prefix}{str(n).zfill(digits)}"
|
||||
coupons_data.append({
|
||||
'code': code,
|
||||
'type': prefix,
|
||||
'points': 0,
|
||||
'plan_id': plan_id,
|
||||
'expires_at': None
|
||||
})
|
||||
await coupon_dao.create_coupons(db, coupons_data)
|
||||
return to_generate
|
||||
|
||||
@staticmethod
|
||||
async def redeem_coupon(code: str, user_id: int) -> dict:
|
||||
"""
|
||||
@@ -173,15 +185,23 @@ class CouponService:
|
||||
if not success:
|
||||
raise errors.ServerError(msg='兑换失败,请稍后重试')
|
||||
|
||||
# 调用 points_service add_points 方法为用户增加为积分
|
||||
success = await points_service.add_points_from_coupon(user_id, coupon.points, coupon.id)
|
||||
if coupon.plan_id:
|
||||
# 兑换订阅
|
||||
from backend.app.admin.service.subscribe_service import subscribe_service
|
||||
await subscribe_service.create_or_renew_subscription(
|
||||
db, user_id, coupon.plan_id, order_id=None, actual_paid_amount=0
|
||||
)
|
||||
else:
|
||||
# 调用 points_service add_points 方法为用户增加为积分
|
||||
success = await points_service.add_points_from_coupon(user_id, coupon.points, coupon.id)
|
||||
|
||||
if not success:
|
||||
raise errors.ServerError(msg='兑换积分失败,请稍后重试')
|
||||
if not success:
|
||||
raise errors.ServerError(msg='兑换积分失败,请稍后重试')
|
||||
|
||||
return {
|
||||
'code': coupon.code,
|
||||
'points': coupon.points,
|
||||
'plan_id': coupon.plan_id,
|
||||
'used_at': datetime.now()
|
||||
}
|
||||
|
||||
|
||||
@@ -455,7 +455,7 @@ class FileService:
|
||||
# 映射到枚举类型
|
||||
format_mapping = {
|
||||
'jpeg': ImageFormat.JPEG,
|
||||
'jpg': ImageFormat.JPEG,
|
||||
'jpg': ImageFormat.JPG,
|
||||
'png': ImageFormat.PNG,
|
||||
'gif': ImageFormat.GIF,
|
||||
'bmp': ImageFormat.BMP,
|
||||
@@ -739,7 +739,12 @@ class FileService:
|
||||
db_file.id,
|
||||
UpdateFileParam(
|
||||
storage_path=cloud_path,
|
||||
details={"status": "pending", "cloud_path": cloud_path, "wx_user_id": wx_user_id},
|
||||
details={
|
||||
"status": "pending",
|
||||
"cloud_path": cloud_path,
|
||||
"wx_user_id": wx_user_id,
|
||||
"key": cloud_path,
|
||||
},
|
||||
),
|
||||
)
|
||||
db_file.storage_path = cloud_path
|
||||
@@ -824,6 +829,7 @@ class FileService:
|
||||
"download_url": url,
|
||||
"download_url_expire_ts": expire_ts,
|
||||
"wx_user_id": wx_user_id,
|
||||
"key": cloud_path,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -865,6 +871,7 @@ class FileService:
|
||||
"download_url": url,
|
||||
"download_url_expire_ts": expire_ts,
|
||||
"wx_user_id": wx_user_id,
|
||||
"key": cloud_path,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -875,7 +882,7 @@ class FileService:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_presigned_download_url(file_id: int, wx_user_id: int) -> str:
|
||||
async def get_presigned_download_url(file_id: int, wx_user_id: int, original: bool = False) -> str:
|
||||
async with async_db_session() as db:
|
||||
db_file = await file_dao.get(db, file_id)
|
||||
if not db_file:
|
||||
@@ -888,32 +895,64 @@ class FileService:
|
||||
if not cloud_path:
|
||||
raise errors.ServerError(msg="文件路径缺失")
|
||||
cos = CosClient()
|
||||
cos_key = cloud_path
|
||||
url = details.get("download_url")
|
||||
expire_ts = int(details.get("download_url_expire_ts") or 0)
|
||||
from datetime import datetime, timezone as dt_tz
|
||||
now_ts = int(datetime.now(dt_tz.utc).timestamp())
|
||||
if (not url) or (now_ts >= expire_ts):
|
||||
expired_seconds = 30 * 24 * 60 * 60
|
||||
ctype = db_file.content_type or 'application/octet-stream'
|
||||
ext = FileService._mime_to_ext(ctype, None)
|
||||
filename = f"{file_id}.{ext}"
|
||||
params = {
|
||||
'response-content-disposition': f'attachment; filename={filename}',
|
||||
'response-content-type': ctype,
|
||||
}
|
||||
url = cos.get_presigned_download_url(cos_key, expired_seconds, params=params)
|
||||
expire_ts = now_ts + expired_seconds - 60
|
||||
async with async_db_session.begin() as wdb:
|
||||
await file_dao.update(
|
||||
wdb,
|
||||
file_id,
|
||||
UpdateFileParam(details={
|
||||
**details,
|
||||
"download_url": url,
|
||||
"download_url_expire_ts": expire_ts,
|
||||
})
|
||||
)
|
||||
return url
|
||||
if original:
|
||||
cos_key = details.get("key")
|
||||
if not cos_key:
|
||||
base_path = cloud_path or ""
|
||||
cos_key = base_path.replace("_avif", "")
|
||||
url = details.get("download_origin_url")
|
||||
expire_ts = int(details.get("download_origin_url_expire_ts") or 0)
|
||||
from datetime import datetime, timezone as dt_tz
|
||||
now_ts = int(datetime.now(dt_tz.utc).timestamp())
|
||||
if (not url) or (now_ts >= expire_ts):
|
||||
expired_seconds = 30 * 24 * 60 * 60
|
||||
ctype = db_file.content_type or 'application/octet-stream'
|
||||
ext = FileService._mime_to_ext(ctype, None)
|
||||
filename = f"{file_id}.{ext}"
|
||||
params = {
|
||||
'response-content-disposition': f'attachment; filename={filename}',
|
||||
'response-content-type': ctype,
|
||||
}
|
||||
url = cos.get_presigned_download_url(cos_key, expired_seconds, params=params)
|
||||
expire_ts = now_ts + expired_seconds - 60
|
||||
async with async_db_session.begin() as wdb:
|
||||
await file_dao.update(
|
||||
wdb,
|
||||
file_id,
|
||||
UpdateFileParam(details={
|
||||
**details,
|
||||
"download_origin_url": url,
|
||||
"download_origin_url_expire_ts": expire_ts,
|
||||
})
|
||||
)
|
||||
return url
|
||||
else:
|
||||
cos_key = cloud_path
|
||||
url = details.get("download_url")
|
||||
expire_ts = int(details.get("download_url_expire_ts") or 0)
|
||||
from datetime import datetime, timezone as dt_tz
|
||||
now_ts = int(datetime.now(dt_tz.utc).timestamp())
|
||||
if (not url) or (now_ts >= expire_ts):
|
||||
expired_seconds = 30 * 24 * 60 * 60
|
||||
ctype = db_file.content_type or 'application/octet-stream'
|
||||
ext = FileService._mime_to_ext(ctype, None)
|
||||
filename = f"{file_id}.{ext}"
|
||||
params = {
|
||||
'response-content-disposition': f'attachment; filename={filename}',
|
||||
'response-content-type': ctype,
|
||||
}
|
||||
url = cos.get_presigned_download_url(cos_key, expired_seconds, params=params)
|
||||
expire_ts = now_ts + expired_seconds - 60
|
||||
async with async_db_session.begin() as wdb:
|
||||
await file_dao.update(
|
||||
wdb,
|
||||
file_id,
|
||||
UpdateFileParam(details={
|
||||
**details,
|
||||
"download_url": url,
|
||||
"download_url_expire_ts": expire_ts,
|
||||
})
|
||||
)
|
||||
return url
|
||||
|
||||
file_service = FileService()
|
||||
|
||||
@@ -189,59 +189,61 @@ class PointsService:
|
||||
if not points_account:
|
||||
return False
|
||||
available = max(0, (points_account.balance or 0) - (points_account.frozen_balance or 0))
|
||||
if available <= 0:
|
||||
return False
|
||||
# 仅扣除当前可用余额与请求金额中的较小值
|
||||
deduct_amount = min(available, amount)
|
||||
current_balance = points_account.balance
|
||||
|
||||
# 批次扣减(按 FIFO)
|
||||
remaining = deduct_amount
|
||||
alloc_details = []
|
||||
if action == POINTS_ACTION_REFUND_DEDUCT and related_id is not None:
|
||||
target_lot = await points_lot_dao.get_by_order(db, related_id)
|
||||
if not target_lot:
|
||||
return False
|
||||
take = min(target_lot.points_remaining, remaining)
|
||||
if take > 0:
|
||||
await points_lot_dao.deduct_from_lot(db, target_lot.id, take)
|
||||
alloc_details.append({"lot_id": target_lot.id, "order_id": target_lot.order_id, "points": take})
|
||||
remaining -= take
|
||||
else:
|
||||
|
||||
lot_items = await points_lot_dao.list_available(db, user_id)
|
||||
for lot in lot_items:
|
||||
if remaining <= 0:
|
||||
break
|
||||
take = min(lot.points_remaining, remaining)
|
||||
spend_log = None
|
||||
new_balance = current_balance
|
||||
|
||||
if deduct_amount > 0:
|
||||
if action == POINTS_ACTION_REFUND_DEDUCT and related_id is not None:
|
||||
target_lot = await points_lot_dao.get_by_order(db, related_id)
|
||||
if not target_lot:
|
||||
return False
|
||||
take = min(target_lot.points_remaining, remaining)
|
||||
if take > 0:
|
||||
await points_lot_dao.deduct_from_lot(db, lot.id, take)
|
||||
alloc_details.append({"lot_id": lot.id, "order_id": lot.order_id, "points": take})
|
||||
await points_lot_dao.deduct_from_lot(db, target_lot.id, take)
|
||||
alloc_details.append({"lot_id": target_lot.id, "order_id": target_lot.order_id, "points": take})
|
||||
remaining -= take
|
||||
# 扣减账户余额(仅扣已分摊的金额)
|
||||
result = await points_dao.deduct_balance_atomic(db, user_id, deduct_amount)
|
||||
if not result:
|
||||
return False
|
||||
new_balance = current_balance - deduct_amount
|
||||
|
||||
# 记录积分变动日志
|
||||
if action is None:
|
||||
action = POINTS_ACTION_SPEND
|
||||
spend_log = await points_log_dao.add_log(db, {
|
||||
"user_id": user_id,
|
||||
"action": action,
|
||||
"amount": deduct_amount,
|
||||
"balance_after": new_balance,
|
||||
"related_id": related_id,
|
||||
"details": details or {}
|
||||
})
|
||||
if action == POINTS_ACTION_REFUND_DEDUCT:
|
||||
await points_dao.add_refunded_atomic(db, user_id, amount)
|
||||
# 持久化分摊记录
|
||||
for a in alloc_details:
|
||||
alloc = PointsConsumptionAlloc(user_id=user_id, lot_id=a["lot_id"], spend_log_id=spend_log.id, points=a["points"])
|
||||
await points_alloc_dao.add(db, alloc)
|
||||
# 如果请求金额大于可扣金额,则为欠费部分创建待扣记录
|
||||
else:
|
||||
lot_items = await points_lot_dao.list_available(db, user_id)
|
||||
for lot in lot_items:
|
||||
if remaining <= 0:
|
||||
break
|
||||
take = min(lot.points_remaining, remaining)
|
||||
if take > 0:
|
||||
await points_lot_dao.deduct_from_lot(db, lot.id, take)
|
||||
alloc_details.append({"lot_id": lot.id, "order_id": lot.order_id, "points": take})
|
||||
remaining -= take
|
||||
|
||||
result = await points_dao.deduct_balance_atomic(db, user_id, deduct_amount)
|
||||
if not result:
|
||||
return False
|
||||
new_balance = current_balance - deduct_amount
|
||||
|
||||
if action is None:
|
||||
action = POINTS_ACTION_SPEND
|
||||
spend_log = await points_log_dao.add_log(db, {
|
||||
"user_id": user_id,
|
||||
"action": action,
|
||||
"amount": deduct_amount,
|
||||
"balance_after": new_balance,
|
||||
"related_id": related_id,
|
||||
"details": details or {}
|
||||
})
|
||||
if action == POINTS_ACTION_REFUND_DEDUCT:
|
||||
await points_dao.add_refunded_atomic(db, user_id, amount)
|
||||
for a in alloc_details:
|
||||
alloc = PointsConsumptionAlloc(
|
||||
user_id=user_id,
|
||||
lot_id=a["lot_id"],
|
||||
spend_log_id=spend_log.id,
|
||||
points=a["points"]
|
||||
)
|
||||
await points_alloc_dao.add(db, alloc)
|
||||
# 如果请求金额大于可扣金额,则为欠费部分创建待扣记录(支持全额欠费)
|
||||
if amount > deduct_amount:
|
||||
debt_amount = amount - deduct_amount
|
||||
await points_debt_dao.add_pending(db, user_id, debt_amount, related_id, details)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
from typing import List
|
||||
|
||||
from backend.app.admin.crud.points_product_crud import points_product_dao
|
||||
from backend.app.admin.crud.subscribe_crud import subscription_plan_dao
|
||||
from backend.app.admin.model.points_product import PointsProduct
|
||||
from backend.app.admin.model.subscribe import SubscriptionPlan
|
||||
from backend.database.db import async_db_session
|
||||
from sqlalchemy import select, and_, or_, exists
|
||||
from backend.app.admin.model.wx_pay import WxOrder
|
||||
@@ -22,7 +24,6 @@ class ProductService:
|
||||
{"title": "+50%", "description": "加赠50积分", "points": 150, "amount_cents": 500, "one_time": False},
|
||||
{"title": "+100%", "description": "加赠200积分", "points": 400, "amount_cents": 1000, "one_time": False},
|
||||
{"title": "+200%", "description": "加赠800积分", "points": 1200, "amount_cents": 2000, "one_time": False},
|
||||
# {"title": "100积分", "description": "测试100积分", "points": 100, "amount_cents": 1, "one_time": False},
|
||||
]
|
||||
payload = items or default
|
||||
async with async_db_session.begin() as db:
|
||||
@@ -40,15 +41,59 @@ class ProductService:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def init_plans(items: List[dict] | None = None) -> int:
|
||||
default = [
|
||||
{
|
||||
"name": "月度订阅",
|
||||
"price": 1990,
|
||||
"cycle_type": "month",
|
||||
"cycle_length": 1,
|
||||
"max_cycle_usage": 0,
|
||||
"features": {"label": "-10%"},
|
||||
},
|
||||
{
|
||||
"name": "季度订阅",
|
||||
"price": 4990,
|
||||
"cycle_type": "month",
|
||||
"cycle_length": 3,
|
||||
"max_cycle_usage": 0,
|
||||
"features": {"label": "-20%"},
|
||||
},
|
||||
{
|
||||
"name": "年度订阅",
|
||||
"price": 12990,
|
||||
"cycle_type": "year",
|
||||
"cycle_length": 1,
|
||||
"max_cycle_usage": 0,
|
||||
"features": {"label": "-50%"},
|
||||
},
|
||||
]
|
||||
payload = items or default
|
||||
async with async_db_session.begin() as db:
|
||||
count = 0
|
||||
for it in payload:
|
||||
plan = SubscriptionPlan(
|
||||
name=it["name"],
|
||||
price=it["price"],
|
||||
cycle_type=it["cycle_type"],
|
||||
cycle_length=it["cycle_length"],
|
||||
max_cycle_usage=it.get("max_cycle_usage", 0),
|
||||
status="enabled",
|
||||
features=it.get("features"),
|
||||
)
|
||||
await subscription_plan_dao.add(db, plan)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def list_for_user(user_id: int) -> List[PointsProduct]:
|
||||
"""返回用户可购买的积分商品列表(过滤一次性已购买商品)。"""
|
||||
async with async_db_session.begin() as db:
|
||||
subq = select(WxOrder.id).where(
|
||||
and_(
|
||||
WxOrder.user_id == user_id,
|
||||
WxOrder.product_id == PointsProduct.id,
|
||||
WxOrder.trade_state == 'SUCCESS',
|
||||
WxOrder.trade_state == "SUCCESS",
|
||||
)
|
||||
).limit(1)
|
||||
stmt = (
|
||||
|
||||
185
backend/app/admin/service/subscribe_service.py
Normal file
185
backend/app/admin/service/subscribe_service.py
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.subscribe_crud import (
|
||||
subscription_plan_dao,
|
||||
user_subscription_dao,
|
||||
subscription_usage_log_dao
|
||||
)
|
||||
from backend.app.admin.model.subscribe import UserSubscription, SubscriptionUsageLog
|
||||
from backend.common.exception import errors
|
||||
|
||||
|
||||
class SubscribeService:
|
||||
async def has_active_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> bool:
|
||||
subscription = await user_subscription_dao.get_active_subscription(db, user_id)
|
||||
return bool(subscription)
|
||||
|
||||
async def check_and_record_usage(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
usage_type: str,
|
||||
amount: int,
|
||||
business_id: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
统一计费入口:检查用户是否可以使用订阅额度
|
||||
|
||||
Returns:
|
||||
bool: True 表示订阅已覆盖本次消耗,无需扣减积分
|
||||
False 表示无有效订阅或订阅额度不足,需走积分逻辑
|
||||
"""
|
||||
# 1. 获取用户当前有效订阅
|
||||
subscription = await user_subscription_dao.get_active_subscription(db, user_id)
|
||||
if not subscription:
|
||||
return False
|
||||
|
||||
# 2. 获取订阅计划详情(为了检查 max_cycle_usage)
|
||||
plan = await subscription_plan_dao.get(db, subscription.plan_id)
|
||||
if not plan:
|
||||
# 异常情况:有订阅但找不到计划,保险起见返回 False 走积分
|
||||
return False
|
||||
|
||||
# 3. 检查硬限制 (max_cycle_usage)
|
||||
# 如果 max_cycle_usage > 0,则需要检查额度
|
||||
if plan.max_cycle_usage > 0:
|
||||
if subscription.user_cycle_usage + amount > plan.max_cycle_usage:
|
||||
# 额度不足,拒绝使用订阅(根据策略,这里可以选择直接拒绝任务,或者回退到积分)
|
||||
# 根据用户需求:"订阅模式下不考虑减扣积分...如果有订阅则不需要减扣积分"
|
||||
# 这里如果订阅额度用完了,应该算作"订阅失效"的一种特例?
|
||||
# 或者更严格地:订阅用户额度用完 = 任务失败。
|
||||
# 但为了灵活性,这里返回 False,允许业务层决定是否 fallback 到积分。
|
||||
# 如果业务层希望严格执行订阅限制,可以根据 False 再去判断是否有 active subscription
|
||||
# 但为了简单,假设返回 False 就意味着"订阅没兜住"
|
||||
return False
|
||||
|
||||
# 4. 记录使用日志
|
||||
usage_log = SubscriptionUsageLog(
|
||||
user_id=user_id,
|
||||
user_subscription_id=subscription.id,
|
||||
plan_id=subscription.plan_id,
|
||||
usage_type=usage_type,
|
||||
usage_amount=amount,
|
||||
business_id=business_id,
|
||||
used_at=datetime.now()
|
||||
)
|
||||
await subscription_usage_log_dao.add(db, usage_log)
|
||||
|
||||
# 5. 更新周期使用量
|
||||
await user_subscription_dao.increment_usage(db, subscription.id, amount)
|
||||
|
||||
return True
|
||||
|
||||
async def create_or_renew_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
plan_id: int,
|
||||
order_id: Optional[int] = None,
|
||||
actual_paid_amount: int = 0
|
||||
) -> UserSubscription:
|
||||
plan = await subscription_plan_dao.get(db, plan_id)
|
||||
if not plan:
|
||||
raise errors.NotFoundError(msg="订阅计划不存在")
|
||||
|
||||
active_sub = await user_subscription_dao.get_active_subscription(db, user_id)
|
||||
now = datetime.now()
|
||||
today = now.date()
|
||||
|
||||
if active_sub and active_sub.plan_id != plan_id:
|
||||
await user_subscription_dao.update(db, active_sub.id, {"status": "canceled"})
|
||||
|
||||
if active_sub and active_sub.plan_id == plan_id and active_sub.current_cycle_end_at > now:
|
||||
start_date = active_sub.current_cycle_end_at.date()
|
||||
else:
|
||||
start_date = today
|
||||
|
||||
start_at = datetime.combine(start_date, datetime.min.time())
|
||||
|
||||
if plan.cycle_type == 'month':
|
||||
end_at = start_at + timedelta(days=30 * plan.cycle_length)
|
||||
elif plan.cycle_type == 'year':
|
||||
end_at = start_at + relativedelta(years=plan.cycle_length)
|
||||
else:
|
||||
end_at = start_at + timedelta(days=plan.cycle_length)
|
||||
new_sub = UserSubscription(
|
||||
user_id=user_id,
|
||||
plan_id=plan_id,
|
||||
status='active',
|
||||
current_cycle_start_at=start_at,
|
||||
current_cycle_end_at=end_at,
|
||||
auto_renew=False,
|
||||
last_order_id=order_id,
|
||||
user_cycle_usage=0,
|
||||
actual_paid_amount=actual_paid_amount,
|
||||
refund_amount=0
|
||||
)
|
||||
await user_subscription_dao.add(db, new_sub)
|
||||
return new_sub
|
||||
|
||||
async def compute_refund_amount_for_order(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
order_id: int
|
||||
) -> dict:
|
||||
sub = await user_subscription_dao.get_by_order_id(db, order_id)
|
||||
if not sub or sub.user_id != user_id:
|
||||
raise errors.NotFoundError(msg="当前无有效订阅")
|
||||
now = datetime.now()
|
||||
total_delta = sub.current_cycle_end_at - sub.current_cycle_start_at
|
||||
total_seconds = total_delta.total_seconds()
|
||||
if total_seconds <= 0:
|
||||
refund_val = 0
|
||||
else:
|
||||
remaining_delta = sub.current_cycle_end_at - now
|
||||
remaining_seconds = max(0, remaining_delta.total_seconds())
|
||||
refund_val = int(sub.actual_paid_amount * (remaining_seconds / total_seconds))
|
||||
return {
|
||||
"refund_amount": refund_val,
|
||||
"subscription_id": sub.id
|
||||
}
|
||||
|
||||
async def cancel_subscription_for_order(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
order_id: int,
|
||||
refund_amount: Optional[int] = None
|
||||
) -> dict:
|
||||
sub = await user_subscription_dao.get_by_order_id(db, order_id)
|
||||
if not sub:
|
||||
raise errors.NotFoundError(msg="当前无有效订阅")
|
||||
now = datetime.now()
|
||||
if refund_amount is None:
|
||||
data = await self.compute_refund_amount_for_order(db, user_id, order_id)
|
||||
refund_val = data["refund_amount"]
|
||||
else:
|
||||
refund_val = refund_amount
|
||||
await user_subscription_dao.update(
|
||||
db,
|
||||
sub.id,
|
||||
{
|
||||
"status": "canceled",
|
||||
"refund_amount": refund_val,
|
||||
"refund_at": now
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"refund_amount": refund_val,
|
||||
"subscription_id": sub.id
|
||||
}
|
||||
|
||||
|
||||
subscribe_service = SubscribeService()
|
||||
@@ -132,6 +132,63 @@ class WxPayService:
|
||||
'paySign': pay_sign,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def create_jsapi_subscription_order(user_id: int, payer_openid: str, plan_id: int) -> dict:
|
||||
async with async_db_session.begin() as db:
|
||||
from backend.utils.snowflake import snowflake
|
||||
generated_id = snowflake.generate()
|
||||
from backend.app.admin.crud.subscribe_crud import subscription_plan_dao
|
||||
plan = await subscription_plan_dao.get(db, plan_id)
|
||||
if not plan or plan.status != 'enabled':
|
||||
raise RuntimeError('订阅计划不可用')
|
||||
order = WxOrder(
|
||||
user_id=user_id,
|
||||
out_trade_no=str(generated_id),
|
||||
description=f"订阅 {plan.name}",
|
||||
amount_cents=plan.price,
|
||||
payer_openid=payer_openid,
|
||||
trade_state='NOTPAY',
|
||||
product_id=plan_id,
|
||||
points=None,
|
||||
)
|
||||
order.id = generated_id
|
||||
await wx_order_dao.add(db, order)
|
||||
await db.flush()
|
||||
await db.refresh(order)
|
||||
|
||||
notify_url = f"{settings.SERVER_HOST}:{settings.SERVER_PORT}{settings.FASTAPI_API_V1_PATH}/wxpay/notify"
|
||||
wxpay = WxPayService._build_wxpay_instance(notify_url)
|
||||
payer = {"openid": payer_openid}
|
||||
result = WxPayService._safe_call(
|
||||
wxpay.pay,
|
||||
description=f"订阅 {plan.name}",
|
||||
out_trade_no=str(order.id),
|
||||
amount={"total": plan.price, "currency": "CNY"},
|
||||
pay_type=WeChatPayType.JSAPI,
|
||||
payer=payer,
|
||||
)
|
||||
data = WxPayService._parse_result(result)
|
||||
prepay_id = data.get('prepay_id') or ''
|
||||
if prepay_id:
|
||||
await wx_order_dao.set_prepay_id(db, order.id, prepay_id)
|
||||
app_id = settings.WX_SP_APPID or settings.WX_APPID
|
||||
timestamp = str(int(datetime.now().timestamp()))
|
||||
nonce_str = str(order.id)
|
||||
package = f"prepay_id={prepay_id}"
|
||||
sign_type = "RSA"
|
||||
pay_sign = wxpay.sign([app_id, timestamp, nonce_str, package])
|
||||
return {
|
||||
'out_trade_no': str(order.id),
|
||||
'prepay_id': prepay_id,
|
||||
'trade_state': order.trade_state,
|
||||
'appId': app_id,
|
||||
'timeStamp': timestamp,
|
||||
'nonceStr': nonce_str,
|
||||
'package': package,
|
||||
'signType': sign_type,
|
||||
'paySign': pay_sign,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def query_order(out_trade_no: str) -> dict:
|
||||
async with async_db_session.begin() as db:
|
||||
@@ -207,7 +264,9 @@ class WxPayService:
|
||||
'refundable_amount_cents': 0,
|
||||
'amount_per_point': 0,
|
||||
}
|
||||
return await WxPayService._compute_refund_amount_for_order(db, order)
|
||||
if order.points and order.points > 0:
|
||||
return await WxPayService._compute_refund_amount_for_order(db, order)
|
||||
return await WxPayService._compute_subscription_refund_amount(db, order)
|
||||
|
||||
@staticmethod
|
||||
async def _compute_refund_amount_for_order(db, order: WxOrder) -> dict:
|
||||
@@ -240,6 +299,34 @@ class WxPayService:
|
||||
'amount_per_point': amt_per_point,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _compute_subscription_refund_amount(db, order: WxOrder) -> dict:
|
||||
from backend.app.admin.crud.subscribe_crud import user_subscription_dao
|
||||
from datetime import datetime as dt
|
||||
sub = await user_subscription_dao.get_by_order_id(db, order.id)
|
||||
if not sub:
|
||||
return {
|
||||
'order_id': str(order.id),
|
||||
'refundable_points': 0,
|
||||
'refundable_amount_cents': 0,
|
||||
'amount_per_point': 0,
|
||||
}
|
||||
now = dt.now()
|
||||
total_delta = sub.current_cycle_end_at - sub.current_cycle_start_at
|
||||
total_seconds = total_delta.total_seconds()
|
||||
if total_seconds <= 0:
|
||||
refundable_amount = 0
|
||||
else:
|
||||
remaining_delta = sub.current_cycle_end_at - now
|
||||
remaining_seconds = max(0, remaining_delta.total_seconds())
|
||||
refundable_amount = int(sub.actual_paid_amount * (remaining_seconds / total_seconds))
|
||||
return {
|
||||
'order_id': str(order.id),
|
||||
'refundable_points': 0,
|
||||
'refundable_amount_cents': refundable_amount,
|
||||
'amount_per_point': 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def list_orders_for_user(user_id: int, page: int, size: int) -> dict:
|
||||
async with async_db_session() as db:
|
||||
@@ -311,17 +398,23 @@ class WxPayService:
|
||||
async def create_refund(user_id: int, out_trade_no: str, amount_cents: int, reason: Optional[str] = None) -> dict:
|
||||
async with async_db_session.begin() as db:
|
||||
from backend.utils.snowflake import snowflake
|
||||
from backend.app.admin.service.points_service import points_service
|
||||
# 使用退款记录主键 id 作为 out_refund_no
|
||||
generated_id = snowflake.generate()
|
||||
compute = await WxPayService.compute_refund_amount_for_out_trade(out_trade_no)
|
||||
max_refund = int(compute.get('refundable_amount_cents') or 0)
|
||||
amt_per_point = float(compute.get('amount_per_point') or 0)
|
||||
final_amount = min(amount_cents or 0, max_refund)
|
||||
points_to_freeze = int(final_amount / amt_per_point) if amt_per_point > 0 else 0
|
||||
order_obj = await wx_order_dao.get_by_out_trade_no(db, out_trade_no)
|
||||
if order_obj:
|
||||
if not order_obj:
|
||||
raise RuntimeError('订单不存在')
|
||||
if order_obj.points and order_obj.points > 0:
|
||||
from backend.app.admin.service.points_service import points_service
|
||||
compute = await WxPayService._compute_refund_amount_for_order(db, order_obj)
|
||||
max_refund = int(compute.get('refundable_amount_cents') or 0)
|
||||
amt_per_point = float(compute.get('amount_per_point') or 0)
|
||||
final_amount = min(amount_cents or 0, max_refund)
|
||||
points_to_freeze = int(final_amount / amt_per_point) if amt_per_point > 0 else 0
|
||||
await points_service.freeze_points_for_order(db, order_obj.user_id, order_obj.id, points_to_freeze)
|
||||
else:
|
||||
from backend.app.admin.service.subscribe_service import subscribe_service
|
||||
data = await subscribe_service.compute_refund_amount_for_order(db, user_id, order_obj.id)
|
||||
max_refund = int(data.get('refund_amount') or 0)
|
||||
final_amount = min(amount_cents or 0, max_refund)
|
||||
refund = WxRefund(
|
||||
user_id=user_id,
|
||||
out_trade_no=out_trade_no,
|
||||
@@ -426,22 +519,43 @@ class WxPayService:
|
||||
event_type: str | None = None) -> None:
|
||||
async with async_db_session.begin() as db:
|
||||
order = await wx_order_dao.get(db, order_id)
|
||||
if not order or not order.points or order.points <= 0:
|
||||
if not order:
|
||||
return
|
||||
if getattr(order, 'points_granted', False):
|
||||
return
|
||||
from backend.app.admin.crud.points_crud import points_log_dao
|
||||
from backend.common.const import POINTS_ACTION_RECHARGE
|
||||
exists = await points_log_dao.has_log_by_related(db, order.user_id, order.id, POINTS_ACTION_RECHARGE)
|
||||
if exists:
|
||||
await wx_order_dao.update_model(db, order_id, {'points_granted': True})
|
||||
return
|
||||
from backend.app.admin.service.points_service import points_service
|
||||
try:
|
||||
ok = await points_service.add_points_from_order_with_db(db, order.user_id, order.id, order.points, order.amount_cents)
|
||||
if ok:
|
||||
if order.points and order.points > 0:
|
||||
if getattr(order, 'points_granted', False):
|
||||
return
|
||||
from backend.app.admin.crud.points_crud import points_log_dao
|
||||
from backend.common.const import POINTS_ACTION_RECHARGE
|
||||
exists = await points_log_dao.has_log_by_related(db, order.user_id, order.id, POINTS_ACTION_RECHARGE)
|
||||
if exists:
|
||||
await wx_order_dao.update_model(db, order_id, {'points_granted': True})
|
||||
# 成功发放后记录统一兼容的日志
|
||||
return
|
||||
from backend.app.admin.service.points_service import points_service
|
||||
try:
|
||||
ok = await points_service.add_points_from_order_with_db(db, order.user_id, order.id, order.points, order.amount_cents)
|
||||
if ok:
|
||||
await wx_order_dao.update_model(db, order_id, {'points_granted': True})
|
||||
log = WxPayNotifyLog(
|
||||
out_trade_no=order.out_trade_no,
|
||||
event_type=event_type or ('QUERY.SUCCESS' if source == 'query' else 'TRANSACTION.SUCCESS'),
|
||||
verified=verified,
|
||||
raw_text=raw_text,
|
||||
raw_json=raw_json,
|
||||
)
|
||||
await wx_pay_notify_dao.add(db, log)
|
||||
except Exception as e:
|
||||
logging.error(f"Grant points task failed for order {order_id}: {e}")
|
||||
else:
|
||||
from backend.app.admin.crud.subscribe_crud import user_subscription_dao, subscription_plan_dao
|
||||
from backend.app.admin.service.subscribe_service import subscribe_service
|
||||
plan = await subscription_plan_dao.get(db, order.product_id) if order.product_id else None
|
||||
if not plan or plan.status != 'enabled':
|
||||
return
|
||||
exists_sub = await user_subscription_dao.get_by_order_id(db, order.id)
|
||||
if exists_sub:
|
||||
return
|
||||
try:
|
||||
await subscribe_service.create_or_renew_subscription(db, order.user_id, order.product_id, order.id, order.amount_cents)
|
||||
log = WxPayNotifyLog(
|
||||
out_trade_no=order.out_trade_no,
|
||||
event_type=event_type or ('QUERY.SUCCESS' if source == 'query' else 'TRANSACTION.SUCCESS'),
|
||||
@@ -450,8 +564,8 @@ class WxPayService:
|
||||
raw_json=raw_json,
|
||||
)
|
||||
await wx_pay_notify_dao.add(db, log)
|
||||
except Exception as e:
|
||||
logging.error(f"Grant points task failed for order {order_id}: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Grant subscription task failed for order {order_id}: {e}")
|
||||
|
||||
@staticmethod
|
||||
async def handle_refund_notify(raw_body: bytes, timestamp: str | None = None, nonce: str | None = None, signature: str | None = None, serial: str | None = None) -> dict:
|
||||
@@ -519,47 +633,52 @@ class WxPayService:
|
||||
verified: bool = True, event_type: str | None = None) -> None:
|
||||
async with async_db_session.begin() as db:
|
||||
order = await wx_order_dao.get_by_out_trade_no(db, out_trade_no)
|
||||
if not order or not order.points or order.points <= 0:
|
||||
if not order:
|
||||
return
|
||||
refund = await wx_refund_dao.get_by_out_refund_no(db, out_refund_no)
|
||||
if not refund:
|
||||
return
|
||||
if getattr(refund, 'points_deducted', False):
|
||||
return
|
||||
from backend.app.admin.service.points_service import points_service
|
||||
try:
|
||||
# 计算尚未解冻的冻结积分(本订单维度)
|
||||
from backend.app.admin.model.points import PointsLog
|
||||
from sqlalchemy import select, func
|
||||
s_freeze = await db.execute(
|
||||
select(func.coalesce(func.sum(PointsLog.amount), 0)).where(
|
||||
PointsLog.user_id == order.user_id,
|
||||
PointsLog.related_id == order.id,
|
||||
PointsLog.action == 'refund_freeze'
|
||||
if order.points and order.points > 0:
|
||||
from backend.app.admin.service.points_service import points_service
|
||||
try:
|
||||
from backend.app.admin.model.points import PointsLog
|
||||
from sqlalchemy import select, func
|
||||
s_freeze = await db.execute(
|
||||
select(func.coalesce(func.sum(PointsLog.amount), 0)).where(
|
||||
PointsLog.user_id == order.user_id,
|
||||
PointsLog.related_id == order.id,
|
||||
PointsLog.action == 'refund_freeze'
|
||||
)
|
||||
)
|
||||
)
|
||||
s_unfreeze = await db.execute(
|
||||
select(func.coalesce(func.sum(PointsLog.amount), 0)).where(
|
||||
PointsLog.user_id == order.user_id,
|
||||
PointsLog.related_id == order.id,
|
||||
PointsLog.action.in_(['refund_unfreeze','refund_deduct'])
|
||||
s_unfreeze = await db.execute(
|
||||
select(func.coalesce(func.sum(PointsLog.amount), 0)).where(
|
||||
PointsLog.user_id == order.user_id,
|
||||
PointsLog.related_id == order.id,
|
||||
PointsLog.action.in_(['refund_unfreeze','refund_deduct'])
|
||||
)
|
||||
)
|
||||
)
|
||||
frozen_points = int(s_freeze.scalar() or 0) - int(s_unfreeze.scalar() or 0)
|
||||
if frozen_points > 0:
|
||||
# 完成时解冻并扣减对应积分(按冻结量)
|
||||
await points_service.unfreeze_points_for_order(db, order.user_id, order.id, frozen_points, deduct=True)
|
||||
await wx_refund_dao.update_model(db, refund.id, {'points_deducted': True})
|
||||
log = WxPayNotifyLog(
|
||||
out_trade_no=out_refund_no,
|
||||
event_type=event_type or ('REFUND.QUERY.SUCCESS' if source == 'query' else 'REFUND.SUCCESS'),
|
||||
verified=verified,
|
||||
raw_text=raw_text,
|
||||
raw_json=raw_json,
|
||||
)
|
||||
await wx_pay_notify_dao.add(db, log)
|
||||
except Exception as e:
|
||||
logging.error(f"Deduct points task failed for refund {out_refund_no}: {e}")
|
||||
frozen_points = int(s_freeze.scalar() or 0) - int(s_unfreeze.scalar() or 0)
|
||||
if frozen_points > 0:
|
||||
await points_service.unfreeze_points_for_order(db, order.user_id, order.id, frozen_points, deduct=True)
|
||||
except Exception as e:
|
||||
logging.error(f"Deduct points task failed for refund {out_refund_no}: {e}")
|
||||
else:
|
||||
from backend.app.admin.service.subscribe_service import subscribe_service
|
||||
try:
|
||||
await subscribe_service.cancel_subscription_for_order(db, order.user_id, order.id, refund.amount_cents)
|
||||
except Exception as e:
|
||||
logging.error(f"Cancel subscription task failed for refund {out_refund_no}: {e}")
|
||||
await wx_refund_dao.update_model(db, refund.id, {'points_deducted': True})
|
||||
log = WxPayNotifyLog(
|
||||
out_trade_no=out_refund_no,
|
||||
event_type=event_type or ('REFUND.QUERY.SUCCESS' if source == 'query' else 'REFUND.SUCCESS'),
|
||||
verified=verified,
|
||||
raw_text=raw_text,
|
||||
raw_json=raw_json,
|
||||
)
|
||||
await wx_pay_notify_dao.add(db, log)
|
||||
|
||||
@staticmethod
|
||||
async def download_bill(bill_date: str, bill_type: str = 'ALL') -> dict:
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from backend.app.ai.schema.qa import CreateQaExerciseRequest, CreateQaExerciseTaskResponse, QaExerciseSchema, QaExerciseWithQuestionsSchema, QaQuestionSchema, QaSessionSchema, CreateAttemptRequest, TaskStatusResponse, QuestionLatestResultResponse
|
||||
from backend.app.ai.schema.qa import (
|
||||
CreateQaExerciseRequest,
|
||||
CreateQaExerciseTaskResponse,
|
||||
QaExerciseSchema,
|
||||
QaExerciseWithQuestionsSchema,
|
||||
QaQuestionSchema,
|
||||
QaSessionSchema,
|
||||
CreateAttemptRequest,
|
||||
TaskStatusResponse,
|
||||
QuestionLatestResultResponse,
|
||||
ImageConversationInitRequest,
|
||||
ImageConversationInitResponse,
|
||||
ConversationStartRequest,
|
||||
ConversationStartResponse,
|
||||
ConversationSessionSchema,
|
||||
ConversationReplyRequest,
|
||||
ConversationReplyResponse,
|
||||
ConversationLatestResponse,
|
||||
ConversationListResponse,
|
||||
)
|
||||
from backend.common.response.response_schema import response_base, ResponseSchemaModel
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.app.ai.service.qa_service import qa_service
|
||||
@@ -11,10 +30,74 @@ router = APIRouter()
|
||||
|
||||
@router.post('/exercises/tasks', summary='创建练习任务', dependencies=[DependsJwtAuth])
|
||||
async def create_exercise_task(request: Request, obj: CreateQaExerciseRequest) -> ResponseSchemaModel[CreateQaExerciseTaskResponse]:
|
||||
res = await qa_service.create_exercise_task(image_id=obj.image_id, user_id=request.user.id, title=obj.title, description=obj.description)
|
||||
res = await qa_service.create_exercise_task(image_id=obj.image_id, user_id=request.user.id, type=obj.type)
|
||||
return response_base.success(data=CreateQaExerciseTaskResponse(**res))
|
||||
|
||||
|
||||
@router.post('/conversations/setting', summary='获取图片自由对话配置', dependencies=[DependsJwtAuth])
|
||||
async def get_conversation_setting(request: Request, obj: ImageConversationInitRequest) -> ResponseSchemaModel[ImageConversationInitResponse | None]:
|
||||
res = await qa_service.get_conversation_setting(image_id=obj.image_id, user_id=request.user.id)
|
||||
if not res:
|
||||
return response_base.success(data=None)
|
||||
data = ImageConversationInitResponse(
|
||||
image_id=res["image_id"],
|
||||
setting=res["setting"],
|
||||
latest_session=res.get("latest_session"),
|
||||
)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.post('/conversations/start', summary='启动图片自由对话', dependencies=[DependsJwtAuth])
|
||||
async def start_conversation(request: Request, obj: ConversationStartRequest) -> ResponseSchemaModel[ConversationStartResponse]:
|
||||
res = await qa_service.start_conversation(
|
||||
image_id=obj.image_id,
|
||||
user_id=request.user.id,
|
||||
scene=[item.model_dump() for item in obj.scene],
|
||||
event=[item.model_dump() for item in obj.event],
|
||||
style=obj.style.model_dump() if obj.style else None,
|
||||
user_role=obj.user_role.model_dump() if obj.user_role else None,
|
||||
assistant_role=obj.assistant_role.model_dump() if obj.assistant_role else None,
|
||||
level=obj.level,
|
||||
info=obj.info,
|
||||
)
|
||||
data = ConversationStartResponse(**res)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.post('/conversations/{session_id}/reply', summary='回复图片自由对话', dependencies=[DependsJwtAuth])
|
||||
async def reply_conversation(request: Request, session_id: int, obj: ConversationReplyRequest) -> ResponseSchemaModel[ConversationReplyResponse]:
|
||||
res = await qa_service.reply_conversation(
|
||||
session_id=session_id,
|
||||
user_id=request.user.id,
|
||||
input_text=obj.content,
|
||||
)
|
||||
return response_base.success(data=ConversationReplyResponse(**res))
|
||||
|
||||
|
||||
@router.get('/conversations/{image_id}/list', summary='获取图片自由对话列表', dependencies=[DependsJwtAuth])
|
||||
async def list_conversations(request: Request, image_id: int, page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100)) -> ResponseSchemaModel[ConversationListResponse]:
|
||||
res = await qa_service.list_conversations_by_image(
|
||||
image_id=image_id,
|
||||
user_id=request.user.id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return response_base.success(data=ConversationListResponse(**res))
|
||||
|
||||
|
||||
@router.get('/conversations/{session_id}/latest', summary='获取图片自由对话最新消息', dependencies=[DependsJwtAuth])
|
||||
async def get_conversation_latest(request: Request, session_id: int) -> ResponseSchemaModel[ConversationLatestResponse]:
|
||||
res = await qa_service.get_latest_messages(session_id=session_id, user_id=request.user.id)
|
||||
return response_base.success(data=ConversationLatestResponse(**res))
|
||||
|
||||
|
||||
@router.get('/conversations/{session_id}', summary='获取图片自由对话会话信息', dependencies=[DependsJwtAuth])
|
||||
async def get_conversation_session(request: Request, session_id: int) -> ResponseSchemaModel[ConversationSessionSchema]:
|
||||
res = await qa_service.get_conversation_session(session_id=session_id, user_id=request.user.id)
|
||||
data = ConversationSessionSchema(**res)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/exercises/tasks/{task_id}/status', summary='查询练习任务状态', dependencies=[DependsJwtAuth])
|
||||
async def get_exercise_task_status(task_id: int) -> ResponseSchemaModel[TaskStatusResponse]:
|
||||
res = await qa_service.get_task_status(task_id)
|
||||
@@ -22,8 +105,8 @@ async def get_exercise_task_status(task_id: int) -> ResponseSchemaModel[TaskStat
|
||||
|
||||
|
||||
@router.get('/{image_id}/exercises', summary='根据图片获取练习', dependencies=[DependsJwtAuth])
|
||||
async def list_exercises(request: Request, image_id: int) -> ResponseSchemaModel[QaExerciseWithQuestionsSchema | None]:
|
||||
item = await qa_service.list_exercises_by_image(image_id, user_id=request.user.id)
|
||||
async def list_exercises(request: Request, image_id: int, type: str = Query(None)) -> ResponseSchemaModel[QaExerciseWithQuestionsSchema | None]:
|
||||
item = await qa_service.list_exercises_by_image(image_id, user_id=request.user.id, type=type)
|
||||
data = None if not item else QaExerciseWithQuestionsSchema(**item)
|
||||
return response_base.success(data=data)
|
||||
|
||||
@@ -38,7 +121,6 @@ async def submit_attempt(request: Request, question_id: int, obj: CreateAttemptR
|
||||
selected_options=obj.selected_options,
|
||||
input_text=obj.input_text,
|
||||
cloze_options=obj.cloze_options,
|
||||
file_id=obj.file_id,
|
||||
session_id=obj.session_id,
|
||||
is_trial=obj.is_trial,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
from backend.app.ai.model.qa import QaExercise, QaQuestion, QaQuestionAttempt, QaPracticeSession
|
||||
@@ -22,13 +22,11 @@ class QaExerciseCRUD(CRUDPlus[QaExercise]):
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_latest_by_image_id(self, db: AsyncSession, image_id: int) -> Optional[QaExercise]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(self.model.image_id == image_id)
|
||||
.order_by(self.model.created_time.desc(), self.model.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
async def get_latest_by_image_id(self, db: AsyncSession, image_id: int, type: Optional[str] = None) -> Optional[QaExercise]:
|
||||
stmt = select(self.model).where(self.model.image_id == image_id)
|
||||
if type:
|
||||
stmt = stmt.where(self.model.type == type)
|
||||
stmt = stmt.order_by(self.model.created_time.desc(), self.model.id.desc()).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
@@ -43,10 +41,15 @@ class QaQuestionCRUD(CRUDPlus[QaQuestion]):
|
||||
return inst
|
||||
|
||||
async def get_by_exercise_id(self, db: AsyncSession, exercise_id: int) -> List[QaQuestion]:
|
||||
stmt = select(self.model).where(self.model.exercise_id == exercise_id)
|
||||
stmt = select(self.model).where(self.model.exercise_id == exercise_id).order_by(self.model.id)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_latest_by_exercise_id(self, db: AsyncSession, exercise_id: int) -> Optional[QaQuestion]:
|
||||
stmt = select(self.model).where(self.model.exercise_id == exercise_id).order_by(self.model.id.desc()).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
class QaQuestionAttemptCRUD(CRUDPlus[QaQuestionAttempt]):
|
||||
async def get(self, db: AsyncSession, id: int) -> Optional[QaQuestionAttempt]:
|
||||
@@ -136,6 +139,52 @@ class QaPracticeSessionCRUD(CRUDPlus[QaPracticeSession]):
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_latest_session_by_image_user(self, db: AsyncSession, user_id: int, image_id: int, exercise_type: Optional[str] = None) -> Optional[QaPracticeSession]:
|
||||
stmt = (
|
||||
select(QaPracticeSession)
|
||||
.join(QaExercise, QaPracticeSession.exercise_id == QaExercise.id)
|
||||
.where(
|
||||
and_(
|
||||
QaPracticeSession.starter_user_id == user_id,
|
||||
QaExercise.image_id == image_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
if exercise_type:
|
||||
stmt = stmt.where(QaExercise.type == exercise_type)
|
||||
stmt = stmt.order_by(QaPracticeSession.id.desc()).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_list_by_image(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
image_id: int,
|
||||
user_id: int,
|
||||
page: int = 1,
|
||||
page_size: int = 10
|
||||
) -> Tuple[int, List[Tuple[QaPracticeSession, QaExercise]]]:
|
||||
stmt = (
|
||||
select(QaPracticeSession, QaExercise)
|
||||
.join(QaExercise, QaPracticeSession.exercise_id == QaExercise.id)
|
||||
.where(
|
||||
and_(
|
||||
QaPracticeSession.starter_user_id == user_id,
|
||||
QaExercise.image_id == image_id,
|
||||
QaExercise.type == 'free_conversation'
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Count
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total = await db.scalar(count_stmt) or 0
|
||||
|
||||
# Pagination
|
||||
stmt = stmt.order_by(QaPracticeSession.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(stmt)
|
||||
return total, result.all()
|
||||
|
||||
|
||||
qa_session_dao = QaPracticeSessionCRUD(QaPracticeSession)
|
||||
qa_attempt_dao = QaQuestionAttemptCRUD(QaQuestionAttempt)
|
||||
|
||||
@@ -13,7 +13,7 @@ class QaExercise(Base):
|
||||
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
|
||||
image_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('image.id'), nullable=False)
|
||||
created_by: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False)
|
||||
title: Mapped[Optional[str]] = mapped_column(String(100), default=None)
|
||||
type: Mapped[Optional[str]] = mapped_column(String(20), default=None)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, default=None)
|
||||
status: Mapped[str] = mapped_column(String(20), default='draft')
|
||||
question_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
@@ -47,7 +47,7 @@ class QaPracticeSession(Base):
|
||||
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
|
||||
exercise_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('qa_exercise.id'), nullable=False)
|
||||
starter_user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False)
|
||||
share_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
||||
share_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, default=None)
|
||||
status: Mapped[str] = mapped_column(String(20), default='ongoing')
|
||||
started_at: Mapped[Optional[DateTime]] = mapped_column(DateTime, default=None)
|
||||
completed_at: Mapped[Optional[DateTime]] = mapped_column(DateTime, default=None)
|
||||
@@ -67,8 +67,8 @@ class QaQuestionAttempt(Base):
|
||||
question_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('qa_question.id'), nullable=False)
|
||||
exercise_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('qa_exercise.id'), nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False)
|
||||
task_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('image_processing_task.id'), nullable=True)
|
||||
recording_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('recording.id'), nullable=True)
|
||||
task_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('image_processing_task.id'), nullable=True, default=None)
|
||||
recording_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('recording.id'), nullable=True, default=None)
|
||||
choice_options: Mapped[Optional[list]] = mapped_column(MySQLJSON, default=None)
|
||||
cloze_options: Mapped[Optional[str]] = mapped_column(String(100), default=None)
|
||||
input_text: Mapped[Optional[str]] = mapped_column(Text, default=None)
|
||||
|
||||
@@ -10,6 +10,7 @@ from backend.app.admin.schema.wx import DictLevel
|
||||
|
||||
class ImageFormat(str, Enum):
|
||||
JPEG = "jpeg"
|
||||
JPG = "jpg"
|
||||
PNG = "png"
|
||||
GIF = "gif"
|
||||
BMP = "bmp"
|
||||
|
||||
@@ -7,8 +7,8 @@ from backend.common.schema import SchemaBase
|
||||
|
||||
class CreateQaExerciseRequest(SchemaBase):
|
||||
image_id: int
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
class CreateQaExerciseTaskResponse(SchemaBase):
|
||||
@@ -19,10 +19,11 @@ class CreateQaExerciseTaskResponse(SchemaBase):
|
||||
class QaExerciseSchema(SchemaBase):
|
||||
id: str
|
||||
image_id: str
|
||||
title: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: str
|
||||
question_count: int
|
||||
ext: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class QaQuestionSchema(SchemaBase):
|
||||
@@ -43,7 +44,6 @@ class CreateAttemptRequest(SchemaBase):
|
||||
selected_options: Optional[List[str]] = None
|
||||
input_text: Optional[str] = None
|
||||
cloze_options: Optional[List[str]] = None
|
||||
file_id: Optional[int] = None
|
||||
session_id: Optional[int] = None
|
||||
is_trial: bool = False
|
||||
|
||||
@@ -103,6 +103,12 @@ class AudioNode(SchemaBase):
|
||||
stt_text: Optional[str] = None
|
||||
evaluation: 'EvaluationSchema'
|
||||
|
||||
|
||||
class VariationNode(SchemaBase):
|
||||
file_id: Optional[str] = None
|
||||
evaluation: 'EvaluationSchema'
|
||||
|
||||
|
||||
class QuestionLatestResultResponse(SchemaBase):
|
||||
session_id: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
@@ -110,15 +116,18 @@ class QuestionLatestResultResponse(SchemaBase):
|
||||
cloze: Optional[ClozeNode] = None
|
||||
free_text: Optional[FreeTextNode] = None
|
||||
audio: Optional[AudioNode] = None
|
||||
variation: Optional[VariationNode] = None
|
||||
class IncorrectSelectionItem(SchemaBase):
|
||||
content: str
|
||||
error_type: Optional[str] = None
|
||||
error_reason: Optional[str] = None
|
||||
|
||||
|
||||
class SelectedDetail(SchemaBase):
|
||||
correct: List[str] = []
|
||||
incorrect: List[IncorrectSelectionItem] = []
|
||||
|
||||
|
||||
class EvaluationSchema(SchemaBase):
|
||||
type: Optional[str] = None
|
||||
result: Optional[str] = None
|
||||
@@ -127,8 +136,134 @@ class EvaluationSchema(SchemaBase):
|
||||
missing_correct: Optional[List[str]] = None
|
||||
feedback: Optional[str] = None
|
||||
|
||||
# Pydantic forward references resolution
|
||||
|
||||
class ImageConversationInitRequest(SchemaBase):
|
||||
image_id: int
|
||||
|
||||
|
||||
class ImageConversationEventSchema(SchemaBase):
|
||||
event_en: Optional[str] = None
|
||||
event_zh: Optional[str] = None
|
||||
conversation_direction_en: Optional[str] = None
|
||||
conversation_direction_zh: Optional[str] = None
|
||||
style_en: Optional[str] = None
|
||||
style_zh: Optional[str] = None
|
||||
suggested_roles: Optional[List[Dict[str, str]]] = []
|
||||
|
||||
|
||||
class ImageConversationObjectSchema(SchemaBase):
|
||||
object_en: str
|
||||
object_zh: str
|
||||
|
||||
|
||||
class ImageConversationSceneSchema(SchemaBase):
|
||||
scene_en: str
|
||||
scene_zh: str
|
||||
|
||||
|
||||
class ImageConversationAnalysisSchema(SchemaBase):
|
||||
core_objects: List[ImageConversationObjectSchema] = []
|
||||
all_possible_scenes: List[ImageConversationSceneSchema] = []
|
||||
all_possible_events: List[ImageConversationEventSchema] = []
|
||||
|
||||
|
||||
class ImageConversationInitResponse(SchemaBase):
|
||||
image_id: int
|
||||
setting: ImageConversationAnalysisSchema
|
||||
latest_session: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class BilingualItem(SchemaBase):
|
||||
en: str
|
||||
zh: str
|
||||
|
||||
|
||||
class ConversationStartRequest(SchemaBase):
|
||||
image_id: int
|
||||
scene: List[BilingualItem]
|
||||
event: List[BilingualItem]
|
||||
style: Optional[BilingualItem] = None
|
||||
user_role: Optional[BilingualItem] = None
|
||||
assistant_role: Optional[BilingualItem] = None
|
||||
level: Optional[str] = None
|
||||
info: Optional[str] = None
|
||||
|
||||
|
||||
class ConversationAlternativeItemSchema(SchemaBase):
|
||||
alt_en: Optional[str] = None
|
||||
alt_zh: Optional[str] = None
|
||||
|
||||
|
||||
class ConversationAlternativeResponsesSchema(SchemaBase):
|
||||
positive: Optional[ConversationAlternativeItemSchema] = None
|
||||
neutral: Optional[ConversationAlternativeItemSchema] = None
|
||||
negative: Optional[ConversationAlternativeItemSchema] = None
|
||||
|
||||
|
||||
class FreeConversationContentSchema(SchemaBase):
|
||||
response_en: Optional[str] = None
|
||||
response_zh: Optional[str] = None
|
||||
prompt_en: Optional[str] = None
|
||||
prompt_zh: Optional[str] = None
|
||||
alternative_responses: Optional[ConversationAlternativeResponsesSchema] = None
|
||||
correction: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
|
||||
|
||||
class ConversationMessageSchema(SchemaBase):
|
||||
role: str
|
||||
content: FreeConversationContentSchema
|
||||
|
||||
|
||||
class ConversationStartResponse(SchemaBase):
|
||||
task_id: str
|
||||
status: str
|
||||
exercise_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
|
||||
|
||||
class ConversationReplyRequest(SchemaBase):
|
||||
content: str
|
||||
audio_id: Optional[str] = None
|
||||
|
||||
|
||||
class ConversationReplyResponse(SchemaBase):
|
||||
task_id: str
|
||||
status: str
|
||||
session_id: Optional[str] = None
|
||||
|
||||
|
||||
class ConversationLatestResponse(SchemaBase):
|
||||
session_id: str
|
||||
messages: List[ConversationMessageSchema]
|
||||
|
||||
|
||||
class ConversationSessionSchema(SchemaBase):
|
||||
exercise_id: str
|
||||
session_id: str
|
||||
status: str
|
||||
updated_at: Optional[str] = None
|
||||
messages: List[ConversationMessageSchema] = []
|
||||
|
||||
|
||||
class ConversationListItemSchema(SchemaBase):
|
||||
session_id: str
|
||||
scene: List[BilingualItem]
|
||||
event: List[BilingualItem]
|
||||
user_role: Optional[BilingualItem] = None
|
||||
style: Optional[BilingualItem] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class ConversationListResponse(SchemaBase):
|
||||
total: int
|
||||
items: List[ConversationListItemSchema]
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
CreateAttemptTaskResponse.model_rebuild()
|
||||
AttemptResultResponse.model_rebuild()
|
||||
QuestionEvaluationResponse.model_rebuild()
|
||||
QuestionLatestResultResponse.model_rebuild()
|
||||
VariationNode.model_rebuild()
|
||||
|
||||
@@ -44,6 +44,7 @@ from backend.app.admin.model.dict import YdDictLanguage, YdDictType, DictCategor
|
||||
from backend.app.admin.service.yd_dict_service import yd_dict_service
|
||||
from backend.app.admin.service.points_service import points_service
|
||||
from backend.app.admin.service.dict_service import dict_service
|
||||
from backend.app.admin.service.subscribe_service import subscribe_service
|
||||
from backend.database.redis import redis_client
|
||||
from backend.app.admin.schema.wx import DictLevel
|
||||
|
||||
@@ -195,8 +196,9 @@ class ImageService:
|
||||
type = params.type
|
||||
dict_level = DictLevel.LEVEL1.value
|
||||
|
||||
# 检查用户积分是否足够(现在积分没有过期概念)
|
||||
if not await points_service.check_sufficient_points(current_user.id, IMAGE_RECOGNITION_COST):
|
||||
async with async_db_session.begin() as db:
|
||||
has_sub = await subscribe_service.has_active_subscription(db, current_user.id)
|
||||
if not has_sub and not await points_service.check_sufficient_points(current_user.id, IMAGE_RECOGNITION_COST):
|
||||
raise errors.ForbiddenError(
|
||||
msg=f'积分不足,请获取积分后继续使用'
|
||||
)
|
||||
@@ -331,23 +333,30 @@ class ImageService:
|
||||
if not result:
|
||||
raise Exception("Failed to initialize image text")
|
||||
|
||||
# Step 4: Deduct user points
|
||||
task = await image_task_dao.get(db, task_id)
|
||||
if task:
|
||||
image = await image_dao.get(db, task.image_id)
|
||||
if image:
|
||||
total_tokens = task.result.get("token_usage", {}).get("total_tokens", 0)
|
||||
points = math.ceil(max(total_tokens, 1)/1000) * IMAGE_RECOGNITION_COST
|
||||
points_deducted = await points_service.deduct_points_with_db(
|
||||
user_id=task.user_id,
|
||||
amount=math.ceil(points),
|
||||
db=db,
|
||||
related_id=image.id,
|
||||
details={"task_id": task_id},
|
||||
action=POINTS_ACTION_IMAGE_RECOGNITION
|
||||
used_sub = await subscribe_service.check_and_record_usage(
|
||||
db,
|
||||
task.user_id,
|
||||
"image_recognition",
|
||||
math.ceil(points),
|
||||
business_id=image.id,
|
||||
)
|
||||
if not points_deducted:
|
||||
logger.warning(f"Insufficient points for user {task.user_id}: balance is zero, cannot deduct for task {task_id}")
|
||||
if not used_sub:
|
||||
points_deducted = await points_service.deduct_points_with_db(
|
||||
user_id=task.user_id,
|
||||
amount=math.ceil(points),
|
||||
db=db,
|
||||
related_id=image.id,
|
||||
details={"task_id": task_id},
|
||||
action=POINTS_ACTION_IMAGE_RECOGNITION
|
||||
)
|
||||
if not points_deducted:
|
||||
logger.warning(f"Insufficient points for user {task.user_id}: balance is zero, cannot deduct for task {task_id}")
|
||||
|
||||
# Step 5: Update task status to completed
|
||||
await ImageService._update_task_status_with_db(task_id, ImageTaskStatus.COMPLETED, db)
|
||||
@@ -443,7 +452,7 @@ class ImageService:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def _process_image_recognition(task_id: int, proc_type: str) -> None:
|
||||
async def _process_image_recognition(task_id: int, proc_type: str = "word") -> None:
|
||||
"""后台处理图片识别任务 - compatible version for task processor"""
|
||||
# This is maintained for backward compatibility with the task processor
|
||||
# It creates its own database connection like the original implementation
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from backend.app.ai.model.image_task import ImageTaskStatus, ImageProcessingTask
|
||||
from backend.app.ai.crud.image_task_crud import image_task_dao
|
||||
from backend.app.admin.service.points_service import points_service
|
||||
from backend.app.admin.service.subscribe_service import subscribe_service
|
||||
from backend.app.ai.service.rate_limit_service import rate_limit_service
|
||||
from backend.database.db import background_db_session
|
||||
from backend.common.const import LLM_CHAT_COST
|
||||
@@ -58,34 +59,56 @@ class ImageTaskService:
|
||||
|
||||
# Calculate and deduct points
|
||||
total_tokens = 0
|
||||
extra_points = 0
|
||||
extra_details = {}
|
||||
|
||||
if isinstance(token_usage, dict):
|
||||
# Check if token_usage is nested (legacy structure) or direct
|
||||
if "total_tokens" in token_usage:
|
||||
total_tokens = int(token_usage.get("total_tokens") or 0)
|
||||
else:
|
||||
total_tokens = int((token_usage.get("token_usage") or {}).get("total_tokens") or 0)
|
||||
|
||||
# Handle extra points from processor
|
||||
extra_points = int(token_usage.get("extra_points") or 0)
|
||||
extra_details = token_usage.get("extra_details") or {}
|
||||
|
||||
deduct_amount = LLM_CHAT_COST
|
||||
token_cost = LLM_CHAT_COST
|
||||
if total_tokens > 0:
|
||||
units = math.ceil(max(total_tokens, 1) / 1000)
|
||||
deduct_amount = units * LLM_CHAT_COST
|
||||
token_cost = units * LLM_CHAT_COST
|
||||
|
||||
# Use ref_id as the related_id for points record
|
||||
points_deducted = await points_service.deduct_points_with_db(
|
||||
user_id=task.user_id,
|
||||
amount=deduct_amount,
|
||||
db=db,
|
||||
related_id=task.ref_id,
|
||||
details={
|
||||
"task_id": task_id,
|
||||
"ref_type": task.ref_type,
|
||||
"token_usage": total_tokens
|
||||
},
|
||||
action=task.ref_type
|
||||
)
|
||||
|
||||
if not points_deducted:
|
||||
raise Exception("Failed to deduct points")
|
||||
total_deduct = token_cost + extra_points
|
||||
|
||||
used_sub = False
|
||||
if total_deduct > 0:
|
||||
used_sub = await subscribe_service.check_and_record_usage(
|
||||
db,
|
||||
task.user_id,
|
||||
task.ref_type,
|
||||
total_deduct,
|
||||
business_id=task.ref_id,
|
||||
)
|
||||
|
||||
if not used_sub and total_deduct > 0:
|
||||
points_deducted = await points_service.deduct_points_with_db(
|
||||
user_id=task.user_id,
|
||||
amount=total_deduct,
|
||||
db=db,
|
||||
related_id=task.ref_id,
|
||||
details={
|
||||
"task_id": task_id,
|
||||
"ref_type": task.ref_type,
|
||||
"token_usage": total_tokens,
|
||||
"token_cost": token_cost,
|
||||
"extra_points": extra_points,
|
||||
**extra_details
|
||||
},
|
||||
action=task.ref_type
|
||||
)
|
||||
|
||||
if not points_deducted:
|
||||
raise Exception("Failed to deduct points")
|
||||
|
||||
# If result doesn't have token_usage, we might want to add it,
|
||||
# but let's assume processor handles result structure.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,14 @@ from backend.app.admin.service.audit_log_service import audit_log_service
|
||||
from backend.app.ai.service.rate_limit_service import rate_limit_service, SPEECH_ASSESSMENT_SERVICE
|
||||
from backend.database.db import async_db_session
|
||||
from backend.middleware.tencent_cloud import TencentCloud
|
||||
from backend.common.const import SPEECH_ASSESSMENT_COST, POINTS_ACTION_SPEECH_ASSESSMENT
|
||||
from backend.common.const import (
|
||||
SPEECH_ASSESSMENT_COST,
|
||||
POINTS_ACTION_SPEECH_ASSESSMENT,
|
||||
TTS_STANDARD_AUDIO_COST,
|
||||
POINTS_ACTION_TTS_STANDARD_AUDIO,
|
||||
)
|
||||
from backend.app.admin.service.points_service import points_service
|
||||
from backend.app.admin.service.subscribe_service import subscribe_service
|
||||
from backend.core.conf import settings
|
||||
from backend.middleware.qwen import Qwen
|
||||
|
||||
@@ -66,6 +72,10 @@ class RecordingService:
|
||||
if recording:
|
||||
return recording.file_id
|
||||
# 未找到则按需生成
|
||||
async with async_db_session.begin() as db:
|
||||
has_sub = await subscribe_service.has_active_subscription(db, user_id)
|
||||
if not has_sub and not await points_service.check_sufficient_points(user_id, TTS_STANDARD_AUDIO_COST):
|
||||
raise errors.ForbiddenError(msg='积分不足,请获取积分后继续使用')
|
||||
image_text = await image_text_service.get_text_by_id(text_id)
|
||||
if not image_text:
|
||||
return None
|
||||
@@ -93,6 +103,25 @@ class RecordingService:
|
||||
async with async_db_session() as db:
|
||||
recording = await recording_dao.get_standard_by_text_id(db, text_id)
|
||||
if recording:
|
||||
async with async_db_session.begin() as billing_db:
|
||||
used_sub = await subscribe_service.check_and_record_usage(
|
||||
billing_db,
|
||||
user_id,
|
||||
"tts_standard_audio",
|
||||
TTS_STANDARD_AUDIO_COST,
|
||||
business_id=recording.id,
|
||||
)
|
||||
if not used_sub:
|
||||
points_deducted = await points_service.deduct_points_with_db(
|
||||
user_id=user_id,
|
||||
amount=TTS_STANDARD_AUDIO_COST,
|
||||
db=billing_db,
|
||||
related_id=recording.id,
|
||||
details={"recording_id": recording.id, "text_id": text_id},
|
||||
action=POINTS_ACTION_TTS_STANDARD_AUDIO,
|
||||
)
|
||||
if not points_deducted:
|
||||
logger.warning(f"Failed to deduct points for user {user_id} for TTS standard audio")
|
||||
return recording.file_id
|
||||
return None
|
||||
|
||||
@@ -104,7 +133,10 @@ class RecordingService:
|
||||
recording = await recording_dao.get_standard_by_ref(db, 'qa_question', question_id)
|
||||
if recording:
|
||||
return recording.file_id
|
||||
|
||||
async with async_db_session.begin() as db:
|
||||
has_sub = await subscribe_service.has_active_subscription(db, user_id)
|
||||
if not has_sub and not await points_service.check_sufficient_points(user_id, TTS_STANDARD_AUDIO_COST):
|
||||
raise errors.ForbiddenError(msg='积分不足,请获取积分后继续使用')
|
||||
# 2. Get question content
|
||||
from backend.app.ai.crud.qa_crud import qa_question_dao
|
||||
async with async_db_session() as db:
|
||||
@@ -142,6 +174,25 @@ class RecordingService:
|
||||
async with async_db_session() as db:
|
||||
recording = await recording_dao.get_standard_by_ref(db, 'qa_question', question_id)
|
||||
if recording:
|
||||
async with async_db_session.begin() as billing_db:
|
||||
used_sub = await subscribe_service.check_and_record_usage(
|
||||
billing_db,
|
||||
user_id,
|
||||
"tts_standard_audio",
|
||||
TTS_STANDARD_AUDIO_COST,
|
||||
business_id=recording.id,
|
||||
)
|
||||
if not used_sub:
|
||||
points_deducted = await points_service.deduct_points_with_db(
|
||||
user_id=user_id,
|
||||
amount=TTS_STANDARD_AUDIO_COST,
|
||||
db=billing_db,
|
||||
related_id=recording.id,
|
||||
details={"recording_id": recording.id, "question_id": question_id},
|
||||
action=POINTS_ACTION_TTS_STANDARD_AUDIO,
|
||||
)
|
||||
if not points_deducted:
|
||||
logger.warning(f"Failed to deduct points for user {user_id} for TTS question audio")
|
||||
return recording.file_id
|
||||
return None
|
||||
|
||||
@@ -417,47 +468,46 @@ class RecordingService:
|
||||
ref_text = image_text.content
|
||||
image_id = image_text.image_id
|
||||
|
||||
# 检查录音记录是否存在
|
||||
recording = await self.get_recording_by_file_id(file_id)
|
||||
if not recording:
|
||||
# 如果不存在,创建新的录音记录并存储ref_text和image_id
|
||||
try:
|
||||
recording_id = await self.create_recording_record(file_id, ref_text, image_id, image_text_id, 1, user_id)
|
||||
# 重新获取recording对象
|
||||
recording = await self.get_recording_by_file_id(file_id)
|
||||
if not recording:
|
||||
raise RuntimeError(f"Failed to create recording record for file_id {file_id}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to create recording record for file_id {file_id}: {str(e)}")
|
||||
|
||||
# 检查用户积分是否足够(现在积分没有过期概念)
|
||||
if not await points_service.check_sufficient_points(user_id, SPEECH_ASSESSMENT_COST):
|
||||
async with async_db_session.begin() as db:
|
||||
has_sub = await subscribe_service.has_active_subscription(db, user_id)
|
||||
if not has_sub and not await points_service.check_sufficient_points(user_id, SPEECH_ASSESSMENT_COST):
|
||||
raise errors.ForbiddenError(msg='积分不足,请获取积分后继续使用')
|
||||
|
||||
try:
|
||||
# 调用腾讯云SOE API进行语音评估
|
||||
result = await self.tencent_cloud.assessment_speech(file_id, ref_text, str(recording.id), image_id, user_id)
|
||||
|
||||
# 保存完整的识别结果到details字段中
|
||||
details = {"assessment": result}
|
||||
|
||||
# 更新录音记录的details字段
|
||||
success = await self.update_recording_details(recording.id, details)
|
||||
if not success:
|
||||
raise RuntimeError(f"Failed to update recording details for file_id {file_id}")
|
||||
|
||||
# 扣减用户积分
|
||||
async with async_db_session.begin() as db:
|
||||
points_deducted = await points_service.deduct_points_with_db(
|
||||
user_id=user_id,
|
||||
amount=SPEECH_ASSESSMENT_COST,
|
||||
db=db,
|
||||
related_id=recording.id,
|
||||
details={"recording_id": recording.id},
|
||||
action=POINTS_ACTION_SPEECH_ASSESSMENT
|
||||
used_sub = await subscribe_service.check_and_record_usage(
|
||||
db,
|
||||
user_id,
|
||||
"speech_assessment",
|
||||
SPEECH_ASSESSMENT_COST,
|
||||
business_id=recording.id,
|
||||
)
|
||||
if not points_deducted:
|
||||
logger.warning(f"Failed to deduct points for user {user_id} for speech assessment")
|
||||
if not used_sub:
|
||||
points_deducted = await points_service.deduct_points_with_db(
|
||||
user_id=user_id,
|
||||
amount=SPEECH_ASSESSMENT_COST,
|
||||
db=db,
|
||||
related_id=recording.id,
|
||||
details={"recording_id": recording.id},
|
||||
action=POINTS_ACTION_SPEECH_ASSESSMENT
|
||||
)
|
||||
if not points_deducted:
|
||||
logger.warning(f"Failed to deduct points for user {user_id} for speech assessment")
|
||||
|
||||
# 计算耗时
|
||||
duration = time.time() - start_time
|
||||
@@ -506,7 +556,9 @@ class RecordingService:
|
||||
raise RuntimeError(f"Failed to create recording record for file_id {file_id}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to create recording record for file_id {file_id}: {str(e)}")
|
||||
if not await points_service.check_sufficient_points(user_id, SPEECH_ASSESSMENT_COST):
|
||||
async with async_db_session.begin() as db:
|
||||
has_sub = await subscribe_service.has_active_subscription(db, user_id)
|
||||
if not has_sub and not await points_service.check_sufficient_points(user_id, SPEECH_ASSESSMENT_COST):
|
||||
raise RuntimeError('积分不足,请获取积分后继续使用')
|
||||
try:
|
||||
result = await self.tencent_cloud.assessment_speech(file_id, ref_text, str(recording.id), image_id, user_id)
|
||||
@@ -515,14 +567,22 @@ class RecordingService:
|
||||
if not success:
|
||||
raise RuntimeError(f"Failed to update recording details for file_id {file_id}")
|
||||
async with async_db_session.begin() as db:
|
||||
await points_service.deduct_points_with_db(
|
||||
user_id=user_id,
|
||||
amount=SPEECH_ASSESSMENT_COST,
|
||||
db=db,
|
||||
related_id=recording.id,
|
||||
details={"recording_id": recording.id},
|
||||
action=POINTS_ACTION_SPEECH_ASSESSMENT
|
||||
used_sub = await subscribe_service.check_and_record_usage(
|
||||
db,
|
||||
user_id,
|
||||
"speech_assessment",
|
||||
SPEECH_ASSESSMENT_COST,
|
||||
business_id=recording.id,
|
||||
)
|
||||
if not used_sub:
|
||||
await points_service.deduct_points_with_db(
|
||||
user_id=user_id,
|
||||
amount=SPEECH_ASSESSMENT_COST,
|
||||
db=db,
|
||||
related_id=recording.id,
|
||||
details={"recording_id": recording.id},
|
||||
action=POINTS_ACTION_SPEECH_ASSESSMENT
|
||||
)
|
||||
duration = time.time() - start_time
|
||||
if background_tasks:
|
||||
self._log_audit(background_tasks, file_id, ref_text, result, duration, status_code, user_id, image_id, 0)
|
||||
|
||||
@@ -13,7 +13,9 @@ from backend.app.ai.service.image_chat_service import image_chat_service
|
||||
from backend.app.ai.crud.image_curd import image_dao
|
||||
from backend.database.db import async_db_session, background_db_session
|
||||
from backend.core.conf import settings
|
||||
from backend.middleware.qwen import Qwen
|
||||
from backend.core.llm import LLMFactory, AuditLogCallbackHandler
|
||||
from langchain_core.messages import SystemMessage, HumanMessage
|
||||
from backend.core.prompts.sentence_analysis import get_sentence_analysis_prompt
|
||||
from backend.middleware.tencent_hunyuan import Hunyuan
|
||||
from backend.app.admin.schema.wx import DictLevel
|
||||
from backend.app.ai.service.scene_sentence_service import scene_sentence_service
|
||||
@@ -72,118 +74,7 @@ class SceneSentenceProcessor(TaskProcessor):
|
||||
class SentenceService:
|
||||
@staticmethod
|
||||
def _compose_prompt(payload: dict, mode: str) -> str:
|
||||
base = (
|
||||
"你是英语教育场景的专业助手,需基于给定的图片场景信息和基础内容,扩展生成适配英语进阶学习者的「句型卡片、模拟场景对话、句型套用练习」结构化内容,所有内容需贴合场景、功能导向,无语义重复,且符合日常沟通逻辑。\n"
|
||||
"输入信息如下(JSON):\n"
|
||||
f"{json.dumps(payload, ensure_ascii=False)}\n"
|
||||
"输出要求:\n"
|
||||
"1. 内容约束:基于基础句型扩展功能标签、场景说明,每句补充「发音提示(重音/连读)」\n"
|
||||
"2. 格式约束:严格按照下方JSON结构输出,无额外解释,确保字段完整、值为数组/字符串类型。\n"
|
||||
"3. 语言约束:所有英文内容符合日常沟通表达,无语法错误;中文翻译精准,场景说明简洁易懂(≤50字)。\n"
|
||||
)
|
||||
if mode == SENTENCE_TYPE_SCENE_SENTENCE:
|
||||
base = (
|
||||
"你是英语教育场景的专业助手,需基于给定的图片场景信息和基础内容,扩展生成适配英语进阶学习者的[场景句型]结构化内容,所有内容需贴合场景、功能导向,无语义重复,简洁清晰,准确务实,且符合外国人日常口语沟通习惯。\n"
|
||||
"输入信息如下(JSON):\n"
|
||||
f"{json.dumps(payload, ensure_ascii=False)}\n"
|
||||
"输出要求:\n"
|
||||
"0. description是图片的详细描述,围绕描述展开后续的分析。\n"
|
||||
"1. 内容约束:基于基础句型扩展功能标签、场景说明,每句补充「发音提示(重音/连读)」等输出结构中要求的内容,需符合现实生活和真实世界的习惯。\n"
|
||||
"2. 语言约束:所有英文内容符合日常沟通表达,无语法错误;中文翻译精准,场景说明简洁易懂(≤50字)。\n"
|
||||
"3. 输出限制:仅返回JSON字符串,无其他解释文字,确保可被`JSON.parse`直接解析,确保字段完整、值为数组/字符串类型,输出的 JSON 结构是:\n"
|
||||
)
|
||||
struct = (
|
||||
"""
|
||||
"sentence": { // 对象:场景句型模块(适配前端展示)
|
||||
"total": 5, // 数字:句型数量(5-8)
|
||||
"list": [ // 数组:场景句型列表(数量与total一致)
|
||||
{ "seq": 1, // 数字:序号(1-8)
|
||||
"sentence_en": "", // 字符串:英文句型, 使用输入信息中的 desc_en 与之顺序对应的句子
|
||||
"sentence_zh": "", // 字符串:中文翻译,使用输入信息中的 desc_zh 与之顺序对应的句子
|
||||
"function_tags": ["询问", "索要物品"], // 数组:功能标签(主+子)
|
||||
"scene_explanation": "咖啡厅场景向店员礼貌索要菜单,比“Give me the menu”更得体", // 字符串:场景使用说明(≤50字)
|
||||
"pronunciation_tip": "重音在menu /ˈmenjuː/,have a look at 连读为 /hævəlʊkæt/", // 字符串:发音提示(重音/连读)
|
||||
"core_vocab": ["menu", "look"], // 数组:核心词汇
|
||||
"core_vocab_desc": ["n. 菜单", "v. 查看"], // 数组:核心词汇在此句型中的含义(与core_vocab顺序对应)
|
||||
"collocations": ["have a look at + 物品(查看某物)"], // 数组:核心搭配
|
||||
"grammar_point": "情态动词Can表请求(非正式),主谓倒装结构:Can + 主语 + 动词原形", // 核心语法解析
|
||||
"common_mistakes": ["1. 漏介词at(Can I have a look the menu)", "2. look误读为/lʊk/(正确/luːk/)", "3. 忘记在look后加at(Can I have a look at the menu)", ...], // 数组:句型中语法或单词用法可能出错的地方,包括但不限于常见发音错误,场景语气不当,单词单复数错误,主谓倒装错误、省略介词、省略主语等语法错误;
|
||||
"pragmatic_alternative": ["Could I have a look at the menu?(更礼貌,正式场景)", "May I see the menu?(更正式,高阶)", ...], // 语用替代表达
|
||||
"scene_transfer_tip": "迁移至餐厅场景:Can I have a look at the wine list?(把menu替换为wine list)", // 场景迁移提示
|
||||
"difficulty_tag": "intermediate", // 难度标签(beginner/intermediate/advanced)
|
||||
"extended_example": ["Can I have a look at your phone?(向朋友借看手机,非正式场景)", ""], // 数组: 精简拓展例句
|
||||
"response_pairs": [], // 数组:对话回应搭配(3-4个核心回应,含肯定/否定/中性,带场景适配说明,设计意图:形成对话闭环,支持角色扮演/实际互动)
|
||||
"fluency_hacks": "", // 字符串:口语流畅度技巧(≤30字,聚焦填充词/弱读/语气调节,设计意图:贴近母语者表达节奏,避免生硬卡顿)
|
||||
"cultural_note": "", // 字符串:文化适配提示(≤40字,说明中外表达习惯差异,设计意图:避免文化误解,提升沟通得体性)
|
||||
"practice_steps": [], // 数组:分阶练习步骤(3步,每步1句话,可操作,设计意图:提供明确学习路径,衔接输入与输出,提升口语落地能力)
|
||||
"avoid_scenarios": "", // 字符串:避免使用场景(≤35字,明确禁忌场景+替代方案,设计意图:减少用错场合的尴尬,明确使用边界)
|
||||
"self_check_list": [], // 数组:自我检测清单(3-4个可量化检查点,含语法/发音/流畅度维度,设计意图:提供即时自查工具,无需他人批改验证效果)
|
||||
"tone_intensity": "", // 字符串:语气强度标注(≤35字,用“弱/中/强”+适用对象描述,设计意图:直观匹配语气与互动对象,避免语气不当)
|
||||
"similar_sentence_distinction": "", // 字符串:相似句型辨析(≤40字,聚焦使用场景+核心差异,不搞复杂语法,设计意图:理清易混点,避免张冠李戴)
|
||||
"speech_rate_tip": "", // 字符串:语速建议(≤25字,明确日常场景语速+关键部分节奏,设计意图:让表达更自然,提升沟通效率)
|
||||
"personalized_tips": "" // 字符串:个性化学习提示(≤30字,分初学者/进阶者给出重点建议,设计意图:适配不同水平需求,提升学习针对性)
|
||||
} ] }
|
||||
"""
|
||||
)
|
||||
return base + struct
|
||||
if mode == SENTENCE_TYPE_SCENE_DIALOGUE:
|
||||
struct = (
|
||||
"""
|
||||
"dialog": { // 对象:模拟场景对话模块(适配前端对话交互)
|
||||
"roleOptions": ["customer", "barista"], // 数组:可选角色(固定值:customer/barista)
|
||||
"defaultRole": "customer", // 字符串:默认角色(customer/barista二选一)
|
||||
"dialogRound": 2, // 数字:对话轮数(2-3轮)
|
||||
"list": [ // 数组:对话轮次列表(数量与dialogRound一致)
|
||||
{
|
||||
"roundId": "dialog-001", // 字符串:轮次唯一ID
|
||||
"speaker": "barista", // 字符串:本轮说话者(customer/barista)
|
||||
"speakerEn": "Can I help you?", // 字符串:说话者英文内容
|
||||
"speakerZh": "请问需要点什么?", // 字符串:说话者中文翻译
|
||||
"responseOptions": [ // 数组:用户可选回应(固定3条)
|
||||
{
|
||||
"optionId": "resp-001", // 字符串:选项唯一ID
|
||||
"optionEn": "I'd like to order a latte with less sugar.", // 字符串:选项英文内容
|
||||
"optionZh": "我想点一杯少糖的拿铁。", // 字符串:选项中文翻译
|
||||
"feedback": "✅ 完美!该句型是咖啡厅点餐核心表达,with精准补充饮品定制要求" // 字符串:选择后的交互反馈
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
)
|
||||
return base + "生成场景对话结构:" + struct
|
||||
if mode == SENTENCE_TYPE_SCENE_EXERCISE:
|
||||
struct = (
|
||||
"""
|
||||
"sentencePractice": { // 对象:句型套用练习模块(适配前端填空练习)
|
||||
"total": 5, // 数字:练习数量(5-8道)
|
||||
"list": [ // 数组:练习列表(数量与total一致)
|
||||
{
|
||||
"practiceId": "practice-001", // 字符串:练习唯一ID
|
||||
"baseSentenceEn": "I'd like to order ______", // 字符串:基础句型框架(挖空)
|
||||
"baseSentenceZh": "我想点______", // 字符串:框架中文翻译
|
||||
"keywordPool": [ // 数组:可选关键词池(3-4个)
|
||||
{
|
||||
"wordEn": "latte", // 字符串:英文关键词
|
||||
"wordZh": "拿铁", // 字符串:中文翻译
|
||||
"type": "drink" // 字符串:词汇类型(drink/custom/food等)
|
||||
}
|
||||
],
|
||||
"wrongTips": [ // 数组:常见错误提示(2-3条)
|
||||
"错误:order + bread(面包)→ 咖啡厅场景中order后优先接饮品,面包需用“have”搭配"
|
||||
],
|
||||
"extendScene": { // 对象:拓展场景(迁移练习)
|
||||
"sceneTag": "milk_tea_shop", // 字符串:拓展场景标签
|
||||
"extendSentenceEn": "I'd like to order ______", // 字符串:拓展句型框架
|
||||
"extendKeywordPool": ["milk tea", "taro balls", "sugar-free"] // 数组:拓展关键词池
|
||||
}
|
||||
}
|
||||
]
|
||||
"""
|
||||
)
|
||||
return base + "生成句型练习结构:" + struct
|
||||
return base
|
||||
return get_sentence_analysis_prompt(payload, mode)
|
||||
|
||||
@staticmethod
|
||||
async def generate_scene_sentence(image_id: int, user_id: int, payload: dict) -> dict:
|
||||
@@ -305,34 +196,38 @@ class SentenceService:
|
||||
|
||||
@staticmethod
|
||||
async def _call_scene_llm(prompt: str, image_id: int, user_id: int, chat_type: str) -> Dict[str, Any]:
|
||||
model_type = (settings.LLM_MODEL_TYPE or "").lower()
|
||||
if model_type == "qwen":
|
||||
try:
|
||||
qres = await Qwen.chat(
|
||||
messages=[{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}],
|
||||
image_id=image_id,
|
||||
user_id=user_id,
|
||||
api_type=chat_type
|
||||
)
|
||||
if qres and qres.get("success"):
|
||||
return {"success": True, "result": qres.get("result"), "image_chat_id": None, "token_usage": qres.get("token_usage") or {}}
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": False, "error": "LLM call failed"}
|
||||
else:
|
||||
try:
|
||||
res = await Hunyuan.chat(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
image_id=image_id,
|
||||
user_id=user_id,
|
||||
system_prompt=None,
|
||||
chat_type=chat_type
|
||||
)
|
||||
if res and res.get("success"):
|
||||
return res
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": False, "error": "LLM call failed"}
|
||||
messages = [
|
||||
SystemMessage(content="You are a helpful assistant."),
|
||||
HumanMessage(content=prompt)
|
||||
]
|
||||
metadata = {
|
||||
"image_id": image_id,
|
||||
"user_id": user_id,
|
||||
"api_type": chat_type,
|
||||
"model_name": settings.LLM_MODEL_TYPE
|
||||
}
|
||||
try:
|
||||
llm = LLMFactory.create_llm(settings.LLM_MODEL_TYPE)
|
||||
res = await llm.ainvoke(
|
||||
messages,
|
||||
config={"callbacks": [AuditLogCallbackHandler(metadata=metadata)]}
|
||||
)
|
||||
content = res.content
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
|
||||
token_usage = {}
|
||||
if res.response_metadata:
|
||||
token_usage = res.response_metadata.get("token_usage") or res.response_metadata.get("usage") or {}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"result": content,
|
||||
"image_chat_id": None,
|
||||
"token_usage": token_usage
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
async def generate_sentence_exercise_card(image_id: int, user_id: int, scene_tag: str, desc_en: List[str], desc_zh: List[str], core_vocab: List[str], collocations: List[str]) -> Dict[str, Any]:
|
||||
|
||||
258
backend/app/ai/tools/qa_tool.py
Normal file
258
backend/app/ai/tools/qa_tool.py
Normal file
@@ -0,0 +1,258 @@
|
||||
import asyncio
|
||||
from typing import Dict, Any, List
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import hashlib
|
||||
from dashscope import MultiModalConversation
|
||||
from backend.app.admin.service.file_service import file_service
|
||||
from langchain_core.messages import SystemMessage, HumanMessage
|
||||
from backend.core.llm import LLMFactory, AuditLogCallbackHandler
|
||||
from backend.core.conf import settings
|
||||
from backend.core.prompts.scene_variation import get_scene_variation_prompt
|
||||
from backend.database.redis import redis_client
|
||||
|
||||
class SceneVariationGenerator:
|
||||
"""
|
||||
Component for generating scene variations text (Step 1 of the advanced workflow).
|
||||
Using LangChain for LLM interaction.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def generate(
|
||||
payload: Dict[str, Any],
|
||||
image_id: int,
|
||||
user_id: int,
|
||||
model_name: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate scene variations based on image payload.
|
||||
|
||||
Args:
|
||||
payload: Dict containing description, core_vocab, collocations, scene_tag
|
||||
image_id: ID of the source image
|
||||
user_id: ID of the requesting user
|
||||
model_name: Optional model override
|
||||
|
||||
Returns:
|
||||
Dict containing success status, result (parsed JSON), and token usage
|
||||
"""
|
||||
prompt = get_scene_variation_prompt(payload)
|
||||
|
||||
messages = [
|
||||
SystemMessage(content="You are a helpful assistant specialized in creating educational content variations."),
|
||||
HumanMessage(content=prompt)
|
||||
]
|
||||
|
||||
metadata = {
|
||||
"image_id": image_id,
|
||||
"user_id": user_id,
|
||||
"api_type": "scene_variation",
|
||||
"model_name": model_name or settings.LLM_MODEL_TYPE
|
||||
}
|
||||
|
||||
try:
|
||||
llm = LLMFactory.create_llm(model_name or settings.LLM_MODEL_TYPE)
|
||||
res = await llm.ainvoke(
|
||||
messages,
|
||||
config={"callbacks": [AuditLogCallbackHandler(metadata=metadata)]}
|
||||
)
|
||||
|
||||
content = res.content
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
|
||||
# Clean up potential markdown code blocks
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
token_usage = {}
|
||||
if res.response_metadata:
|
||||
token_usage = res.response_metadata.get("token_usage") or res.response_metadata.get("usage") or {}
|
||||
|
||||
try:
|
||||
parsed_result = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Failed to parse LLM response as JSON",
|
||||
"raw_content": content
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"result": parsed_result,
|
||||
"token_usage": token_usage
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
class QwenImageKeyManager:
|
||||
RATE_LIMIT_PER_SECOND = 2
|
||||
WINDOW_SECONDS = 1
|
||||
REDIS_PREFIX = "qwen:image_edit:rate"
|
||||
|
||||
@classmethod
|
||||
def _get_keys(cls) -> List[str]:
|
||||
raw = settings.QWEN_API_KEY or ""
|
||||
if not raw:
|
||||
raw = os.getenv("DASHSCOPE_API_KEY") or ""
|
||||
parts = [p.strip() for p in raw.split(";") if p.strip()]
|
||||
if parts:
|
||||
return parts
|
||||
if raw:
|
||||
return [raw]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _key_id(cls, key: str) -> str:
|
||||
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
||||
|
||||
@classmethod
|
||||
async def acquire_key(cls) -> str:
|
||||
keys = cls._get_keys()
|
||||
if not keys:
|
||||
raise Exception("Qwen API key not configured")
|
||||
now = int(time.time())
|
||||
n = len(keys)
|
||||
start = now % n
|
||||
for i in range(n):
|
||||
key = keys[(start + i) % n]
|
||||
kid = cls._key_id(key)
|
||||
redis_key = f"{cls.REDIS_PREFIX}:{kid}:{now}"
|
||||
count = await redis_client.incr(redis_key)
|
||||
if count == 1:
|
||||
await redis_client.expire(redis_key, cls.WINDOW_SECONDS + 1)
|
||||
if count <= cls.RATE_LIMIT_PER_SECOND:
|
||||
return key
|
||||
await asyncio.sleep(1.0 / cls.RATE_LIMIT_PER_SECOND)
|
||||
now2 = int(time.time())
|
||||
for i in range(n):
|
||||
key = keys[(start + i) % n]
|
||||
kid = cls._key_id(key)
|
||||
redis_key = f"{cls.REDIS_PREFIX}:{kid}:{now2}"
|
||||
count = await redis_client.incr(redis_key)
|
||||
if count == 1:
|
||||
await redis_client.expire(redis_key, cls.WINDOW_SECONDS + 1)
|
||||
if count <= cls.RATE_LIMIT_PER_SECOND:
|
||||
return key
|
||||
raise Exception("Qwen image edit rate limited")
|
||||
|
||||
|
||||
class Illustrator:
|
||||
"""
|
||||
Component for generating edited images based on text descriptions (Step 2 of the advanced workflow).
|
||||
Uses Dashscope MultiModalConversation API.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def generate_image(
|
||||
original_image_url: str,
|
||||
edit_prompt: str,
|
||||
api_key: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Call Dashscope API to edit an image based on the prompt.
|
||||
Note: This is a blocking call wrapper.
|
||||
"""
|
||||
import dashscope
|
||||
dashscope.base_http_api_url = 'https://dashscope.aliyuncs.com/api/v1'
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"image": original_image_url},
|
||||
{"text": edit_prompt}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
if api_key:
|
||||
final_api_key = api_key
|
||||
else:
|
||||
final_api_key = await QwenImageKeyManager.acquire_key()
|
||||
response = await asyncio.to_thread(
|
||||
MultiModalConversation.call,
|
||||
api_key=final_api_key,
|
||||
model="qwen-image-edit-plus",
|
||||
messages=messages,
|
||||
stream=False,
|
||||
n=1,
|
||||
watermark=False,
|
||||
negative_prompt="低质量, 模糊, 扭曲",
|
||||
prompt_extend=True,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
image_url = response.output.choices[0].message.content[0]['image']
|
||||
return {"success": True, "image_url": image_url}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"API Error {response.code}: {response.message}",
|
||||
"status_code": response.status_code
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
async def process_variations(
|
||||
original_file_id: int,
|
||||
user_id: int,
|
||||
variations: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process multiple variations in parallel.
|
||||
|
||||
Args:
|
||||
original_file_id: The file ID of the original image
|
||||
user_id: The user ID for permission check
|
||||
variations: List of variation dicts (from SceneVariationGenerator)
|
||||
|
||||
Returns:
|
||||
List of variations with added 'generated_image_url' field
|
||||
"""
|
||||
# 1. Get original image URL
|
||||
try:
|
||||
original_url = await file_service.get_presigned_download_url(original_file_id, user_id, True)
|
||||
if not original_url:
|
||||
raise Exception("Failed to get download URL for original image")
|
||||
except Exception as e:
|
||||
# If we can't get the original image, fail all
|
||||
for v in variations:
|
||||
v['error'] = f"Original image access failed: {str(e)}"
|
||||
v['success'] = False
|
||||
return variations
|
||||
|
||||
# 2. Create tasks for parallel execution
|
||||
tasks = []
|
||||
for variation in variations:
|
||||
# Construct the edit prompt based on modification point and description
|
||||
# We combine them to give the model better context
|
||||
edit_prompt = f"{variation.get('modification_point', '')}. Describe the image with the following detail: {variation.get('desc_en', '')}"
|
||||
|
||||
tasks.append(
|
||||
Illustrator.generate_image(
|
||||
original_image_url=original_url,
|
||||
edit_prompt=edit_prompt
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Execute in parallel
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# 4. Merge results back into variations
|
||||
for i, res in enumerate(results):
|
||||
if res.get('success'):
|
||||
variations[i]['generated_image_url'] = res.get('image_url')
|
||||
variations[i]['success'] = True
|
||||
else:
|
||||
variations[i]['error'] = res.get('error')
|
||||
variations[i]['success'] = False
|
||||
|
||||
return variations
|
||||
@@ -4,12 +4,15 @@
|
||||
IMAGE_RECOGNITION_COST = 1 # 1000 / 1
|
||||
SPEECH_ASSESSMENT_COST = 1
|
||||
LLM_CHAT_COST = 1
|
||||
IMAGE_GENERATION_COST = 20
|
||||
TTS_STANDARD_AUDIO_COST = 1
|
||||
|
||||
QWEN_TOKEN_COST = 0.002
|
||||
# Points action types
|
||||
POINTS_ACTION_SYSTEM_GIFT = "system_gift"
|
||||
POINTS_ACTION_IMAGE_RECOGNITION = "image_recognition"
|
||||
POINTS_ACTION_SPEECH_ASSESSMENT = "speech_assessment"
|
||||
POINTS_ACTION_TTS_STANDARD_AUDIO = "tts_standard_audio"
|
||||
POINTS_ACTION_RECHARGE = "recharge"
|
||||
POINTS_ACTION_COUPON = "coupon"
|
||||
POINTS_ACTION_SPEND = "spend"
|
||||
|
||||
127
backend/core/llm.py
Normal file
127
backend/core/llm.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.outputs import LLMResult
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_community.chat_models import ChatTongyi, ChatHunyuan
|
||||
|
||||
from backend.app.admin.schema.audit_log import CreateAuditLogParam
|
||||
from backend.app.admin.service.audit_log_service import audit_log_service
|
||||
from backend.core.conf import settings
|
||||
from backend.common.log import log as logger
|
||||
|
||||
|
||||
def _get_primary_qwen_api_key() -> str:
|
||||
raw = settings.QWEN_API_KEY or ""
|
||||
parts = [p.strip() for p in raw.split(";") if p.strip()]
|
||||
if parts:
|
||||
return parts[0]
|
||||
return raw
|
||||
|
||||
|
||||
class AuditLogCallbackHandler(BaseCallbackHandler):
|
||||
def __init__(self, metadata: Optional[Dict[str, Any]] = None):
|
||||
super().__init__()
|
||||
self.metadata = metadata or {}
|
||||
self.start_time = 0.0
|
||||
|
||||
async def on_chat_model_start(
|
||||
self, serialized: Dict[str, Any], messages: List[List[BaseMessage]], **kwargs: Any
|
||||
) -> Any:
|
||||
self.start_time = time.time()
|
||||
if 'metadata' in kwargs:
|
||||
self.metadata.update(kwargs['metadata'])
|
||||
|
||||
# Capture messages for audit log
|
||||
try:
|
||||
msgs = []
|
||||
if messages and len(messages) > 0:
|
||||
for m in messages[0]:
|
||||
msgs.append({"role": m.type, "content": m.content})
|
||||
self.metadata['messages'] = msgs
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def on_llm_end(self, response: LLMResult, **kwargs: Any) -> Any:
|
||||
duration = time.time() - (self.start_time or time.time())
|
||||
try:
|
||||
# Extract info from the first generation
|
||||
generation = response.generations[0][0]
|
||||
message = generation.message
|
||||
content = message.content
|
||||
|
||||
# Token usage
|
||||
token_usage = response.llm_output.get("token_usage") or {}
|
||||
if not token_usage and message.response_metadata:
|
||||
token_usage = message.response_metadata.get("token_usage") or message.response_metadata.get("usage") or {}
|
||||
|
||||
model_name = response.llm_output.get("model_name") or self.metadata.get("model_name") or "unknown"
|
||||
|
||||
# Construct log
|
||||
log_param = CreateAuditLogParam(
|
||||
api_type=self.metadata.get("api_type", "chat"),
|
||||
model_name=model_name,
|
||||
request_data={"messages": self.metadata.get("messages")},
|
||||
response_data={"content": content, "metadata": message.response_metadata},
|
||||
token_usage=token_usage,
|
||||
cost=0.0,
|
||||
duration=duration,
|
||||
status_code=200,
|
||||
called_at=datetime.now(),
|
||||
image_id=self.metadata.get("image_id", 0),
|
||||
user_id=self.metadata.get("user_id", 0),
|
||||
api_version=settings.FASTAPI_API_V1_PATH,
|
||||
error_message=""
|
||||
)
|
||||
await audit_log_service.create(obj=log_param)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write audit log: {e}")
|
||||
|
||||
async def on_llm_error(self, error: BaseException, **kwargs: Any) -> Any:
|
||||
duration = time.time() - (self.start_time or time.time())
|
||||
try:
|
||||
log_param = CreateAuditLogParam(
|
||||
api_type=self.metadata.get("api_type", "chat"),
|
||||
model_name=self.metadata.get("model_name", "unknown"),
|
||||
request_data={"metadata": self.metadata},
|
||||
response_data={"error": str(error)},
|
||||
token_usage={},
|
||||
cost=0.0,
|
||||
duration=duration,
|
||||
status_code=500,
|
||||
called_at=datetime.now(),
|
||||
image_id=self.metadata.get("image_id", 0),
|
||||
user_id=self.metadata.get("user_id", 0),
|
||||
api_version=settings.FASTAPI_API_V1_PATH,
|
||||
error_message=str(error)
|
||||
)
|
||||
await audit_log_service.create(obj=log_param)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write audit log on error: {e}")
|
||||
|
||||
class LLMFactory:
|
||||
@staticmethod
|
||||
def create_llm(model_type: str = None, **kwargs):
|
||||
model_type = (model_type or settings.LLM_MODEL_TYPE or "qwen").lower()
|
||||
|
||||
if model_type == 'qwen':
|
||||
return ChatTongyi(
|
||||
api_key=_get_primary_qwen_api_key(),
|
||||
model_name=settings.QWEN_TEXT_MODEL,
|
||||
**kwargs
|
||||
)
|
||||
elif model_type == 'hunyuan':
|
||||
return ChatHunyuan(
|
||||
hunyuan_secret_id=settings.HUNYUAN_SECRET_ID,
|
||||
hunyuan_secret_key=settings.HUNYUAN_SECRET_KEY,
|
||||
**kwargs
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Unknown model type {model_type}, defaulting to Qwen")
|
||||
return ChatTongyi(
|
||||
api_key=_get_primary_qwen_api_key(),
|
||||
model_name=settings.QWEN_TEXT_MODEL,
|
||||
**kwargs
|
||||
)
|
||||
257
backend/core/prompts/free_conversation.py
Normal file
257
backend/core/prompts/free_conversation.py
Normal file
@@ -0,0 +1,257 @@
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
def get_free_conversation_start_prompt(
|
||||
scene: List[str],
|
||||
event: List[str],
|
||||
user_role: Optional[str],
|
||||
assistant_role: Optional[str],
|
||||
style: Optional[str],
|
||||
level: Optional[str],
|
||||
info: Optional[str],
|
||||
description: str,
|
||||
) -> str:
|
||||
scene_str = ", ".join(scene) if scene else ""
|
||||
event_str = ", ".join(event) if event else ""
|
||||
info_str = info or ""
|
||||
conversation_style = style or ""
|
||||
user_role_str = user_role or ""
|
||||
assistant_role_str = assistant_role or ""
|
||||
level_str = level or "easy"
|
||||
level_prompt = """
|
||||
— Basic Communication, Short & Simple (Like a 5-year-old speaking)
|
||||
- **Vocabulary**: Only high-frequency daily words (≤2-syllable words, e.g., food, drink, table, happy; avoid rare words like "delicious" → use "good", "tasty" max)
|
||||
- **Grammar**: Limited to 3 basic structures:
|
||||
1. Simple present tense (I like this.)
|
||||
2. Present continuous tense (The food is hot.)
|
||||
3. Basic modal verbs (can/will, e.g., I can eat.)
|
||||
- **Sentence Length**: ≤10 words per sentence; 1-2 sentences per response (no complex clauses)
|
||||
- **Expression Goal**: Only complete basic communication (greet, ask simple questions, state likes/dislikes)
|
||||
- **Style Adaptation**: Even formal style stays simple (e.g., formal → "Can you help me?" instead of "Would you mind assisting me?")
|
||||
"""
|
||||
if level_str.lower() == "medium":
|
||||
level_prompt = """
|
||||
— Detailed Discussion + Emotional Expression
|
||||
- **Vocabulary**: High-frequency words + scene-specific collocations (e.g., restaurant → "menu, order, signature dish"; meeting → "task, deadline, suggestion")
|
||||
- **Grammar**: Basic structures + limited complex ones:
|
||||
1. Present perfect tense (I have tried this before.)
|
||||
2. Simple conditional sentences (If we order steak, it will be good.)
|
||||
3. Coordinate clauses (and/but/or, e.g., The food is good but expensive.)
|
||||
- **Sentence Length**: ≤15 words per sentence; 1-2 sentences per response
|
||||
- **Expression Goal**: Add details (e.g., "The steak is hot and juicy") + emotional words (e.g., happy, excited, worried, tired)
|
||||
- **Style Adaptation**: Match style with emotion (e.g., casual → "This is awesome!"; formal → "I am pleased with this plan.")
|
||||
"""
|
||||
elif level_str.lower() == "hard":
|
||||
level_prompt = """
|
||||
Daily Communication + Communication Skills + Extended Expression
|
||||
- **Vocabulary**: Daily words + collocations + advanced synonyms/extended phrases (e.g., "good" → "delicious, flavorful, mouth-watering"; "ask" → "inquire about, seek advice on")
|
||||
- **Grammar**: Full range of structures + sophisticated usage:
|
||||
1. Complex conditional sentences (If we had started earlier, we could have finished on time.)
|
||||
2. Inversion (Rarely do we see such a great plan.)
|
||||
3. Attributive clauses (The restaurant that we visited yesterday is great.)
|
||||
- **Sentence Length**: ≤20 words per sentence; 1-2 sentences per response (concise but rich)
|
||||
- **Expression Goal**:
|
||||
1. Basic communication + details + emotion
|
||||
2. Add **communication skills**: euphemism (e.g., "I’m not sure if this works" instead of "This is bad"), topic guidance (e.g., "Speaking of which, what do you think about..."), persuasion (e.g., "Considering the deadline, we should prioritize this task")
|
||||
3. Extended expression: paraphrase, cultural references (e.g., "This steak is as good as the one in New York")
|
||||
- **Style Adaptation**: Style drives skill usage (e.g., professional → use logical persuasion; friendly → use casual euphemism)
|
||||
"""
|
||||
|
||||
base = f"""
|
||||
You are a professional English conversation partner for intermediate English learners. Follow the rules below to conduct natural, targeted multi-round conversations and output structured JSON.
|
||||
|
||||
// Mandatory Configuration (Fill in before conversation starts)
|
||||
- Scene: {scene_str} (e.g., restaurant)
|
||||
- Event: {event_str} (e.g., dining with friends)
|
||||
- Your Role: {assistant_role_str} (paired with user's role {user_role_str})
|
||||
- User Role: {user_role_str}
|
||||
- The tone and style of the dialogue: {conversation_style}
|
||||
- English Level: {level_str} (fixed as intermediate)
|
||||
- Extra Info: {info_str} (supplementary background)
|
||||
- Image Description: {description} (image details)
|
||||
|
||||
// Level-Specific Language Rules (Dynamic & Mandatory)
|
||||
{level_prompt}
|
||||
|
||||
// Conversation Rules (Strictly Follow)
|
||||
1. **Role & Style Alignment (Critical)**
|
||||
- Stick to your {assistant_role} and strictly match the {conversation_style} requirement:
|
||||
- Casual: Use colloquial English, contractions (wanna, gonna, don’t), short sentences, and friendly tone (fit daily chats/dining with friends).
|
||||
- Formal: Use complete sentences, polite expressions (would you mind, I would suggest), avoid contractions (fit business meetings/negotiations).
|
||||
- Professional: Focus on logicality and persuasion, use scene-specific terminology, clear structure (fit work discussions/training).
|
||||
- Friendly: Warm and approachable, add appropriate emotional words (great, awesome, nice) (fit chatting with acquaintances).
|
||||
- Use intermediate English: scene-specific vocabulary (no rare words), grammar includes complex clauses/present perfect/conditional sentences (avoid overly simple/advanced structures).
|
||||
- Naturally integrate {description} and {info_str} into the conversation (e.g., mention "steak" or "newly opened restaurant").
|
||||
|
||||
2. **Initiation & Anti-Awkwardness Requirements**
|
||||
- You speak first to start the conversation; opening is natural and scene-relevant (no abruptness).
|
||||
- Generate **3 categorized alternative user responses** (1 for each type: positive/neutral/negative) to avoid user awkwardness. Each type must meet:
|
||||
- Consistent with {user_role}, {scene_str}, {event_str} and {conversation_style};
|
||||
- Intermediate-level English (fit user's ability);
|
||||
- Short (1 sentence each, easy for user to choose/modify);
|
||||
- Clear emotional orientation:
|
||||
- Positive: Agree, approve, show enthusiasm (e.g., "That’s a great idea! I love steak!");
|
||||
- Neutral: Objective statement, ask factual questions (e.g., "I haven’t tried it before. Is it expensive?");
|
||||
- Negative: Polite refusal, express doubts (e.g., "I’m not a fan of steak. Do they have seafood?").
|
||||
|
||||
3. **Multi-round Coherence**
|
||||
- This is an incremental conversation: always reference historical dialogue content (never ignore user's previous words).
|
||||
- Keep your response concise (1-2 English sentences, easy for user to follow).
|
||||
- Gently correct user's grammar/vocab mistakes without disrupting flow (e.g., "You can say 'I like this restaurant' instead of 'I like this restaurants' 😊").
|
||||
|
||||
4. **JSON Output Format (Mandatory, No Extra Text)**
|
||||
- Only output a valid JSON string (parseable by JSON.parse), no explanations/role labels.
|
||||
- Fields definition:
|
||||
{{
|
||||
"response_en": "Your English conversation content (1-2 sentences, match {conversation_style})",
|
||||
"response_zh": "Chinese translation of response_en",
|
||||
"prompt_en": "Friendly guide for user to reply",
|
||||
"prompt_zh": "Chinese translation of prompt_en",
|
||||
"alternative_responses": {{
|
||||
"positive": {{
|
||||
"alt_en": "Positive user response (English, 1 sentence)",
|
||||
"alt_zh": "Chinese translation of positive response"
|
||||
}},
|
||||
"neutral": {{
|
||||
"alt_en": "Neutral user response (English, 1 sentence)",
|
||||
"alt_zh": "Chinese translation of neutral response"
|
||||
}},
|
||||
"negative": {{
|
||||
"alt_en": "Negative user response (English, 1 sentence)",
|
||||
"alt_zh": "Chinese translation of negative response"
|
||||
}}
|
||||
}}
|
||||
"correction": "Grammar/vocab correction (English, empty string if no mistake in user's last input)"
|
||||
}}
|
||||
- When appending new conversations, update the JSON based on full dialogue history and maintain style consistency.
|
||||
|
||||
// Output Constraint
|
||||
- Strictly follow the JSON format; any deviation (extra text/invalid fields) is not allowed.
|
||||
"""
|
||||
return base.strip()
|
||||
|
||||
|
||||
def get_free_conversation_reply_prompt(
|
||||
history: List[dict],
|
||||
user_input: str,
|
||||
scene: List[str],
|
||||
event: List[str],
|
||||
user_role: Optional[str],
|
||||
assistant_role: Optional[str],
|
||||
style: Optional[str],
|
||||
level: Optional[str],
|
||||
info: Optional[str],
|
||||
description: str,
|
||||
) -> str:
|
||||
scene_str = ", ".join(scene) if scene else ""
|
||||
event_str = ", ".join(event) if event else ""
|
||||
info_str = info or ""
|
||||
conversation_style = style or ""
|
||||
user_role_str = user_role or ""
|
||||
assistant_role_str = assistant_role or ""
|
||||
level_str = level or "easy"
|
||||
level_prompt="""
|
||||
— Short, Simple, Basic Communication
|
||||
- Vocabulary: Only 1-2 syllable high-frequency words (e.g., food, hot, nice; no complex words)
|
||||
- Grammar: Limited to simple present/present continuous/basic modals (can/will)
|
||||
- Sentence Length: ≤10 words per sentence; 1 sentence max for your response
|
||||
- Correction Style: Direct + simple example (e.g., "Say 'I like it' not 'I like'")
|
||||
"""
|
||||
if level_str == "medium":
|
||||
level_prompt = """
|
||||
Detailed, Emotional, Scene-Specific
|
||||
- Vocabulary: High-frequency words + scene collocations (e.g., restaurant → menu, order)
|
||||
- Grammar: Basic structures + present perfect/simple conditionals (if...then...)
|
||||
- Sentence Length: ≤15 words per sentence; 1-2 sentences for your response
|
||||
- Correction Style: Polite + brief reason (e.g., "Use 'have tried' because it’s a past experience")
|
||||
"""
|
||||
elif level_str == "hard":
|
||||
level_prompt = """
|
||||
Sophisticated, Skillful, Extended Expression
|
||||
- Vocabulary: Daily words + collocations + advanced synonyms (e.g., good → delicious, flavorful)
|
||||
- Grammar: Complex structures (attributive clauses, inversion) + communication skills (euphemism, persuasion)
|
||||
- Sentence Length: ≤20 words per sentence; 1-2 sentences for your response
|
||||
- Correction Style: Polite + optimization suggestion (e.g., "You can say 'I’m afraid this may not work' to sound more formal")
|
||||
"""
|
||||
|
||||
# Construct history string
|
||||
history_str = ""
|
||||
for msg in history:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
history_str += f"{role}: {content}\n"
|
||||
|
||||
base = f"""
|
||||
You are a professional English conversation partner specialized in **continuing multi-round dialogues**. Your core task is to follow up based on conversation history and the user's new input, while maintaining consistency of role, style, and difficulty. Output **only valid JSON** (parseable by JSON.parse), no extra text/explanations.
|
||||
|
||||
// Mandatory Context (Inherited from Initialization, Do Not Modify)
|
||||
- Scene: {scene_str} (e.g., restaurant, meeting room)
|
||||
- Event: {event_str} (e.g., dining with friends, project discussion)
|
||||
- Your Role: {assistant_role_str} (e.g., friend, project manager)
|
||||
- User Role: {user_role_str} (e.g., customer, team member)
|
||||
- Conversation Style: {conversation_style} (e.g., casual/formal/professional; strictly adhere to style norms)
|
||||
- English Level: {level_str} (beginner/intermediate/advanced; follow level-specific language rules below)
|
||||
- Image Context: {description} (core elements of the image, integrate naturally)
|
||||
- Extra Background: {info_str} (supplementary details, reference when relevant)
|
||||
|
||||
// Critical Conversation History (Must Reference to Ensure Coherence)
|
||||
{history_str}
|
||||
|
||||
// User's New Input (Core Analysis Object)
|
||||
User: {user_input}
|
||||
|
||||
// Level-Specific Language Rules (Strictly Follow for All Outputs)
|
||||
{level_prompt}
|
||||
|
||||
// Core Instructions (Priority Order: Coherence > Error Correction > Natural Progression)
|
||||
1. **Analyze User Input & Correct Errors (Critical)**
|
||||
- Check for grammar, vocabulary, spelling mistakes. If any, write a **level-matched polite correction** in the "correction" field (empty string if no errors).
|
||||
- If input is empty/unclear/irrelevant to scene/event: Politely ask for clarification (match your role/style/level), and skip alternative responses temporarily if needed.
|
||||
- If input deviates from the topic: Gently guide back to the scene/event (e.g., "That’s interesting! By the way, what do you think of the steak here?")
|
||||
|
||||
2. **Generate Your Response (Strictly Bound to Context)**
|
||||
- Stay in your role ({assistant_role_str}) and match {conversation_style} (e.g., casual → use contractions; formal → avoid contractions).
|
||||
- **Must reference the conversation history** (e.g., if user mentioned "I don’t like steak" before, don’t ask "Do you like steak?").
|
||||
- Integrate {description} and {info_str} naturally (avoid forced references).
|
||||
- Keep it concise (follow level-specific sentence rules) and **advance the conversation** (don’t repeat the same topic).
|
||||
|
||||
3. **Generate Guide Prompt (Help User Continue Talking)**
|
||||
- Write a **topic-related suggestion** (in English and Chinese) for what the user can say next (e.g., "Talk about your favorite food" / "聊聊你最喜欢的菜").
|
||||
- Prompt must be closely related to YOUR current response (not the user’s input alone).
|
||||
|
||||
4. **Generate Emotional Alternative Responses (3 Types)**
|
||||
- Create 3 options (positive/neutral/negative) for the user to reply to **YOUR response** (not the history).
|
||||
- Each alternative must match {level_str}, {conversation_style}, and the current dialogue context.
|
||||
- Each alternative is 1 sentence only; translations must be accurate and colloquial.
|
||||
|
||||
// Output Format (JSON Only, No Deviation Allowed)
|
||||
{{
|
||||
"response_en": "Your level/style-matched English response (1-2 sentences max)",
|
||||
"response_zh": "Accurate colloquial Chinese translation of your response",
|
||||
"prompt_en": "English guide prompt (suggest what user can say next)",
|
||||
"prompt_zh": "Colloquial Chinese translation of the guide prompt",
|
||||
"alternative_responses": {{
|
||||
"positive": {{
|
||||
"alt_en": "Level/style-matched positive response to YOUR reply (1 sentence)",
|
||||
"alt_zh": "Colloquial Chinese translation"
|
||||
}},
|
||||
"neutral": {{
|
||||
"alt_en": "Level/style-matched neutral response to YOUR reply (1 sentence)",
|
||||
"alt_zh": "Colloquial Chinese translation"
|
||||
}},
|
||||
"negative": {{
|
||||
"alt_en": "Level/style-matched negative response to YOUR reply (1 sentence)",
|
||||
"alt_zh": "Colloquial Chinese translation"
|
||||
}}
|
||||
}},
|
||||
"correction": "Level-matched polite correction (empty string if no errors; English only)"
|
||||
}}
|
||||
|
||||
// Forbidden Behaviors
|
||||
- Do NOT reintroduce the scene/event (history already includes it).
|
||||
- Do NOT use vocabulary/grammar beyond the specified {level_str}.
|
||||
- Do NOT generate responses unrelated to the conversation history.
|
||||
- Do NOT output anything except the required JSON.
|
||||
"""
|
||||
return base.strip()
|
||||
|
||||
28
backend/core/prompts/qa_exercise.py
Normal file
28
backend/core/prompts/qa_exercise.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import json
|
||||
|
||||
def get_qa_exercise_prompt(payload: dict) -> str:
|
||||
return (
|
||||
'### 任务目标\n'
|
||||
'请基于给定的图片英语描述,生成【3-4个细节类半开放问题】,返回包含**问题、多版本回答、正确/错误选项、填词模式**的结构化JSON数据,用于英语口语练习程序自动化调用。\n'
|
||||
'### 图片描述\n'
|
||||
+ json.dumps(payload, ensure_ascii=False) + '\n'
|
||||
'### 生成要求\n'
|
||||
'1. 问题规则:细节类半开放特殊疑问句,覆盖至少2个维度(主体特征/动作行为/场景环境), 每个问题的维度不能重复,题干和选项都是英文;\n'
|
||||
'2. JSON数据规则:\n'
|
||||
' - 根节点:`qa_list`(数组,3-4个问答对象);\n'
|
||||
' - 每个问答对象字段:\n'
|
||||
' 1. `question`:问题内容;\n'
|
||||
' 2. `dimension`:考察维度;\n'
|
||||
' 3. `key_pronunciation_words`:核心发音单词(2-3个);\n'
|
||||
' 4. `answers`:多版本回答(spoken/written/friendly);\n'
|
||||
' 5. `correct_options`:正确选项数组(含`content`/`type`字段),每个选项都是一个陈述句;\n'
|
||||
' 6. `incorrect_options`:错误选项数组(含`content`/`error_type`/`error_reason`字段),无语法类干扰;\n'
|
||||
' 7. `cloze`:填词模式专项字段:\n'
|
||||
' - `correct_word`:填空处原词,一个正确选项;\n'
|
||||
' - `sentence`:含 correct_word 的完整句子;\n'
|
||||
' - `distractor_words`:近义词干扰项数组(3-4个,无语法类干扰)。\n'
|
||||
'3. 输出限制:仅返回JSON字符串,无其他解释文字,确保可被`JSON.parse`直接解析。\n'
|
||||
'输入图片描述:' + json.dumps(payload, ensure_ascii=False) + '\n'
|
||||
'### 输出JSON格式\n'
|
||||
'{ "qa_list": [ { "question": "", "dimension": "", "key_pronunciation_words": [], "answers": { "spoken": "", "written": "", "friendly": "", "lively": "" }, "correct_options": [ { "content": "", "type": "core" } ], "incorrect_options": [ { "content": "", "error_type": "词汇混淆", "error_reason": "" } ], "cloze": { "sentence": "", "correct_word": "", "distractor_words": [] } } ] }'
|
||||
)
|
||||
184
backend/core/prompts/recognition.py
Normal file
184
backend/core/prompts/recognition.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from typing import List
|
||||
|
||||
def get_recognition_prompt(type: str, exclude_words: List[str] | None = None) -> str:
|
||||
"""获取图像识别提示词"""
|
||||
|
||||
if type == 'word':
|
||||
prompt = (
|
||||
"""
|
||||
Vision-to-English-Chinese education module.
|
||||
Core objective: Analyze the image based on its PRIMARY SCENE (e.g., office, restaurant, subway, kitchen) and CENTRAL OBJECTS, generate English-Chinese sentence pairs for three learning levels (matching primary/intermediate/advanced English learners), with sentences focused on PRACTICAL, REUSABLE communication (not just grammatical complexity).
|
||||
|
||||
// LEVEL Definition (Binding learning goals + functions + complexity)
|
||||
level1 (Beginner):
|
||||
- Learning goal: Recognize core vocabulary + use basic functional sentences (describe objects/scenes, simple requests)
|
||||
- Vocab: High-frequency daily words (no uncommon words)
|
||||
- Grammar: Present continuous, modal verbs (can/could/would), simple clauses
|
||||
- Word count per sentence: ≤15 words
|
||||
- Sentence type: 6 unique functional types (detailed description, polite request, ask for information, suggest action, state need, confirm fact, express feeling)
|
||||
- The sentence structure of the described object: quantity + name + feature + purpose.
|
||||
|
||||
level2 (Intermediate):
|
||||
- Learning goal: Master scene-specific collocations + practical communication sentences (daily/office interaction)
|
||||
- Vocab: Scene-specific common words + fixed collocations (e.g., "print a document", "place an order")
|
||||
- Grammar: Complex clauses, passive voice, subjunctive mood (as appropriate to the scene)
|
||||
- Word count per sentence: ≤25 words
|
||||
- Sentence type: 8-12 unique functional types (detailed scene analysis, formal/informal contrast, conditional statement, explain purpose, ask follow-up questions, express suggestion, summarize information, clarify meaning)
|
||||
|
||||
// Output Requirements
|
||||
1. JSON Structure (add core vocab/collocation for easy parsing):
|
||||
{
|
||||
"scene_tag": ["xxx", "xxx"], // e.g., "office", "café", "supermarket" (Multiple tags that are consistent with the main scene of the picture)
|
||||
"description": "", // Clear and accurate description of the content of the picture, including but not limited to objects, relationships, colors, etc.
|
||||
"level1": {
|
||||
"desc_en": ["sentence1", "sentence2", ...], // 6 to 8 distinct sentences with different modalities (without repeating the same meaning or function. Don't use Chinese. Consistent with native English speakers' daily communication habits)
|
||||
"desc_zh": ["translation1", "translation2", ...], // one-to-one with desc_en, chinese translation must be natural and not stiff, consistent with native English speakers' daily communication habits.
|
||||
},
|
||||
"level2": {
|
||||
"desc_en": [
|
||||
"Requirement: 8-12 daily spoken English sentences matching the image scenario (prioritize short sentences, ≤20 words)",
|
||||
"Type: Declarative sentences / polite interrogative sentences that can be used directly (avoid formal language and complex clauses)",
|
||||
"Scenario Adaptation: Strictly align with the real-life scenario shown in the image (e.g., restaurant ordering, asking for directions on the subway, chatting with friends, etc.)",
|
||||
"Core Principle: Natural and not stiff, consistent with native English speakers' daily communication habits (e.g., prefer \"How's it going?\" over \"How are you recently?\")"
|
||||
],
|
||||
"desc_zh": [
|
||||
"Requirement: Colloquial Chinese translations of the corresponding English sentences",
|
||||
"Principle: Avoid literal translations and formal expressions; conform to daily Chinese speaking habits (e.g., translate \"Could you pass the salt?\" as \"能递下盐吗?\" instead of \"你能把盐递给我吗?\")",
|
||||
"Adaptability: Translations should fit the logical expression of Chinese scenarios (e.g., more polite for workplace communication, more casual for friend chats)"
|
||||
],
|
||||
"core_vocab": [
|
||||
"Requirement: 5-8 core spoken words for the scenario",
|
||||
"Standard: High-frequency daily use (avoid rare words and academic terms); can directly replace key words in sentences for reuse",
|
||||
"Example: For the \"supermarket shopping\" scenario, prioritize words like \"discount, check out, cart\" that can be directly applied to sentences"
|
||||
],
|
||||
"collocations": [
|
||||
"Requirement: 5-8 high-frequency spoken collocations for the scenario",
|
||||
"Standard: Short and practical fixed collocations; can be used by directly replacing core words (avoid complex phrases)",
|
||||
"Example: For the \"food delivery ordering\" scenario, collocations include \"order food, pick up the phone (for delivery calls), track the order\""
|
||||
],
|
||||
"pragmatic_notes": [
|
||||
"Requirement: 2-4 scenario-specific pragmatic notes (avoid general descriptions)",
|
||||
"Content: Clear usage scenarios + tone adaptation + practical skills (e.g., \"Suitable for chatting with friends; casual tone; starting with the filler word 'actually' makes it more natural\")",
|
||||
"Practical Value: Include \"replacement skills\" (e.g., \"Sentence pattern 'I'm in the mood for + [food]' can be used by directly replacing the food noun\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
2. Uniqueness: No repetition in SEMANTICS/FUNCTIONS (not just literal repetition) — e.g., avoid two sentences both meaning "This is a laptop" (even with different wording).
|
||||
3. Focus: Prioritize ARTIFICIAL/CENTRAL objects and PRIMARY scene (ignore trivial background elements) — e.g., for a café image, focus on "coffee", "barista", "menu" (not "wall", "floor").
|
||||
4. Practicality: All sentences must be directly usable in real-life communication (avoid meaningless grammatical exercises like "I am eat a apple" corrected to "I am eating an apple").
|
||||
5. Accuracy: Translations must be accurate (not literal) and match the context of the image scene.
|
||||
6. Output Limit: Only return the JSON string, without any explanatory text. Ensure that it can be directly parsed by `JSON.parse`.
|
||||
"""
|
||||
)
|
||||
|
||||
if exclude_words:
|
||||
exclude_str = ". ".join(exclude_words)
|
||||
prompt += f"Avoid using these words: {exclude_str}."
|
||||
|
||||
return prompt
|
||||
|
||||
elif type == 'food':
|
||||
return (
|
||||
"你是一个专业美食识别AI,请严格按以下步骤分析图片:\n"
|
||||
"1. 识别最显著菜品名称(需具体到品种/烹饪方式):\n"
|
||||
"- 示例:清蒸鲈鱼(非清蒸鱼)、罗宋汤(非蔬菜汤)\n"
|
||||
"- 无法确定具体菜品时返回“无法识别出菜品”\n"
|
||||
"2. 提取核心食材(3-5种主料):\n"
|
||||
"- 排除调味料(油/盐/酱油等)\n"
|
||||
"- 混合菜(如沙拉/炒饭)列出可见食材\n"
|
||||
"- 无法识别时写“未知”\n"
|
||||
"3. 输出格式(严格JSON), 如果有多个占据显著位置的菜品,可以将多个菜品罗列出来放到 json 数组中:\n"
|
||||
"[{ dish_name: 具体菜品名1 | 无法识别出菜品, method: 烹饪方式, main_ingredients: [食材1, 食材2] },\n"
|
||||
"{ dish_name: 具体菜品名2 | 无法识别出菜品, method: 烹饪方式, main_ingredients: [食材1, 食材2] }]"
|
||||
)
|
||||
elif type == 'scene':
|
||||
return (
|
||||
"""
|
||||
# 角色
|
||||
你是专注于英语教育的轻量级场景化句型分析助手,仅输出JSON格式结果,无多余解释/话术。
|
||||
|
||||
# 输入信息
|
||||
场景标签:scene_tag
|
||||
英文句型:sentence_en
|
||||
中文翻译:sentence_zh
|
||||
|
||||
# 输出要求
|
||||
1. 功能标签:生成2个标签(主标签+子标签),主标签仅限「询问/请求/陈述/表达需求/建议/确认/表达感受/指出位置」,子标签需贴合场景和句型核心功能(如“索要物品”“点餐”“职场沟通”);
|
||||
2. 场景说明:50-80字,简洁说明该句型的使用场景、语用价值(如礼貌性/适配对象),语言通俗,适配英语进阶学习者;
|
||||
3. 输出格式:严格遵循以下JSON结构,无换行/多余字符:
|
||||
{
|
||||
"functionTags": ["主标签", "子标签"],
|
||||
"sceneExplanation": "场景说明文本"
|
||||
}
|
||||
|
||||
# 约束
|
||||
- 功能标签必须贴合「场景标签」+「句型内容」,不脱离场景;
|
||||
- 场景说明不堆砌术语,聚焦“怎么用/什么时候用”,而非语法分析;
|
||||
- 严格控制字符数,功能标签仅2个,场景说明50-80字。
|
||||
|
||||
# 示例参考
|
||||
【输入】
|
||||
场景标签:café
|
||||
英文句型:Can I have a look at the menu?
|
||||
中文翻译:我能看一下菜单吗?
|
||||
【输出】
|
||||
{"functionTags":["询问","索要物品"],"sceneExplanation":"该句型适用于咖啡厅/餐厅场景,向服务人员礼貌索要菜单,比直接说“Give me the menu”更得体,适配所有餐饮消费场景的基础沟通。"}
|
||||
"""
|
||||
)
|
||||
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
def get_conversation_prompt_for_image_dialogue(payload: dict) -> str:
|
||||
description = payload.get("description") or ""
|
||||
scene_tags = payload.get("scene_tags") or []
|
||||
scene_str = ", ".join(scene_tags) if scene_tags else ""
|
||||
base = f"""
|
||||
Refer to the description of the picture. Analyze the uploaded image to comprehensively identify all possible scene types and all possible events that are logically feasible in daily life, without binding scenes to events (i.e., one event can match multiple scenes, and one scene can correspond to multiple events). All results must include both English and Chinese to serve as flexible optional tags for users to start English conversations, ensuring relevance to the image content and practicality for daily communication practice.
|
||||
Picture Description: {description}.
|
||||
// Analysis Rules (Must Follow Strictly)
|
||||
Core Object Identification Rules:
|
||||
Extract 3-5 core objects from the image (the most prominent and representative objects, e.g., menu, laptop, shopping bag, cake).
|
||||
Provide both English name and Chinese translation for each core object (format: object_en(object_zh)), which serves as the basis for inferring scenes and events.
|
||||
Scene Identification Rules:
|
||||
Identify 3-6 possible scenes based on the core objects and visual elements of the image; scenes can be general or specific (e.g., if core objects include "menu, steak", scenes can cover restaurant, café, food court, home kitchen).
|
||||
Scenes must be common daily/office scenarios (avoid rare or abstract scenes like "space station").
|
||||
Provide both English name and Chinese translation for each scene (format: scene_en(scene_zh)), and do not limit the number of events matching each scene.
|
||||
Event Identification Rules:
|
||||
Identify 5-8 possible events that are logically feasible in daily life; events can be loosely associated with the image’s core objects (e.g., even if the image shows a restaurant, events can include dining with friends, blind date, working remotely, celebrating a promotion).
|
||||
Events must be specific and actionable (avoid vague descriptions like "doing something").
|
||||
For each event, provide English name + Chinese translation + bilingual brief conversation direction (10-20 words per direction, explaining the focus of the conversation for this event).
|
||||
No need to bind events to specific scenes; prioritize enriching event diversity to expand users' conversation options.
|
||||
For each event, supplement 3 key attributes (guide targeted dialogue practice):
|
||||
Conversation Style: Match the event’s atmosphere (e.g., birthday celebration → casual/cheerful; business negotiation → formal/serious), output as bilingual (style_en/style_zh).
|
||||
Suggested Roles: 2-3 common role pairs suitable for the event (e.g., blind date → man & woman, stranger & stranger), output as bilingual role items.
|
||||
Bilingual Conversation Direction: 10-20 words per language, explaining the focus of the conversation for this event (e.g., "talking about hobbies and future plans" / "谈论兴趣爱好和未来规划").
|
||||
Do not bind events to specific scenes; prioritize enriching event diversity to expand users' conversation options.
|
||||
Output Constraints:
|
||||
Only return a JSON string (no explanatory text, no extra comments).
|
||||
Ensure the JSON can be directly parsed by JSON.parse.
|
||||
Strictly control the quantity of scenes and events within the specified range to avoid overwhelming users with options.
|
||||
Output JSON Structure:
|
||||
{{
|
||||
"image_analysis": {{
|
||||
"core_objects": [ {{"object_en": "xxx", "object_zh": "xxx"}}, ...], // 4-7 core objects, bilingual
|
||||
"all_possible_scenes": [{{"scene_en": "xxx", "scene_zh": "xxx"}}, ...], // 4-7 scenes, bilingual, independent
|
||||
"all_possible_events": [
|
||||
{{
|
||||
"event_en": "string", // English event name (e.g., "dining with friends")
|
||||
"event_zh": "string", // Chinese event name (e.g., "和朋友聚餐")
|
||||
"conversation_direction_en": "string", // English conversation focus (e.g., "talking about food taste and restaurant recommendations")
|
||||
"conversation_direction_zh": "string", // Chinese conversation focus (e.g., "谈论食物口味和餐厅推荐")
|
||||
"style_en": "string",
|
||||
"style_zh": "string",
|
||||
"suggested_roles": [
|
||||
{{"role1_en": "string", "role1_zh": "string", "role2_en": "string", "role2_zh": "string"}},
|
||||
...// 2-4 role pairs
|
||||
],
|
||||
}}, ...// 4-7 events in total, independent of scenes
|
||||
]
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
return base
|
||||
62
backend/core/prompts/scene_variation.py
Normal file
62
backend/core/prompts/scene_variation.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import json
|
||||
|
||||
def get_scene_variation_prompt(payload: dict) -> str:
|
||||
scene_tag = payload.get("scene_tag")
|
||||
core_vocab = payload.get("core_vocab")
|
||||
collocations = payload.get("collocations")
|
||||
description = payload.get("description")
|
||||
|
||||
return f"""
|
||||
Vision-to-English-Chinese Listening Description Generator (Intermediate Level).
|
||||
Core Objective: Based on the ORIGINAL IMAGE'S scene tags, core vocabulary, and collocations, generate 2 sets of NEW English-Chinese sentence pairs (each set for one new image) for Intermediate English learners. The new descriptions must: 1) Serve listening practice (clear, distinguishable, key information prominent); 2) Expand learning scope via diverse modifications (synonyms/antonyms, background replacement, perspective shift, etc.); 3) Include new practical vocabulary/collocations; 4) Corresponding to a specific modification of the original image (ensure "description-image" consistency).
|
||||
|
||||
// Reusable Assets from Original Image (MUST use these to ensure learning continuity)
|
||||
- Original Description: {description} (e.g., "A blue cup on a table with a print on it") — new descriptions must be modified based on the original one.
|
||||
- Original Scene Tags: {scene_tag} (e.g., "office", "café", "supermarket") — new descriptions must stay in this scene (no scene switching).
|
||||
- Original Core Vocab: {core_vocab} (e.g., "cup", "table", "print") — new descriptions can use synonyms/antonyms or extend related words (e.g., "cup" → "mug", "table" → "desk", "print" → "scan").
|
||||
- Original Collocations: {collocations} (e.g., "print a document", "place an order") — new descriptions can adapt, extend, or reverse these collocations (e.g., "print a document" → "scan a report", "place an order" → "cancel an order").
|
||||
|
||||
// Intermediate Level Definition (Strictly Follow)
|
||||
- Vocab: Scene-specific common words + extended synonyms/antonyms + new related vocabulary (avoid rare/academic terms).
|
||||
- Grammar: Complex clauses, passive voice, conditional statements (as appropriate to the scene).
|
||||
- Word Count: ≤25 words per sentence (concise but informative, suitable for listening comprehension).
|
||||
- Style: Natural colloquial English (consistent with native speakers' daily/office communication) — avoid formal/written language.
|
||||
|
||||
// Allowed Modification Dimensions (At Least 1 Dimension per Description, No Repetition Across 2 Sets)
|
||||
1. Vocabulary Transformation: Replace original core words with synonyms/antonyms (e.g., "blue" → "navy", "buy" → "purchase", "arrive" → "depart").
|
||||
2. Background Replacement: Change the original scene's background (e.g., café → office pantry, subway → bus, kitchen → restaurant kitchen).
|
||||
3. Perspective Shift: Adjust the observation perspective (e.g., front view → side view, close-up → wide shot, user's perspective → third-person perspective).
|
||||
4. Posture/Action Modification: Change the posture of people/objects or add/modify actions (e.g., "sitting at the desk" → "standing beside the desk", "a closed laptop" → "an open laptop displaying a report").
|
||||
5. Subject Transformation: Add/remove/replace core objects (e.g., "a cup on the table" → "a mug and a notebook on the table", "a pen" → "a marker", remove "a tissue box").
|
||||
6. Collocation Adaptation: Extend or reverse original collocations (e.g., "take notes" → "take detailed notes", "make a call" → "miss a call").
|
||||
|
||||
// Key Requirements for Listening Practice
|
||||
1. Distinguishability: The 2 sets of descriptions must have CLEAR DIFFERENCES in core information (e.g., Image 1: synonyms + posture change, Image 2: background replacement + add object, Image 3: antonyms + perspective shift) — avoid ambiguous or similar descriptions.
|
||||
2. Clarity: Key modification information (new vocabulary, background, perspective, etc.) must be placed at the BEGINNING of the sentence (e.g., "In a office pantry, a navy mug sits beside an open laptop" → not "There's something beside the laptop in a different room").
|
||||
3. New Learning Content: Each description must include 2 new elements (vocabulary/collocations/modifications) for learners to acquire (e.g., new word "pantry", new collocation "open laptop displaying a report").
|
||||
4. Practicality: Sentences must be directly usable in real-life communication (e.g., "Actually, I prefer using a marker to take notes in meetings" instead of "A marker is used for taking notes in meetings").
|
||||
5. Translation Quality: Chinese translations (desc_zh) must be colloquial, accurate (no literal translations), and match the English context (e.g., "navy mug" → "藏青色马克杯" instead of "海军杯", "office pantry" → "办公室茶水间" instead of "办公室食品储藏室").
|
||||
|
||||
// Output Structure (JSON, ONLY return JSON string, no extra text)
|
||||
{{
|
||||
"new_descriptions": [
|
||||
{{
|
||||
"image_id": 1,
|
||||
"modification_type": "Specific dimension (e.g., 'synonyms + posture change')",
|
||||
"modification_point": "Detailed modification based on original image (e.g., 'Replace 'blue cup' with 'navy mug'; change 'sitting' to 'standing beside the desk')",
|
||||
"desc_en": "Intermediate-level English sentence (meets vocabulary/grammar/word count requirements)",
|
||||
"desc_zh": "Colloquial Chinese translation",
|
||||
"core_vocab": ["new_word1", "new_word2"], // 2-3 new words (synonyms/antonyms/extended words)
|
||||
"collocation": "Practical adapted collocation (e.g., 'open laptop displaying a report')",
|
||||
"learning_note": "Brief explanation of new content (e.g., 'navy: a dark blue color; suitable for describing objects in formal scenes')"
|
||||
}},...
|
||||
]
|
||||
}}
|
||||
|
||||
// Output Rules
|
||||
1. Only return JSON string (no explanatory text) — ensure direct parsing via JSON.parse.
|
||||
2. Modification types across 2 sets must be different (cover diverse dimensions).
|
||||
3. Modification points must be SPECIFIC and operable (avoid vague descriptions like "change something").
|
||||
4. Sentences must be natural oral English (no rigid grammatical structures).
|
||||
5. New core vocab and collocations must be closely related to the original image's content (ensure learning continuity).
|
||||
"""
|
||||
120
backend/core/prompts/sentence_analysis.py
Normal file
120
backend/core/prompts/sentence_analysis.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
from backend.common.const import (
|
||||
SENTENCE_TYPE_SCENE_SENTENCE,
|
||||
SENTENCE_TYPE_SCENE_DIALOGUE,
|
||||
SENTENCE_TYPE_SCENE_EXERCISE
|
||||
)
|
||||
|
||||
def get_sentence_analysis_prompt(payload: dict, mode: str) -> str:
|
||||
base = (
|
||||
"你是英语教育场景的专业助手,需基于给定的图片场景信息和基础内容,扩展生成适配英语进阶学习者的「句型卡片、模拟场景对话、句型套用练习」结构化内容,所有内容需贴合场景、功能导向,无语义重复,且符合日常沟通逻辑。\n"
|
||||
"输入信息如下(JSON):\n"
|
||||
f"{json.dumps(payload, ensure_ascii=False)}\n"
|
||||
"输出要求:\n"
|
||||
"1. 内容约束:基于基础句型扩展功能标签、场景说明,每句补充「发音提示(重音/连读)」\n"
|
||||
"2. 格式约束:严格按照下方JSON结构输出,无额外解释,确保字段完整、值为数组/字符串类型。\n"
|
||||
"3. 语言约束:所有英文内容符合日常沟通表达,无语法错误;中文翻译精准,场景说明简洁易懂(≤50字)。\n"
|
||||
)
|
||||
if mode == SENTENCE_TYPE_SCENE_SENTENCE:
|
||||
base = (
|
||||
"你是英语教育场景的专业助手,需基于给定的图片场景信息和基础内容,扩展生成适配英语进阶学习者的[场景句型]结构化内容,所有内容需贴合场景、功能导向,无语义重复,简洁清晰,准确务实,且符合外国人日常口语沟通习惯。\n"
|
||||
"输入信息如下(JSON):\n"
|
||||
f"{json.dumps(payload, ensure_ascii=False)}\n"
|
||||
"输出要求:\n"
|
||||
"0. description是图片的详细描述,围绕描述展开后续的分析。\n"
|
||||
"1. 内容约束:基于基础句型扩展功能标签、场景说明,每句补充「发音提示(重音/连读)」等输出结构中要求的内容,需符合现实生活和真实世界的习惯。\n"
|
||||
"2. 语言约束:所有英文内容符合日常沟通表达,无语法错误;中文翻译精准,场景说明简洁易懂(≤50字)。\n"
|
||||
"3. 输出限制:仅返回JSON字符串,无其他解释文字,确保可被`JSON.parse`直接解析,确保字段完整、值为数组/字符串类型,输出的 JSON 结构是:\n"
|
||||
)
|
||||
struct = (
|
||||
"""
|
||||
"sentence": { // 对象:场景句型模块(适配前端展示)
|
||||
"total": 5, // 数字:句型数量(5-8)
|
||||
"list": [ // 数组:场景句型列表(数量与total一致)
|
||||
{ "seq": 1, // 数字:序号(1-8)
|
||||
"sentence_en": "", // 字符串:英文句型, 使用输入信息中的 desc_en 与之顺序对应的句子
|
||||
"sentence_zh": "", // 字符串:中文翻译,使用输入信息中的 desc_zh 与之顺序对应的句子
|
||||
"function_tags": ["询问", "索要物品"], // 数组:功能标签(主+子)
|
||||
"scene_explanation": "咖啡厅场景向店员礼貌索要菜单,比“Give me the menu”更得体", // 字符串:场景使用说明(≤50字)
|
||||
"pronunciation_tip": "重音在menu /ˈmenjuː/,have a look at 连读为 /hævəlʊkæt/", // 字符串:发音提示(重音/连读)
|
||||
"core_vocab": ["menu", "look"], // 数组:核心词汇
|
||||
"core_vocab_desc": ["n. 菜单", "v. 查看"], // 数组:核心词汇在此句型中的含义(与core_vocab顺序对应)
|
||||
"collocations": ["have a look at + 物品(查看某物)"], // 数组:核心搭配
|
||||
"grammar_point": "情态动词Can表请求(非正式),主谓倒装结构:Can + 主语 + 动词原形", // 核心语法解析
|
||||
"common_mistakes": ["1. 漏介词at(Can I have a look the menu)", "2. look误读为/lʊk/(正确/luːk/)", "3. 忘记在look后加at(Can I have a look at the menu)", ...], // 数组:句型中语法或单词用法可能出错的地方,包括但不限于常见发音错误,场景语气不当,单词单复数错误,主谓倒装错误、省略介词、省略主语等语法错误;
|
||||
"pragmatic_alternative": ["Could I have a look at the menu?(更礼貌,正式场景)", "May I see the menu?(更正式,高阶)", ...], // 语用替代表达
|
||||
"scene_transfer_tip": "迁移至餐厅场景:Can I have a look at the wine list?(把menu替换为wine list)", // 场景迁移提示
|
||||
"difficulty_tag": "intermediate", // 难度标签(beginner/intermediate/advanced)
|
||||
"extended_example": ["Can I have a look at your phone?(向朋友借看手机,非正式场景)", ""], // 数组: 精简拓展例句
|
||||
"response_pairs": [], // 数组:对话回应搭配(3-4个核心回应,含肯定/否定/中性,带场景适配说明,设计意图:形成对话闭环,支持角色扮演/实际互动)
|
||||
"fluency_hacks": "", // 字符串:口语流畅度技巧(≤30字,聚焦填充词/弱读/语气调节,设计意图:贴近母语者表达节奏,避免生硬卡顿)
|
||||
"cultural_note": "", // 字符串:文化适配提示(≤40字,说明中外表达习惯差异,设计意图:避免文化误解,提升沟通得体性)
|
||||
"practice_steps": [], // 数组:分阶练习步骤(3步,每步1句话,可操作,设计意图:提供明确学习路径,衔接输入与输出,提升口语落地能力)
|
||||
"avoid_scenarios": "", // 字符串:避免使用场景(≤35字,明确禁忌场景+替代方案,设计意图:减少用错场合的尴尬,明确使用边界)
|
||||
"self_check_list": [], // 数组:自我检测清单(3-4个可量化检查点,含语法/发音/流畅度维度,设计意图:提供即时自查工具,无需他人批改验证效果)
|
||||
"tone_intensity": "", // 字符串:语气强度标注(≤35字,用“弱/中/强”+适用对象描述,设计意图:直观匹配语气与互动对象,避免语气不当)
|
||||
"similar_sentence_distinction": "", // 字符串:相似句型辨析(≤40字,聚焦使用场景+核心差异,不搞复杂语法,设计意图:理清易混点,避免张冠李戴)
|
||||
"speech_rate_tip": "", // 字符串:语速建议(≤25字,明确日常场景语速+关键部分节奏,设计意图:让表达更自然,提升沟通效率)
|
||||
"personalized_tips": "" // 字符串:个性化学习提示(≤30字,分初学者/进阶者给出重点建议,设计意图:适配不同水平需求,提升学习针对性)
|
||||
} ] }
|
||||
"""
|
||||
)
|
||||
return base + struct
|
||||
if mode == SENTENCE_TYPE_SCENE_DIALOGUE:
|
||||
struct = (
|
||||
"""
|
||||
"dialog": { // 对象:模拟场景对话模块(适配前端对话交互)
|
||||
"roleOptions": ["customer", "barista"], // 数组:可选角色(固定值:customer/barista)
|
||||
"defaultRole": "customer", // 字符串:默认角色(customer/barista二选一)
|
||||
"dialogRound": 2, // 数字:对话轮数(2-3轮)
|
||||
"list": [ // 数组:对话轮次列表(数量与dialogRound一致)
|
||||
{
|
||||
"roundId": "dialog-001", // 字符串:轮次唯一ID
|
||||
"speaker": "barista", // 字符串:本轮说话者(customer/barista)
|
||||
"speakerEn": "Can I help you?", // 字符串:说话者英文内容
|
||||
"speakerZh": "请问需要点什么?", // 字符串:说话者中文翻译
|
||||
"responseOptions": [ // 数组:用户可选回应(固定3条)
|
||||
{
|
||||
"optionId": "resp-001", // 字符串:选项唯一ID
|
||||
"optionEn": "I'd like to order a latte with less sugar.", // 字符串:选项英文内容
|
||||
"optionZh": "我想点一杯少糖的拿铁。", // 字符串:选项中文翻译
|
||||
"feedback": "✅ 完美!该句型是咖啡厅点餐核心表达,with精准补充饮品定制要求" // 字符串:选择后的交互反馈
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
)
|
||||
return base + "生成场景对话结构:" + struct
|
||||
if mode == SENTENCE_TYPE_SCENE_EXERCISE:
|
||||
struct = (
|
||||
"""
|
||||
"sentencePractice": { // 对象:句型套用练习模块(适配前端填空练习)
|
||||
"total": 5, // 数字:练习数量(5-8道)
|
||||
"list": [ // 数组:练习列表(数量与total一致)
|
||||
{
|
||||
"practiceId": "practice-001", // 字符串:练习唯一ID
|
||||
"baseSentenceEn": "I'd like to order ______", // 字符串:基础句型框架(挖空)
|
||||
"baseSentenceZh": "我想点______", // 字符串:框架中文翻译
|
||||
"keywordPool": [ // 数组:可选关键词池(3-4个)
|
||||
{
|
||||
"wordEn": "latte", // 字符串:英文关键词
|
||||
"wordZh": "拿铁", // 字符串:中文翻译
|
||||
"type": "drink" // 字符串:词汇类型(drink/custom/food等)
|
||||
}
|
||||
],
|
||||
"wrongTips": [ // 数组:常见错误提示(2-3条)
|
||||
"错误:order + bread(面包)→ 咖啡厅场景中order后优先接饮品,面包需用“have”搭配"
|
||||
],
|
||||
"extendScene": { // 对象:拓展场景(迁移练习)
|
||||
"sceneTag": "milk_tea_shop", // 字符串:拓展场景标签
|
||||
"extendSentenceEn": "I'd like to order ______", // 字符串:拓展句型框架
|
||||
"extendKeywordPool": ["milk tea", "taro balls", "sugar-free"] // 数组:拓展关键词池
|
||||
}
|
||||
}
|
||||
]
|
||||
"""
|
||||
)
|
||||
return base + "生成句型练习结构:" + struct
|
||||
return base
|
||||
@@ -19,7 +19,7 @@ app = register_app()
|
||||
|
||||
@app.get("/")
|
||||
async def read_root():
|
||||
# await wx_user_index_history()
|
||||
await wx_user_index_history()
|
||||
# res = await SentenceService()._process_scene_task(2111026809104629760, 2108963527040565248)
|
||||
return {"Hello": f"World, {datetime.now().isoformat()}"}
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ from sqlalchemy.exc import IntegrityError
|
||||
from asyncio import sleep
|
||||
|
||||
|
||||
def _get_primary_qwen_api_key() -> str:
|
||||
raw = settings.QWEN_API_KEY or ""
|
||||
parts = [p.strip() for p in raw.split(";") if p.strip()]
|
||||
if parts:
|
||||
return parts[0]
|
||||
return raw
|
||||
|
||||
|
||||
class Qwen:
|
||||
# 创建一个类级别的线程池执行器
|
||||
_executor = ThreadPoolExecutor(max_workers=10)
|
||||
@@ -33,7 +41,7 @@ class Qwen:
|
||||
|
||||
@staticmethod
|
||||
async def text_to_speak(content: str, image_text_id: int | None = None, image_id: int | None = None, user_id: int | None = None, ref_type: str | None = None, ref_id: int | None = None) -> Dict[str, Any]:
|
||||
api_key = settings.QWEN_API_KEY
|
||||
api_key = _get_primary_qwen_api_key()
|
||||
model_name = "qwen3-tts-flash"
|
||||
voice = "Jennifer"
|
||||
language_type = "English"
|
||||
@@ -185,7 +193,7 @@ class Qwen:
|
||||
|
||||
@staticmethod
|
||||
async def chat(messages: List[Dict[str, str]], image_id: int = 0, user_id: int = 0, api_type: str = "chat") -> Dict[str, Any]:
|
||||
api_key = settings.QWEN_API_KEY
|
||||
api_key = _get_primary_qwen_api_key()
|
||||
model_name = settings.QWEN_TEXT_MODEL
|
||||
start_time = time.time()
|
||||
start_at = datetime.now()
|
||||
@@ -272,169 +280,9 @@ class Qwen:
|
||||
@staticmethod
|
||||
def get_recognition_prompt(type: str, exclude_words: List[str] | None = None) -> str:
|
||||
"""获取图像识别提示词"""
|
||||
# 根据dict_level确定词汇级别
|
||||
vocabulary_level = "elementary level"
|
||||
specificity = "basic and common"
|
||||
from backend.core.prompts.recognition import get_recognition_prompt as get_prompt
|
||||
return get_prompt(type, exclude_words)
|
||||
|
||||
# if dict_level:
|
||||
# if dict_level == "LEVEL1":
|
||||
# vocabulary_level = "elementary level"
|
||||
# specificity = "basic and common"
|
||||
# elif dict_level == "LEVEL2":
|
||||
# vocabulary_level = "junior high school level"
|
||||
# specificity = "more specific and detailed"
|
||||
# elif dict_level == "LEVEL3":
|
||||
# vocabulary_level = "college English test level"
|
||||
# specificity = "precise and technical"
|
||||
# elif dict_level == "LEVEL4":
|
||||
# vocabulary_level = "TOEFL/IELTS level"
|
||||
# specificity = "highly specialized and academic"
|
||||
|
||||
if type == 'word':
|
||||
|
||||
prompt = (
|
||||
# "Vision-to-English education module."
|
||||
# "Analyze image. Output JSON: "
|
||||
# "Output JSON: {LEVEL1: [{description: str, desc_ipa:str, ref_word: str, word_ipa: str}, ...], LEVEL2: {...}, LEVEL3: {...}}. "
|
||||
# "Each level: 4 singular lowercase nouns(single-word only, no hyphens or compounds) with one 20-word description each."
|
||||
# "And each description must have a corresponding International Phonetic Alphabet (IPA) transcription in the 'desc_ipa' field."
|
||||
# "Vocabulary progression: basic and common → some details and specific → technical and academic. "
|
||||
# "Ensure all ref_words are unique across levels - no repetition."
|
||||
# "Focus: primary/central/artificial objects."
|
||||
|
||||
# v2:
|
||||
# "Vision-to-English-Chinese education module. Analyze and describe the image in three levels: "
|
||||
# "LEVEL1 (simple vocabulary and basic grammar, ~10 words),"
|
||||
# "LEVEL2 (detailed and complex vocabulary, 15-20 words),"
|
||||
# "LEVEL3 (professional, uncommon words and complex grammar, ≤25 words)."
|
||||
# "For each level, provide 6-8 English sentences and Chinese translations."
|
||||
# "Output JSON: {LEVEL1: {desc_en:[], desc_zh:[]}, LEVEL2: {}, LEVEL3: {}}."
|
||||
# "Ensure all description are unique - no repetition."
|
||||
# "Focus: primary/central/artificial objects."
|
||||
|
||||
# v3
|
||||
"""
|
||||
Vision-to-English-Chinese education module.
|
||||
Core objective: Analyze the image based on its PRIMARY SCENE (e.g., office, restaurant, subway, kitchen) and CENTRAL OBJECTS, generate English-Chinese sentence pairs for three learning levels (matching primary/intermediate/advanced English learners), with sentences focused on PRACTICAL, REUSABLE communication (not just grammatical complexity).
|
||||
|
||||
// LEVEL Definition (Binding learning goals + functions + complexity)
|
||||
level1 (Beginner):
|
||||
- Learning goal: Recognize core vocabulary + use basic functional sentences (describe objects/scenes, simple requests)
|
||||
- Vocab: High-frequency daily words (no uncommon words)
|
||||
- Grammar: Present continuous, modal verbs (can/could/would), simple clauses
|
||||
- Word count per sentence: ≤15 words
|
||||
- Sentence type: 6 unique functional types (detailed description, polite request, ask for information, suggest action, state need, confirm fact, express feeling)
|
||||
- The sentence structure of the described object: quantity + name + feature + purpose.
|
||||
|
||||
level2 (Intermediate):
|
||||
- Learning goal: Master scene-specific collocations + practical communication sentences (daily/office interaction)
|
||||
- Vocab: Scene-specific common words + fixed collocations (e.g., "print a document", "place an order")
|
||||
- Grammar: Complex clauses, passive voice, subjunctive mood (as appropriate to the scene)
|
||||
- Word count per sentence: ≤25 words
|
||||
- Sentence type: 8-12 unique functional types (detailed scene analysis, formal/informal contrast, conditional statement, explain purpose, ask follow-up questions, express suggestion, summarize information, clarify meaning)
|
||||
|
||||
// Output Requirements
|
||||
1. JSON Structure (add core vocab/collocation for easy parsing):
|
||||
{
|
||||
"scene_tag": ["xxx", "xxx"], // e.g., "office", "café", "supermarket" (Multiple tags that are consistent with the main scene of the picture)
|
||||
"description": "", // Clear and accurate description of the content of the picture, including but not limited to objects, relationships, colors, etc.
|
||||
"level1": {
|
||||
"desc_en": ["sentence1", "sentence2", ...], // 6 to 8 distinct sentences with different modalities (without repeating the same meaning or function. Don't use Chinese. Consistent with native English speakers' daily communication habits)
|
||||
"desc_zh": ["translation1", "translation2", ...], // one-to-one with desc_en, chinese translation must be natural and not stiff, consistent with native English speakers' daily communication habits.
|
||||
},
|
||||
"level2": {
|
||||
"desc_en": [
|
||||
"Requirement: 8-12 daily spoken English sentences matching the image scenario (prioritize short sentences, ≤20 words)",
|
||||
"Type: Declarative sentences / polite interrogative sentences that can be used directly (avoid formal language and complex clauses)",
|
||||
"Scenario Adaptation: Strictly align with the real-life scenario shown in the image (e.g., restaurant ordering, asking for directions on the subway, chatting with friends, etc.)",
|
||||
"Core Principle: Natural and not stiff, consistent with native English speakers' daily communication habits (e.g., prefer \"How's it going?\" over \"How are you recently?\")"
|
||||
],
|
||||
"desc_zh": [
|
||||
"Requirement: Colloquial Chinese translations of the corresponding English sentences",
|
||||
"Principle: Avoid literal translations and formal expressions; conform to daily Chinese speaking habits (e.g., translate \"Could you pass the salt?\" as \"能递下盐吗?\" instead of \"你能把盐递给我吗?\")",
|
||||
"Adaptability: Translations should fit the logical expression of Chinese scenarios (e.g., more polite for workplace communication, more casual for friend chats)"
|
||||
],
|
||||
"core_vocab": [
|
||||
"Requirement: 5-8 core spoken words for the scenario",
|
||||
"Standard: High-frequency daily use (avoid rare words and academic terms); can directly replace key words in sentences for reuse",
|
||||
"Example: For the \"supermarket shopping\" scenario, prioritize words like \"discount, check out, cart\" that can be directly applied to sentences"
|
||||
],
|
||||
"collocations": [
|
||||
"Requirement: 5-8 high-frequency spoken collocations for the scenario",
|
||||
"Standard: Short and practical fixed collocations; can be used by directly replacing core words (avoid complex phrases)",
|
||||
"Example: For the \"food delivery ordering\" scenario, collocations include \"order food, pick up the phone (for delivery calls), track the order\""
|
||||
],
|
||||
"pragmatic_notes": [
|
||||
"Requirement: 2-4 scenario-specific pragmatic notes (avoid general descriptions)",
|
||||
"Content: Clear usage scenarios + tone adaptation + practical skills (e.g., \"Suitable for chatting with friends; casual tone; starting with the filler word 'actually' makes it more natural\")",
|
||||
"Practical Value: Include \"replacement skills\" (e.g., \"Sentence pattern 'I'm in the mood for + [food]' can be used by directly replacing the food noun\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
2. Uniqueness: No repetition in SEMANTICS/FUNCTIONS (not just literal repetition) — e.g., avoid two sentences both meaning "This is a laptop" (even with different wording).
|
||||
3. Focus: Prioritize ARTIFICIAL/CENTRAL objects and PRIMARY scene (ignore trivial background elements) — e.g., for a café image, focus on "coffee", "barista", "menu" (not "wall", "floor").
|
||||
4. Practicality: All sentences must be directly usable in real-life communication (avoid meaningless grammatical exercises like "I am eat a apple" corrected to "I am eating an apple").
|
||||
5. Accuracy: Translations must be accurate (not literal) and match the context of the image scene.
|
||||
6. Output Limit: Only return the JSON string, without any explanatory text. Ensure that it can be directly parsed by `JSON.parse`.
|
||||
"""
|
||||
)
|
||||
|
||||
if exclude_words:
|
||||
exclude_str = ". ".join(exclude_words)
|
||||
prompt += f"Avoid using these words: {exclude_str}."
|
||||
|
||||
return prompt
|
||||
elif type == 'food':
|
||||
return (
|
||||
"你是一个专业美食识别AI,请严格按以下步骤分析图片:\n"
|
||||
"1. 识别最显著菜品名称(需具体到品种/烹饪方式):\n"
|
||||
"- 示例:清蒸鲈鱼(非清蒸鱼)、罗宋汤(非蔬菜汤)\n"
|
||||
"- 无法确定具体菜品时返回“无法识别出菜品”\n"
|
||||
"2. 提取核心食材(3-5种主料):\n"
|
||||
"- 排除调味料(油/盐/酱油等)\n"
|
||||
"- 混合菜(如沙拉/炒饭)列出可见食材\n"
|
||||
"- 无法识别时写“未知”\n"
|
||||
"3. 输出格式(严格JSON), 如果有多个占据显著位置的菜品,可以将多个菜品罗列出来放到 json 数组中:\n"
|
||||
"[{ dish_name: 具体菜品名1 | 无法识别出菜品, method: 烹饪方式, main_ingredients: [食材1, 食材2] },\n"
|
||||
"{ dish_name: 具体菜品名2 | 无法识别出菜品, method: 烹饪方式, main_ingredients: [食材1, 食材2] }]"
|
||||
)
|
||||
elif type == 'scene':
|
||||
return (
|
||||
"""
|
||||
# 角色
|
||||
你是专注于英语教育的轻量级场景化句型分析助手,仅输出JSON格式结果,无多余解释/话术。
|
||||
|
||||
# 输入信息
|
||||
场景标签:scene_tag
|
||||
英文句型:sentence_en
|
||||
中文翻译:sentence_zh
|
||||
|
||||
# 输出要求
|
||||
1. 功能标签:生成2个标签(主标签+子标签),主标签仅限「询问/请求/陈述/表达需求/建议/确认/表达感受/指出位置」,子标签需贴合场景和句型核心功能(如“索要物品”“点餐”“职场沟通”);
|
||||
2. 场景说明:50-80字,简洁说明该句型的使用场景、语用价值(如礼貌性/适配对象),语言通俗,适配英语进阶学习者;
|
||||
3. 输出格式:严格遵循以下JSON结构,无换行/多余字符:
|
||||
{
|
||||
"functionTags": ["主标签", "子标签"],
|
||||
"sceneExplanation": "场景说明文本"
|
||||
}
|
||||
|
||||
# 约束
|
||||
- 功能标签必须贴合「场景标签」+「句型内容」,不脱离场景;
|
||||
- 场景说明不堆砌术语,聚焦“怎么用/什么时候用”,而非语法分析;
|
||||
- 严格控制字符数,功能标签仅2个,场景说明50-80字。
|
||||
|
||||
# 示例参考
|
||||
【输入】
|
||||
场景标签:café
|
||||
英文句型:Can I have a look at the menu?
|
||||
中文翻译:我能看一下菜单吗?
|
||||
【输出】
|
||||
{"functionTags":["询问","索要物品"],"sceneExplanation":"该句型适用于咖啡厅/餐厅场景,向服务人员礼貌索要菜单,比直接说“Give me the menu”更得体,适配所有餐饮消费场景的基础沟通。"}
|
||||
"""
|
||||
)
|
||||
|
||||
else:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def recognize_image(params: QwenRecognizeImageParams) -> Dict[str, Any]:
|
||||
@@ -474,7 +322,7 @@ level2 (Intermediate):
|
||||
"""通用API调用方法"""
|
||||
|
||||
model_name = ""
|
||||
api_key = settings.QWEN_API_KEY
|
||||
api_key = _get_primary_qwen_api_key()
|
||||
response = None
|
||||
start_time = time.time()
|
||||
start_at = datetime.now()
|
||||
@@ -641,7 +489,7 @@ level2 (Intermediate):
|
||||
# 轮询任务状态
|
||||
start_time = time.time()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.QWEN_API_KEY}",
|
||||
"Authorization": f"Bearer {_get_primary_qwen_api_key()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ asgi-correlation-id==4.3.4
|
||||
# via fastapi-best-architecture
|
||||
asgiref==3.8.1
|
||||
# via fastapi-best-architecture
|
||||
async-timeout==5.0.1 ; python_full_version < '3.11.3'
|
||||
async-timeout==4.0.3 ; python_full_version < '3.11.3'
|
||||
# via
|
||||
# asyncpg
|
||||
# redis
|
||||
@@ -138,6 +138,8 @@ jinja2==3.1.6
|
||||
# via
|
||||
# fastapi
|
||||
# fastapi-best-architecture
|
||||
langchain==1.2.3
|
||||
langchain-community==0.4.1
|
||||
kombu==5.5.1
|
||||
# via celery
|
||||
loguru==0.7.3
|
||||
@@ -193,7 +195,7 @@ pydantic==2.11.0
|
||||
# sqlalchemy-crud-plus
|
||||
pydantic-core==2.33.0
|
||||
# via pydantic
|
||||
pydantic-settings==2.8.1
|
||||
pydantic-settings==2.10.1
|
||||
# via fastapi-best-architecture
|
||||
pygments==2.19.1
|
||||
# via rich
|
||||
|
||||
Reference in New Issue
Block a user