diff --git a/.env.example b/.env.example index 65e351ea82..8b9990d0b9 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ # Ollama URL for the backend to connect # The path '/ollama' will be redirected to the specified backend URL -OLLAMA_BASE_URL='http://localhost:11434' +# OLLAMA_BASE_URL='http://localhost:11434' -OPENAI_API_BASE_URL='' -OPENAI_API_KEY='' +# OPENAI_API_BASE_URL='' +# OPENAI_API_KEY='' # AUTOMATIC1111_BASE_URL="http://localhost:7860" @@ -21,7 +21,12 @@ SCARF_NO_ANALYTICS=true DO_NOT_TRACK=true ANONYMIZED_TELEMETRY=false +# 数据库 - PostgreSQL +DATABASE_URL=postgresql://sylar@localhost:5432/openwebui +# 向量数据库 +VECTOR_DB=chroma +EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 # Mem0 API Key export MEM0_API_KEY="" @@ -36,3 +41,9 @@ export EMAIL_VERIFICATION_CODE_TTL="" export EMAIL_VERIFICATION_SEND_INTERVAL="" export EMAIL_VERIFICATION_MAX_ATTEMPTS="" +# alipay +ALIPAY_APP_ID=2021000000000000 +ALIPAY_PRIVATE_KEY=MIIEvQIBADANBg... # (应用私钥,完整内容) +ALIPAY_PUBLIC_KEY=MIIBIjANBgkqh... #(支付宝公钥,完整内容) +ALIPAY_NOTIFY_URL=https://test.com/api/billing/payment/notify +ALIPAY_SANDBOX=false \ No newline at end of file diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 253200e1fc..93b8d76637 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -875,3 +875,14 @@ PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split() #################################### EXTERNAL_PWA_MANIFEST_URL = os.environ.get("EXTERNAL_PWA_MANIFEST_URL") + + +#################################### +# ALIPAY PAYMENT +#################################### + +ALIPAY_APP_ID = os.environ.get("ALIPAY_APP_ID", "") +ALIPAY_PRIVATE_KEY = os.environ.get("ALIPAY_PRIVATE_KEY", "") # 应用私钥 +ALIPAY_PUBLIC_KEY = os.environ.get("ALIPAY_PUBLIC_KEY", "") # 支付宝公钥 +ALIPAY_NOTIFY_URL = os.environ.get("ALIPAY_NOTIFY_URL", "") # 异步通知地址 +ALIPAY_SANDBOX = os.environ.get("ALIPAY_SANDBOX", "false").lower() == "true" # 沙箱模式 diff --git a/backend/open_webui/migrations/versions/h1i2j3k4l5m6_add_payment_order_table.py b/backend/open_webui/migrations/versions/h1i2j3k4l5m6_add_payment_order_table.py new file mode 100644 index 0000000000..52f10c3102 --- /dev/null +++ b/backend/open_webui/migrations/versions/h1i2j3k4l5m6_add_payment_order_table.py @@ -0,0 +1,48 @@ +"""Add payment_order table + +Revision ID: h1i2j3k4l5m6 +Revises: 2b3c4d5e6f7g +Create Date: 2025-12-07 22:00:00.000000 + +添加支付订单表,用于存储用户自助充值订单信息。 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "h1i2j3k4l5m6" +down_revision = "2b3c4d5e6f7g" +branch_labels = None +depends_on = None + + +def upgrade(): + """创建 payment_order 表""" + op.create_table( + "payment_order", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("out_trade_no", sa.String(64), unique=True, nullable=False), + sa.Column("trade_no", sa.String(64), nullable=True), + sa.Column("amount", sa.Integer(), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("payment_method", sa.String(20), nullable=False, server_default="alipay"), + sa.Column("qr_code", sa.Text(), nullable=True), + sa.Column("paid_at", sa.BigInteger(), nullable=True), + sa.Column("created_at", sa.BigInteger(), nullable=False), + sa.Column("updated_at", sa.BigInteger(), nullable=False), + sa.Column("expired_at", sa.BigInteger(), nullable=False), + ) + + # 创建索引 + op.create_index("ix_payment_order_user_id", "payment_order", ["user_id"]) + op.create_index("ix_payment_order_out_trade_no", "payment_order", ["out_trade_no"], unique=True) + op.create_index("ix_payment_order_status", "payment_order", ["status"]) + + +def downgrade(): + """删除 payment_order 表""" + op.drop_index("ix_payment_order_status", table_name="payment_order") + op.drop_index("ix_payment_order_out_trade_no", table_name="payment_order") + op.drop_index("ix_payment_order_user_id", table_name="payment_order") + op.drop_table("payment_order") diff --git a/backend/open_webui/models/billing.py b/backend/open_webui/models/billing.py index 8bce6b6d14..301d442398 100644 --- a/backend/open_webui/models/billing.py +++ b/backend/open_webui/models/billing.py @@ -68,6 +68,25 @@ class RechargeLog(Base): created_at = Column(BigInteger, nullable=False) +class PaymentOrder(Base): + """支付订单表""" + + __tablename__ = "payment_order" + + id = Column(String, primary_key=True) # 内部订单号 + user_id = Column(String, nullable=False, index=True) + out_trade_no = Column(String(64), unique=True, nullable=False) # 商户订单号 + trade_no = Column(String(64), nullable=True) # 支付宝交易号 + amount = Column(Integer, nullable=False) # 金额(毫,1元=10000毫) + status = Column(String(20), default="pending") # pending/paid/closed/refunded + payment_method = Column(String(20), default="alipay") # alipay + qr_code = Column(Text, nullable=True) # 支付二维码内容 + paid_at = Column(BigInteger, nullable=True) # 支付时间 + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + expired_at = Column(BigInteger, nullable=False) # 订单过期时间 + + #################### # Pydantic Models #################### @@ -122,6 +141,25 @@ class RechargeLogModel(BaseModel): model_config = ConfigDict(from_attributes=True) +class PaymentOrderModel(BaseModel): + """支付订单 Pydantic 模型(以毫为单位,1元=10000毫)""" + + id: str + user_id: str + out_trade_no: str + trade_no: Optional[str] = None + amount: int # 毫 + status: str + payment_method: str + qr_code: Optional[str] = None + paid_at: Optional[int] = None + created_at: int + updated_at: int + expired_at: int + + model_config = ConfigDict(from_attributes=True) + + #################### # Data Access Layer #################### @@ -270,7 +308,106 @@ class RechargeLogTable: ] +class PaymentOrderTable: + """支付订单数据访问层""" + + def create( + self, + user_id: str, + out_trade_no: str, + amount: int, + qr_code: str, + expired_at: int, + payment_method: str = "alipay", + ) -> PaymentOrderModel: + """创建支付订单""" + now = int(time.time()) + with get_db() as db: + order = PaymentOrder( + id=str(uuid.uuid4()), + user_id=user_id, + out_trade_no=out_trade_no, + amount=amount, + status="pending", + payment_method=payment_method, + qr_code=qr_code, + created_at=now, + updated_at=now, + expired_at=expired_at, + ) + db.add(order) + db.commit() + db.refresh(order) + return PaymentOrderModel.model_validate(order) + + def get_by_id(self, order_id: str) -> Optional[PaymentOrderModel]: + """根据ID获取订单""" + with get_db() as db: + order = db.query(PaymentOrder).filter_by(id=order_id).first() + return PaymentOrderModel.model_validate(order) if order else None + + def get_by_out_trade_no(self, out_trade_no: str) -> Optional[PaymentOrderModel]: + """根据商户订单号获取订单""" + with get_db() as db: + order = db.query(PaymentOrder).filter_by(out_trade_no=out_trade_no).first() + return PaymentOrderModel.model_validate(order) if order else None + + def get_by_user_id( + self, user_id: str, limit: int = 50, offset: int = 0 + ) -> list[PaymentOrderModel]: + """获取用户支付订单""" + with get_db() as db: + orders = ( + db.query(PaymentOrder) + .filter_by(user_id=user_id) + .order_by(PaymentOrder.created_at.desc()) + .limit(limit) + .offset(offset) + .all() + ) + return [PaymentOrderModel.model_validate(o) for o in orders] + + def update_status( + self, + out_trade_no: str, + status: str, + trade_no: Optional[str] = None, + paid_at: Optional[int] = None, + ) -> bool: + """更新订单状态""" + with get_db() as db: + order = db.query(PaymentOrder).filter_by(out_trade_no=out_trade_no).first() + if not order: + return False + + order.status = status + order.updated_at = int(time.time()) + if trade_no: + order.trade_no = trade_no + if paid_at: + order.paid_at = paid_at + + db.commit() + return True + + def close_expired_orders(self) -> int: + """关闭过期订单""" + now = int(time.time()) + with get_db() as db: + result = ( + db.query(PaymentOrder) + .filter( + PaymentOrder.status == "pending", + PaymentOrder.expired_at < now, + ) + .update({"status": "closed", "updated_at": now}) + ) + db.commit() + return result + + # 单例实例 ModelPricings = ModelPricingTable() BillingLogs = BillingLogTable() RechargeLogs = RechargeLogTable() +PaymentOrders = PaymentOrderTable() diff --git a/backend/open_webui/routers/billing.py b/backend/open_webui/routers/billing.py index 0cfb72093f..41e34953ae 100644 --- a/backend/open_webui/routers/billing.py +++ b/backend/open_webui/routers/billing.py @@ -418,3 +418,220 @@ async def get_recharge_logs( except Exception as e: log.error(f"查询充值记录失败: {e}") raise HTTPException(status_code=500, detail=f"查询充值记录失败: {str(e)}") + + +#################### +# Payment API (用户自助充值) +#################### + + +class CreateOrderRequest(BaseModel): + """创建充值订单请求""" + + amount: float = Field(..., gt=0, le=10000, description="充值金额(元),1-10000") + + +class CreateOrderResponse(BaseModel): + """创建充值订单响应""" + + order_id: str + out_trade_no: str + qr_code: str + amount: float + expired_at: int + + +class OrderStatusResponse(BaseModel): + """订单状态响应""" + + order_id: str + status: str + amount: float + paid_at: Optional[int] = None + + +@router.post("/payment/create", response_model=CreateOrderResponse) +async def create_payment_order(req: CreateOrderRequest, user=Depends(get_verified_user)): + """ + 创建充值订单(生成支付二维码) + + 需要登录 + """ + import uuid as uuid_module + + from open_webui.utils.alipay import create_qr_payment, is_alipay_configured + from open_webui.models.billing import PaymentOrders + + # 检查支付宝配置 + if not is_alipay_configured(): + raise HTTPException(status_code=503, detail="支付功能暂未开放,请联系管理员") + + # 验证金额 + if req.amount < 1: + raise HTTPException(status_code=400, detail="充值金额最低1元") + if req.amount > 10000: + raise HTTPException(status_code=400, detail="充值金额最高10000元") + + # 生成订单号: CK + 时间戳 + 随机字符 + out_trade_no = f"CK{int(time.time())}{uuid_module.uuid4().hex[:8].upper()}" + + # 调用支付宝创建订单 + success, msg, qr_code = create_qr_payment( + out_trade_no=out_trade_no, + amount_yuan=req.amount, + subject="Cakumi账户充值", + ) + + if not success: + log.error(f"创建支付订单失败: {msg}") + raise HTTPException(status_code=500, detail=f"创建订单失败: {msg}") + + # 保存订单到数据库 + now = int(time.time()) + expired_at = now + 900 # 15分钟后过期 + + order = PaymentOrders.create( + user_id=user.id, + out_trade_no=out_trade_no, + amount=int(req.amount * 10000), # 元 → 毫 + qr_code=qr_code, + expired_at=expired_at, + payment_method="alipay", + ) + + log.info(f"创建支付订单成功: {out_trade_no}, 用户={user.id}, 金额={req.amount}元") + + return CreateOrderResponse( + order_id=order.id, + out_trade_no=out_trade_no, + qr_code=qr_code, + amount=req.amount, + expired_at=expired_at, + ) + + +@router.get("/payment/status/{order_id}", response_model=OrderStatusResponse) +async def get_payment_status(order_id: str, user=Depends(get_verified_user)): + """ + 查询订单状态(前端轮询) + + 需要登录 + """ + from open_webui.models.billing import PaymentOrders + + order = PaymentOrders.get_by_id(order_id) + + if not order: + raise HTTPException(status_code=404, detail="订单不存在") + + # 确保只能查询自己的订单 + if order.user_id != user.id: + raise HTTPException(status_code=403, detail="无权查询该订单") + + return OrderStatusResponse( + order_id=order.id, + status=order.status, + amount=order.amount / 10000, # 毫 → 元 + paid_at=order.paid_at, + ) + + +@router.post("/payment/notify") +async def alipay_notify(request): + """ + 支付宝异步通知回调 + + 注意:此接口无需登录验证 + """ + from fastapi import Request + from open_webui.utils.alipay import verify_notify_sign + from open_webui.models.billing import PaymentOrders, RechargeLog + from open_webui.models.users import Users + import uuid as uuid_module + + # 获取回调参数 + form_data = await request.form() + params = dict(form_data) + + log.info(f"收到支付宝回调: {params.get('out_trade_no')}") + + # 验签 + if not verify_notify_sign(params): + log.error("支付宝回调验签失败") + return "fail" + + out_trade_no = params.get("out_trade_no") + trade_no = params.get("trade_no") + trade_status = params.get("trade_status") + + # 只处理支付成功状态 + if trade_status not in ["TRADE_SUCCESS", "TRADE_FINISHED"]: + log.info(f"支付宝回调,非成功状态: {trade_status}") + return "success" + + # 查询订单 + order = PaymentOrders.get_by_out_trade_no(out_trade_no) + if not order: + log.error(f"支付宝回调,订单不存在: {out_trade_no}") + return "success" + + # 幂等检查:已处理的订单直接返回成功 + if order.status == "paid": + log.info(f"支付宝回调,订单已处理: {out_trade_no}") + return "success" + + # 更新订单状态 + now = int(time.time()) + PaymentOrders.update_status( + out_trade_no=out_trade_no, + status="paid", + trade_no=trade_no, + paid_at=now, + ) + + # 增加用户余额 + try: + from open_webui.models.users import User as UserModel + + with get_db() as db: + user = db.query(UserModel).filter_by(id=order.user_id).first() + if user: + user.balance = (user.balance or 0) + order.amount + db.commit() + + # 记录充值日志 + recharge_log = RechargeLog( + id=str(uuid_module.uuid4()), + user_id=order.user_id, + amount=order.amount, + operator_id="system", # 系统自动充值 + remark=f"支付宝充值,订单号: {out_trade_no}", + created_at=now, + ) + db.add(recharge_log) + db.commit() + + log.info( + f"支付成功: 用户={order.user_id}, 金额={order.amount / 10000:.2f}元, " + f"订单={out_trade_no}" + ) + except Exception as e: + log.error(f"支付回调处理失败: {e}") + # 即使余额更新失败,也返回 success,避免支付宝重复回调 + # 后续可通过定时任务修复 + + return "success" + + +@router.get("/payment/config") +async def get_payment_config(): + """ + 获取支付配置状态 + + 公开接口,用于前端判断是否显示充值功能 + """ + from open_webui.utils.alipay import is_alipay_configured + + return { + "alipay_enabled": is_alipay_configured(), + } diff --git a/backend/open_webui/utils/alipay.py b/backend/open_webui/utils/alipay.py new file mode 100644 index 0000000000..272228281f --- /dev/null +++ b/backend/open_webui/utils/alipay.py @@ -0,0 +1,234 @@ +""" +支付宝支付服务 + +使用当面付(扫码支付)模式 +""" + +import logging +from typing import Optional, Tuple +from urllib.parse import parse_qs, unquote + +from open_webui.env import ( + ALIPAY_APP_ID, + ALIPAY_PRIVATE_KEY, + ALIPAY_PUBLIC_KEY, + ALIPAY_NOTIFY_URL, + ALIPAY_SANDBOX, +) + +log = logging.getLogger(__name__) + + +def is_alipay_configured() -> bool: + """检查支付宝是否已配置""" + return bool(ALIPAY_APP_ID and ALIPAY_PRIVATE_KEY and ALIPAY_PUBLIC_KEY) + + +def get_alipay_client(): + """ + 获取支付宝客户端 + + Returns: + DefaultAlipayClient 实例 + """ + if not is_alipay_configured(): + raise ValueError("支付宝配置不完整,请检查环境变量") + + try: + from alipay.aop.api.AlipayClientConfig import AlipayClientConfig + from alipay.aop.api.DefaultAlipayClient import DefaultAlipayClient + except ImportError: + raise ImportError("请安装 alipay-sdk-python: pip install alipay-sdk-python") + + config = AlipayClientConfig() + config.app_id = ALIPAY_APP_ID + config.app_private_key = ALIPAY_PRIVATE_KEY + config.alipay_public_key = ALIPAY_PUBLIC_KEY + + if ALIPAY_SANDBOX: + config.server_url = "https://openapi-sandbox.dl.alipaydev.com/gateway.do" + else: + config.server_url = "https://openapi.alipay.com/gateway.do" + + return DefaultAlipayClient(alipay_client_config=config) + + +def create_qr_payment( + out_trade_no: str, amount_yuan: float, subject: str = "账户充值" +) -> Tuple[bool, str, Optional[str]]: + """ + 创建扫码支付订单 + + Args: + out_trade_no: 商户订单号 + amount_yuan: 金额(元) + subject: 订单标题 + + Returns: + Tuple[success, message, qr_code] + """ + try: + from alipay.aop.api.domain.AlipayTradePrecreateModel import ( + AlipayTradePrecreateModel, + ) + from alipay.aop.api.request.AlipayTradePrecreateRequest import ( + AlipayTradePrecreateRequest, + ) + + client = get_alipay_client() + + model = AlipayTradePrecreateModel() + model.out_trade_no = out_trade_no + model.total_amount = f"{amount_yuan:.2f}" + model.subject = subject + model.timeout_express = "15m" # 15分钟过期 + + request = AlipayTradePrecreateRequest(biz_model=model) + if ALIPAY_NOTIFY_URL: + request.notify_url = ALIPAY_NOTIFY_URL + + response = client.execute(request) + + # 解析响应 + if hasattr(response, "code") and response.code == "10000": + qr_code = response.qr_code + log.info(f"创建支付宝订单成功: {out_trade_no}") + return True, "success", qr_code + else: + error_msg = getattr(response, "sub_msg", None) or getattr( + response, "msg", "未知错误" + ) + log.error(f"创建支付宝订单失败: {out_trade_no}, {error_msg}") + return False, error_msg, None + + except Exception as e: + log.error(f"创建支付宝订单异常: {e}") + return False, str(e), None + + +def query_payment(out_trade_no: str) -> Tuple[str, Optional[str]]: + """ + 查询订单状态 + + Args: + out_trade_no: 商户订单号 + + Returns: + Tuple[status, trade_no] + status: WAIT_BUYER_PAY / TRADE_CLOSED / TRADE_SUCCESS / TRADE_FINISHED / NOT_FOUND / ERROR + """ + try: + from alipay.aop.api.domain.AlipayTradeQueryModel import AlipayTradeQueryModel + from alipay.aop.api.request.AlipayTradeQueryRequest import ( + AlipayTradeQueryRequest, + ) + + client = get_alipay_client() + + model = AlipayTradeQueryModel() + model.out_trade_no = out_trade_no + + request = AlipayTradeQueryRequest(biz_model=model) + response = client.execute(request) + + if hasattr(response, "code") and response.code == "10000": + trade_status = response.trade_status + trade_no = response.trade_no + return trade_status, trade_no + else: + return "NOT_FOUND", None + + except Exception as e: + log.error(f"查询支付宝订单失败: {e}") + return "ERROR", None + + +def close_payment(out_trade_no: str) -> bool: + """ + 关闭订单 + + Args: + out_trade_no: 商户订单号 + + Returns: + 是否成功 + """ + try: + from alipay.aop.api.domain.AlipayTradeCloseModel import AlipayTradeCloseModel + from alipay.aop.api.request.AlipayTradeCloseRequest import ( + AlipayTradeCloseRequest, + ) + + client = get_alipay_client() + + model = AlipayTradeCloseModel() + model.out_trade_no = out_trade_no + + request = AlipayTradeCloseRequest(biz_model=model) + response = client.execute(request) + + if hasattr(response, "code") and response.code == "10000": + log.info(f"关闭支付宝订单成功: {out_trade_no}") + return True + else: + log.error(f"关闭支付宝订单失败: {out_trade_no}") + return False + + except Exception as e: + log.error(f"关闭支付宝订单异常: {e}") + return False + + +def verify_notify_sign(params: dict) -> bool: + """ + 验证支付宝异步通知签名 + + Args: + params: 支付宝回调参数 + + Returns: + 签名是否有效 + """ + if not is_alipay_configured(): + return False + + try: + from alipay.aop.api.util.SignatureUtils import verify_with_rsa + + # 获取签名 + sign = params.get("sign", "") + sign_type = params.get("sign_type", "RSA2") + + if not sign: + log.error("回调参数缺少签名") + return False + + # 构建待验签字符串(按字母排序,排除 sign 和 sign_type) + sorted_params = sorted( + [(k, v) for k, v in params.items() if k not in ("sign", "sign_type") and v] + ) + unsigned_string = "&".join([f"{k}={v}" for k, v in sorted_params]) + + # 验签 + if sign_type == "RSA2": + result = verify_with_rsa( + ALIPAY_PUBLIC_KEY, unsigned_string.encode("utf-8"), sign + ) + else: + # RSA (SHA1) + from alipay.aop.api.util.SignatureUtils import verify_with_rsa as verify_rsa1 + + result = verify_rsa1( + ALIPAY_PUBLIC_KEY, unsigned_string.encode("utf-8"), sign + ) + + if result: + log.info(f"支付宝回调验签成功: {params.get('out_trade_no')}") + else: + log.error(f"支付宝回调验签失败: {params.get('out_trade_no')}") + + return result + + except Exception as e: + log.error(f"支付宝回调验签异常: {e}") + return False diff --git a/backend/requirements.txt b/backend/requirements.txt index 193870b3f3..061655f3ad 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,6 +14,9 @@ authlib==1.6.5 requests==2.32.5 aiohttp==3.12.15 + +# Payment +alipay-sdk-python==3.6.915 async-timeout aiocache aiofiles diff --git a/package-lock.json b/package-lock.json index 0e067e3cea..60f34629da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,7 @@ "prosemirror-tables": "^1.7.1", "prosemirror-view": "^1.34.3", "pyodide": "^0.28.2", + "qrcode": "^1.5.4", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.6", "svelte-sonner": "^0.3.19", @@ -5387,7 +5388,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5397,7 +5397,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5966,6 +5965,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/canvg": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", @@ -6438,7 +6446,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6451,7 +6458,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -7304,6 +7310,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -7449,6 +7464,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -7583,7 +7604,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encoding-sniffer": { @@ -9465,7 +9485,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11079,6 +11098,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -11197,7 +11225,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11381,6 +11408,15 @@ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", "license": "MIT" }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -11966,6 +12002,141 @@ "node": ">=18.0.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -12153,6 +12324,21 @@ "throttleit": "^1.0.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -12940,6 +13126,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -13350,7 +13542,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -13381,7 +13572,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -15814,6 +16004,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index 1c203d4c61..8a78fa7b6c 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "prosemirror-tables": "^1.7.1", "prosemirror-view": "^1.34.3", "pyodide": "^0.28.2", + "qrcode": "^1.5.4", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.6", "svelte-sonner": "^0.3.19", diff --git a/src/lib/apis/billing/index.ts b/src/lib/apis/billing/index.ts index 1efc6c0c9b..bed515a490 100644 --- a/src/lib/apis/billing/index.ts +++ b/src/lib/apis/billing/index.ts @@ -327,3 +327,120 @@ export const getRechargeLogsByUserId = async ( return res; }; + +// ========== 支付相关 API ========== + +export interface CreateOrderResponse { + order_id: string; + out_trade_no: string; + qr_code: string; + amount: number; + expired_at: number; +} + +export interface OrderStatusResponse { + order_id: string; + status: string; + amount: number; + paid_at: number | null; +} + +export interface PaymentConfig { + alipay_enabled: boolean; +} + +/** + * 获取支付配置状态 + */ +export const getPaymentConfig = async (): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/billing/payment/config`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail || err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +/** + * 创建充值订单 + */ +export const createPaymentOrder = async ( + token: string, + amount: number +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/billing/payment/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ amount }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail || err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +/** + * 查询订单状态 + */ +export const getPaymentStatus = async ( + token: string, + orderId: string +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/billing/payment/status/${orderId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail || err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/billing/BillingLogsTable.svelte b/src/lib/components/billing/BillingLogsTable.svelte index 4b4e23b082..93a78ee270 100644 --- a/src/lib/components/billing/BillingLogsTable.svelte +++ b/src/lib/components/billing/BillingLogsTable.svelte @@ -138,27 +138,28 @@ - - - - - - - + + + + + + + {#each mergedLogs as log (log.id)} - - + - - - + - - diff --git a/src/lib/components/billing/RechargeCard.svelte b/src/lib/components/billing/RechargeCard.svelte new file mode 100644 index 0000000000..c42b3b708e --- /dev/null +++ b/src/lib/components/billing/RechargeCard.svelte @@ -0,0 +1,236 @@ + + +
+

