""" 计费与支付管理后端 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, }}