Files
TradeMate Dev 2a107a42f3 feat: credit-based billing system
- New DB models: credit_packages, subscription_plans, user_credits, credit_consumptions, credit_purchases
- CreditService: balance, deduct, add_credits, grant_free_trial, history
- User API: /api/v1/credits/* (balance/history/packages/purchase/subscribe)
- Admin API: /api/v1/admin/credit-* (CRUD packages/plans, user credits, consumptions)
- PaymentService.create_credit_order + handle_callback for credit purchases
- Credit deduction on: discovery, translate, marketing, ai_chat, followup
- Free trial 30 credits on registration
- Documentation: docs/CREDIT_SYSTEM.md
2026-06-12 10:39:45 +08:00

156 lines
6.1 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.ai.router import get_ai_router
from app.ai.local_faq import match_faq
from app.api.v1.deps import get_current_user_id
from app.models.system_config import SystemConfig
from app.services.admin import AdminService
from app.services.credit import CreditService
import logging
import time
import re
import json
logger = logging.getLogger(__name__)
router = APIRouter()
ACTION_INSTRUCTIONS = """
当用户想要执行操作时(如添加客户、创建产品、生成报价单、发送跟进、营销生成等),请执行以下步骤:
1. 从用户消息中提取所有必要的信息
2. 在回复末尾附上 JSON 格式的动作块,格式如下:
```actions
[{"type": "create_customer", "label": "添加客户", "fields": {"name": "...", "phone": "...", "email": "...", "company": "...", "country": "...", "notes": "..."}}]
```
支持的 action type 及字段说明:
- create_customer:添加客户,fields 支持 name(必填), phone, email, company, country, website, notes
- create_product:添加产品,fields 支持 name(必填), name_en, description, description_en, category, price, price_unit(默认USD), moq, keywords(逗号分隔)
- create_quotation:生成报价单,fields 支持 customer_name(必填), product_info(必填), quantity(必填), price, terms
- scan_followups:扫描待跟进客户,fields 不需要(空对象)
- send_followup:发送跟进消息,fields 支持 customer_name(必填), message(必填)
- generate_marketing:生成营销素材,fields 支持 product_name(必填), target_market, tone(如professional/casual), language
- discovery_search:搜索潜在客户,fields 支持 keywords(必填), country, industry
- navigate:跳转到指定页面,fields 支持 path(必填, 如 /customers /products /quotations /marketing /discovery /followup /translate /team /analytics)
- search_users:搜索用户,fields 支持 query(必填)
- update_user:修改用户信息,fields 支持 user_id(必填), username, phone, email, role, status
- update_config:更新系统配置,fields 支持 key(必填), value(必填)
- review_certification:审核认证,fields 支持 id(必填), action(approved/rejected), reason
- process_invoice:处理发票,fields 支持 id(必填), action(approve/reject)
如果用户没有提供足够信息,请先询问缺少的字段,不要生成 action。
如果用户明确表示要执行操作但缺少信息,生成 action 但标注缺失的字段。
"""
class ChatRequest(BaseModel):
message: str
history: Optional[List[Dict[str, str]]] = None
@router.get("/quick-questions")
async def get_quick_questions(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "ai_assistant_quick_questions")
)
cfg = result.scalar_one_or_none()
return cfg.value if cfg and cfg.value else []
@router.post("/chat")
async def chat(
data: ChatRequest,
request: Request,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
t_start = time.time()
if not data.message.strip():
raise HTTPException(status_code=422, detail="Message is required")
t0 = time.time()
prompt_config = await db.execute(
select(SystemConfig).where(SystemConfig.key == "ai_assistant_prompt")
)
quick_config = await db.execute(
select(SystemConfig).where(SystemConfig.key == "ai_assistant_quick_questions")
)
t1 = time.time()
prompt_row = prompt_config.scalar_one_or_none()
base_prompt = prompt_row.value if prompt_row else None
quick_row = quick_config.scalar_one_or_none()
quick_questions = quick_row.value if quick_row else None
t2 = time.time()
action_keywords = ["帮我添加", "帮我创建", "帮我新增", "帮我新建", "帮我录入", "帮.+加", "添加客户.*电话", "创建客户.*电话"]
needs_action = any(re.search(k, data.message) for k in action_keywords)
system_prompt = base_prompt or ""
if needs_action:
system_prompt += "\n\n" + ACTION_INSTRUCTIONS
faq_answer = match_faq(data.message)
if faq_answer and not needs_action:
reply = faq_answer
provider = "local-faq"
actions = []
t4 = time.time()
logger.info(
f"CHAT [{user_id[:8]}] FAQ match | "
f"db={t1-t0:.2f}s orm={t2-t1:.2f}s total={t4-t_start:.2f}s"
)
else:
credit_svc = CreditService(db)
ok, balance = await credit_svc.deduct(user_id, "ai_chat")
if not ok:
raise HTTPException(
status_code=402,
detail=f"次数不足 (剩余 {balance:.1f})"
)
t3 = time.time()
ai = get_ai_router()
result = await ai.chat(data.message, data.history or [], system_prompt)
t4 = time.time()
reply = result.get("reply", "")
provider = result.get("provider_used", "")
actions = []
action_match = re.search(r'```actions\s*\n(.*?)\n```', reply, re.DOTALL)
if action_match:
try:
actions = json.loads(action_match.group(1))
reply = reply[:action_match.start()].strip()
except (json.JSONDecodeError, Exception) as e:
logger.warning(f"Failed to parse actions: {e}")
logger.info(
f"CHAT [{user_id[:8]}] AI | "
f"db={t1-t0:.2f}s orm={t2-t1:.2f}s setup={t3-t2:.2f}s ai={t4-t3:.2f}s total={t4-t_start:.2f}s"
)
client_ip = request.client.host if request.client else None
await AdminService(db).log_usage(
user_id, "ai.chat",
{"message": data.message[:200], "reply_length": len(reply), "provider": provider},
ip=client_ip,
)
logger.info(f"CHAT [{user_id[:8]}] done total={time.time()-t_start:.2f}s")
return {
"reply": reply,
"provider": provider,
"quick_questions": quick_questions,
"actions": actions,
}