From 4187cdd64b4cf04ecb57ad12f4e8e4290e1dc95f Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 16 Mar 2026 09:32:46 -0500 Subject: [PATCH] 'data' --- .env.example | 2 +- README.md | 10 +-- app/config.py | 4 +- app/main.py | 4 +- app/payment/zpay.py | 3 +- app/routers/salon_meetup.py | 167 ++++++++++++++++++++++++++++++++++-- app/services/payment.py | 32 ++++--- run.py | 2 +- 8 files changed, 191 insertions(+), 33 deletions(-) diff --git a/.env.example b/.env.example index 8f5c641..71ee9ba 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 0bc21ca..6c8a2cb 100644 --- a/README.md +++ b/README.md @@ -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` 后重启 diff --git a/app/config.py b/app/config.py index 23c7dcf..5014c99 100644 --- a/app/config.py +++ b/app/config.py @@ -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") diff --git a/app/main.py b/app/main.py index 14cf48f..2b5f250 100644 --- a/app/main.py +++ b/app/main.py @@ -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, ) diff --git a/app/payment/zpay.py b/app/payment/zpay.py index 6978457..8fa6f2d 100644 --- a/app/payment/zpay.py +++ b/app/payment/zpay.py @@ -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} diff --git a/app/routers/salon_meetup.py b/app/routers/salon_meetup.py index 056101d..2e43a02 100644 --- a/app/routers/salon_meetup.py +++ b/app/routers/salon_meetup.py @@ -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: + """支付成功后兜底更新 solanRed:order_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", ""), } diff --git a/app/services/payment.py b/app/services/payment.py index ba6a4c3..75c94a3 100644 --- a/app/services/payment.py +++ b/app/services/payment.py @@ -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, { diff --git a/run.py b/run.py index 3dfdd03..90d9ffc 100644 --- a/run.py +++ b/run.py @@ -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