Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9898ab372 | ||
|
|
4187cdd64b | ||
|
|
710c30837a | ||
|
|
e84c02a329 |
@@ -4,7 +4,7 @@
|
|||||||
# 可用 ngrok 等隧道:BASE_URL=https://xxx.ngrok.io
|
# 可用 ngrok 等隧道:BASE_URL=https://xxx.ngrok.io
|
||||||
# 或直接使用生产地址测试回调(需 payjsapi 已部署到公网)
|
# 或直接使用生产地址测试回调(需 payjsapi 已部署到公网)
|
||||||
PORT=8007
|
PORT=8007
|
||||||
BASE_URL=https://api.hackrobot.cn
|
BASE_URL=https://api.nomadyt.com
|
||||||
|
|
||||||
# 支付渠道:zpay | xorpay
|
# 支付渠道:zpay | xorpay
|
||||||
PAYMENT_PROVIDER=zpay
|
PAYMENT_PROVIDER=zpay
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -17,7 +17,7 @@
|
|||||||
## 环境
|
## 环境
|
||||||
|
|
||||||
本地调试:`BASE_URL` 需为 ZPAY 可访问地址(如 ngrok 暴露 8700 端口)
|
本地调试:`BASE_URL` 需为 ZPAY 可访问地址(如 ngrok 暴露 8700 端口)
|
||||||
线上部署:`BASE_URL=https://api.hackrobot.cn`
|
线上部署:`BASE_URL=https://api.nomadyt.com`
|
||||||
|
|
||||||
## 模块结构
|
## 模块结构
|
||||||
|
|
||||||
@@ -73,11 +73,11 @@ python run.py
|
|||||||
|
|
||||||
## 线上部署
|
## 线上部署
|
||||||
|
|
||||||
域名:`https://api.hackrobot.cn`
|
域名:`https://api.nomadyt.com`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env 中设置
|
# .env 中设置
|
||||||
BASE_URL=https://api.hackrobot.cn
|
BASE_URL=https://api.nomadyt.com
|
||||||
PAYMENT_PROVIDER=zpay
|
PAYMENT_PROVIDER=zpay
|
||||||
# 其他 ZPAY、PocketBase、Memos 配置见 .env.example
|
# 其他 ZPAY、PocketBase、Memos 配置见 .env.example
|
||||||
```
|
```
|
||||||
@@ -86,9 +86,9 @@ PAYMENT_PROVIDER=zpay
|
|||||||
|
|
||||||
本地正常、Ubuntu 部署后 api.hackrobot.cn 调用支付失败,常见原因:
|
本地正常、Ubuntu 部署后 api.hackrobot.cn 调用支付失败,常见原因:
|
||||||
|
|
||||||
1. **BASE_URL 配置错误**:必须为 ZPAY 可公网访问的地址。错误示例:`http://127.0.0.1:8700`、`http://localhost`。正确:`https://api.hackrobot.cn`(域名需解析到本机,nginx 反向代理对应端口)。
|
1. **BASE_URL 配置错误**:必须为 ZPAY 可公网访问的地址。错误示例:`http://127.0.0.1:8700`、`http://localhost`。正确:`https://api.nomadyt.com`(域名需解析到本机,nginx 反向代理对应端口)。
|
||||||
|
|
||||||
2. **服务器无法访问 zpayz.cn**:防火墙、DNS 或网络限制。访问 `https://api.hackrobot.cn/health?check_zpay=1` 自检:
|
2. **服务器无法访问 zpayz.cn**:防火墙、DNS 或网络限制。访问 `https://api.nomadyt.com/health?check_zpay=1` 自检:
|
||||||
- `zpayz_reachable: false` → 开放出站或检查网络
|
- `zpayz_reachable: false` → 开放出站或检查网络
|
||||||
- `zpayz_error` 含 "SSL" → 执行 `apt install ca-certificates` 后重启
|
- `zpayz_error` 含 "SSL" → 执行 `apt install ca-certificates` 后重启
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ MEMOS_TOKEN = os.getenv(
|
|||||||
"eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg",
|
"eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 回调地址(用于生成 notify_url)
|
# 回调地址(用于生成 notify_url)。salon 线上部署 api.nomadyt.com
|
||||||
BASE_URL = os.getenv("BASE_URL", "https://api.hackrobot.cn")
|
BASE_URL = os.getenv("BASE_URL", "https://api.nomadyt.com")
|
||||||
|
|
||||||
# MinIO 对象存储
|
# MinIO 对象存储
|
||||||
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minioweb.hackrobot.cn")
|
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minioweb.hackrobot.cn")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- common: /submit_meetup_application、/create_user
|
- common: /submit_meetup_application、/create_user
|
||||||
|
|
||||||
本地调试:BASE_URL 需为 ZPAY 可访问地址(如 ngrok)
|
本地调试:BASE_URL 需为 ZPAY 可访问地址(如 ngrok)
|
||||||
线上部署:BASE_URL=https://api.hackrobot.cn(必须为 ZPAY 可公网访问的地址)
|
线上部署:BASE_URL=https://api.nomadyt.com(必须为 ZPAY 可公网访问的地址)
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
@@ -97,7 +97,7 @@ def _safe_stderr_write(message: str) -> None:
|
|||||||
|
|
||||||
def _should_trace_request(path: str) -> bool:
|
def _should_trace_request(path: str) -> bool:
|
||||||
return any(
|
return any(
|
||||||
segment in path for segment in ("/nomadvip", "/digital", "/cnomadcna", "/nomadlms")
|
segment in path for segment in ("/nomadvip", "/digital", "/cnomadcna", "/nomadlms", "/salon")
|
||||||
)
|
)
|
||||||
|
|
||||||
# 启动时校验 BASE_URL(线上部署必须为公网可访问地址,否则 ZPAY 无法回调)
|
# 启动时校验 BASE_URL(线上部署必须为公网可访问地址,否则 ZPAY 无法回调)
|
||||||
@@ -108,7 +108,7 @@ def _check_base_url():
|
|||||||
return
|
return
|
||||||
if "localhost" in url or "127.0.0.1" in url or url.startswith("http://192.168.") or url.startswith("http://10."):
|
if "localhost" in url or "127.0.0.1" in url or url.startswith("http://192.168.") or url.startswith("http://10."):
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"BASE_URL=%s 疑似内网地址,ZPAY 无法回调!线上部署请设置 BASE_URL 为公网地址,如 https://api.hackrobot.cn",
|
"BASE_URL=%s 疑似内网地址,ZPAY 无法回调!线上部署请设置 BASE_URL 为公网地址,如 https://api.nomadyt.com",
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -246,4 +246,5 @@ class ZPayProvider(PaymentProvider):
|
|||||||
status = int(result.get("status", 0))
|
status = int(result.get("status", 0))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
status = 0
|
status = 0
|
||||||
return {"paid": status == 1, "status": status}
|
money = result.get("money", "")
|
||||||
|
return {"paid": status == 1, "status": status, "money": money}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
digital/cnomadcna/nomadlms 共享的支付逻辑
|
digital/cnomadcna/nomadlms 共享的支付逻辑
|
||||||
根据 prefix 生成对应 notify_path,业务互不影响
|
根据 prefix 生成对应 notify_path,业务互不影响
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
@@ -156,6 +157,7 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
|||||||
type_map = {
|
type_map = {
|
||||||
"book": "购买书籍",
|
"book": "购买书籍",
|
||||||
"meetup": "数字游民社区报名",
|
"meetup": "数字游民社区报名",
|
||||||
|
"salon": "沙龙报名茶歇费",
|
||||||
"video": "视频解锁",
|
"video": "视频解锁",
|
||||||
"vip": "VIP会员充值",
|
"vip": "VIP会员充值",
|
||||||
}
|
}
|
||||||
@@ -215,43 +217,46 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
|||||||
if not order_id:
|
if not order_id:
|
||||||
return {"paid": False, "error": "missing order_id"}
|
return {"paid": False, "error": "missing order_id"}
|
||||||
|
|
||||||
try:
|
def _query_pb() -> dict | None:
|
||||||
from ..services.pb import get_pb_client
|
try:
|
||||||
|
from ..services.pb import get_pb_client
|
||||||
|
pb = get_pb_client()
|
||||||
|
records = pb.collection("payments").get_list(1, 1, {"filter": f'order_id = "{order_id}"'})
|
||||||
|
if records.items:
|
||||||
|
return {"paid": True, "source": "pocketbase"}
|
||||||
|
except Exception as error:
|
||||||
|
_safe_log(logging.WARNING, "%s order_status PocketBase failed: %s", project_name, error)
|
||||||
|
return None
|
||||||
|
|
||||||
pb = get_pb_client()
|
def _query_xorpay() -> dict | None:
|
||||||
records = pb.collection("payments").get_list(
|
try:
|
||||||
1, 1, {"filter": f'order_id = "{order_id}"'}
|
xorpay = get_payment_provider_by_name("xorpay")
|
||||||
)
|
r = xorpay.query_order_status(order_id, timeout=6)
|
||||||
if records.items:
|
if r.get("paid"):
|
||||||
return {"paid": True, "source": "pocketbase"}
|
return {"paid": True, "source": "xorpay", "status": r.get("status", "")}
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
_safe_log(logging.WARNING, "%s order_status PocketBase failed: %s", project_name, error)
|
_safe_log(logging.WARNING, "%s order_status XorPay failed: %s", project_name, error)
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
def _query_zpay() -> dict | None:
|
||||||
xorpay = get_payment_provider_by_name("xorpay")
|
try:
|
||||||
xorpay_result = xorpay.query_order_status(order_id, timeout=8)
|
zpay = get_payment_provider_by_name("zpay")
|
||||||
if xorpay_result.get("paid"):
|
if isinstance(zpay, ZPayProvider):
|
||||||
return {
|
r = zpay.query_order_status(order_id, timeout=6)
|
||||||
"paid": True,
|
if r.get("paid"):
|
||||||
"source": "xorpay",
|
return {"paid": True, "source": "zpay", "status": r.get("status", "")}
|
||||||
"status": xorpay_result.get("status", ""),
|
except Exception as error:
|
||||||
}
|
_safe_log(logging.WARNING, "%s order_status ZPAY failed: %s", project_name, error)
|
||||||
except Exception as error:
|
return None
|
||||||
_safe_log(logging.WARNING, "%s order_status XorPay failed: %s", project_name, error)
|
|
||||||
|
|
||||||
try:
|
|
||||||
zpay = get_payment_provider_by_name("zpay")
|
|
||||||
if isinstance(zpay, ZPayProvider):
|
|
||||||
zpay_result = zpay.query_order_status(order_id, timeout=8)
|
|
||||||
if zpay_result.get("paid"):
|
|
||||||
return {
|
|
||||||
"paid": True,
|
|
||||||
"source": "zpay",
|
|
||||||
"status": zpay_result.get("status", ""),
|
|
||||||
}
|
|
||||||
except Exception as error:
|
|
||||||
_safe_log(logging.WARNING, "%s order_status ZPAY failed: %s", project_name, error)
|
|
||||||
|
|
||||||
|
# 并行查询 PB、ZPAY、XorPay,任一返回 paid 即立即返回(PC 扫码用 ZPAY,微信用 XorPay)
|
||||||
|
pb_task = asyncio.to_thread(_query_pb)
|
||||||
|
xorpay_task = asyncio.to_thread(_query_xorpay)
|
||||||
|
zpay_task = asyncio.to_thread(_query_zpay)
|
||||||
|
results = await asyncio.gather(pb_task, xorpay_task, zpay_task)
|
||||||
|
for r in results:
|
||||||
|
if r:
|
||||||
|
return r
|
||||||
return {"paid": False}
|
return {"paid": False}
|
||||||
|
|
||||||
@router.post("/xorpay_notify")
|
@router.post("/xorpay_notify")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,10 +22,12 @@ class PocketBaseSDK:
|
|||||||
self._client: Optional[PocketBase] = None
|
self._client: Optional[PocketBase] = None
|
||||||
|
|
||||||
def get_client(self) -> PocketBase:
|
def get_client(self) -> PocketBase:
|
||||||
"""获取已认证的 PocketBase 客户端"""
|
"""获取已认证的 PocketBase 客户端(首次登录时认证,后续复用)"""
|
||||||
if self._client is None:
|
if self._client is None:
|
||||||
|
import logging
|
||||||
self._client = PocketBase(self.url)
|
self._client = PocketBase(self.url)
|
||||||
self._client.admins.auth_with_password(self.admin_email, self.admin_password)
|
self._client.admins.auth_with_password(self.admin_email, self.admin_password)
|
||||||
|
logging.getLogger(__name__).info("PocketBase admin 登录成功")
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
def collection(self, name: str):
|
def collection(self, name: str):
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ def handle_payment_success(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
# meetup/salon 新用户:支付成功后才创建用户
|
# meetup/salon 新用户:支付成功后才创建用户。solanRed 记录创建时用的是 pending_xxx,查找需用原值
|
||||||
|
solan_lookup_user_id = user_id
|
||||||
if pay_type in ("meetup", "salon") and user_id.startswith("pending_"):
|
if pay_type in ("meetup", "salon") and user_id.startswith("pending_"):
|
||||||
email = _decode_pending_email(user_id)
|
email = _decode_pending_email(user_id)
|
||||||
if email:
|
if email:
|
||||||
@@ -151,25 +152,30 @@ def handle_payment_success(
|
|||||||
solan_collection = None
|
solan_collection = None
|
||||||
|
|
||||||
if solan is not None:
|
if solan is not None:
|
||||||
|
update_payload = {
|
||||||
|
"order_id": order_id,
|
||||||
|
"amount": amount_cents,
|
||||||
|
}
|
||||||
|
if pay_type == "salon":
|
||||||
|
update_payload["vip"] = True
|
||||||
|
elif pay_type == "meetup":
|
||||||
|
update_payload["type"] = pay_type
|
||||||
existing = solan.get_list(1, 1, {
|
existing = solan.get_list(1, 1, {
|
||||||
"filter": f'user_id = "{user_id}"',
|
"filter": f'user_id = "{solan_lookup_user_id}"',
|
||||||
"sort": "-created",
|
"sort": "-created",
|
||||||
})
|
})
|
||||||
if existing.items:
|
if existing.items:
|
||||||
record_id = existing.items[0].id
|
record_id = existing.items[0].id
|
||||||
solan.update(record_id, {
|
patch = dict(update_payload)
|
||||||
"type": pay_type,
|
if solan_lookup_user_id != user_id:
|
||||||
"order_id": order_id,
|
patch["user_id"] = user_id
|
||||||
"amount": amount_cents,
|
solan.update(record_id, patch)
|
||||||
})
|
|
||||||
logging.info(f"{solan_collection} 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}")
|
logging.info(f"{solan_collection} 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}")
|
||||||
else:
|
else:
|
||||||
solan.create({
|
create_payload = {"user_id": user_id, **update_payload}
|
||||||
"user_id": user_id,
|
if pay_type == "salon":
|
||||||
"type": pay_type,
|
create_payload["type"] = ""
|
||||||
"order_id": order_id,
|
solan.create(create_payload)
|
||||||
"amount": amount_cents,
|
|
||||||
})
|
|
||||||
logging.info(f"{solan_collection} 完整记录创建(兜底) - user_id: {user_id}")
|
logging.info(f"{solan_collection} 完整记录创建(兜底) - user_id: {user_id}")
|
||||||
elif pay_type not in ("meetup", "salon"):
|
elif pay_type not in ("meetup", "salon"):
|
||||||
existing = pb_client.collection("payments").get_list(1, 1, {
|
existing = pb_client.collection("payments").get_list(1, 1, {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"""PocketBase 客户端(基于 SDK)"""
|
"""PocketBase 客户端(基于 SDK)"""
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
from app.config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
||||||
from app.sdk.pocketbase import PocketBaseSDK
|
from app.sdk.pocketbase import PocketBaseSDK
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
_pb_sdk: PocketBaseSDK | None = None
|
_pb_sdk: PocketBaseSDK | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -20,8 +17,6 @@ def _get_pb_sdk() -> PocketBaseSDK:
|
|||||||
|
|
||||||
|
|
||||||
def get_pb_client():
|
def get_pb_client():
|
||||||
"""获取已认证的 PocketBase 客户端"""
|
"""获取已认证的 PocketBase 客户端(复用已登录实例,不重复打日志)"""
|
||||||
sdk = _get_pb_sdk()
|
sdk = _get_pb_sdk()
|
||||||
client = sdk.get_client()
|
return sdk.get_client()
|
||||||
logger.info("PocketBase admin 登录成功")
|
|
||||||
return client
|
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ pydantic
|
|||||||
python-multipart
|
python-multipart
|
||||||
pocketbase
|
pocketbase
|
||||||
requests
|
requests
|
||||||
minio
|
minio
|
||||||
|
playwright
|
||||||
2
run.py
2
run.py
@@ -16,7 +16,7 @@ if __name__ == "__main__":
|
|||||||
port = int(os.getenv("PORT", "8007"))
|
port = int(os.getenv("PORT", "8007"))
|
||||||
reload_enabled = os.getenv("UVICORN_RELOAD", "0").strip().lower() in ("1", "true", "yes")
|
reload_enabled = os.getenv("UVICORN_RELOAD", "0").strip().lower() in ("1", "true", "yes")
|
||||||
try:
|
try:
|
||||||
sys.stderr.write(f"\n[payjsapi] startup http://0.0.0.0:{port}\n")
|
sys.stderr.write(f"\n[salonapi] startup http://0.0.0.0:{port}\n")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user