This commit is contained in:
eric
2026-03-16 09:32:46 -05:00
parent 710c30837a
commit 4187cdd64b
8 changed files with 191 additions and 33 deletions

View File

@@ -4,7 +4,7 @@
# 可用 ngrok 等隧道BASE_URL=https://xxx.ngrok.io
# 或直接使用生产地址测试回调(需 payjsapi 已部署到公网)
PORT=8007
BASE_URL=https://api.hackrobot.cn
BASE_URL=https://api.nomadyt.com
# 支付渠道zpay | xorpay
PAYMENT_PROVIDER=zpay

View File

@@ -17,7 +17,7 @@
## 环境
本地调试:`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
# .env 中设置
BASE_URL=https://api.hackrobot.cn
BASE_URL=https://api.nomadyt.com
PAYMENT_PROVIDER=zpay
# 其他 ZPAY、PocketBase、Memos 配置见 .env.example
```
@@ -86,9 +86,9 @@ PAYMENT_PROVIDER=zpay
本地正常、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_error` 含 "SSL" → 执行 `apt install ca-certificates` 后重启

View File

@@ -43,8 +43,8 @@ MEMOS_TOKEN = os.getenv(
"eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg",
)
# 回调地址(用于生成 notify_url
BASE_URL = os.getenv("BASE_URL", "https://api.hackrobot.cn")
# 回调地址(用于生成 notify_url。salon 线上部署 api.nomadyt.com
BASE_URL = os.getenv("BASE_URL", "https://api.nomadyt.com")
# MinIO 对象存储
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minioweb.hackrobot.cn")

View File

@@ -11,7 +11,7 @@
- common: /submit_meetup_application、/create_user
本地调试BASE_URL 需为 ZPAY 可访问地址(如 ngrok
线上部署BASE_URL=https://api.hackrobot.cn(必须为 ZPAY 可公网访问的地址)
线上部署BASE_URL=https://api.nomadyt.com(必须为 ZPAY 可公网访问的地址)
"""
import logging
import sys
@@ -108,7 +108,7 @@ def _check_base_url():
return
if "localhost" in url or "127.0.0.1" in url or url.startswith("http://192.168.") or url.startswith("http://10."):
logging.warning(
"BASE_URL=%s 疑似内网地址ZPAY 无法回调!线上部署请设置 BASE_URL 为公网地址,如 https://api.hackrobot.cn",
"BASE_URL=%s 疑似内网地址ZPAY 无法回调!线上部署请设置 BASE_URL 为公网地址,如 https://api.nomadyt.com",
BASE_URL,
)

View File

@@ -246,4 +246,5 @@ class ZPayProvider(PaymentProvider):
status = int(result.get("status", 0))
except (ValueError, TypeError):
status = 0
return {"paid": status == 1, "status": status}
money = result.get("money", "")
return {"paid": status == 1, "status": status, "money": money}

View File

@@ -246,18 +246,67 @@ def _decode_pending_email(user_id: str) -> str | None:
return None
def _query_zpay_paid(order_id: str) -> bool:
def _query_zpay_order(order_id: str) -> dict | None:
"""查询 ZPAY 订单状态,返回 {paid, money}"""
try:
from ..payment import get_payment_provider
from ..payment.zpay import ZPayProvider
provider = get_payment_provider()
if not isinstance(provider, ZPayProvider):
return False
return None
result = provider.query_order_status(order_id, timeout=10)
return bool(result.get("paid"))
return result if result.get("paid") else None
except Exception as e:
logging.warning(f"salon complete-order zpay query failed: {e}")
return False
return None
def _query_xorpay_paid(order_id: str) -> tuple[bool, str]:
"""查询 XorPay 是否已支付,返回 (paid, pay_price)"""
try:
from ..payment import get_payment_provider
from ..payment.xorpay import XorPayProvider
provider = get_payment_provider()
if not isinstance(provider, XorPayProvider):
return False, ""
result = provider.query_order_status(order_id, timeout=8)
if result.get("paid"):
return True, str(result.get("pay_price", "") or "")
return False, ""
except Exception as e:
logging.warning(f"salon complete-order xorpay query failed: {e}")
return False, ""
def _update_solan_red_on_payment(token: str, user_id: str, order_id: str, amount_cents: int) -> None:
"""支付成功后兜底更新 solanRedorder_id、amount、vip"""
base = (PB_URL or "").rstrip("/")
try:
r = requests.get(
f"{base}/api/collections/solanRed/records",
params={"filter": f'user_id = "{user_id}"', "sort": "-created", "perPage": 1},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
if r.status_code != 200:
return
data = r.json()
items = data.get("items") or []
if not items:
return
rec_id = items[0].get("id")
if not rec_id:
return
patch = {"order_id": order_id, "amount": amount_cents, "vip": True}
requests.patch(
f"{base}/api/collections/solanRed/records/{rec_id}",
json=patch,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
timeout=10,
)
logging.info(f"salon complete-order 兜底更新 solanRed - user_id: {user_id}, amount: {amount_cents}")
except Exception as e:
logging.warning(f"salon complete-order 兜底更新 solanRed 失败: {e}")
@router.post("/complete-order")
@@ -288,13 +337,46 @@ async def complete_order(item: dict):
total = (r.json().get("totalItems") or 0) if r.status_code == 200 else 0
if total > 0:
break
if attempt < max_retries - 1 and _query_zpay_paid(order_id):
time.sleep(2)
continue
if attempt < max_retries - 1:
paid_via = None
pay_price = ""
zpay_result = _query_zpay_order(order_id)
if zpay_result:
paid_via = "zpay"
pay_price = str(zpay_result.get("money", "") or zpay_result.get("pay_price", ""))
if not paid_via:
xorpay_paid, xorpay_price = _query_xorpay_paid(order_id)
if xorpay_paid:
paid_via = "xorpay"
pay_price = xorpay_price
if paid_via:
from ..services.payment import handle_payment_success
price = pay_price if pay_price and float(pay_price or 0) > 0 else "1"
try:
handle_payment_success(
order_id,
price,
f"salon-{paid_via}-poll",
use_memos=False,
site_id=SITE_ID,
)
time.sleep(1)
continue
except Exception as e:
logging.warning(f"salon complete-order 手动触发支付处理失败: {e}")
time.sleep(2)
continue
resp_preview = r.text[:200] if r.ok else f"status={r.status_code}"
logging.warning(f"salon complete-order 未找到订单: order_id={order_id}, attempt={attempt + 1}, pb_resp={resp_preview}")
raise HTTPException(status_code=400, detail="订单不存在或未支付成功")
# 兜底更新 solanRed回调可能未到确保 order_id、amount、vip 正确(按 user_id 查找pending_ 用户记录存的是 pending_xxx
zpay_result = _query_zpay_order(order_id)
money_yuan = float(zpay_result.get("money", 0) or 0) if zpay_result else 0
amount_cents = int(money_yuan * 100) if money_yuan > 0 else 100
lookup_user_id = user_id
_update_solan_red_on_payment(token, lookup_user_id, order_id, amount_cents)
if user_id.startswith("pending_"):
email = _decode_pending_email(user_id)
if not email:
@@ -314,7 +396,28 @@ async def complete_order(item: dict):
"is_new": True,
}
return {"ok": True, "token": None, "record": None, "is_new": False}
# 非 pending 用户:返回 solanRed 记录供前端显示微信号
record = None
try:
esc = lookup_user_id.replace("\\", "\\\\").replace('"', '\\"')
r = requests.get(
f"{base}/api/collections/solanRed/records",
params={"filter": f'user_id = "{esc}"', "sort": "-created", "perPage": 1},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
if r.status_code == 200:
items = r.json().get("items") or []
if items:
rec = items[0]
record = {
"wechatId": rec.get("wechatId"),
"nickname": rec.get("nickname"),
"media": rec.get("media"),
}
except Exception:
pass
return {"ok": True, "token": None, "record": record, "is_new": False}
@router.get("/vip/check")
@@ -368,10 +471,15 @@ async def join_by_wechat(wechat_id: str = ""):
record_dict = None
if rec:
record_dict = {
"id": _get(rec, "id"),
"user_id": _get(rec, "user_id"),
"wechatId": _get(rec, "wechatId"),
"nickname": _get(rec, "nickname"),
"media": _get(rec, "media"),
"amount": amount,
"order_id": _get(rec, "order_id"),
"vip": vip,
"is_volunteer": bool(_get(rec, "is_volunteer")),
}
return {
"hasRecord": has_record,
@@ -387,6 +495,47 @@ async def join_by_wechat(wechat_id: str = ""):
return {"hasRecord": False, "record": None, "isComplete": False, "isWechatFriend": False, "isApproved": False, "isRejected": False, "vip": False}
@router.get("/join/volunteers")
async def join_volunteers():
"""获取志愿者列表 - solanRed 中 is_volunteer=true && is_approved=true"""
token, err = _get_admin_token()
if not token:
return {"volunteer": None}
base = (PB_URL or "").rstrip("/")
if not base:
return {"volunteer": None}
try:
r = requests.get(
f"{base}/api/collections/solanRed/records",
params={
"filter": 'is_volunteer = true && is_approved = true',
"sort": "-created",
"perPage": 1,
},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
if r.status_code != 200:
return {"volunteer": None}
data = r.json()
items = data.get("items") or []
volunteer = None
if items:
rec = items[0]
nickname = str(rec.get("xiaohongshu_nickname") or rec.get("nickname") or "").strip() or "志愿者"
media = str(rec.get("media") or "").strip() or None
xiaohongshu_url = str(rec.get("xiaohongshu_url") or "").strip() or None
volunteer = {
"nickname": nickname,
"avatar": media if media else None,
"xiaohongshu_url": xiaohongshu_url if xiaohongshu_url else None,
}
return {"volunteer": volunteer}
except Exception as e:
logging.warning(f"salon join volunteers 查询失败: {e}")
return {"volunteer": None}
class XiaohongshuProfileRequest(BaseModel):
url: str
@@ -1151,6 +1300,7 @@ async def submit_join(item: dict):
# PocketBase type 仅接受 session-1/session-2 等digital-nomad/volunteer 映射为空
pb_type = raw_type if raw_type in ("session-1", "session-2") else ""
qualify = bool(item.get("qualify", False)) # 女性且小红书帖子>10 时自动通过
pb_data = {
"user_id": user_id,
"nickname": item.get("nickname", ""),
@@ -1169,6 +1319,7 @@ async def submit_join(item: dict):
"xiaohongshu_url": item.get("xiaohongshu_url", ""),
"is_volunteer": bool(item.get("is_volunteer", False)),
"is_wechat_friend": bool(item.get("is_wechat_friend", False)),
"is_approved": qualify, # 符合条件时自动通过审核
"age_range": item.get("age_range", ""),
"first_time": item.get("first_time", ""),
}

View File

@@ -111,7 +111,8 @@ def handle_payment_success(
if not user_id:
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_"):
email = _decode_pending_email(user_id)
if email:
@@ -151,25 +152,30 @@ def handle_payment_success(
solan_collection = 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, {
"filter": f'user_id = "{user_id}"',
"filter": f'user_id = "{solan_lookup_user_id}"',
"sort": "-created",
})
if existing.items:
record_id = existing.items[0].id
solan.update(record_id, {
"type": pay_type,
"order_id": order_id,
"amount": amount_cents,
})
patch = dict(update_payload)
if solan_lookup_user_id != user_id:
patch["user_id"] = user_id
solan.update(record_id, patch)
logging.info(f"{solan_collection} 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}")
else:
solan.create({
"user_id": user_id,
"type": pay_type,
"order_id": order_id,
"amount": amount_cents,
})
create_payload = {"user_id": user_id, **update_payload}
if pay_type == "salon":
create_payload["type"] = ""
solan.create(create_payload)
logging.info(f"{solan_collection} 完整记录创建(兜底) - user_id: {user_id}")
elif pay_type not in ("meetup", "salon"):
existing = pb_client.collection("payments").get_list(1, 1, {

2
run.py
View File

@@ -16,7 +16,7 @@ if __name__ == "__main__":
port = int(os.getenv("PORT", "8007"))
reload_enabled = os.getenv("UVICORN_RELOAD", "0").strip().lower() in ("1", "true", "yes")
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()
except (OSError, ValueError):
pass