fix: 恢复 rsync 误删的近期开发(agents/skills/mcp/knowledge/workflow/tenant/billing/storage/privacy/promotion/achievement/automation/code-exec/developer/distribution/im-bot/portal/casdoor 等后端模块 + 对应前端页面路由, 补回 main.py 挂载与 App.tsx 路由
This commit is contained in:
1 parent
d95b847fee
commit
e8e5ffa1a9
65 files changed
+35624
-339
No files matched your search
@@ -0,0 +1,462 @@
|
||||
"""
|
||||
计费与支付管理后端 API
|
||||
- 计费规则、充值套餐、交易记录、账单管理、支付配置
|
||||
- 路由前缀: /app/api/admin/billing
|
||||
"""
|
||||
import uuid
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Query, HTTPException, Depends
|
||||
from ...database import SessionLocal, get_db
|
||||
from ..auth import get_current_user
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CST_TZ = None
|
||||
|
||||
def _format_cst_time(dt_val):
|
||||
global CST_TZ
|
||||
if CST_TZ is None:
|
||||
from datetime import timezone
|
||||
CST_TZ = timezone(timedelta(hours=8))
|
||||
if dt_val is None:
|
||||
return None
|
||||
if isinstance(dt_val, str):
|
||||
return dt_val
|
||||
if dt_val.tzinfo is None:
|
||||
dt_val = dt_val.replace(tzinfo=CST_TZ)
|
||||
return dt_val.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _row_to_dict(row, columns):
|
||||
return {col: _format_cst_time(val) if isinstance(val, datetime) else val for col, val in zip(columns, row)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 计费规则
|
||||
# ============================================================
|
||||
|
||||
@router.get("/rules")
|
||||
async def list_billing_rules(
|
||||
status: str = Query("", description="状态筛选"),
|
||||
billing_type: str = Query("", description="计费类型筛选"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""获取计费规则列表"""
|
||||
where = "1=1"
|
||||
params = {}
|
||||
if status:
|
||||
where += " AND status=:status"
|
||||
params["status"] = status
|
||||
if billing_type:
|
||||
where += " AND billing_type=:bt"
|
||||
params["bt"] = billing_type
|
||||
|
||||
total = db.execute(text(f"SELECT COUNT(*) FROM billing_rules WHERE {where}"), params).scalar()
|
||||
offset = (page - 1) * page_size
|
||||
rows = db.execute(text(f"""
|
||||
SELECT * FROM billing_rules WHERE {where}
|
||||
ORDER BY created_at DESC LIMIT :limit OFFSET :offset
|
||||
"""), {**params, "limit": page_size, "offset": offset}).fetchall()
|
||||
cols = ['id','name','description','billing_type','model_id','model_name','rate','currency',
|
||||
'min_charge','free_quota','tiered_pricing','status','created_at','updated_at']
|
||||
items = [_row_to_dict(r, cols) for r in rows]
|
||||
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
|
||||
|
||||
|
||||
@router.post("/rules/create")
|
||||
async def create_billing_rule(
|
||||
name: str = Query(...),
|
||||
description: str = Query(""),
|
||||
billing_type: str = Query("per_token"),
|
||||
model_id: str = Query(""),
|
||||
model_name: str = Query(""),
|
||||
rate: float = Query(0.0),
|
||||
min_charge: float = Query(0.01),
|
||||
free_quota: int = Query(0),
|
||||
tiered_pricing: str = Query("", description="阶梯定价JSON"),
|
||||
status: str = Query("active"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""创建计费规则"""
|
||||
rid = str(uuid.uuid4())
|
||||
tp = None
|
||||
if tiered_pricing:
|
||||
try:
|
||||
tp = json.loads(tiered_pricing)
|
||||
except:
|
||||
tp = None
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO billing_rules (id, name, description, billing_type, model_id, model_name, rate,
|
||||
min_charge, free_quota, tiered_pricing, status)
|
||||
VALUES (:id, :name, :desc, :bt, :mid, :mname, :rate, :mc, :fq, :tp, :status)
|
||||
"""), {
|
||||
"id": rid, "name": name, "desc": description, "bt": billing_type,
|
||||
"mid": model_id or None, "mname": model_name or None, "rate": rate,
|
||||
"mc": min_charge, "fq": free_quota, "tp": json.dumps(tp) if tp else None,
|
||||
"status": status
|
||||
})
|
||||
db.commit()
|
||||
return {"code": 200, "data": {"id": rid, "message": "计费规则创建成功"}}
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}")
|
||||
async def update_billing_rule(
|
||||
rule_id: str,
|
||||
name: str = Query(None),
|
||||
description: str = Query(None),
|
||||
rate: float = Query(None),
|
||||
min_charge: float = Query(None),
|
||||
free_quota: int = Query(None),
|
||||
status: str = Query(None),
|
||||
tiered_pricing: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""更新计费规则"""
|
||||
sets = []
|
||||
params = {"id": rule_id}
|
||||
for field, val in [("name", name), ("description", description), ("rate", rate),
|
||||
("min_charge", min_charge), ("free_quota", free_quota), ("status", status)]:
|
||||
if val is not None:
|
||||
sets.append(f"{field}=:{field}")
|
||||
params[field] = val
|
||||
if tiered_pricing is not None:
|
||||
sets.append("tiered_pricing=:tp")
|
||||
try:
|
||||
params["tp"] = json.dumps(json.loads(tiered_pricing))
|
||||
except:
|
||||
params["tp"] = None
|
||||
|
||||
if not sets:
|
||||
return {"code": 200, "data": {"message": "无更新"}}
|
||||
db.execute(text(f"UPDATE billing_rules SET {','.join(sets)} WHERE id=:id"), params)
|
||||
db.commit()
|
||||
return {"code": 200, "data": {"message": "更新成功"}}
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
async def delete_billing_rule(rule_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""删除计费规则"""
|
||||
db.execute(text("UPDATE billing_rules SET status='inactive' WHERE id=:id"), {"id": rule_id})
|
||||
db.commit()
|
||||
return {"code": 200, "data": {"message": "规则已停用"}}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 充值套餐
|
||||
# ============================================================
|
||||
|
||||
@router.get("/packages")
|
||||
async def list_packages(
|
||||
status: str = Query(""),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""获取充值套餐列表"""
|
||||
where = "1=1"
|
||||
params = {}
|
||||
if status:
|
||||
where += " AND status=:status"
|
||||
params["status"] = status
|
||||
rows = db.execute(text(f"""
|
||||
SELECT * FROM recharge_packages WHERE {where}
|
||||
ORDER BY sort_order ASC, price ASC
|
||||
"""), params).fetchall()
|
||||
cols = ['id','name','description','tokens','bonus_tokens','price','original_price',
|
||||
'currency','valid_days','is_popular','sort_order','status','created_at','updated_at']
|
||||
items = [_row_to_dict(r, cols) for r in rows]
|
||||
return {"code": 200, "data": items}
|
||||
|
||||
|
||||
@router.post("/packages/create")
|
||||
async def create_package(
|
||||
name: str = Query(...),
|
||||
description: str = Query(""),
|
||||
tokens: int = Query(...),
|
||||
bonus_tokens: int = Query(0),
|
||||
price: float = Query(...),
|
||||
original_price: float = Query(0),
|
||||
valid_days: int = Query(365),
|
||||
is_popular: int = Query(0),
|
||||
sort_order: int = Query(0),
|
||||
status: str = Query("active"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""创建充值套餐"""
|
||||
pid = str(uuid.uuid4())
|
||||
db.execute(text("""
|
||||
INSERT INTO recharge_packages (id, name, description, tokens, bonus_tokens, price, original_price,
|
||||
valid_days, is_popular, sort_order, status)
|
||||
VALUES (:id, :name, :desc, :tokens, :bonus, :price, :oprice, :vd, :pop, :sort, :status)
|
||||
"""), {
|
||||
"id": pid, "name": name, "desc": description, "tokens": tokens, "bonus": bonus_tokens,
|
||||
"price": price, "oprice": original_price or None, "vd": valid_days,
|
||||
"pop": is_popular, "sort": sort_order, "status": status
|
||||
})
|
||||
db.commit()
|
||||
return {"code": 200, "data": {"id": pid, "message": "套餐创建成功"}}
|
||||
|
||||
|
||||
@router.put("/packages/{package_id}")
|
||||
async def update_package(
|
||||
package_id: str,
|
||||
name: str = Query(None),
|
||||
price: float = Query(None),
|
||||
tokens: int = Query(None),
|
||||
bonus_tokens: int = Query(None),
|
||||
status: str = Query(None),
|
||||
is_popular: int = Query(None),
|
||||
sort_order: int = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""更新充值套餐"""
|
||||
sets = []
|
||||
params = {"id": package_id}
|
||||
for field, val in [("name", name), ("price", price), ("tokens", tokens),
|
||||
("bonus_tokens", bonus_tokens), ("status", status),
|
||||
("is_popular", is_popular), ("sort_order", sort_order)]:
|
||||
if val is not None:
|
||||
sets.append(f"{field}=:{field}")
|
||||
params[field] = val
|
||||
if not sets:
|
||||
return {"code": 200, "data": {"message": "无更新"}}
|
||||
db.execute(text(f"UPDATE recharge_packages SET {','.join(sets)} WHERE id=:id"), params)
|
||||
db.commit()
|
||||
return {"code": 200, "data": {"message": "更新成功"}}
|
||||
|
||||
|
||||
@router.delete("/packages/{package_id}")
|
||||
async def delete_package(package_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""删除充值套餐"""
|
||||
db.execute(text("UPDATE recharge_packages SET status='inactive' WHERE id=:id"), {"id": package_id})
|
||||
db.commit()
|
||||
return {"code": 200, "data": {"message": "套餐已停用"}}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 交易记录
|
||||
# ============================================================
|
||||
|
||||
@router.get("/transactions")
|
||||
async def list_transactions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user_id: str = Query("", description="用户筛选"),
|
||||
transaction_type: str = Query("", description="类型筛选"),
|
||||
start_date: str = Query("", description="开始日期"),
|
||||
end_date: str = Query("", description="结束日期"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""获取交易记录"""
|
||||
where = "1=1"
|
||||
params = {}
|
||||
if user_id:
|
||||
where += " AND bt.user_id=:uid"
|
||||
params["uid"] = user_id
|
||||
if transaction_type:
|
||||
where += " AND bt.transaction_type=:tt"
|
||||
params["tt"] = transaction_type
|
||||
if start_date:
|
||||
where += " AND bt.created_at>=:sd"
|
||||
params["sd"] = start_date
|
||||
if end_date:
|
||||
where += " AND bt.created_at<=:ed"
|
||||
params["ed"] = end_date + " 23:59:59"
|
||||
|
||||
total = db.execute(text(f"SELECT COUNT(*) FROM billing_transactions bt WHERE {where}"), params).scalar()
|
||||
offset = (page - 1) * page_size
|
||||
rows = db.execute(text(f"""
|
||||
SELECT bt.*, u.username
|
||||
FROM billing_transactions bt
|
||||
LEFT JOIN users u ON bt.user_id = u.id
|
||||
WHERE {where}
|
||||
ORDER BY bt.created_at DESC LIMIT :limit OFFSET :offset
|
||||
"""), {**params, "limit": page_size, "offset": offset}).fetchall()
|
||||
cols = ['id','tenant_id','user_id','order_id','transaction_type','amount','tokens',
|
||||
'balance_after','model_id','model_name','description','metadata','status',
|
||||
'created_at','username']
|
||||
items = [_row_to_dict(r, cols) for r in rows]
|
||||
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 账单管理
|
||||
# ============================================================
|
||||
|
||||
@router.get("/invoices")
|
||||
async def list_invoices(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status: str = Query(""),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""获取账单列表"""
|
||||
where = "1=1"
|
||||
params = {}
|
||||
if status:
|
||||
where += " AND status=:status"
|
||||
params["status"] = status
|
||||
|
||||
total = db.execute(text(f"SELECT COUNT(*) FROM invoices WHERE {where}"), params).scalar()
|
||||
offset = (page - 1) * page_size
|
||||
rows = db.execute(text(f"""
|
||||
SELECT i.*, u.username
|
||||
FROM invoices i
|
||||
LEFT JOIN users u ON i.user_id = u.id
|
||||
WHERE {where}
|
||||
ORDER BY i.created_at DESC LIMIT :limit OFFSET :offset
|
||||
"""), {**params, "limit": page_size, "offset": offset}).fetchall()
|
||||
cols = ['id','invoice_number','tenant_id','user_id','period_start','period_end',
|
||||
'subtotal','discount','tax','total','items','status','issued_at','paid_at',
|
||||
'due_at','payment_method','created_at','updated_at','username']
|
||||
items = [_row_to_dict(r, cols) for r in rows]
|
||||
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
|
||||
|
||||
|
||||
@router.post("/invoices/generate")
|
||||
async def generate_invoice(
|
||||
user_id: str = Query(..., description="用户ID"),
|
||||
period_start: str = Query(..., description="周期开始"),
|
||||
period_end: str = Query(..., description="周期结束"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""生成账单"""
|
||||
# Calculate usage from token_usage_logs
|
||||
usage = db.execute(text("""
|
||||
SELECT model_name, SUM(total_tokens) as tokens, SUM(cost) as total_cost
|
||||
FROM token_usage_logs
|
||||
WHERE user_id=:uid AND created_at>=:ps AND created_at<=:pe
|
||||
GROUP BY model_name
|
||||
"""), {"uid": user_id, "ps": period_start, "pe": period_end}).fetchall()
|
||||
|
||||
if not usage:
|
||||
return {"code": 200, "data": {"message": "该周期内无使用记录"}}
|
||||
|
||||
items = []
|
||||
subtotal = 0
|
||||
for row in usage:
|
||||
model_name = row[0] or "未知模型"
|
||||
tokens = row[1] or 0
|
||||
cost = float(row[2] or 0)
|
||||
items.append({"name": model_name, "quantity": tokens, "unit_price": cost / tokens if tokens > 0 else 0, "amount": cost})
|
||||
subtotal += cost
|
||||
|
||||
invoice_number = f"INV-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||
iid = str(uuid.uuid4())
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO invoices (id, invoice_number, user_id, period_start, period_end, subtotal, total, items, status)
|
||||
VALUES (:id, :num, :uid, :ps, :pe, :sub, :total, :items, 'issued')
|
||||
"""), {
|
||||
"id": iid, "num": invoice_number, "uid": user_id,
|
||||
"ps": period_start, "pe": period_end, "sub": subtotal,
|
||||
"total": subtotal, "items": json.dumps(items)
|
||||
})
|
||||
db.commit()
|
||||
return {"code": 200, "data": {"id": iid, "invoice_number": invoice_number, "total": subtotal, "items": items}}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 支付配置 (扩展现有 payment_configs)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/payment-configs")
|
||||
async def list_payment_configs(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""获取支付配置列表"""
|
||||
rows = db.execute(text("SELECT * FROM payment_configs ORDER BY created_at DESC")).fetchall()
|
||||
cols = ['id','type','app_id','merchant_id','api_key','cert_path','gateway_url','status',
|
||||
'created_at','updated_at','api_v3_key','cert_serial_no','alipay_public_key',
|
||||
'app_private_key','notify_url','return_url']
|
||||
items = []
|
||||
for r in rows:
|
||||
d = _row_to_dict(r, cols)
|
||||
# Mask sensitive fields
|
||||
d['api_key'] = '***' if d.get('api_key') else None
|
||||
d['api_v3_key'] = '***' if d.get('api_v3_key') else None
|
||||
d['app_private_key'] = '***' if d.get('app_private_key') else None
|
||||
items.append(d)
|
||||
return {"code": 200, "data": items}
|
||||
|
||||
|
||||
@router.put("/payment-configs/{config_id}")
|
||||
async def update_payment_config(
|
||||
config_id: str,
|
||||
status: str = Query(None),
|
||||
app_id: str = Query(None),
|
||||
merchant_id: str = Query(None),
|
||||
gateway_url: str = Query(None),
|
||||
notify_url: str = Query(None),
|
||||
return_url: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""更新支付配置"""
|
||||
sets = []
|
||||
params = {"id": config_id}
|
||||
for field, val in [("status", status), ("app_id", app_id), ("merchant_id", merchant_id),
|
||||
("gateway_url", gateway_url), ("notify_url", notify_url), ("return_url", return_url)]:
|
||||
if val is not None:
|
||||
sets.append(f"{field}=:{field}")
|
||||
params[field] = val
|
||||
if not sets:
|
||||
return {"code": 200, "data": {"message": "无更新"}}
|
||||
db.execute(text(f"UPDATE payment_configs SET {','.join(sets)} WHERE id=:id"), params)
|
||||
db.commit()
|
||||
return {"code": 200, "data": {"message": "支付配置已更新"}}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 统计
|
||||
# ============================================================
|
||||
|
||||
@router.get("/stats/overview")
|
||||
async def billing_stats(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""计费统计概览"""
|
||||
total_revenue = db.execute(text("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM billing_transactions
|
||||
WHERE transaction_type='recharge' AND status='completed'
|
||||
""")).scalar() or 0
|
||||
|
||||
total_consumption = db.execute(text("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM billing_transactions
|
||||
WHERE transaction_type='consumption' AND status='completed'
|
||||
""")).scalar() or 0
|
||||
|
||||
total_transactions = db.execute(text("SELECT COUNT(*) FROM billing_transactions")).scalar()
|
||||
pending_invoices = db.execute(text("SELECT COUNT(*) FROM invoices WHERE status IN ('issued','overdue')")).scalar()
|
||||
paid_invoices = db.execute(text("SELECT COUNT(*) FROM invoices WHERE status='paid'")).scalar()
|
||||
active_rules = db.execute(text("SELECT COUNT(*) FROM billing_rules WHERE status='active'")).scalar()
|
||||
active_packages = db.execute(text("SELECT COUNT(*) FROM recharge_packages WHERE status='active'")).scalar()
|
||||
|
||||
# Revenue by day (last 7 days)
|
||||
revenue_7d = db.execute(text("""
|
||||
SELECT DATE(created_at) as d, COALESCE(SUM(amount), 0) as rev
|
||||
FROM billing_transactions
|
||||
WHERE transaction_type='recharge' AND status='completed'
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
GROUP BY DATE(created_at) ORDER BY d
|
||||
""")).fetchall()
|
||||
daily_revenue = [{"date": str(r[0]), "revenue": float(r[1])} for r in revenue_7d]
|
||||
|
||||
return {"code": 200, "data": {
|
||||
"total_revenue": float(total_revenue),
|
||||
"total_consumption": float(total_consumption),
|
||||
"total_transactions": total_transactions,
|
||||
"pending_invoices": pending_invoices,
|
||||
"paid_invoices": paid_invoices,
|
||||
"active_rules": active_rules,
|
||||
"active_packages": active_packages,
|
||||
"daily_revenue_7d": daily_revenue,
|
||||
}}
|
||||
Reference in new issue
Block a user