503 lines
19 KiB
Python
503 lines
19 KiB
Python
"""
|
||
salon 加入流程:check-user、ensure-user、complete-order
|
||
克隆 meetup 逻辑,使用 site_id=salon
|
||
"""
|
||
import logging
|
||
import time
|
||
|
||
import requests
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel
|
||
|
||
from ..config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
||
|
||
SITE_ID = "salon"
|
||
router = APIRouter(prefix="/api/salon", tags=["salon"])
|
||
DEFAULT_PASSWORD = "12345678"
|
||
|
||
_SALON_ADMIN_TOKEN_CACHE: tuple[str, float] | None = None
|
||
_TOKEN_CACHE_TTL = 300
|
||
|
||
|
||
def _get_admin_token() -> tuple[str | None, str | None]:
|
||
global _SALON_ADMIN_TOKEN_CACHE
|
||
now = time.time()
|
||
if _SALON_ADMIN_TOKEN_CACHE and _SALON_ADMIN_TOKEN_CACHE[1] > now:
|
||
return _SALON_ADMIN_TOKEN_CACHE[0], None
|
||
|
||
if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
|
||
return None, "PB_ADMIN_EMAIL 或 PB_ADMIN_PASSWORD 未配置"
|
||
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=4,
|
||
)
|
||
if r.status_code == 200:
|
||
data = r.json()
|
||
token = data.get("token")
|
||
if token:
|
||
_SALON_ADMIN_TOKEN_CACHE = (token, now + _TOKEN_CACHE_TTL)
|
||
return 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, token: str | None = None) -> bool:
|
||
if not token:
|
||
token, _ = _get_admin_token()
|
||
if not token:
|
||
return False
|
||
try:
|
||
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=4,
|
||
)
|
||
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):
|
||
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=4,
|
||
)
|
||
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, token) 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"salon check-user error: {e}")
|
||
raise HTTPException(status_code=500, detail="操作失败")
|
||
|
||
|
||
@router.post("/ensure-user")
|
||
async def ensure_user(item: EnsureUserRequest):
|
||
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,
|
||
"live_allowed": True,
|
||
},
|
||
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"salon ensure-user create failed: status={create_r.status_code}, msg={msg}")
|
||
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"salon 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:
|
||
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"salon complete-order zpay query failed: {e}")
|
||
return False
|
||
|
||
|
||
@router.post("/complete-order")
|
||
async def complete_order(item: dict):
|
||
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 != "salon":
|
||
raise HTTPException(status_code=400, detail="订单格式无效")
|
||
|
||
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}" && site_id = "{SITE_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:
|
||
paid_via = None
|
||
pay_price = ""
|
||
if _query_zpay_paid(order_id):
|
||
paid_via = "zpay"
|
||
try:
|
||
from ..payment.zpay import ZPayProvider
|
||
from ..payment import get_payment_provider
|
||
p = get_payment_provider()
|
||
if isinstance(p, ZPayProvider):
|
||
r = p.query_order_status(order_id, timeout=8)
|
||
if r.get("paid"):
|
||
pay_price = str(r.get("pay_price", "") or r.get("money", ""))
|
||
except Exception:
|
||
pass
|
||
if not paid_via:
|
||
try:
|
||
from ..payment.xorpay import XorPayProvider
|
||
from ..payment import get_payment_provider
|
||
p = get_payment_provider()
|
||
if isinstance(p, XorPayProvider):
|
||
r = p.query_order_status(order_id, timeout=8)
|
||
if r.get("paid"):
|
||
paid_via = "xorpay"
|
||
pay_price = str(r.get("pay_price", ""))
|
||
except Exception:
|
||
pass
|
||
if paid_via:
|
||
from ..services.payment import handle_payment_success
|
||
# pay_price 为空时用默认茶歇费 1 元
|
||
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}")
|
||
if paid_via:
|
||
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="订单不存在或未支付成功")
|
||
|
||
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,
|
||
}
|
||
|
||
return {"ok": True, "token": None, "record": None, "is_new": False}
|
||
|
||
|
||
@router.get("/vip/check")
|
||
async def vip_check(user_id: str = ""):
|
||
"""检查用户是否为 salon VIP"""
|
||
if not user_id:
|
||
return {"vip": False}
|
||
token, _ = _get_admin_token()
|
||
return {"vip": _check_site_vip(user_id, token)}
|
||
|
||
|
||
@router.get("/join/by-wechat")
|
||
async def join_by_wechat(wechat_id: str = ""):
|
||
"""按微信号查询 solanRed 记录,供 salon 前端提交后刷新状态"""
|
||
if not wechat_id or not wechat_id.strip():
|
||
return {"hasRecord": False, "record": None, "isComplete": False, "isWechatFriend": False, "isApproved": False, "isRejected": False, "vip": False}
|
||
try:
|
||
from ..services import get_pb_client
|
||
pb_client = get_pb_client()
|
||
solan = pb_client.collection("solanRed")
|
||
safe_wechat = wechat_id.strip().replace("\\", "\\\\").replace('"', '\\"')
|
||
existing = solan.get_list(1, 1, {"filter": f'wechatId = "{safe_wechat}"', "sort": "-created"})
|
||
if not existing.items:
|
||
return {"hasRecord": False, "record": None, "isComplete": False, "isWechatFriend": False, "isApproved": False, "isRejected": False, "vip": False}
|
||
rec = existing.items[0]
|
||
amount = rec.get("amount") or 0
|
||
is_complete = bool(rec.get("order_id")) and (amount or 0) > 0
|
||
return {
|
||
"hasRecord": True,
|
||
"record": rec,
|
||
"isComplete": is_complete,
|
||
"isWechatFriend": bool(rec.get("is_wechat_friend")),
|
||
"isApproved": bool(rec.get("is_approved")),
|
||
"isRejected": bool(rec.get("is_rejected")),
|
||
"vip": bool(rec.get("vip")),
|
||
}
|
||
except Exception as e:
|
||
logging.warning("salon join/by-wechat error: %s", e)
|
||
return {"hasRecord": False, "record": None, "isComplete": False, "isWechatFriend": False, "isApproved": False, "isRejected": False, "vip": False}
|
||
|
||
|
||
@router.get("/join/status")
|
||
async def join_status(user_id: str = ""):
|
||
"""检查用户是否已提交 salon 加入申请"""
|
||
if not user_id:
|
||
return {"hasRecord": False, "isComplete": False}
|
||
try:
|
||
from ..services import get_pb_client
|
||
pb_client = get_pb_client()
|
||
solan = pb_client.collection("solanRed")
|
||
existing = solan.get_list(1, 1, {"filter": f'user_id = "{user_id}"', "sort": "-created"})
|
||
has_record = len(existing.items) > 0
|
||
rec = existing.items[0] if existing.items else None
|
||
is_complete = bool(rec and rec.get("order_id") and rec.get("amount", 0) > 0)
|
||
return {"hasRecord": has_record, "isComplete": is_complete}
|
||
except Exception:
|
||
return {"hasRecord": False, "isComplete": False}
|
||
|
||
|
||
def _validate_xiaohongshu_url(url: str) -> dict | None:
|
||
"""校验小红书主页 URL 并尝试爬取用户名/头像/粉丝数/性别。返回 None 表示校验失败。"""
|
||
url = (url or "").strip()
|
||
if not url:
|
||
return None
|
||
if "xiaohongshu.com" not in url.lower():
|
||
return None
|
||
if not url.startswith("http://") and not url.startswith("https://"):
|
||
return None
|
||
try:
|
||
r = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"})
|
||
if r.status_code != 200:
|
||
return None
|
||
html = r.text
|
||
nickname = ""
|
||
avatar = ""
|
||
followers = 0
|
||
gender = ""
|
||
import re
|
||
if '"nickname"' in html or '"user"' in html:
|
||
m = re.search(r'"nickname"\s*:\s*"([^"]+)"', html)
|
||
if m:
|
||
nickname = m.group(1)
|
||
m = re.search(r'"avatar"\s*:\s*"([^"]+)"', html)
|
||
if m:
|
||
avatar = m.group(1).replace("\\u002F", "/")
|
||
m = re.search(r'"follows"\s*:\s*(\d+)', html)
|
||
if m:
|
||
followers = int(m.group(1))
|
||
if not nickname and '"desc"' in html:
|
||
m = re.search(r'"desc"\s*:\s*"([^"]+)"', html)
|
||
if m:
|
||
nickname = m.group(1)[:20]
|
||
return {"nickname": nickname or "用户", "avatar": avatar, "followers": followers, "gender": gender, "url": url}
|
||
except Exception as e:
|
||
logging.warning(f"xiaohongshu crawl failed: {e}")
|
||
return {"nickname": "用户", "avatar": "", "followers": 0, "gender": "", "url": url}
|
||
|
||
|
||
@router.post("/join")
|
||
async def submit_join(item: dict):
|
||
"""salon 加入社区报名表单,写入 solanRed 集合。需提供 xiaohongshu_url,提交前校验并爬取信息。"""
|
||
user_id = item.get("user_id")
|
||
if not user_id:
|
||
raise HTTPException(status_code=400, detail="missing user_id")
|
||
|
||
xiaohongshu_url = (item.get("xiaohongshu_url") or "").strip()
|
||
if not xiaohongshu_url:
|
||
raise HTTPException(status_code=400, detail="请填写小红书主页地址")
|
||
|
||
profile = _validate_xiaohongshu_url(xiaohongshu_url)
|
||
if not profile:
|
||
raise HTTPException(status_code=400, detail="小红书主页地址无效或无法访问,请检查后重试")
|
||
|
||
try:
|
||
from ..services import get_pb_client
|
||
pb_client = get_pb_client()
|
||
solan = pb_client.collection("solanRed")
|
||
wechat_id = (item.get("wechatId") or "").strip()
|
||
if wechat_id:
|
||
safe_wechat = wechat_id.replace("\\", "\\\\").replace('"', '\\"')
|
||
existing = solan.get_list(1, 1, {"filter": f'wechatId = "{safe_wechat}"'})
|
||
if existing.items:
|
||
raise HTTPException(status_code=400, detail="该微信号已报名,请勿重复提交")
|
||
pb_data = {
|
||
"user_id": user_id,
|
||
"nickname": item.get("nickname", ""),
|
||
"occupation": item.get("occupation", ""),
|
||
"reason": item.get("reason", ""),
|
||
"wechatId": item.get("wechatId", ""),
|
||
"gender": profile.get("gender", "") or item.get("gender", ""),
|
||
"education": item.get("education", ""),
|
||
"gradYear": item.get("gradYear", ""),
|
||
"phone": item.get("phone", ""),
|
||
"single": item.get("single", ""),
|
||
"media": item.get("media", ""),
|
||
"calculated_age": item.get("calculated_age", 0),
|
||
"type": item.get("type", ""),
|
||
"order_id": "",
|
||
"amount": 0,
|
||
"xiaohongshu_url": xiaohongshu_url,
|
||
"xiaohongshu_nickname": profile.get("nickname", ""),
|
||
"xiaohongshu_avatar": profile.get("avatar", ""),
|
||
"xiaohongshu_followers": profile.get("followers", 0),
|
||
}
|
||
record = solan.create(pb_data)
|
||
logging.info(f"salon 申请表单提交成功 - user_id: {user_id}, record_id: {record.id}")
|
||
return {"ok": True, "user_id": user_id, "record_id": record.id}
|
||
except Exception as e:
|
||
logging.error(f"salon join 提交失败: {e}")
|
||
raise HTTPException(status_code=500, detail="submit failed")
|