s''
This commit is contained in:
17
.env.example
17
.env.example
@@ -1,13 +1,16 @@
|
|||||||
# ========== 本地调试 ==========
|
# ========== 本地调试 ==========
|
||||||
# BASE_URL 需为 ZPAY 可访问的回调地址。本地调试时 ZPAY 无法回调 localhost,
|
# BASE_URL 需为 ZPAY 可访问的回调地址。本地调试时 ZPAY 无法回调 localhost/内网,
|
||||||
# 可用 ngrok 等隧道:BASE_URL=https://xxx.ngrok.io
|
# 可用 ngrok 等隧道:BASE_URL=https://xxx.ngrok.io
|
||||||
# 或直接使用生产地址测试回调(需 payjsapi 已部署到公网)
|
# 或直接使用生产地址测试回调(需 payjsapi 已部署到公网)
|
||||||
|
# nomadvip/digital/cnomadcna/nomadlms 的 zpay_notify 均依赖此地址,未正确配置会导致 PocketBase/Memos 不更新
|
||||||
|
# 本地调试:ngrok 暴露 8700 后设置 BASE_URL=https://xxx.ngrok.io
|
||||||
|
# 线上部署:BASE_URL=https://api.hackrobot.cn
|
||||||
|
|
||||||
# ========== 生产部署 ==========
|
# ========== 生产部署 ==========
|
||||||
# BASE_URL 必须为 payjsapi 实际公网地址,ZPAY 异步通知会请求此地址
|
# BASE_URL 必须为 payjsapi 实际公网地址,ZPAY 异步通知会请求此地址
|
||||||
BASE_URL=https://api.hackrobot.cn
|
BASE_URL=https://api.hackrobot.cn
|
||||||
|
|
||||||
# 支付渠道:xorpay | zpay(默认 zpay)
|
# 支付渠道:zpay(已替代 xorpay)| xorpay
|
||||||
PAYMENT_PROVIDER=zpay
|
PAYMENT_PROVIDER=zpay
|
||||||
|
|
||||||
# XorPay
|
# XorPay
|
||||||
@@ -23,6 +26,14 @@ PB_URL=https://pocketbase.hackrobot.cn
|
|||||||
PB_ADMIN_EMAIL=your@email.com
|
PB_ADMIN_EMAIL=your@email.com
|
||||||
PB_ADMIN_PASSWORD=your_password
|
PB_ADMIN_PASSWORD=your_password
|
||||||
|
|
||||||
# Memos
|
# Memos(仅 nomadvip 使用)
|
||||||
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
|
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
|
||||||
MEMOS_TOKEN=your_token
|
MEMOS_TOKEN=your_token
|
||||||
|
|
||||||
|
# MinIO 对象存储(可选)
|
||||||
|
MINIO_ENDPOINT=minioweb.hackrobot.cn
|
||||||
|
MINIO_PORT=443
|
||||||
|
MINIO_ACCESS_KEY=
|
||||||
|
MINIO_SECRET_KEY=
|
||||||
|
MINIO_BUCKET=hackrobot
|
||||||
|
MINIO_UPLOAD_PREFIX=joins
|
||||||
|
|||||||
78
README.md
78
README.md
@@ -1,6 +1,53 @@
|
|||||||
# PayJS API
|
# PayJS API
|
||||||
|
|
||||||
支付 API 服务,支持多支付渠道切换(XorPay、ZPAY)。
|
支付 API 服务,支持多支付渠道(XorPay、ZPAY),按项目模块化拆分。
|
||||||
|
|
||||||
|
## 路由与项目对应
|
||||||
|
|
||||||
|
| 项目 | 路由前缀 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| nomadvip | `/nomadvip/*` | 固定 ZPAY,独立实例 |
|
||||||
|
| digital | `/digital/*` | 使用 PAYMENT_PROVIDER |
|
||||||
|
| cnomadcna | `/cnomadcna/*` | 使用 PAYMENT_PROVIDER |
|
||||||
|
| nomadlms | `/nomadlms/*` | 使用 PAYMENT_PROVIDER |
|
||||||
|
| legacy | `/payh5`、`/zpay_notify` 等 | 向后兼容 |
|
||||||
|
|
||||||
|
各项目业务互不影响,可单独配置。
|
||||||
|
|
||||||
|
## 环境
|
||||||
|
|
||||||
|
本地调试:`BASE_URL` 需为 ZPAY 可访问地址(如 ngrok 暴露 8700 端口)
|
||||||
|
线上部署:`BASE_URL=https://api.hackrobot.cn`
|
||||||
|
|
||||||
|
## 模块结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── config.py # 配置(环境变量)
|
||||||
|
├── main.py # FastAPI 入口,挂载路由
|
||||||
|
├── sdk/ # SDK 组件化封装
|
||||||
|
│ ├── payment/ # 支付网关(ZPAY/XorPay)
|
||||||
|
│ ├── pocketbase.py # PocketBase 数据库
|
||||||
|
│ ├── minio.py # MinIO 对象存储
|
||||||
|
│ └── memos.py # Memos 用户(仅 nomadvip 使用)
|
||||||
|
├── services/ # 共享业务逻辑(基于 SDK)
|
||||||
|
│ ├── pb.py # PocketBase 客户端
|
||||||
|
│ ├── memos.py # Memos 用户创建
|
||||||
|
│ ├── minio.py # MinIO 上传
|
||||||
|
│ └── payment.py # 支付成功处理
|
||||||
|
├── routers/ # 项目路由
|
||||||
|
│ ├── nomadvip.py # /nomadvip/*
|
||||||
|
│ ├── digital.py # /digital/*
|
||||||
|
│ ├── cnomadcna.py # /cnomadcna/*
|
||||||
|
│ ├── nomadlms.py # /nomadlms/*
|
||||||
|
│ ├── legacy.py # /payh5 等(向后兼容)
|
||||||
|
│ └── common.py # /submit_meetup_application、/create_user
|
||||||
|
└── payment/
|
||||||
|
├── base.py # 支付抽象基类
|
||||||
|
├── xorpay.py # XorPay 实现
|
||||||
|
├── zpay.py # ZPAY 实现(易支付兼容)
|
||||||
|
└── factory.py # 渠道工厂
|
||||||
|
```
|
||||||
|
|
||||||
## 支付渠道
|
## 支付渠道
|
||||||
|
|
||||||
@@ -11,17 +58,28 @@
|
|||||||
|
|
||||||
切换渠道:设置环境变量 `PAYMENT_PROVIDER=xorpay` 或 `zpay`,参考 `.env.example`。
|
切换渠道:设置环境变量 `PAYMENT_PROVIDER=xorpay` 或 `zpay`,参考 `.env.example`。
|
||||||
|
|
||||||
## 模块结构
|
## 本地调试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制配置
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 本地调试时 BASE_URL 需 ZPAY 可访问,可用 ngrok:
|
||||||
|
# ngrok http 8700 -> 将 BASE_URL 设为 ngrok 提供的 https 地址
|
||||||
|
|
||||||
|
python run.py
|
||||||
|
# 或 uvicorn app.main:app --host 0.0.0.0 --port 8700 --reload
|
||||||
```
|
```
|
||||||
app/
|
|
||||||
├── config.py # 配置(环境变量)
|
## 线上部署
|
||||||
├── main.py # FastAPI 入口
|
|
||||||
└── payment/
|
域名:`https://api.hackrobot.cn`
|
||||||
├── base.py # 支付抽象基类
|
|
||||||
├── xorpay.py # XorPay 实现
|
```bash
|
||||||
├── zpay.py # ZPAY 实现(易支付兼容)
|
# .env 中设置
|
||||||
└── factory.py # 渠道工厂
|
BASE_URL=https://api.hackrobot.cn
|
||||||
|
PAYMENT_PROVIDER=zpay
|
||||||
|
# 其他 ZPAY、PocketBase、Memos 配置见 .env.example
|
||||||
```
|
```
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@ from typing import Literal
|
|||||||
|
|
||||||
PaymentChannel = Literal["xorpay", "zpay"]
|
PaymentChannel = Literal["xorpay", "zpay"]
|
||||||
|
|
||||||
# 当前使用的支付渠道(默认 zpay,与 /join 报名支付流程一致)
|
# 当前使用的支付渠道(zpay 替代 xorpay,payjs2 接口业务保留)
|
||||||
PAYMENT_PROVIDER: PaymentChannel = os.getenv("PAYMENT_PROVIDER", "zpay").lower()
|
PAYMENT_PROVIDER: PaymentChannel = os.getenv("PAYMENT_PROVIDER", "zpay").lower()
|
||||||
|
|
||||||
# XorPay 配置
|
# XorPay 配置
|
||||||
@@ -33,3 +33,15 @@ MEMOS_TOKEN = os.getenv(
|
|||||||
|
|
||||||
# 回调地址(用于生成 notify_url)
|
# 回调地址(用于生成 notify_url)
|
||||||
BASE_URL = os.getenv("BASE_URL", "https://api.hackrobot.cn")
|
BASE_URL = os.getenv("BASE_URL", "https://api.hackrobot.cn")
|
||||||
|
|
||||||
|
# MinIO 对象存储
|
||||||
|
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minioweb.hackrobot.cn")
|
||||||
|
MINIO_PORT = int(os.getenv("MINIO_PORT", "443"))
|
||||||
|
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "")
|
||||||
|
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "")
|
||||||
|
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "hackrobot")
|
||||||
|
MINIO_USE_SSL = os.getenv("MINIO_USE_SSL", "true").lower() == "true"
|
||||||
|
_minio_ep = os.getenv("MINIO_ENDPOINT", "minioweb.hackrobot.cn")
|
||||||
|
_minio_bucket = os.getenv("MINIO_BUCKET", "hackrobot")
|
||||||
|
MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", f"https://{_minio_ep}/{_minio_bucket}")
|
||||||
|
MINIO_UPLOAD_PREFIX = os.getenv("MINIO_UPLOAD_PREFIX", "joins")
|
||||||
|
|||||||
488
app/main.py
488
app/main.py
@@ -1,32 +1,37 @@
|
|||||||
"""
|
"""
|
||||||
支付 API 主入口
|
支付 API 主入口
|
||||||
支持多支付渠道(XorPay、ZPAY),通过 PAYMENT_PROVIDER 环境变量切换
|
支持多支付渠道(XorPay、ZPAY),通过 PAYMENT_PROVIDER 环境变量切换
|
||||||
|
|
||||||
|
路由模块化,按项目拆分:
|
||||||
|
- nomadvip: /nomadvip/* - 固定 ZPAY
|
||||||
|
- digital: /digital/* - 使用 PAYMENT_PROVIDER
|
||||||
|
- cnomadcna: /cnomadcna/* - 使用 PAYMENT_PROVIDER
|
||||||
|
- nomadlms: /nomadlms/* - 使用 PAYMENT_PROVIDER
|
||||||
|
- legacy: /payh5、/zpay_notify 等(向后兼容)
|
||||||
|
- common: /submit_meetup_application、/create_user
|
||||||
|
|
||||||
|
本地调试:BASE_URL 需为 ZPAY 可访问地址(如 ngrok)
|
||||||
|
线上部署:https://api.hackrobot.cn
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import time
|
|
||||||
from typing import Dict, Any
|
|
||||||
|
|
||||||
import requests
|
from fastapi import FastAPI
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
|
||||||
from pocketbase import PocketBase
|
|
||||||
|
|
||||||
from .config import (
|
from .routers import (
|
||||||
BASE_URL,
|
nomadvip_router,
|
||||||
PB_URL,
|
digital_router,
|
||||||
PB_ADMIN_EMAIL,
|
cnomadcna_router,
|
||||||
PB_ADMIN_PASSWORD,
|
nomadlms_router,
|
||||||
MEMOS_API,
|
legacy_router,
|
||||||
MEMOS_TOKEN,
|
common_router,
|
||||||
ZPAY_PID,
|
|
||||||
ZPAY_KEY,
|
|
||||||
)
|
)
|
||||||
from .payment import get_payment_provider
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI(
|
||||||
|
title="PayJS API",
|
||||||
|
description="nomadvip / digital / cnomadcna / nomadlms 支付接口",
|
||||||
|
version="2.0",
|
||||||
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -46,434 +51,27 @@ logging.basicConfig(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 已处理订单(防重复)
|
# 挂载路由
|
||||||
processed_orders: set[str] = set()
|
app.include_router(nomadvip_router)
|
||||||
|
app.include_router(digital_router)
|
||||||
|
app.include_router(cnomadcna_router)
|
||||||
|
app.include_router(nomadlms_router)
|
||||||
|
app.include_router(legacy_router)
|
||||||
|
app.include_router(common_router)
|
||||||
|
|
||||||
|
|
||||||
def get_pb_client():
|
@app.get("/")
|
||||||
client = PocketBase(PB_URL)
|
async def root():
|
||||||
try:
|
"""健康检查"""
|
||||||
client.admins.auth_with_password(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD)
|
|
||||||
print("PocketBase admin 登录成功")
|
|
||||||
except Exception as e:
|
|
||||||
print("PocketBase admin 登录失败:", e)
|
|
||||||
raise e
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
def create_memos_user(user_id: str, password: str = "123456"):
|
|
||||||
try:
|
|
||||||
if user_id.startswith("user_"):
|
|
||||||
cleaned_username = "user" + user_id.split("user_", 1)[1]
|
|
||||||
elif "_" in user_id:
|
|
||||||
cleaned_username = user_id.replace("_", "")
|
|
||||||
else:
|
|
||||||
cleaned_username = user_id
|
|
||||||
|
|
||||||
username = cleaned_username
|
|
||||||
print(f"正在为用户创建 memos 账号: {username}")
|
|
||||||
|
|
||||||
memos_data = {
|
|
||||||
"username": username,
|
|
||||||
"password": password,
|
|
||||||
"role": "USER",
|
|
||||||
"state": "NORMAL",
|
|
||||||
}
|
|
||||||
memos_headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {MEMOS_TOKEN}",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.post(MEMOS_API, json=memos_data, headers=memos_headers, timeout=10)
|
|
||||||
|
|
||||||
if response.status_code in (200, 201):
|
|
||||||
result = response.json()
|
|
||||||
print(f"memos 用户创建成功: {username}")
|
|
||||||
logging.info(f"memos 用户创建成功: {username}")
|
|
||||||
return {"success": True, "data": result}
|
|
||||||
else:
|
|
||||||
error_text = response.text
|
|
||||||
print(f"memos 创建失败: {response.status_code} {error_text}")
|
|
||||||
logging.error(f"memos 创建失败 - username: {username}, status: {response.status_code}, response: {error_text}")
|
|
||||||
return {"success": False, "error": error_text}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"创建 memos 用户异常: {e}")
|
|
||||||
logging.error(f"创建 memos 用户异常 - username: {user_id}, error: {e}")
|
|
||||||
return {"success": False, "error": str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_order_id(order_id: str) -> tuple[str, str]:
|
|
||||||
"""从 order_id 解析 user_id 和 pay_type"""
|
|
||||||
if "_order_" not in order_id:
|
|
||||||
return "", "unknown"
|
|
||||||
prefix = order_id.split("_order_")[0]
|
|
||||||
if "_" in prefix:
|
|
||||||
parts = prefix.rsplit("_", 1)
|
|
||||||
return parts[0], parts[1].lower()
|
|
||||||
return prefix, "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_payment_success(order_id: str, pay_price: str, provider_name: str):
|
|
||||||
"""支付成功后的统一业务处理"""
|
|
||||||
user_id, pay_type = _parse_order_id(order_id)
|
|
||||||
if not user_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},订单: {order_id}")
|
|
||||||
|
|
||||||
if pay_type == "vip":
|
|
||||||
create_memos_user(user_id)
|
|
||||||
|
|
||||||
if pay_type == "meetup":
|
|
||||||
logging.info(f"meetup 类型支付成功,不创建 memos 用户 - user_id: {user_id}")
|
|
||||||
|
|
||||||
valid_types = ["vip", "book", "meetup", "video"]
|
|
||||||
if pay_type not in valid_types:
|
|
||||||
pay_type = "vip"
|
|
||||||
|
|
||||||
amount_cents = int(float(pay_price or 0) * 100)
|
|
||||||
|
|
||||||
try:
|
|
||||||
pb_client = get_pb_client()
|
|
||||||
if pay_type == "meetup":
|
|
||||||
solan = pb_client.collection("solan")
|
|
||||||
existing = solan.get_list(1, 1, {
|
|
||||||
"filter": f'user_id = "{user_id}"',
|
|
||||||
"sort": "-created",
|
|
||||||
})
|
|
||||||
if existing.items:
|
|
||||||
record_id = existing.items[0].id
|
|
||||||
solan.update(record_id, {
|
|
||||||
"type": "meetup",
|
|
||||||
"order_id": order_id,
|
|
||||||
"amount": amount_cents,
|
|
||||||
})
|
|
||||||
logging.info(f"meetup 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}")
|
|
||||||
else:
|
|
||||||
solan.create({
|
|
||||||
"user_id": user_id,
|
|
||||||
"type": "meetup",
|
|
||||||
"order_id": order_id,
|
|
||||||
"amount": amount_cents,
|
|
||||||
})
|
|
||||||
logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}")
|
|
||||||
else:
|
|
||||||
pb_client.collection("payments").create({
|
|
||||||
"user_id": user_id,
|
|
||||||
"type": pay_type,
|
|
||||||
"order_id": order_id,
|
|
||||||
"amount": amount_cents,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
print(f"PocketBase 操作失败: {e}")
|
|
||||||
logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}")
|
|
||||||
finally:
|
|
||||||
processed_orders.add(order_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 支付接口 ====================
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/payh5")
|
|
||||||
async def payh5(item: dict):
|
|
||||||
"""创建 H5 支付订单(根据 PAYMENT_PROVIDER 使用对应渠道)"""
|
|
||||||
print("payh5 item:", item)
|
|
||||||
|
|
||||||
total_fee_yuan = f"{item['total_fee'] / 100:.2f}"
|
|
||||||
user_id = item.get("user_id", "unknown")
|
|
||||||
pay_type = item.get("type", "unknown").lower()
|
|
||||||
|
|
||||||
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
|
||||||
timestamp = int(time.time())
|
|
||||||
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
|
||||||
|
|
||||||
type_map = {
|
|
||||||
"book": "购买书籍",
|
|
||||||
"meetup": "数字游民社区报名",
|
|
||||||
"video": "视频解锁",
|
|
||||||
"vip": "VIP会员充值",
|
|
||||||
}
|
|
||||||
name = type_map.get(pay_type, "商品支付")
|
|
||||||
|
|
||||||
provider = get_payment_provider()
|
|
||||||
notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify"
|
|
||||||
notify_url = f"{BASE_URL.rstrip('/')}{notify_path}"
|
|
||||||
|
|
||||||
# 支付方式:前端可传 channel=alipay|wxpay,ZPAY 使用;XorPay 固定 jsapi
|
|
||||||
channel = item.get("channel", "alipay")
|
|
||||||
|
|
||||||
result = provider.create_order(
|
|
||||||
order_id=order_id,
|
|
||||||
name=name,
|
|
||||||
total_fee_yuan=total_fee_yuan,
|
|
||||||
pay_type=channel,
|
|
||||||
notify_url=notify_url,
|
|
||||||
return_url=item.get("return_url"),
|
|
||||||
client_ip=item.get("client_ip"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 兼容旧版前端:保留 xorpay_params / xorpay_url 字段名
|
|
||||||
if provider.name == "xorpay":
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"xorpay_params": result.get("params", {}),
|
|
||||||
"xorpay_url": result.get("pay_url", ""),
|
|
||||||
"provider": provider.name,
|
|
||||||
}
|
|
||||||
# ZPAY:确保所有参数为字符串,避免前端传参问题
|
|
||||||
params = result.get("params", {})
|
|
||||||
params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""}
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"params": params_str,
|
"service": "payjsapi",
|
||||||
"pay_url": result.get("pay_url", ""),
|
"routes": {
|
||||||
"provider": provider.name,
|
"nomadvip": "/nomadvip/payh5, /nomadvip/payh5/redirect, /nomadvip/zpay_order_status, /nomadvip/zpay_notify",
|
||||||
"submit_method": "POST",
|
"digital": "/digital/payh5, /digital/payh5/redirect, /digital/zpay_order_status, /digital/zpay_notify, /digital/xorpay_notify",
|
||||||
|
"cnomadcna": "/cnomadcna/payh5, ...",
|
||||||
|
"nomadlms": "/nomadlms/payh5, ...",
|
||||||
|
"legacy": "/payh5, /payh5/redirect, /zpay_notify, /xorpay_notify, /zpay_order_status",
|
||||||
|
"common": "/submit_meetup_application, /create_user",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/payh5/redirect")
|
|
||||||
async def payh5_redirect(item: dict, request: Request):
|
|
||||||
"""
|
|
||||||
创建支付订单,使用 ZPAY API接口支付(mapi.php),返回 302 重定向。
|
|
||||||
根据 device 区分:PC 跳转 payurl,H5/微信 跳转 payurl 或 payurl2(微信H5)。
|
|
||||||
文档:https://z-pay.cn/doc.html#d3
|
|
||||||
"""
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
|
|
||||||
provider = get_payment_provider()
|
|
||||||
if provider.name != "zpay":
|
|
||||||
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay 渠道")
|
|
||||||
|
|
||||||
total_fee = float(item.get("total_fee", 80))
|
|
||||||
total_fee_yuan = f"{total_fee / 100:.2f}"
|
|
||||||
user_id = item.get("user_id", "unknown")
|
|
||||||
pay_type = item.get("type", "unknown").lower()
|
|
||||||
channel = item.get("channel", "alipay")
|
|
||||||
return_url = item.get("return_url")
|
|
||||||
device = item.get("device", "pc") # pc | h5 | wechat,微信内传 wechat 走收银台
|
|
||||||
|
|
||||||
client_ip = item.get("client_ip") or request.client.host if request.client else "127.0.0.1"
|
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
|
||||||
if forwarded:
|
|
||||||
client_ip = forwarded.split(",")[0].strip()
|
|
||||||
|
|
||||||
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
|
||||||
timestamp = int(time.time())
|
|
||||||
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
|
||||||
|
|
||||||
type_map = {
|
|
||||||
"book": "购买书籍",
|
|
||||||
"meetup": "数字游民社区报名",
|
|
||||||
"video": "视频解锁",
|
|
||||||
"vip": "VIP会员充值",
|
|
||||||
}
|
|
||||||
name = type_map.get(pay_type, "商品支付")
|
|
||||||
notify_url = f"{BASE_URL.rstrip('/')}/zpay_notify"
|
|
||||||
|
|
||||||
from .payment.zpay import ZPayProvider
|
|
||||||
|
|
||||||
if not isinstance(provider, ZPayProvider):
|
|
||||||
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay")
|
|
||||||
|
|
||||||
result = provider.create_order_api(
|
|
||||||
order_id=order_id,
|
|
||||||
name=name,
|
|
||||||
total_fee_yuan=total_fee_yuan,
|
|
||||||
pay_type=channel,
|
|
||||||
notify_url=notify_url,
|
|
||||||
return_url=return_url,
|
|
||||||
client_ip=client_ip,
|
|
||||||
device=device,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.get("status") != "ok":
|
|
||||||
raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败"))
|
|
||||||
|
|
||||||
payurl = result.get("payurl")
|
|
||||||
payurl2 = result.get("payurl2")
|
|
||||||
img = result.get("img")
|
|
||||||
|
|
||||||
# PC 端:payurl 为 wap 链接,在 PC 浏览器会显示错误页面。支付宝/微信均需展示二维码
|
|
||||||
if device == "pc" and img:
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=200,
|
|
||||||
content={
|
|
||||||
"status": "ok",
|
|
||||||
"qrcode_mode": True,
|
|
||||||
"order_id": order_id,
|
|
||||||
"img": img,
|
|
||||||
"payurl": payurl,
|
|
||||||
"return_url": return_url,
|
|
||||||
"channel": channel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# 微信内:payurl2 为 checkmweb,会提示「请外微信外打开」。改用收银台表单(submit.php)由 Z-Pay 识别 UA
|
|
||||||
if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"):
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id})
|
|
||||||
# 返回 302 时附带 order_id,供 digital 写入 cookie 用于轮询检测支付状态(微信 H5 可能不跳转)
|
|
||||||
headers = {"X-Pay-Order-Id": order_id}
|
|
||||||
if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2:
|
|
||||||
return RedirectResponse(url=payurl2, status_code=302, headers=headers)
|
|
||||||
if payurl:
|
|
||||||
return RedirectResponse(url=payurl, status_code=302, headers=headers)
|
|
||||||
raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/zpay_order_status")
|
|
||||||
async def zpay_order_status(out_trade_no: str):
|
|
||||||
"""
|
|
||||||
查询 ZPAY 订单支付状态,用于 PC 二维码页轮询。
|
|
||||||
文档:https://z-pay.cn/doc.html 查询单个订单
|
|
||||||
"""
|
|
||||||
provider = get_payment_provider()
|
|
||||||
if provider.name != "zpay":
|
|
||||||
raise HTTPException(status_code=400, detail="仅 zpay 渠道支持")
|
|
||||||
url = f"https://zpayz.cn/api.php?act=order&pid={ZPAY_PID}&key={ZPAY_KEY}&out_trade_no={out_trade_no}"
|
|
||||||
try:
|
|
||||||
resp = requests.get(url, timeout=10)
|
|
||||||
result = resp.json() if resp.ok else {}
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"ZPAY 订单查询失败: {e}")
|
|
||||||
return {"paid": False, "error": str(e)}
|
|
||||||
# ZPAY 错误时可能返回 code='error',需安全解析
|
|
||||||
try:
|
|
||||||
code = result.get("code", 0)
|
|
||||||
code = int(code) if isinstance(code, (int, float)) or str(code).isdigit() else 0
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
code = 0
|
|
||||||
if code != 1:
|
|
||||||
return {"paid": False, "msg": result.get("msg", "查询失败")}
|
|
||||||
try:
|
|
||||||
status = int(result.get("status", 0))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
status = 0
|
|
||||||
return {"paid": status == 1, "status": status}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/xorpay_notify")
|
|
||||||
async def xorpay_notify(request: Request):
|
|
||||||
"""XorPay 异步通知(POST form)"""
|
|
||||||
form_data = await request.form()
|
|
||||||
data = {k: v for k, v in form_data.items()}
|
|
||||||
|
|
||||||
logging.info(f"XorPay 异步通知: {data}")
|
|
||||||
|
|
||||||
order_id = data.get("order_id", "")
|
|
||||||
if order_id in processed_orders:
|
|
||||||
return "ok"
|
|
||||||
|
|
||||||
provider = get_payment_provider()
|
|
||||||
if provider.name != "xorpay":
|
|
||||||
raise HTTPException(status_code=400, detail="xorpay_notify only for xorpay")
|
|
||||||
|
|
||||||
if not provider.verify_notify(data):
|
|
||||||
logging.error(f"XorPay 验签失败: {data}")
|
|
||||||
raise HTTPException(status_code=400, detail="sign error")
|
|
||||||
|
|
||||||
parsed = provider.parse_notify(data)
|
|
||||||
if parsed.get("trade_status") != "success":
|
|
||||||
return "ok"
|
|
||||||
|
|
||||||
_handle_payment_success(order_id, parsed.get("pay_price", data.get("pay_price", "")), "xorpay")
|
|
||||||
return provider.success_response()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/zpay_notify")
|
|
||||||
@app.post("/zpay_notify")
|
|
||||||
async def zpay_notify(request: Request):
|
|
||||||
"""ZPAY 异步通知(GET 或 POST)"""
|
|
||||||
if request.method == "GET":
|
|
||||||
data = dict(request.query_params)
|
|
||||||
else:
|
|
||||||
form_data = await request.form()
|
|
||||||
data = {k: v for k, v in form_data.items()}
|
|
||||||
|
|
||||||
logging.info(f"ZPAY 异步通知: {data}")
|
|
||||||
|
|
||||||
order_id = data.get("out_trade_no", "")
|
|
||||||
if order_id in processed_orders:
|
|
||||||
return "success"
|
|
||||||
|
|
||||||
provider = get_payment_provider()
|
|
||||||
if provider.name != "zpay":
|
|
||||||
raise HTTPException(status_code=400, detail="zpay_notify only for zpay")
|
|
||||||
|
|
||||||
if not provider.verify_notify(data):
|
|
||||||
logging.error(f"ZPAY 验签失败: {data}")
|
|
||||||
raise HTTPException(status_code=400, detail="sign error")
|
|
||||||
|
|
||||||
parsed = provider.parse_notify(data)
|
|
||||||
if parsed.get("trade_status") != "success":
|
|
||||||
return "success"
|
|
||||||
|
|
||||||
_handle_payment_success(order_id, parsed.get("pay_price", data.get("money", "")), "zpay")
|
|
||||||
return provider.success_response()
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 其他接口 ====================
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/submit_meetup_application")
|
|
||||||
async def submit_meetup_application(item: dict):
|
|
||||||
print("submit_meetup_application item:", item)
|
|
||||||
|
|
||||||
user_id = item.get("user_id")
|
|
||||||
if not user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="missing user_id")
|
|
||||||
|
|
||||||
try:
|
|
||||||
pb_client = get_pb_client()
|
|
||||||
solan = pb_client.collection("solan")
|
|
||||||
|
|
||||||
pb_data = {
|
|
||||||
"user_id": user_id,
|
|
||||||
"nickname": item.get("nickname", ""),
|
|
||||||
"occupation": item.get("occupation", ""),
|
|
||||||
"reason": item.get("reason", ""),
|
|
||||||
"wechatId": item.get("wechatId", ""),
|
|
||||||
"gender": item.get("gender", ""),
|
|
||||||
"education": item.get("education", ""),
|
|
||||||
"gradYear": item.get("gradYear", ""),
|
|
||||||
"phone": item.get("phone", ""),
|
|
||||||
"calculated_age": item.get("calculated_age", 0),
|
|
||||||
"type": "",
|
|
||||||
"order_id": "",
|
|
||||||
"amount": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
record = solan.create(pb_data)
|
|
||||||
logging.info(f"meetup 申请表单提交成功(待支付) - user_id: {user_id}, record_id: {record.id}")
|
|
||||||
|
|
||||||
return {"status": "ok", "record_id": record.id}
|
|
||||||
except Exception as e:
|
|
||||||
print(f"meetup 申请提交失败: {e}")
|
|
||||||
logging.error(f"meetup 申请提交失败 - user_id: {user_id}, error: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail="submit failed")
|
|
||||||
|
|
||||||
|
|
||||||
class CreateUserRequest(BaseModel):
|
|
||||||
username: str | None = None
|
|
||||||
password: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/create_user")
|
|
||||||
async def create_user(item: CreateUserRequest):
|
|
||||||
timenew = str(int(time.time()))
|
|
||||||
memos_data = {
|
|
||||||
"username": item.username or f"user{timenew}",
|
|
||||||
"password": item.password or "123456",
|
|
||||||
"role": "USER",
|
|
||||||
"state": "NORMAL",
|
|
||||||
}
|
|
||||||
memos_headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {MEMOS_TOKEN}",
|
|
||||||
}
|
|
||||||
memos_resp = requests.post(MEMOS_API, json=memos_data, headers=memos_headers)
|
|
||||||
memos_result = memos_resp.json()
|
|
||||||
print("memos_result-", memos_result)
|
|
||||||
return memos_result
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
支付渠道工厂
|
支付渠道工厂
|
||||||
根据配置返回对应的支付 Provider
|
根据配置返回对应的支付 Provider
|
||||||
|
默认使用 zpay 替代 xorpay
|
||||||
"""
|
"""
|
||||||
from ..config import (
|
from ..config import (
|
||||||
PAYMENT_PROVIDER,
|
PAYMENT_PROVIDER,
|
||||||
|
|||||||
23
app/routers/__init__.py
Normal file
23
app/routers/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""
|
||||||
|
项目路由模块
|
||||||
|
- nomadvip: /nomadvip/*
|
||||||
|
- digital: /digital/*
|
||||||
|
- cnomadcna: /cnomadcna/*
|
||||||
|
- nomadlms: /nomadlms/*
|
||||||
|
- legacy: /payh5, /zpay_notify 等(向后兼容 digital/cnomadcna)
|
||||||
|
"""
|
||||||
|
from .nomadvip import router as nomadvip_router
|
||||||
|
from .digital import router as digital_router
|
||||||
|
from .cnomadcna import router as cnomadcna_router
|
||||||
|
from .nomadlms import router as nomadlms_router
|
||||||
|
from .legacy import router as legacy_router
|
||||||
|
from .common import router as common_router
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"nomadvip_router",
|
||||||
|
"digital_router",
|
||||||
|
"cnomadcna_router",
|
||||||
|
"nomadlms_router",
|
||||||
|
"legacy_router",
|
||||||
|
"common_router",
|
||||||
|
]
|
||||||
BIN
app/routers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/_digital_base.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/_digital_base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/cnomadcna.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/cnomadcna.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/common.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/common.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/digital.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/digital.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/legacy.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/legacy.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/nomadlms.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/nomadlms.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/nomadvip.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/nomadvip.cpython-312.pyc
Normal file
Binary file not shown.
213
app/routers/_digital_base.py
Normal file
213
app/routers/_digital_base.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
digital/cnomadcna/nomadlms 共享的支付逻辑
|
||||||
|
根据 prefix 生成对应 notify_path,业务互不影响
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
|
||||||
|
from ..config import BASE_URL, ZPAY_PID, ZPAY_KEY
|
||||||
|
from ..payment import get_payment_provider
|
||||||
|
from ..payment.zpay import ZPayProvider
|
||||||
|
from ..services import handle_payment_success, processed_orders
|
||||||
|
|
||||||
|
|
||||||
|
def create_digital_router(prefix: str, project_name: str) -> APIRouter:
|
||||||
|
"""
|
||||||
|
创建 digital/cnomadcna/nomadlms 的支付路由
|
||||||
|
prefix: 如 "/digital", "/cnomadcna", "/nomadlms" 或 ""(legacy)
|
||||||
|
project_name: 用于日志,如 "digital", "cnomadcna", "nomadlms", "legacy"
|
||||||
|
"""
|
||||||
|
router = APIRouter(prefix=prefix, tags=[project_name])
|
||||||
|
|
||||||
|
def _notify_path(path: str) -> str:
|
||||||
|
base = BASE_URL.rstrip("/")
|
||||||
|
if prefix:
|
||||||
|
return f"{base}{prefix}{path}"
|
||||||
|
return f"{base}{path}"
|
||||||
|
|
||||||
|
@router.post("/payh5")
|
||||||
|
async def payh5(item: dict):
|
||||||
|
"""创建 H5 支付订单"""
|
||||||
|
print(f"{project_name}/payh5 item:", item)
|
||||||
|
total_fee_yuan = f"{item['total_fee'] / 100:.2f}"
|
||||||
|
user_id = item.get("user_id", "unknown")
|
||||||
|
pay_type = item.get("type", "unknown").lower()
|
||||||
|
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||||
|
timestamp = int(time.time())
|
||||||
|
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||||||
|
type_map = {
|
||||||
|
"book": "购买书籍",
|
||||||
|
"meetup": "数字游民社区报名",
|
||||||
|
"video": "视频解锁",
|
||||||
|
"vip": "VIP会员充值",
|
||||||
|
}
|
||||||
|
name = type_map.get(pay_type, "商品支付")
|
||||||
|
provider = get_payment_provider()
|
||||||
|
notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify"
|
||||||
|
notify_url = _notify_path(notify_path)
|
||||||
|
channel = item.get("channel", "alipay")
|
||||||
|
result = provider.create_order(
|
||||||
|
order_id=order_id,
|
||||||
|
name=name,
|
||||||
|
total_fee_yuan=total_fee_yuan,
|
||||||
|
pay_type=channel,
|
||||||
|
notify_url=notify_url,
|
||||||
|
return_url=item.get("return_url"),
|
||||||
|
client_ip=item.get("client_ip"),
|
||||||
|
)
|
||||||
|
if provider.name == "xorpay":
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"xorpay_params": result.get("params", {}),
|
||||||
|
"xorpay_url": result.get("pay_url", ""),
|
||||||
|
"provider": provider.name,
|
||||||
|
}
|
||||||
|
params = result.get("params", {})
|
||||||
|
params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""}
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"params": params_str,
|
||||||
|
"pay_url": result.get("pay_url", ""),
|
||||||
|
"provider": provider.name,
|
||||||
|
"submit_method": "POST",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/payh5/redirect")
|
||||||
|
async def payh5_redirect(item: dict, request: Request):
|
||||||
|
"""ZPAY mapi 接口,返回 302 或二维码"""
|
||||||
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
|
|
||||||
|
provider = get_payment_provider()
|
||||||
|
if provider.name != "zpay":
|
||||||
|
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay 渠道")
|
||||||
|
total_fee = float(item.get("total_fee", 80))
|
||||||
|
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||||
|
user_id = item.get("user_id", "unknown")
|
||||||
|
pay_type = item.get("type", "unknown").lower()
|
||||||
|
channel = item.get("channel", "alipay")
|
||||||
|
return_url = item.get("return_url")
|
||||||
|
device = item.get("device", "pc")
|
||||||
|
client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1")
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
client_ip = forwarded.split(",")[0].strip()
|
||||||
|
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||||
|
timestamp = int(time.time())
|
||||||
|
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||||||
|
type_map = {
|
||||||
|
"book": "购买书籍",
|
||||||
|
"meetup": "数字游民社区报名",
|
||||||
|
"video": "视频解锁",
|
||||||
|
"vip": "VIP会员充值",
|
||||||
|
}
|
||||||
|
name = type_map.get(pay_type, "商品支付")
|
||||||
|
notify_url = _notify_path("/zpay_notify")
|
||||||
|
if not isinstance(provider, ZPayProvider):
|
||||||
|
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay")
|
||||||
|
result = provider.create_order_api(
|
||||||
|
order_id=order_id,
|
||||||
|
name=name,
|
||||||
|
total_fee_yuan=total_fee_yuan,
|
||||||
|
pay_type=channel,
|
||||||
|
notify_url=notify_url,
|
||||||
|
return_url=return_url,
|
||||||
|
client_ip=client_ip,
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
if result.get("status") != "ok":
|
||||||
|
raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败"))
|
||||||
|
payurl = result.get("payurl")
|
||||||
|
payurl2 = result.get("payurl2")
|
||||||
|
img = result.get("img")
|
||||||
|
if device == "pc" and img:
|
||||||
|
return JSONResponse(status_code=200, content={
|
||||||
|
"status": "ok", "qrcode_mode": True, "order_id": order_id,
|
||||||
|
"img": img, "payurl": payurl, "return_url": return_url, "channel": channel,
|
||||||
|
})
|
||||||
|
if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"):
|
||||||
|
return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id})
|
||||||
|
headers = {"X-Pay-Order-Id": order_id}
|
||||||
|
if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2:
|
||||||
|
return RedirectResponse(url=payurl2, status_code=302, headers=headers)
|
||||||
|
if payurl:
|
||||||
|
return RedirectResponse(url=payurl, status_code=302, headers=headers)
|
||||||
|
raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接")
|
||||||
|
|
||||||
|
@router.get("/zpay_order_status")
|
||||||
|
async def zpay_order_status(out_trade_no: str):
|
||||||
|
"""查询 ZPAY 订单支付状态"""
|
||||||
|
provider = get_payment_provider()
|
||||||
|
if provider.name != "zpay":
|
||||||
|
raise HTTPException(status_code=400, detail="仅 zpay 渠道支持")
|
||||||
|
url = f"https://zpayz.cn/api.php?act=order&pid={ZPAY_PID}&key={ZPAY_KEY}&out_trade_no={out_trade_no}"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
result = resp.json() if resp.ok else {}
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"ZPAY 订单查询失败: {e}")
|
||||||
|
return {"paid": False, "error": str(e)}
|
||||||
|
try:
|
||||||
|
code = result.get("code", 0)
|
||||||
|
code = int(code) if isinstance(code, (int, float)) or str(code).isdigit() else 0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
code = 0
|
||||||
|
if code != 1:
|
||||||
|
return {"paid": False, "msg": result.get("msg", "查询失败")}
|
||||||
|
try:
|
||||||
|
status = int(result.get("status", 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
status = 0
|
||||||
|
return {"paid": status == 1, "status": status}
|
||||||
|
|
||||||
|
@router.post("/xorpay_notify")
|
||||||
|
async def xorpay_notify(request: Request):
|
||||||
|
"""XorPay 异步通知"""
|
||||||
|
form_data = await request.form()
|
||||||
|
data = {k: v for k, v in form_data.items()}
|
||||||
|
logging.info(f"{project_name} XorPay 异步通知: {data}")
|
||||||
|
order_id = data.get("order_id", "")
|
||||||
|
if order_id in processed_orders:
|
||||||
|
return "ok"
|
||||||
|
provider = get_payment_provider()
|
||||||
|
if provider.name != "xorpay":
|
||||||
|
raise HTTPException(status_code=400, detail="xorpay_notify only for xorpay")
|
||||||
|
if not provider.verify_notify(data):
|
||||||
|
logging.error(f"{project_name} XorPay 验签失败: {data}")
|
||||||
|
raise HTTPException(status_code=400, detail="sign error")
|
||||||
|
parsed = provider.parse_notify(data)
|
||||||
|
if parsed.get("trade_status") != "success":
|
||||||
|
return "ok"
|
||||||
|
handle_payment_success(order_id, parsed.get("pay_price", data.get("pay_price", "")), f"{project_name}-xorpay", use_memos=False)
|
||||||
|
return provider.success_response()
|
||||||
|
|
||||||
|
@router.get("/zpay_notify")
|
||||||
|
@router.post("/zpay_notify")
|
||||||
|
async def zpay_notify(request: Request):
|
||||||
|
"""ZPAY 异步通知"""
|
||||||
|
if request.method == "GET":
|
||||||
|
data = dict(request.query_params)
|
||||||
|
else:
|
||||||
|
form_data = await request.form()
|
||||||
|
data = {k: v for k, v in form_data.items()}
|
||||||
|
logging.info(f"{project_name} ZPAY 异步通知: {data}")
|
||||||
|
order_id = data.get("out_trade_no", "")
|
||||||
|
if order_id in processed_orders:
|
||||||
|
return "success"
|
||||||
|
provider = get_payment_provider()
|
||||||
|
if provider.name != "zpay":
|
||||||
|
raise HTTPException(status_code=400, detail="zpay_notify only for zpay")
|
||||||
|
if not provider.verify_notify(data):
|
||||||
|
logging.error(f"{project_name} ZPAY 验签失败: {data}")
|
||||||
|
raise HTTPException(status_code=400, detail="sign error")
|
||||||
|
parsed = provider.parse_notify(data)
|
||||||
|
if parsed.get("trade_status") != "success":
|
||||||
|
return "success"
|
||||||
|
handle_payment_success(order_id, parsed.get("pay_price", data.get("money", "")), f"{project_name}-zpay", use_memos=False)
|
||||||
|
return provider.success_response()
|
||||||
|
|
||||||
|
return router
|
||||||
4
app/routers/cnomadcna.py
Normal file
4
app/routers/cnomadcna.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""cnomadcna 项目支付接口:/cnomadcna/*"""
|
||||||
|
from ._digital_base import create_digital_router
|
||||||
|
|
||||||
|
router = create_digital_router(prefix="/cnomadcna", project_name="cnomadcna")
|
||||||
65
app/routers/common.py
Normal file
65
app/routers/common.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""共享接口:meetup 申请、create_user 等"""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..services import get_pb_client
|
||||||
|
from ..sdk.memos import MemosSDK
|
||||||
|
from ..config import MEMOS_API, MEMOS_TOKEN
|
||||||
|
|
||||||
|
router = APIRouter(tags=["common"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/submit_meetup_application")
|
||||||
|
async def submit_meetup_application(item: dict):
|
||||||
|
"""数字游民社区报名表单"""
|
||||||
|
print("submit_meetup_application item:", item)
|
||||||
|
user_id = item.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="missing user_id")
|
||||||
|
try:
|
||||||
|
pb_client = get_pb_client()
|
||||||
|
solan = pb_client.collection("solan")
|
||||||
|
pb_data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"nickname": item.get("nickname", ""),
|
||||||
|
"occupation": item.get("occupation", ""),
|
||||||
|
"reason": item.get("reason", ""),
|
||||||
|
"wechatId": item.get("wechatId", ""),
|
||||||
|
"gender": item.get("gender", ""),
|
||||||
|
"education": item.get("education", ""),
|
||||||
|
"gradYear": item.get("gradYear", ""),
|
||||||
|
"phone": item.get("phone", ""),
|
||||||
|
"calculated_age": item.get("calculated_age", 0),
|
||||||
|
"type": "",
|
||||||
|
"order_id": "",
|
||||||
|
"amount": 0,
|
||||||
|
}
|
||||||
|
record = solan.create(pb_data)
|
||||||
|
logging.info(f"meetup 申请表单提交成功(待支付) - user_id: {user_id}, record_id: {record.id}")
|
||||||
|
return {"status": "ok", "record_id": record.id}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"meetup 申请提交失败: {e}")
|
||||||
|
logging.error(f"meetup 申请提交失败 - user_id: {user_id}, error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="submit failed")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create_user")
|
||||||
|
async def create_user(item: CreateUserRequest):
|
||||||
|
"""创建 Memos 用户(基于 MemosSDK)"""
|
||||||
|
sdk = MemosSDK(api_url=MEMOS_API, token=MEMOS_TOKEN, enabled=bool(MEMOS_API and MEMOS_TOKEN))
|
||||||
|
if not sdk.enabled:
|
||||||
|
return {"error": "Memos 未配置"}
|
||||||
|
timenew = str(int(time.time()))
|
||||||
|
username = item.username or f"user{timenew}"
|
||||||
|
password = item.password or "123456"
|
||||||
|
result = sdk.create_user_by_username(username=username, password=password)
|
||||||
|
print("memos_result-", result)
|
||||||
|
return result
|
||||||
4
app/routers/digital.py
Normal file
4
app/routers/digital.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""digital 项目支付接口:/digital/*"""
|
||||||
|
from ._digital_base import create_digital_router
|
||||||
|
|
||||||
|
router = create_digital_router(prefix="/digital", project_name="digital")
|
||||||
4
app/routers/legacy.py
Normal file
4
app/routers/legacy.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""向后兼容:/payh5、/zpay_notify 等(digital/cnomadcna/nomadlms 旧版调用)"""
|
||||||
|
from ._digital_base import create_digital_router
|
||||||
|
|
||||||
|
router = create_digital_router(prefix="", project_name="legacy")
|
||||||
4
app/routers/nomadlms.py
Normal file
4
app/routers/nomadlms.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""nomadlms 项目支付接口:/nomadlms/*"""
|
||||||
|
from ._digital_base import create_digital_router
|
||||||
|
|
||||||
|
router = create_digital_router(prefix="/nomadlms", project_name="nomadlms")
|
||||||
163
app/routers/nomadvip.py
Normal file
163
app/routers/nomadvip.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""nomadvip 项目支付接口:/nomadvip/*,固定 ZPAY"""
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from ..config import BASE_URL, ZPAY_PID, ZPAY_KEY
|
||||||
|
from ..payment.zpay import ZPayProvider
|
||||||
|
from ..services import handle_payment_success, processed_orders
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/nomadvip", tags=["nomadvip"])
|
||||||
|
_nomadvip_zpay = ZPayProvider(pid=ZPAY_PID, key=ZPAY_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/payh5")
|
||||||
|
async def nomadvip_payh5(item: dict):
|
||||||
|
"""nomadvip 专用:固定 ZPAY,表单提交支付"""
|
||||||
|
print("nomadvip/payh5 item:", item)
|
||||||
|
total_fee = int(item.get("total_fee", 8800))
|
||||||
|
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||||
|
user_id = item.get("user_id", "unknown")
|
||||||
|
pay_type = (item.get("type") or "vip").lower()
|
||||||
|
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||||
|
timestamp = int(time.time())
|
||||||
|
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||||||
|
type_map = {
|
||||||
|
"book": "购买书籍",
|
||||||
|
"meetup": "数字游民社区报名",
|
||||||
|
"video": "视频解锁",
|
||||||
|
"vip": "VIP会员充值",
|
||||||
|
}
|
||||||
|
name = type_map.get(pay_type, "VIP会员充值")
|
||||||
|
notify_url = f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify"
|
||||||
|
channel = item.get("channel", "alipay")
|
||||||
|
result = _nomadvip_zpay.create_order(
|
||||||
|
order_id=order_id,
|
||||||
|
name=name,
|
||||||
|
total_fee_yuan=total_fee_yuan,
|
||||||
|
pay_type=channel,
|
||||||
|
notify_url=notify_url,
|
||||||
|
return_url=item.get("return_url"),
|
||||||
|
client_ip=item.get("client_ip"),
|
||||||
|
)
|
||||||
|
params = result.get("params", {})
|
||||||
|
params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""}
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"params": params_str,
|
||||||
|
"pay_url": result.get("pay_url", ""),
|
||||||
|
"provider": "zpay",
|
||||||
|
"submit_method": "POST",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/payh5/redirect")
|
||||||
|
async def nomadvip_payh5_redirect(item: dict, request: Request):
|
||||||
|
"""nomadvip 专用:ZPAY mapi 接口"""
|
||||||
|
total_fee = float(item.get("total_fee", 8800))
|
||||||
|
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||||
|
user_id = item.get("user_id", "unknown")
|
||||||
|
pay_type = (item.get("type") or "vip").lower()
|
||||||
|
channel = item.get("channel", "alipay")
|
||||||
|
return_url = item.get("return_url")
|
||||||
|
device = item.get("device", "pc")
|
||||||
|
client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1")
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
client_ip = forwarded.split(",")[0].strip()
|
||||||
|
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||||
|
timestamp = int(time.time())
|
||||||
|
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||||||
|
type_map = {"book": "购买书籍", "meetup": "数字游民社区报名", "video": "视频解锁", "vip": "VIP会员充值"}
|
||||||
|
name = type_map.get(pay_type, "VIP会员充值")
|
||||||
|
notify_url = f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify"
|
||||||
|
result = _nomadvip_zpay.create_order_api(
|
||||||
|
order_id=order_id,
|
||||||
|
name=name,
|
||||||
|
total_fee_yuan=total_fee_yuan,
|
||||||
|
pay_type=channel,
|
||||||
|
notify_url=notify_url,
|
||||||
|
return_url=return_url,
|
||||||
|
client_ip=client_ip,
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
if result.get("status") != "ok":
|
||||||
|
raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败"))
|
||||||
|
payurl = result.get("payurl")
|
||||||
|
payurl2 = result.get("payurl2")
|
||||||
|
img = result.get("img")
|
||||||
|
if device == "pc" and img:
|
||||||
|
return JSONResponse(status_code=200, content={
|
||||||
|
"status": "ok", "qrcode_mode": True, "order_id": order_id,
|
||||||
|
"img": img, "payurl": payurl, "return_url": return_url, "channel": channel,
|
||||||
|
})
|
||||||
|
if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"):
|
||||||
|
return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id})
|
||||||
|
headers = {"X-Pay-Order-Id": order_id}
|
||||||
|
if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2:
|
||||||
|
return RedirectResponse(url=payurl2, status_code=302, headers=headers)
|
||||||
|
if payurl:
|
||||||
|
return RedirectResponse(url=payurl, status_code=302, headers=headers)
|
||||||
|
raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/zpay_order_status")
|
||||||
|
async def nomadvip_zpay_order_status(out_trade_no: str):
|
||||||
|
"""nomadvip 专用:查询 ZPAY 订单支付状态"""
|
||||||
|
url = f"https://zpayz.cn/api.php?act=order&pid={ZPAY_PID}&key={ZPAY_KEY}&out_trade_no={out_trade_no}"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
result = resp.json() if resp.ok else {}
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"nomadvip ZPAY 订单查询失败: {e}")
|
||||||
|
return {"paid": False, "error": str(e)}
|
||||||
|
try:
|
||||||
|
code = result.get("code", 0)
|
||||||
|
code = int(code) if isinstance(code, (int, float)) or str(code).isdigit() else 0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
code = 0
|
||||||
|
if code != 1:
|
||||||
|
return {"paid": False, "msg": result.get("msg", "查询失败")}
|
||||||
|
try:
|
||||||
|
status = int(result.get("status", 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
status = 0
|
||||||
|
return {"paid": status == 1, "status": status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/zpay_notify")
|
||||||
|
@router.post("/zpay_notify")
|
||||||
|
async def nomadvip_zpay_notify(request: Request):
|
||||||
|
"""nomadvip 专用:ZPAY 异步通知"""
|
||||||
|
if request.method == "GET":
|
||||||
|
data = dict(request.query_params)
|
||||||
|
else:
|
||||||
|
form_data = await request.form()
|
||||||
|
data = {k: v for k, v in form_data.items()}
|
||||||
|
logging.info(f"nomadvip ZPAY 异步通知: {data}")
|
||||||
|
order_id = data.get("out_trade_no", "")
|
||||||
|
if order_id in processed_orders:
|
||||||
|
logging.info(f"nomadvip 订单已处理,跳过: {order_id}")
|
||||||
|
return "success"
|
||||||
|
if not _nomadvip_zpay.verify_notify(data):
|
||||||
|
logging.error(f"nomadvip ZPAY 验签失败: {data}")
|
||||||
|
raise HTTPException(status_code=400, detail="sign error")
|
||||||
|
parsed = _nomadvip_zpay.parse_notify(data)
|
||||||
|
trade_status = parsed.get("trade_status") or data.get("trade_status", "")
|
||||||
|
if trade_status != "success" and trade_status != "TRADE_SUCCESS":
|
||||||
|
logging.info(f"nomadvip 非成功状态,跳过: trade_status={trade_status}")
|
||||||
|
return "success"
|
||||||
|
pay_price = parsed.get("pay_price") or data.get("money", "")
|
||||||
|
try:
|
||||||
|
handle_payment_success(order_id, pay_price, "nomadvip-zpay", use_memos=True)
|
||||||
|
logging.info(f"nomadvip 业务处理完成: order_id={order_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
return _nomadvip_zpay.success_response()
|
||||||
20
app/sdk/__init__.py
Normal file
20
app/sdk/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
PayJS API SDK 模块
|
||||||
|
- payment: 支付网关(ZPAY/XorPay)
|
||||||
|
- pocketbase: PocketBase 数据库
|
||||||
|
- minio: MinIO 对象存储
|
||||||
|
- memos: Memos 用户(仅 nomadvip 使用)
|
||||||
|
"""
|
||||||
|
from .payment import get_payment_provider, ZPayProvider, XorPayProvider
|
||||||
|
from .pocketbase import PocketBaseSDK
|
||||||
|
from .minio import MinioSDK
|
||||||
|
from .memos import MemosSDK
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_payment_provider",
|
||||||
|
"ZPayProvider",
|
||||||
|
"XorPayProvider",
|
||||||
|
"PocketBaseSDK",
|
||||||
|
"MinioSDK",
|
||||||
|
"MemosSDK",
|
||||||
|
]
|
||||||
BIN
app/sdk/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/sdk/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/sdk/__pycache__/memos.cpython-312.pyc
Normal file
BIN
app/sdk/__pycache__/memos.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/sdk/__pycache__/minio.cpython-312.pyc
Normal file
BIN
app/sdk/__pycache__/minio.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/sdk/__pycache__/pocketbase.cpython-312.pyc
Normal file
BIN
app/sdk/__pycache__/pocketbase.cpython-312.pyc
Normal file
Binary file not shown.
95
app/sdk/memos.py
Normal file
95
app/sdk/memos.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Memos SDK
|
||||||
|
用户创建等操作,仅 nomadvip 使用
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class MemosSDK:
|
||||||
|
"""Memos 用户管理 SDK"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_url: str,
|
||||||
|
token: str,
|
||||||
|
enabled: bool = True,
|
||||||
|
):
|
||||||
|
self.api_url = api_url.rstrip("/")
|
||||||
|
self.token = token
|
||||||
|
self.enabled = enabled and bool(api_url and token)
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
password: str = "123456",
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
创建 Memos 用户
|
||||||
|
:param user_id: PocketBase 用户 ID,如 user1766043770158
|
||||||
|
:return: {"success": bool, "data"?: dict, "error"?: str}
|
||||||
|
"""
|
||||||
|
if not self.enabled:
|
||||||
|
return {"success": False, "error": "Memos 未启用"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if user_id.startswith("user_"):
|
||||||
|
cleaned = "user" + user_id.split("user_", 1)[1]
|
||||||
|
elif "_" in user_id:
|
||||||
|
cleaned = user_id.replace("_", "")
|
||||||
|
else:
|
||||||
|
cleaned = user_id
|
||||||
|
|
||||||
|
username = cleaned
|
||||||
|
data = {
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"role": "USER",
|
||||||
|
"state": "NORMAL",
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
}
|
||||||
|
resp = requests.post(
|
||||||
|
self.api_url,
|
||||||
|
json=data,
|
||||||
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
logging.info(f"memos 用户创建成功: {username}")
|
||||||
|
return {"success": True, "data": resp.json()}
|
||||||
|
else:
|
||||||
|
logging.error(f"memos 创建失败 - username: {username}, status: {resp.status_code}, response: {resp.text}")
|
||||||
|
return {"success": False, "error": resp.text}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"创建 memos 用户异常 - user_id: {user_id}, error: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def create_user_by_username(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str = "123456",
|
||||||
|
) -> dict:
|
||||||
|
"""按用户名直接创建(用于 /create_user 接口)"""
|
||||||
|
if not self.enabled:
|
||||||
|
return {"success": False, "error": "Memos 未启用"}
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"role": "USER",
|
||||||
|
"state": "NORMAL",
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
}
|
||||||
|
resp = requests.post(self.api_url, json=data, headers=headers, timeout=10)
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"创建 memos 用户异常 - username: {username}, error: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
92
app/sdk/minio.py
Normal file
92
app/sdk/minio.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
MinIO SDK
|
||||||
|
对象存储封装,支持配置注入
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
from minio import Minio
|
||||||
|
MINIO_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
MINIO_AVAILABLE = False
|
||||||
|
Minio = None
|
||||||
|
|
||||||
|
|
||||||
|
class MinioSDK:
|
||||||
|
"""MinIO 对象存储 SDK"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
end_point: str,
|
||||||
|
access_key: str,
|
||||||
|
secret_key: str,
|
||||||
|
bucket: str = "hackrobot",
|
||||||
|
use_ssl: bool = True,
|
||||||
|
port: Optional[int] = None,
|
||||||
|
region: str = "china",
|
||||||
|
public_url: Optional[str] = None,
|
||||||
|
upload_prefix: str = "joins",
|
||||||
|
):
|
||||||
|
if not MINIO_AVAILABLE:
|
||||||
|
raise ImportError("minio 未安装,请执行: pip install minio")
|
||||||
|
self.end_point = end_point
|
||||||
|
self.port = port or (443 if use_ssl else 80)
|
||||||
|
self.access_key = access_key
|
||||||
|
self.secret_key = secret_key
|
||||||
|
self.bucket = bucket
|
||||||
|
self.use_ssl = use_ssl
|
||||||
|
self.region = region
|
||||||
|
self.public_url = public_url or f"https://{end_point}/{bucket}"
|
||||||
|
self.upload_prefix = upload_prefix.rstrip("/")
|
||||||
|
self._client: Optional[Minio] = None
|
||||||
|
|
||||||
|
def get_client(self) -> "Minio":
|
||||||
|
"""获取 MinIO 客户端"""
|
||||||
|
if self._client is None:
|
||||||
|
endpoint = f"{self.end_point}:{self.port}" if self.port not in (80, 443) else self.end_point
|
||||||
|
self._client = Minio(
|
||||||
|
endpoint,
|
||||||
|
access_key=self.access_key,
|
||||||
|
secret_key=self.secret_key,
|
||||||
|
secure=self.use_ssl,
|
||||||
|
region=self.region,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def generate_object_key(self, ext: str) -> str:
|
||||||
|
"""生成上传用的对象键"""
|
||||||
|
ext = ext.lstrip(".")
|
||||||
|
suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
||||||
|
return f"{self.upload_prefix}/{int(time.time() * 1000)}_{suffix}.{ext}"
|
||||||
|
|
||||||
|
def upload_buffer(
|
||||||
|
self,
|
||||||
|
data: bytes,
|
||||||
|
object_key: str,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
上传文件到 MinIO
|
||||||
|
:return: {"url": str, "object_key": str}
|
||||||
|
"""
|
||||||
|
client = self.get_client()
|
||||||
|
from io import BytesIO
|
||||||
|
stream = BytesIO(data)
|
||||||
|
client.put_object(
|
||||||
|
self.bucket,
|
||||||
|
object_key,
|
||||||
|
stream,
|
||||||
|
len(data),
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
url = f"{self.public_url.rstrip('/')}/{object_key}"
|
||||||
|
return {"url": url, "object_key": object_key}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
"""是否启用(需配置 access_key/secret_key)"""
|
||||||
|
return bool(self.end_point and self.access_key and self.secret_key)
|
||||||
15
app/sdk/payment/__init__.py
Normal file
15
app/sdk/payment/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
支付网关 SDK
|
||||||
|
支持 ZPAY、XorPay,通过配置切换
|
||||||
|
"""
|
||||||
|
from app.payment.base import PaymentProvider
|
||||||
|
from app.payment.zpay import ZPayProvider
|
||||||
|
from app.payment.xorpay import XorPayProvider
|
||||||
|
from app.payment.factory import get_payment_provider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PaymentProvider",
|
||||||
|
"ZPayProvider",
|
||||||
|
"XorPayProvider",
|
||||||
|
"get_payment_provider",
|
||||||
|
]
|
||||||
BIN
app/sdk/payment/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/sdk/payment/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
69
app/sdk/pocketbase.py
Normal file
69
app/sdk/pocketbase.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
PocketBase SDK
|
||||||
|
数据库操作封装,支持配置注入
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pocketbase import PocketBase
|
||||||
|
|
||||||
|
|
||||||
|
class PocketBaseSDK:
|
||||||
|
"""PocketBase 客户端 SDK"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
admin_email: str,
|
||||||
|
admin_password: str,
|
||||||
|
):
|
||||||
|
self.url = url
|
||||||
|
self.admin_email = admin_email
|
||||||
|
self.admin_password = admin_password
|
||||||
|
self._client: Optional[PocketBase] = None
|
||||||
|
|
||||||
|
def get_client(self) -> PocketBase:
|
||||||
|
"""获取已认证的 PocketBase 客户端"""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = PocketBase(self.url)
|
||||||
|
self._client.admins.auth_with_password(self.admin_email, self.admin_password)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def collection(self, name: str):
|
||||||
|
"""获取集合"""
|
||||||
|
return self.get_client().collection(name)
|
||||||
|
|
||||||
|
def create_payment(self, user_id: str, pay_type: str, order_id: str, amount: int) -> dict:
|
||||||
|
"""创建支付记录"""
|
||||||
|
return self.collection("payments").create({
|
||||||
|
"user_id": user_id,
|
||||||
|
"type": pay_type,
|
||||||
|
"order_id": order_id,
|
||||||
|
"amount": amount,
|
||||||
|
})
|
||||||
|
|
||||||
|
def update_meetup_payment(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
order_id: str,
|
||||||
|
amount: int,
|
||||||
|
) -> None:
|
||||||
|
"""更新或创建 meetup 支付记录"""
|
||||||
|
solan = self.collection("solan")
|
||||||
|
existing = solan.get_list(1, 1, {
|
||||||
|
"filter": f'user_id = "{user_id}"',
|
||||||
|
"sort": "-created",
|
||||||
|
})
|
||||||
|
if existing.items:
|
||||||
|
record_id = existing.items[0].id
|
||||||
|
solan.update(record_id, {
|
||||||
|
"type": "meetup",
|
||||||
|
"order_id": order_id,
|
||||||
|
"amount": amount,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
solan.create({
|
||||||
|
"user_id": user_id,
|
||||||
|
"type": "meetup",
|
||||||
|
"order_id": order_id,
|
||||||
|
"amount": amount,
|
||||||
|
})
|
||||||
16
app/services/__init__.py
Normal file
16
app/services/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
共享业务逻辑,基于 SDK 封装
|
||||||
|
- PocketBase、Memos 通过 app.sdk 使用
|
||||||
|
- 仅 nomadvip 涉及 Memos
|
||||||
|
"""
|
||||||
|
from .pb import get_pb_client
|
||||||
|
from .memos import create_memos_user
|
||||||
|
from .payment import handle_payment_success, parse_order_id, processed_orders
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_pb_client",
|
||||||
|
"create_memos_user",
|
||||||
|
"handle_payment_success",
|
||||||
|
"parse_order_id",
|
||||||
|
"processed_orders",
|
||||||
|
]
|
||||||
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/memos.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/memos.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/payment.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/payment.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/pb.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/pb.cpython-312.pyc
Normal file
Binary file not shown.
21
app/services/memos.py
Normal file
21
app/services/memos.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Memos 用户创建(基于 SDK,仅 nomadvip 使用)"""
|
||||||
|
from app.config import MEMOS_API, MEMOS_TOKEN
|
||||||
|
from app.sdk.memos import MemosSDK
|
||||||
|
|
||||||
|
_memos_sdk: MemosSDK | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_memos_sdk() -> MemosSDK:
|
||||||
|
global _memos_sdk
|
||||||
|
if _memos_sdk is None:
|
||||||
|
_memos_sdk = MemosSDK(
|
||||||
|
api_url=MEMOS_API,
|
||||||
|
token=MEMOS_TOKEN,
|
||||||
|
enabled=bool(MEMOS_API and MEMOS_TOKEN),
|
||||||
|
)
|
||||||
|
return _memos_sdk
|
||||||
|
|
||||||
|
|
||||||
|
def create_memos_user(user_id: str, password: str = "123456"):
|
||||||
|
"""创建 Memos 用户"""
|
||||||
|
return _get_memos_sdk().create_user(user_id=user_id, password=password)
|
||||||
51
app/services/minio.py
Normal file
51
app/services/minio.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""MinIO 对象存储(基于 SDK)"""
|
||||||
|
from app.config import (
|
||||||
|
MINIO_ENDPOINT,
|
||||||
|
MINIO_PORT,
|
||||||
|
MINIO_ACCESS_KEY,
|
||||||
|
MINIO_SECRET_KEY,
|
||||||
|
MINIO_BUCKET,
|
||||||
|
MINIO_USE_SSL,
|
||||||
|
MINIO_PUBLIC_URL,
|
||||||
|
MINIO_UPLOAD_PREFIX,
|
||||||
|
)
|
||||||
|
from app.sdk.minio import MinioSDK
|
||||||
|
|
||||||
|
_minio_sdk: MinioSDK | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_minio_sdk() -> MinioSDK:
|
||||||
|
global _minio_sdk
|
||||||
|
if _minio_sdk is None:
|
||||||
|
_minio_sdk = MinioSDK(
|
||||||
|
end_point=MINIO_ENDPOINT,
|
||||||
|
access_key=MINIO_ACCESS_KEY,
|
||||||
|
secret_key=MINIO_SECRET_KEY,
|
||||||
|
bucket=MINIO_BUCKET,
|
||||||
|
use_ssl=MINIO_USE_SSL,
|
||||||
|
port=MINIO_PORT,
|
||||||
|
public_url=MINIO_PUBLIC_URL,
|
||||||
|
upload_prefix=MINIO_UPLOAD_PREFIX,
|
||||||
|
)
|
||||||
|
return _minio_sdk
|
||||||
|
|
||||||
|
|
||||||
|
def get_minio_client():
|
||||||
|
"""获取 MinIO 客户端(未配置时返回 None)"""
|
||||||
|
sdk = _get_minio_sdk()
|
||||||
|
if not sdk.enabled:
|
||||||
|
return None
|
||||||
|
return sdk.get_client()
|
||||||
|
|
||||||
|
|
||||||
|
def upload_buffer(data: bytes, object_key: str, content_type: str = "application/octet-stream"):
|
||||||
|
"""上传文件到 MinIO(未配置时抛出异常)"""
|
||||||
|
sdk = _get_minio_sdk()
|
||||||
|
if not sdk.enabled:
|
||||||
|
raise RuntimeError("MinIO 未配置,请设置 MINIO_ACCESS_KEY、MINIO_SECRET_KEY")
|
||||||
|
return sdk.upload_buffer(data, object_key, content_type)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_object_key(ext: str) -> str:
|
||||||
|
"""生成上传用的对象键"""
|
||||||
|
return _get_minio_sdk().generate_object_key(ext)
|
||||||
86
app/services/payment.py
Normal file
86
app/services/payment.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""支付成功后的统一业务处理(基于 SDK)"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .pb import get_pb_client
|
||||||
|
from .memos import create_memos_user
|
||||||
|
|
||||||
|
# 已处理订单(防重复)
|
||||||
|
processed_orders: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_order_id(order_id: str) -> tuple[str, str]:
|
||||||
|
"""从 order_id 解析 user_id 和 pay_type"""
|
||||||
|
if "_order_" not in order_id:
|
||||||
|
return "", "unknown"
|
||||||
|
prefix = order_id.split("_order_")[0]
|
||||||
|
if "_" in prefix:
|
||||||
|
parts = prefix.rsplit("_", 1)
|
||||||
|
return parts[0], parts[1].lower()
|
||||||
|
return prefix, "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def handle_payment_success(
|
||||||
|
order_id: str,
|
||||||
|
pay_price: str,
|
||||||
|
provider_name: str,
|
||||||
|
*,
|
||||||
|
use_memos: bool = False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
支付成功后的统一业务处理
|
||||||
|
:param use_memos: 是否创建 Memos 用户,仅 nomadvip 为 True
|
||||||
|
"""
|
||||||
|
user_id, pay_type = parse_order_id(order_id)
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},订单: {order_id}")
|
||||||
|
|
||||||
|
if pay_type == "vip" and use_memos:
|
||||||
|
create_memos_user(user_id)
|
||||||
|
|
||||||
|
if pay_type == "meetup":
|
||||||
|
logging.info(f"meetup 类型支付成功,不创建 memos 用户 - user_id: {user_id}")
|
||||||
|
|
||||||
|
valid_types = ["vip", "book", "meetup", "video"]
|
||||||
|
if pay_type not in valid_types:
|
||||||
|
pay_type = "vip"
|
||||||
|
|
||||||
|
amount_cents = int(float(pay_price or 0) * 100)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pb_client = get_pb_client()
|
||||||
|
if pay_type == "meetup":
|
||||||
|
solan = pb_client.collection("solan")
|
||||||
|
existing = solan.get_list(1, 1, {
|
||||||
|
"filter": f'user_id = "{user_id}"',
|
||||||
|
"sort": "-created",
|
||||||
|
})
|
||||||
|
if existing.items:
|
||||||
|
record_id = existing.items[0].id
|
||||||
|
solan.update(record_id, {
|
||||||
|
"type": "meetup",
|
||||||
|
"order_id": order_id,
|
||||||
|
"amount": amount_cents,
|
||||||
|
})
|
||||||
|
logging.info(f"meetup 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}")
|
||||||
|
else:
|
||||||
|
solan.create({
|
||||||
|
"user_id": user_id,
|
||||||
|
"type": "meetup",
|
||||||
|
"order_id": order_id,
|
||||||
|
"amount": amount_cents,
|
||||||
|
})
|
||||||
|
logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}")
|
||||||
|
else:
|
||||||
|
pb_client.collection("payments").create({
|
||||||
|
"user_id": user_id,
|
||||||
|
"type": pay_type,
|
||||||
|
"order_id": order_id,
|
||||||
|
"amount": amount_cents,
|
||||||
|
})
|
||||||
|
processed_orders.add(order_id)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"PocketBase 操作失败: {e}")
|
||||||
|
logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}")
|
||||||
|
raise
|
||||||
24
app/services/pb.py
Normal file
24
app/services/pb.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""PocketBase 客户端(基于 SDK)"""
|
||||||
|
from app.config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
||||||
|
from app.sdk.pocketbase import PocketBaseSDK
|
||||||
|
|
||||||
|
_pb_sdk: PocketBaseSDK | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pb_sdk() -> PocketBaseSDK:
|
||||||
|
global _pb_sdk
|
||||||
|
if _pb_sdk is None:
|
||||||
|
_pb_sdk = PocketBaseSDK(
|
||||||
|
url=PB_URL,
|
||||||
|
admin_email=PB_ADMIN_EMAIL,
|
||||||
|
admin_password=PB_ADMIN_PASSWORD,
|
||||||
|
)
|
||||||
|
return _pb_sdk
|
||||||
|
|
||||||
|
|
||||||
|
def get_pb_client():
|
||||||
|
"""获取已认证的 PocketBase 客户端"""
|
||||||
|
sdk = _get_pb_sdk()
|
||||||
|
client = sdk.get_client()
|
||||||
|
print("PocketBase admin 登录成功")
|
||||||
|
return client
|
||||||
@@ -4,4 +4,5 @@ sqlalchemy
|
|||||||
pydantic
|
pydantic
|
||||||
python-multipart
|
python-multipart
|
||||||
pocketbase
|
pocketbase
|
||||||
requests
|
requests
|
||||||
|
minio
|
||||||
44
手机微信支付调试说明.md
Normal file
44
手机微信支付调试说明.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 手机微信支付调试说明
|
||||||
|
|
||||||
|
前端(vipgroup 等)与 payjsapi 同机时,支付请求由服务端发起,应使用 `127.0.0.1:8700`。
|
||||||
|
|
||||||
|
## 1. 同机部署(vipgroup + payjsapi 都在本机)
|
||||||
|
|
||||||
|
**无需配置**。默认使用 `http://127.0.0.1:8700`。
|
||||||
|
|
||||||
|
若 `.env.local` 中有 `PAYMENT_API_URL=http://0.0.0.0:8700`,请删除或改为:
|
||||||
|
```bash
|
||||||
|
PAYMENT_API_URL=http://127.0.0.1:8700
|
||||||
|
```
|
||||||
|
(0.0.0.0 无法作为连接目标,会导致立即失败)
|
||||||
|
|
||||||
|
## 2. 确认 payjsapi 已启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\project\payjsapi
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
应监听 `0.0.0.0:8700`,可从局域网访问。
|
||||||
|
|
||||||
|
## 3. 确认防火墙放行 8700 端口
|
||||||
|
|
||||||
|
Windows 防火墙可能阻止手机访问 8700。若 vipgroup 与 payjsapi 同机,请求是服务端内部调用,一般无问题。
|
||||||
|
|
||||||
|
## 4. 查看控制台日志
|
||||||
|
|
||||||
|
支付失败时,vipgroup 的 Next.js 控制台会输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
[pay] 请求失败: ... apiUrl: http://xxx:8700 requestHost: xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
- 若 `apiUrl` 为 `http://0.0.0.0:8700` → 在 `.env.local` 中设置 `PAYMENT_API_URL=http://192.168.41.222:8700`
|
||||||
|
- 若 `apiUrl` 正确但仍失败 → 检查 payjsapi 是否运行、网络是否可达
|
||||||
|
|
||||||
|
## 5. vipgroup 需包含的配置逻辑
|
||||||
|
|
||||||
|
若 vipgroup 与 lms/nomadlms 结构不同,需确保:
|
||||||
|
|
||||||
|
1. 调用 `/api/pay` 或直接调用 payjsapi 时,使用正确的 `PAYMENT_API_URL`
|
||||||
|
2. 本地开发时 `PAYMENT_API_URL` 指向 `http://{你的局域网IP}:8700`
|
||||||
Reference in New Issue
Block a user