From 90378502cff343de3ca8aec1d5aa58fe3c4ff935 Mon Sep 17 00:00:00 2001 From: eric Date: Thu, 12 Mar 2026 19:50:50 -0500 Subject: [PATCH] 's' --- .env.example | 16 +- app/__pycache__/config.cpython-312.pyc | Bin 2773 -> 3369 bytes app/__pycache__/main.cpython-312.pyc | Bin 5593 -> 5670 bytes app/config.py | 20 +- app/main.py | 2 + app/routers/__init__.py | 2 + .../__pycache__/__init__.cpython-312.pyc | Bin 675 -> 718 bytes .../__pycache__/common.cpython-312.pyc | Bin 3840 -> 3842 bytes app/routers/common.py | 2 +- app/routers/meetup.py | 315 ++++++++++++++++++ app/sdk/__pycache__/memos.cpython-312.pyc | Bin 3844 -> 3846 bytes app/sdk/memos.py | 4 +- .../__pycache__/memos.cpython-312.pyc | Bin 1030 -> 1032 bytes .../__pycache__/payment.cpython-312.pyc | Bin 4649 -> 8652 bytes app/services/memos.py | 2 +- app/services/payment.py | 89 ++++- requirements.txt | 1 + run.py | 7 +- 18 files changed, 438 insertions(+), 22 deletions(-) create mode 100644 app/routers/meetup.py diff --git a/.env.example b/.env.example index de04868..803fa98 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,12 @@ # ========== 本地调试 ========== +# PORT: 默认 8007,与前端 PAYMENT_API_URL 一致 # BASE_URL 需为 ZPAY 可访问的回调地址。本地调试时 ZPAY 无法回调 localhost/内网, # 可用 ngrok 等隧道:BASE_URL=https://xxx.ngrok.io # 或直接使用生产地址测试回调(需 payjsapi 已部署到公网) -# nomadvip/digital/cnomadcna/nomadlms 的 zpay_notify 均依赖此地址,未正确配置会导致 PocketBase/Memos 不更新 -# 本地调试:ngrok 暴露 8700 后设置 BASE_URL=https://xxx.ngrok.io -# 线上部署:BASE_URL=https://api.hackrobot.cn - -# ========== 生产部署(Ubuntu 等)========== -# 线上部署必须正确配置,否则会显示「支付失败」: -# 1. BASE_URL 必须为 payjsapi 公网地址,ZPAY 需能访问此地址做异步回调 -# 错误示例:http://127.0.0.1:8700、http://localhost(ZPAY 无法访问) -# 正确示例:https://api.hackrobot.cn(域名需解析到本机并配置 nginx 反向代理) -# 2. 服务器需能访问 zpayz.cn(检查防火墙、DNS),可访问 /health?check_zpay=1 自检 +PORT=8007 BASE_URL=https://api.hackrobot.cn -# 支付渠道:zpay(已替代 xorpay)| xorpay +# 支付渠道:zpay | xorpay PAYMENT_PROVIDER=zpay # XorPay @@ -25,7 +17,7 @@ XORPAY_SECRET=your_secret ZPAY_PID=2025121809351743 ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89 -# PocketBase +# PocketBase(check-user、ensure-user、支付回调必需) PB_URL=https://pocketbase.hackrobot.cn PB_ADMIN_EMAIL=your@email.com PB_ADMIN_PASSWORD=your_password diff --git a/app/__pycache__/config.cpython-312.pyc b/app/__pycache__/config.cpython-312.pyc index c7df1b5785cb5a95d096454c81dcf1e0258147e4..b4c1ca1ed38288b2e97625aa650a850ad92565f0 100644 GIT binary patch delta 1588 zcmaJ>O;6lL7@qORdb}Sdgz&Ws-i4*GB+Zr*QUz$6l!i#C`BZa2zIZnoC-v4@Bd>)@ z6cr*xEfI$X`53iYP9%aGhyH~sxh!lVR98s7u1a0ZTW&Qc=P<;a!QJ5%9sOdF#vp%hAGVoG>g%uxWRZ%5NJG(c9TGflS)`huD+I; zQZH?KMrqOF+7TT4k}P|R!S)`<2`u?$N~@=GCSY1PdcX^Ww;k|b2tRhfH(`3Q-Q!JM zJN?mrf|ZWO_++#ux~_nZA?MhwbcA{)1KNh?BHeEj(^No6O?#BiP}AOk_G5#}0Uh;B zD_zXB#(_5(AcOl3&$K(VbwAD?s*;|{xyPo3)B%@Rc~R*hL2sx<4iEbl{(OoYsy>cS z_-Z8`s`@mbx|9i(oy7S=v%XN(XL!U{H7;c;G4f-MT-JWC!fEU=8(JQ0G7zfz9FJ<* z#{i$w1|EaoIlrOuNod6wK7FX-2N#&(FPwL*@-=t0X#X$_UE!i`YgW-9G-jAZtYXvF z%JX-*JjE!*ykXuf8rO*ene&=u=~#0VE(?+KGxsd+N>{IZ``z^Qsc*iYcFyysafLZDi=p4JSaQMKh`lhUmv38KBk;ztcp4y8 zy`>u(sS+%$VjA1$Y7 zA&=HR3n6^Vu#o!?0T|r_U3(z;3dnCFKuj#pu1LE`-bQjAWp>ffHX7PR`E8V6;{OJ* z<>^Nk9$tO{QXap!9nU-&dkIFIe^7O?F81t*g{oL(*2Oikc6_3qnIwtY*}_ufUoNpU z_3O<2nVKZmx%3XFR>!NO>*H(VwUcM-*>faOJFo7u0JiKSz)QZkug>-FaI;mtdShK* m(`zHs^}!1y@mHQ|kM2i7qGNgLx0#1CkGB34laQMvyZitv2Oxm} delta 1095 zcmZ{jy=&V*6u@=*wB>Kdj%~@7+QNI2ZLz|g8l~`LOXQrogFLka5wyTcly0|@5J3F?ME^FEtOI^GRDEA z*NlzQW&XP`Y8d;D0Ahu#TO!Xn=xFXx?{T8mo4+yBjRPy-{>3PdYL)fLl(`qI2Wi!atX)4l^6OWP`~SkEF= zVKbtYSG1_L1>0e<%EH2LMT1}55!iX%Ah$|mrbo}HGp*<*Ri@Q|T}K~taM!7hx%VPz zt2OV`sxedc;9l6hal-F5u_0M8?4;h%7DgXcg diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index 9a67bc20252959e9f22a2e88db85670c555fe987..296eaf938cd82243d12b440d8844e8c5a88a71b2 100755 GIT binary patch delta 789 zcmZuu&uzFK1ZjPi@z*)v~tyutRE?INL4K zEC76x)f|4ed7R_0h*58%`W<7l<{-nx9ekE$D|BJzU6w}!0iR=TgjEA3w`ZVx$Rc=| z6*j!MdIlD=9K+wpS1`KkB8NDBXbTZM$8*zH?$=R|v}~&&;xe@(iC8dOZlYR;H1P%l z-Si$s(m=kzi7t9?qVK`d3XfMgfsAdJAP4hz9GTyuL~Ppx-(rrWYm zY1a({^JCyOdQ5u&ag;vRit;+oIwG(1#QaH&K&SRuP@5(PV~9m*9c z2#H&?NA5@{98fKB#!I6m+)F^Gr`N!l=p*(pI$Rtr-rF;dwgg*NsTj=;s~w- z98nhlUtzZs?}`Gwkf`_|S_H$5klF3n{ht>D9>1gFYwrXDk% zvb1X=EHT|I-4Q4AnCpyPNUw@=c%WE{A8b1Hb{7*nT!4l$|1I!4wsgh($ delta 84 zcmX@dx|o&sG%qg~0}#xo2ik#NEj#t0MPIhoB#j- diff --git a/app/routers/__pycache__/common.cpython-312.pyc b/app/routers/__pycache__/common.cpython-312.pyc index 488ec7a8d05b4cbe63399f18be3685bc76d169c2..97523289445236070cfc856aac179a5c8946f8db 100644 GIT binary patch delta 46 zcmZpWYm(zV&CAQh00dHQn=_L)@`mwnau^yJo0yuJTWrqaQD tuple[str | None, str | None]: + """返回 (token, error_detail)。token 为 None 时 error_detail 为失败原因""" + if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD: + return None, "PB_ADMIN_EMAIL 或 PB_ADMIN_PASSWORD 未配置" + base = (PB_URL or "").rstrip("/") + # PocketBase v0.23+ 使用 _superusers,旧版用 admins + for path in ["/api/collections/_superusers/auth-with-password", "/api/admins/auth-with-password"]: + try: + r = requests.post( + f"{base}{path}", + json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, + timeout=10, + ) + if r.status_code == 200: + data = r.json() + return data.get("token"), None + if r.status_code == 404: + continue # 尝试下一个路径 + try: + msg = r.json().get("message", r.text[:100]) + except Exception: + msg = r.text[:100] + logging.warning(f"PocketBase admin auth failed: status={r.status_code}, msg={msg}") + return None, f"PocketBase 管理员认证失败: {msg}" + except requests.exceptions.ConnectionError as e: + logging.error(f"PocketBase 连接失败: {e}") + return None, f"无法连接 {PB_URL},请检查网络或 PB_URL" + except Exception as e: + logging.warning(f"PocketBase auth try {path}: {e}") + return None, "PocketBase 管理员认证失败: 请确认 PB_URL 及管理员账号密码正确" + + +class CheckUserRequest(BaseModel): + email: str + + +class EnsureUserRequest(BaseModel): + email: str + password: str | None = None + + +def _check_site_vip(user_id: str, site_id: str = "meetup") -> bool: + """检查用户是否有有效 site_vip(未过期)""" + token, _ = _get_admin_token() + if not token: + return False + try: + from datetime import datetime + + r = requests.get( + f"{PB_URL}/api/collections/site_vip/records", + params={ + "filter": f'user_id = "{user_id}" && site_id = "{site_id}"', + "perPage": 1, + "sort": "-expires_at", + }, + headers={"Authorization": f"Bearer {token}"}, + timeout=10, + ) + if r.status_code != 200: + return False + data = r.json() + items = data.get("items") or [] + if not items: + return False + rec = items[0] + exp = rec.get("expires_at") + if not exp: + return True + try: + from datetime import datetime as _dt, timezone + + exp_dt = _dt.fromisoformat(exp.replace("Z", "+00:00")) + now = _dt.now(timezone.utc) + if exp_dt.tzinfo is None: + exp_dt = exp_dt.replace(tzinfo=timezone.utc) + return exp_dt > now + except Exception: + return True + except Exception: + return False + + +@router.post("/check-user") +async def check_user(item: CheckUserRequest): + """检查邮箱是否已在 users 表存在,以及 VIP 状态(新/老用户判断)""" + email = (item.email or "").strip() + if not email: + raise HTTPException(status_code=400, detail="请输入邮箱") + + token, err = _get_admin_token() + if not token: + raise HTTPException(status_code=500, detail=f"服务配置错误:{err}") + + try: + filter_val = f'email="{email}"' + r = requests.get( + f"{PB_URL}/api/collections/users/records", + params={"filter": filter_val, "perPage": 1}, + headers={"Authorization": f"Bearer {token}"}, + timeout=10, + ) + if r.status_code != 200: + raise HTTPException(status_code=500, detail="查询失败") + data = r.json() + items = data.get("items") or [] + exists = len(items) > 0 + user_id = items[0].get("id") if items else None + vip = _check_site_vip(user_id, "meetup") if user_id else False + return {"ok": True, "exists": exists, "vip": vip, "user_id": user_id} + except HTTPException: + raise + except Exception as e: + logging.error(f"check-user error: {e}") + raise HTTPException(status_code=500, detail="操作失败") + + +@router.post("/ensure-user") +async def ensure_user(item: EnsureUserRequest): + """确保用户存在:老用户验证密码,新用户用默认密码 12345678 创建""" + email = (item.email or "").strip() + if not email: + raise HTTPException(status_code=400, detail="请输入邮箱") + + password = (item.password or "").strip() or DEFAULT_PASSWORD + + try: + # 尝试用密码登录 + login_r = requests.post( + f"{PB_URL}/api/collections/users/auth-with-password", + json={"identity": email, "password": password}, + timeout=10, + ) + if login_r.status_code == 200: + data = login_r.json() + return { + "ok": True, + "user_id": data.get("record", {}).get("id"), + "token": data.get("token"), + "record": data.get("record"), + "is_new": False, + } + + try: + err_data = login_r.json() + except Exception: + err_data = {} + is_not_found = login_r.status_code == 400 and ( + "Invalid" in str(err_data.get("message", "")) + or "identity" in str(err_data.get("message", "")).lower() + ) + if not is_not_found and item.password: + raise HTTPException(status_code=400, detail="密码错误") + + # 新用户:创建 + token, err = _get_admin_token() + if not token: + raise HTTPException(status_code=500, detail=f"服务配置错误:{err}") + + create_r = requests.post( + f"{PB_URL}/api/collections/users/records", + json={ + "email": email, + "password": DEFAULT_PASSWORD, + "passwordConfirm": DEFAULT_PASSWORD, + }, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + timeout=10, + ) + if create_r.status_code != 200: + try: + create_err = create_r.json() + except Exception: + create_err = {} + data = create_err.get("data", {}) + if ( + create_r.status_code == 400 + and "already" in str(data.get("email", {}).get("message", "")).lower() + ): + raise HTTPException(status_code=400, detail="该邮箱已注册,请输入密码登录") + msg = create_err.get("message", "创建账号失败") + logging.warning(f"ensure-user create failed: status={create_r.status_code}, msg={msg}, data={data}") + raise HTTPException(status_code=500, detail=msg) + + # 登录新用户 + final_r = requests.post( + f"{PB_URL}/api/collections/users/auth-with-password", + json={"identity": email, "password": DEFAULT_PASSWORD}, + timeout=10, + ) + if final_r.status_code != 200: + raise HTTPException(status_code=500, detail="登录失败") + + auth_data = final_r.json() + return { + "ok": True, + "user_id": auth_data.get("record", {}).get("id"), + "token": auth_data.get("token"), + "record": auth_data.get("record"), + "is_new": True, + } + except HTTPException: + raise + except Exception as e: + logging.error(f"ensure-user error: {e}") + raise HTTPException(status_code=500, detail="操作失败") + + +def _decode_pending_email(user_id: str) -> str | None: + if not user_id.startswith("pending_"): + return None + try: + return bytes.fromhex(user_id[8:]).decode("utf-8") + except Exception: + return None + + +def _query_zpay_paid(order_id: str) -> bool: + """查询 ZPAY 订单是否已支付""" + try: + from ..payment import get_payment_provider + from ..payment.zpay import ZPayProvider + provider = get_payment_provider() + if not isinstance(provider, ZPayProvider): + return False + result = provider.query_order_status(order_id, timeout=10) + return bool(result.get("paid")) + except Exception as e: + logging.warning(f"complete-order zpay query failed: {e}") + return False + + +@router.post("/complete-order") +async def complete_order(item: dict): + """ + 支付成功后完成订单:验证订单、为新用户同步 session。 + 支持 pending_ 订单(新用户)和已存在用户订单(user_id 为 PB id)。 + 异步通知可能晚于用户回跳,故在 site_vip 未找到时轮询重试(ZPAY 已确认支付时)。 + """ + order_id = (item.get("order_id") or "").strip() + if not order_id: + raise HTTPException(status_code=400, detail="缺少 order_id") + + base = (PB_URL or "").rstrip("/") + token, err = _get_admin_token() + if not token: + raise HTTPException(status_code=500, detail=f"服务配置错误:{err}") + + from ..services.payment import parse_order_id + + user_id, pay_type = parse_order_id(order_id) + if not user_id or pay_type != "meetup": + raise HTTPException(status_code=400, detail="订单格式无效") + + # 验证订单:site_vip 中需存在该 order_id(异步通知可能晚于用户回跳,ZPAY 已支付时轮询重试) + max_retries = 4 + for attempt in range(max_retries): + r = requests.get( + f"{base}/api/collections/site_vip/records", + params={"filter": f'order_id = "{order_id}"', "perPage": 1}, + headers={"Authorization": f"Bearer {token}"}, + timeout=10, + ) + 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 + resp_preview = r.text[:200] if r.ok else f"status={r.status_code}" + logging.warning(f"complete-order 未找到订单: order_id={order_id}, attempt={attempt + 1}, pb_resp={resp_preview}") + raise HTTPException(status_code=400, detail="订单不存在或未支付成功") + + # pending_ 新用户:用邮箱+默认密码登录返回 token + if user_id.startswith("pending_"): + email = _decode_pending_email(user_id) + if not email: + raise HTTPException(status_code=400, detail="订单格式无效") + login_r = requests.post( + f"{base}/api/collections/users/auth-with-password", + json={"identity": email, "password": DEFAULT_PASSWORD}, + timeout=10, + ) + if login_r.status_code != 200: + raise HTTPException(status_code=500, detail="登录失败,请稍后重试") + auth_data = login_r.json() + return { + "ok": True, + "token": auth_data.get("token"), + "record": auth_data.get("record"), + "is_new": True, + } + + # 已存在用户(PB user_id):订单已验证,返回 ok,前端已有 session 则无需 token + return {"ok": True, "token": None, "record": None, "is_new": False} diff --git a/app/sdk/__pycache__/memos.cpython-312.pyc b/app/sdk/__pycache__/memos.cpython-312.pyc index aa04cfcbf3a2e101e1feb186aee547dfa76f20fa..79f09bb442207cc36f63eafa6db36dd35682753d 100644 GIT binary patch delta 48 zcmZpXYm?(W&CAQh00imwn=@Z-Oq@^aoejEa+8_|$-8Hs5RjC4UUF diff --git a/app/sdk/memos.py b/app/sdk/memos.py index 2f89a47..5498b66 100644 --- a/app/sdk/memos.py +++ b/app/sdk/memos.py @@ -24,7 +24,7 @@ class MemosSDK: def create_user( self, user_id: str, - password: str = "123456", + password: str = "12345678", ) -> dict: """ 创建 Memos 用户 @@ -72,7 +72,7 @@ class MemosSDK: def create_user_by_username( self, username: str, - password: str = "123456", + password: str = "12345678", ) -> dict: """按用户名直接创建(用于 /create_user 接口)""" if not self.enabled: diff --git a/app/services/__pycache__/memos.cpython-312.pyc b/app/services/__pycache__/memos.cpython-312.pyc index a3e0ce62bd38f19fa1c33b9b7467a6523e072a78..3be7f445057100f63c74072a9f13cd2f01ce9937 100644 GIT binary patch delta 36 qcmZqU=-}Wz&CAQh00gP_n=>;u^6D{jau^yJo0yuJTTFIhJ^}!hb_jz2 delta 34 ocmeC+Xyf2L&CAQh00do+)@P<|H%`(KxO0 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 delta 2072 zcmZ`)&2Jk;6rb5IufNx|(>Sr4I&R|DEfsCyLKUj2Rnw+aAOuLQRcUcDlW5bm*P7il zX*4DU^#D>2pgn-pL_(Dis&XmhKR`li`8Xgrw9BOz4%{Lr94aJuv$oU33QyXZH*eni zy*I!4*dNZ`8%uqkOvVt%`Gv2oY2%Yre&sfdK1J^!f@+9hBH6fxiA)q*YREMiBhXb_ zX{a?7BMDJ$U4(sNSQS^HRk#MN$`xoej_#nf z*gA$V5RAA+BG(Q9dXmd5?x`I+LR{V9qlZ3ue^eV;AyEOlCOvnULnL+=w7I9Q6WUQIvQxlUJUWjv{`*=so!knF z@z{J;*j`DI6iJ6`Hr8a4A$wM(^)4svIFHW@Qu~CxChQQMn-rTlFPQF=b^;QJ$v)u; zqI1M`l3kV8#eRTbBq*9Bxv+`>o&chhUZof{-v+2c$8~$l)h^WD)otDaDo+Oc*=~*l z3n2+_;%fR<6L8+jzGO`5$Z6(@9!iuxQ<`L${7}wMmE?y7 z6)X=+s<4d9LO&F}2=?QLFBatQ3M%N;R1h&u;>?&PJ0hx4Uhbhyozz^Q7W@33*||A&!#GrO%3G+HzT1M XLq={h9;ykH%6$CtN42M6SeW`hC@HBy diff --git a/app/services/memos.py b/app/services/memos.py index 2003790..b5ac157 100644 --- a/app/services/memos.py +++ b/app/services/memos.py @@ -16,6 +16,6 @@ def _get_memos_sdk() -> MemosSDK: return _memos_sdk -def create_memos_user(user_id: str, password: str = "123456"): +def create_memos_user(user_id: str, password: str = "12345678"): """创建 Memos 用户""" return _get_memos_sdk().create_user(user_id=user_id, password=password) diff --git a/app/services/payment.py b/app/services/payment.py index f7a3d3c..44c54dd 100644 --- a/app/services/payment.py +++ b/app/services/payment.py @@ -1,11 +1,15 @@ """支付成功后的统一业务处理(基于 SDK)""" import logging +import requests + +from ..config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD from .pb import get_pb_client from .memos import create_memos_user # 已处理订单(防重复) processed_orders: set[str] = set() +DEFAULT_PASSWORD = "12345678" def parse_order_id(order_id: str) -> tuple[str, str]: @@ -19,6 +23,77 @@ def parse_order_id(order_id: str) -> tuple[str, str]: return prefix, "unknown" +def _decode_pending_email(user_id: str) -> str | None: + """从 pending_hex 格式解析邮箱""" + if not user_id.startswith("pending_"): + return None + hex_part = user_id[8:] # 去掉 "pending_" + try: + return bytes.fromhex(hex_part).decode("utf-8") + except Exception: + return None + + +def _create_user_by_email(email: str) -> str | None: + """用管理员创建用户,返回 user_id。若已存在则返回已有用户 id""" + token, _ = _get_admin_token() + if not token: + return None + base = (PB_URL or "").rstrip("/") + r = requests.post( + f"{base}/api/collections/users/records", + json={ + "email": email, + "password": DEFAULT_PASSWORD, + "passwordConfirm": DEFAULT_PASSWORD, + }, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}, + timeout=10, + ) + if r.status_code == 200: + return r.json().get("id") + if r.status_code == 400: + try: + err = r.json() + if "already" in str(err.get("data", {}).get("email", {}).get("message", "")).lower(): + # 用户已存在,查询获取 id + get_r = requests.get( + f"{base}/api/collections/users/records", + params={"filter": f'email="{email}"', "perPage": 1}, + headers={"Authorization": f"Bearer {token}"}, + timeout=10, + ) + if get_r.status_code == 200: + items = get_r.json().get("items", []) + if items: + return items[0].get("id") + except Exception: + pass + logging.warning(f"create user failed: {r.status_code} {r.text[:200]}") + return None + + +def _get_admin_token() -> tuple[str | None, str | None]: + """获取 PocketBase 管理员 token""" + if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD: + return None, "未配置" + base = (PB_URL or "").rstrip("/") + for path in ["/api/collections/_superusers/auth-with-password", "/api/admins/auth-with-password"]: + try: + r = requests.post( + f"{base}{path}", + json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, + timeout=10, + ) + if r.status_code == 200: + return r.json().get("token"), None + if r.status_code == 404: + continue + except Exception: + pass + return None, "认证失败" + + def handle_payment_success( order_id: str, pay_price: str, @@ -36,10 +111,22 @@ def handle_payment_success( if not user_id: return + # meetup 新用户:支付成功后才创建用户 + if pay_type == "meetup" and user_id.startswith("pending_"): + email = _decode_pending_email(user_id) + if email: + real_id = _create_user_by_email(email) + if real_id: + user_id = real_id + logging.info(f"meetup 支付成功,已创建用户: {email}") + else: + logging.error(f"meetup 支付成功但创建用户失败: {email}") + return + print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},站点: {site_id},订单: {order_id}") if pay_type == "vip" and use_memos: - create_memos_user(user_id) + create_memos_user(user_id, password="12345678") if pay_type == "meetup": logging.info(f"meetup 类型支付成功,不创建 memos 用户 - user_id: {user_id}") diff --git a/requirements.txt b/requirements.txt index dcec336..b5436da 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ fastapi uvicorn +python-dotenv sqlalchemy pydantic python-multipart diff --git a/run.py b/run.py index a2aa31a..b2e01ae 100755 --- a/run.py +++ b/run.py @@ -1,6 +1,11 @@ import uvicorn +from dotenv import load_dotenv + +load_dotenv() if __name__ == "__main__": - uvicorn.run("app.main:app", host="0.0.0.0", port=8700, reload=True) + import os + port = int(os.getenv("PORT", "8007")) + uvicorn.run("app.main:app", host="0.0.0.0", port=port, reload=True) # uvicorn.run("app.main:app", host="0.0.0.0", port=8200, reload=True)