4 Commits
main ... test

Author SHA1 Message Date
eric
e9898ab372 's' 2026-03-16 11:45:46 -05:00
eric
4187cdd64b 'data' 2026-03-16 09:32:46 -05:00
eric
710c30837a 's' 2026-03-16 06:53:17 -05:00
eric
e84c02a329 '小红书解析' 2026-03-16 04:27:53 -05:00
12 changed files with 1057 additions and 84 deletions

View File

@@ -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

View File

@@ -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` 后重启

View File

@@ -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")

View File

@@ -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,
) )

View File

@@ -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}

View File

@@ -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

View File

@@ -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):

View File

@@ -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, {

View File

@@ -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

View File

@@ -6,4 +6,5 @@ pydantic
python-multipart python-multipart
pocketbase pocketbase
requests requests
minio minio
playwright

2
run.py
View File

@@ -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