+ {$i18n.t('账户充值')} +

+ + {#if !alipayEnabled} + +
+ + + +

{$i18n.t('充值功能暂未开放')}

+

{$i18n.t('请联系管理员')}

+
+ {:else if !orderInfo} + +
+
+ {#each amountOptions as amount} + + {/each} +
+ + +
+ (selectedAmount = null)} + placeholder={$i18n.t('自定义金额 (1-10000)')} + class="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg + bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-gray-100 + focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ + + + +

+ {$i18n.t('支持支付宝扫码支付')} +

+
+ {:else} + +
+
+ 支付二维码 +
+ +
+

+ ¥{orderInfo.amount} +

+

+ {$i18n.t('请使用支付宝扫码支付')} +

+
+ +
+ {$i18n.t('剩余时间')}: {formatCountdown(countdown)} +
+ + +
+ {/if} +
diff --git a/src/routes/(app)/billing/+page.svelte b/src/routes/(app)/billing/+page.svelte index ff231349bf..5bc4bafa0e 100644 --- a/src/routes/(app)/billing/+page.svelte +++ b/src/routes/(app)/billing/+page.svelte @@ -6,6 +6,7 @@ import BillingLogsTable from '$lib/components/billing/BillingLogsTable.svelte'; import BillingStatsChart from '$lib/components/billing/BillingStatsChart.svelte'; import LowBalanceAlert from '$lib/components/billing/LowBalanceAlert.svelte'; + import RechargeCard from '$lib/components/billing/RechargeCard.svelte'; import { toast } from 'svelte-sonner'; const i18n = getContext('i18n'); @@ -30,9 +31,9 @@ : 'md:max-w-[calc(100%-49px)]'}" >
-
+
-
{$i18n.t('时间')}{$i18n.t('模型')}{$i18n.t('类型')}{$i18n.t('输入Token')}{$i18n.t('输出Token')}{$i18n.t('费用')}{$i18n.t('余额')}{$i18n.t('时间')}{$i18n.t('模型')}{$i18n.t('类型')}{$i18n.t('输入Token')}{$i18n.t('输出Token')}{$i18n.t('费用')}{$i18n.t('余额')}
{formatDate(log.created_at)} + {formatDate(log.created_at)} {log.model_id} +
{getLogTypeLabel(log.displayType)} @@ -182,14 +183,14 @@ {/if}
{log.prompt_tokens.toLocaleString()} + {log.prompt_tokens.toLocaleString()} {log.completion_tokens.toLocaleString()} = 0}> + = 0}> {formatCurrency(log.cost, true)} + {log.balance_after !== null ? formatCurrency(log.balance_after, false) : '-'}