mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 04:15:25 +00:00
feat:补充支付功能,需要更新env
This commit is contained in:
parent
9121bc7022
commit
6147cc9acf
13 changed files with 1260 additions and 40 deletions
17
.env.example
17
.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
|
||||
|
|
@ -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" # 沙箱模式
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
234
backend/open_webui/utils/alipay.py
Normal file
234
backend/open_webui/utils/alipay.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
214
package-lock.json
generated
214
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<PaymentConfig> => {
|
||||
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<CreateOrderResponse> => {
|
||||
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<OrderStatusResponse> => {
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -138,27 +138,28 @@
|
|||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-800">
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold">{$i18n.t('时间')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold">{$i18n.t('模型')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold">{$i18n.t('类型')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold">{$i18n.t('输入Token')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold">{$i18n.t('输出Token')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold">{$i18n.t('费用')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold">{$i18n.t('余额')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold whitespace-nowrap">{$i18n.t('时间')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold whitespace-nowrap">{$i18n.t('模型')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold whitespace-nowrap">{$i18n.t('类型')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold whitespace-nowrap">{$i18n.t('输入Token')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold whitespace-nowrap">{$i18n.t('输出Token')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold whitespace-nowrap">{$i18n.t('费用')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold whitespace-nowrap">{$i18n.t('余额')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each mergedLogs as log (log.id)}
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td class="px-4 py-3 text-sm">{formatDate(log.created_at)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<td class="px-4 py-3 text-sm whitespace-nowrap">{formatDate(log.created_at)}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<code
|
||||
class="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded text-xs font-mono"
|
||||
class="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded text-xs font-mono max-w-[160px] truncate inline-block align-middle"
|
||||
title={log.model_id}
|
||||
>
|
||||
{log.model_id}
|
||||
</code>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold {getLogTypeClass(log.displayType)}">
|
||||
{getLogTypeLabel(log.displayType)}
|
||||
|
|
@ -182,14 +183,14 @@
|
|||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-sm">{log.prompt_tokens.toLocaleString()}</td>
|
||||
<td class="px-4 py-3 text-right text-sm">
|
||||
<td class="px-4 py-3 text-right text-sm whitespace-nowrap">{log.prompt_tokens.toLocaleString()}</td>
|
||||
<td class="px-4 py-3 text-right text-sm whitespace-nowrap">
|
||||
{log.completion_tokens.toLocaleString()}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-sm font-semibold" class:text-green-600={log.cost < 0} class:text-red-600={log.cost >= 0}>
|
||||
<td class="px-4 py-3 text-right text-sm font-semibold whitespace-nowrap" class:text-green-600={log.cost < 0} class:text-red-600={log.cost >= 0}>
|
||||
{formatCurrency(log.cost, true)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-sm">
|
||||
<td class="px-4 py-3 text-right text-sm whitespace-nowrap">
|
||||
{log.balance_after !== null ? formatCurrency(log.balance_after, false) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
236
src/lib/components/billing/RechargeCard.svelte
Normal file
236
src/lib/components/billing/RechargeCard.svelte
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy, getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { balance } from '$lib/stores';
|
||||
import { createPaymentOrder, getPaymentStatus, getPaymentConfig, getBalance } from '$lib/apis/billing';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
// 预设金额选项
|
||||
const amountOptions = [10, 50, 100, 200, 500, 1000];
|
||||
|
||||
let selectedAmount: number | null = null;
|
||||
let customAmount = '';
|
||||
let loading = false;
|
||||
let alipayEnabled = false;
|
||||
let orderInfo: { order_id: string; qr_code: string; expired_at: number; amount: number } | null = null;
|
||||
let qrCodeDataUrl = '';
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let countdown = 0;
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// 计算最终金额
|
||||
$: finalAmount = selectedAmount || (customAmount ? parseFloat(customAmount) : 0);
|
||||
$: isValidAmount = finalAmount >= 1 && finalAmount <= 10000;
|
||||
|
||||
// 检查支付配置
|
||||
onMount(async () => {
|
||||
try {
|
||||
const config = await getPaymentConfig();
|
||||
alipayEnabled = config.alipay_enabled;
|
||||
} catch (e) {
|
||||
console.error('获取支付配置失败', e);
|
||||
}
|
||||
});
|
||||
|
||||
// 创建订单
|
||||
const createOrder = async () => {
|
||||
if (!isValidAmount) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const result = await createPaymentOrder(localStorage.token, finalAmount);
|
||||
orderInfo = {
|
||||
order_id: result.order_id,
|
||||
qr_code: result.qr_code,
|
||||
expired_at: result.expired_at,
|
||||
amount: result.amount
|
||||
};
|
||||
|
||||
// 生成二维码图片
|
||||
qrCodeDataUrl = await QRCode.toDataURL(result.qr_code, { width: 200, margin: 2 });
|
||||
|
||||
// 计算倒计时
|
||||
countdown = result.expired_at - Math.floor(Date.now() / 1000);
|
||||
|
||||
// 开始轮询订单状态
|
||||
startPolling();
|
||||
// 开始倒计时
|
||||
startCountdown();
|
||||
} catch (error: any) {
|
||||
toast.error(error.detail || $i18n.t('创建订单失败'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 开始倒计时
|
||||
const startCountdown = () => {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
|
||||
countdownTimer = setInterval(() => {
|
||||
if (!orderInfo) return;
|
||||
|
||||
countdown = orderInfo.expired_at - Math.floor(Date.now() / 1000);
|
||||
if (countdown <= 0) {
|
||||
stopAll();
|
||||
orderInfo = null;
|
||||
toast.error($i18n.t('订单已过期,请重新创建'));
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 轮询订单状态
|
||||
const startPolling = () => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!orderInfo) return;
|
||||
|
||||
try {
|
||||
const status = await getPaymentStatus(localStorage.token, orderInfo.order_id);
|
||||
if (status.status === 'paid') {
|
||||
stopAll();
|
||||
toast.success($i18n.t('充值成功!已到账') + ` ¥${status.amount}`);
|
||||
orderInfo = null;
|
||||
|
||||
// 刷新余额
|
||||
try {
|
||||
const balanceInfo = await getBalance(localStorage.token);
|
||||
balance.set(balanceInfo);
|
||||
} catch (e) {
|
||||
console.error('刷新余额失败', e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('轮询订单状态失败', e);
|
||||
}
|
||||
}, 3000); // 每3秒轮询一次
|
||||
};
|
||||
|
||||
const stopAll = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelOrder = () => {
|
||||
stopAll();
|
||||
orderInfo = null;
|
||||
};
|
||||
|
||||
const formatCountdown = (seconds: number) => {
|
||||
if (seconds <= 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
stopAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{$i18n.t('账户充值')}
|
||||
</h3>
|
||||
|
||||
{#if !alipayEnabled}
|
||||
<!-- 支付未配置 -->
|
||||
<div class="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-sm">{$i18n.t('充值功能暂未开放')}</p>
|
||||
<p class="text-xs mt-1">{$i18n.t('请联系管理员')}</p>
|
||||
</div>
|
||||
{:else if !orderInfo}
|
||||
<!-- 金额选择 -->
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each amountOptions as amount}
|
||||
<button
|
||||
class="py-2 px-3 rounded-lg border text-sm font-medium transition-colors
|
||||
{selectedAmount === amount
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-indigo-300 dark:hover:border-indigo-500 text-gray-700 dark:text-gray-300'}"
|
||||
on:click={() => {
|
||||
selectedAmount = amount;
|
||||
customAmount = '';
|
||||
}}
|
||||
>
|
||||
¥{amount}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 自定义金额 -->
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={customAmount}
|
||||
on:input={() => (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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 充值按钮 -->
|
||||
<button
|
||||
on:click={createOrder}
|
||||
disabled={!isValidAmount || loading}
|
||||
class="w-full py-2.5 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 dark:disabled:bg-gray-600
|
||||
text-white font-medium rounded-lg transition-colors disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<span class="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></span>
|
||||
{$i18n.t('创建订单中...')}
|
||||
</span>
|
||||
{:else}
|
||||
{$i18n.t('立即充值')} {isValidAmount ? `¥${finalAmount}` : ''}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
{$i18n.t('支持支付宝扫码支付')}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 支付二维码 -->
|
||||
<div class="text-center space-y-4">
|
||||
<div class="inline-block p-3 bg-white rounded-lg shadow-sm">
|
||||
<img src={qrCodeDataUrl} alt="支付二维码" class="w-48 h-48" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
¥{orderInfo.amount}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('请使用支付宝扫码支付')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-orange-500 dark:text-orange-400">
|
||||
{$i18n.t('剩余时间')}: {formatCountdown(countdown)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={cancelOrder}
|
||||
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
{$i18n.t('取消订单')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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)]'}"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto min-w-[320px]">
|
||||
<div class="billing-page max-w-4xl mx-auto px-4 py-6 space-y-6 pb-16">
|
||||
<div class="billing-page max-w-7xl mx-auto px-4 py-6 pb-16">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div class="page-header mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{$i18n.t('计费中心')}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('查看您的余额、消费记录和统计信息')}
|
||||
|
|
@ -42,20 +43,27 @@
|
|||
<!-- 余额不足警告 -->
|
||||
<LowBalanceAlert />
|
||||
|
||||
<!-- 两栏布局:左侧自适应,右侧固定宽度 -->
|
||||
<div class="flex flex-col lg:flex-row gap-6 mt-6">
|
||||
<!-- 左侧主内容区(自适应宽度) -->
|
||||
<div class="flex-1 min-w-0 space-y-6 order-2 lg:order-1">
|
||||
<!-- 余额卡片 -->
|
||||
<div class="balance-section">
|
||||
<BalanceDisplay />
|
||||
</div>
|
||||
|
||||
<!-- 统计图表 -->
|
||||
<div class="stats-section">
|
||||
<BillingStatsChart />
|
||||
</div>
|
||||
|
||||
<!-- 消费记录 -->
|
||||
<div class="logs-section">
|
||||
<BillingLogsTable />
|
||||
</div>
|
||||
|
||||
<!-- 右侧充值卡片(固定宽度,移动端显示在上方) -->
|
||||
<div class="lg:w-[360px] lg:flex-shrink-0 order-1 lg:order-2">
|
||||
<div class="lg:sticky lg:top-6">
|
||||
<RechargeCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue