This commit is contained in:
root
2026-06-07 23:18:53 +08:00
parent ebd3d6a317
commit 934b9a4a23
36 changed files with 2272 additions and 296 deletions

View File

@@ -46,6 +46,15 @@ ENHANCED_MEETUPS: list[dict[str, Any]] = [
],
"isUpcoming": idx % 5 != 0,
"status": "published",
"mode": "online" if idx % 6 == 0 else "hybrid" if idx % 4 == 0 else "offline",
"maxAttendees": 80 if idx % 6 == 0 else 36 if idx % 4 == 0 else 24,
"organizer": f"{city['name']}城市主理人",
"accessLevel": "vip" if idx % 6 == 0 else "members" if idx % 4 == 0 else "public",
"meetingProvider": "mirotalk" if idx % 4 == 0 or idx % 6 == 0 else "",
"mirotalkRoom": f"nomadcna-{city['slug']}-remote-roundtable" if idx % 4 == 0 or idx % 6 == 0 else "",
"meetingUrl": "",
"replayUrl": "",
"features": ["group_video", "screen_share", "whiteboard", "host_controls"] if idx % 4 == 0 or idx % 6 == 0 else [],
}
for idx, city in enumerate(CITIES, start=1)
]

View File

@@ -188,6 +188,7 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = {
text("filename", max=255),
text("contentType", max=120),
number("size", only_int=True),
text("objectKey", max=500),
],
"meetups": [
text("city", required=True, max=120),
@@ -203,6 +204,15 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = {
json_field("attendees"),
boolean("isUpcoming"),
text("status", max=40),
text("mode", max=40),
number("maxAttendees", only_int=True),
text("organizer", max=120),
text("accessLevel", max=40),
text("meetingProvider", max=80),
text("mirotalkRoom", max=160),
text("meetingUrl", max=500),
text("replayUrl", max=500),
json_field("features"),
],
"discussions": [
text("title", required=True, max=255),

View File

@@ -14,11 +14,12 @@ from typing import Any
import httpx
from fastapi import FastAPI, File, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import Response as FastAPIResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from .minio_storage import upload_bytes_to_minio, upload_file_to_minio
from .payment import XorPayProvider, ZPayProvider, provider_for
from .pb_client import PocketBaseError, pb, pb_quote
from .settings import get_settings
@@ -229,7 +230,13 @@ async def find_membership(user_id: str) -> dict[str, Any] | None:
def internal_auth_password() -> str:
password = settings.email_auth_password.strip()
return password if len(password) >= 8 else "NomadCNAEmailLogin2026!"
return password if len(password) >= 8 else "12345678"
def auth_password_for_email(email: str) -> str:
if email.lower().strip() == settings.pb_admin_email.lower().strip():
return settings.pb_admin_password
return internal_auth_password()
async def ensure_profile_for_user(
@@ -260,7 +267,7 @@ async def login_with_internal_password(email: str) -> dict[str, Any]:
return await pb.request(
"POST",
"/api/collections/users/auth-with-password",
json={"identity": email, "password": internal_auth_password()},
json={"identity": email, "password": auth_password_for_email(email)},
)
@@ -276,8 +283,8 @@ async def ensure_user(
existing = await find_user_by_email(normalized)
if existing:
update_payload: dict[str, Any] = {
"password": internal_auth_password(),
"passwordConfirm": internal_auth_password(),
"password": auth_password_for_email(normalized),
"passwordConfirm": auth_password_for_email(normalized),
}
if name and not existing.get("name"):
update_payload["name"] = name
@@ -295,8 +302,8 @@ async def ensure_user(
"email": normalized,
"emailVisibility": True,
"verified": True,
"password": internal_auth_password(),
"passwordConfirm": internal_auth_password(),
"password": auth_password_for_email(normalized),
"passwordConfirm": auth_password_for_email(normalized),
"name": name or normalized.split("@")[0],
},
)
@@ -912,24 +919,7 @@ async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[s
max_size = 200 * 1024 * 1024 if is_content_upload else 20 * 1024 * 1024
if file.size and file.size > max_size:
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_size // 1024 // 1024}MB")
suffix = Path(file.filename or "upload.bin").suffix
name = f"{uuid.uuid4().hex}{suffix}"
target = settings.upload_dir / name
total = 0
try:
with target.open("wb") as out:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > max_size:
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_size // 1024 // 1024}MB")
out.write(chunk)
except Exception:
target.unlink(missing_ok=True)
raise
url = f"{settings.upload_public_base_url}/uploads/{name}"
upload = await upload_file_to_minio(file, purpose=purpose or "uploads", max_size=max_size)
user_id = ""
_, user = await current_user(request)
user_id = user["id"] if user else ""
@@ -937,13 +927,14 @@ async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[s
"media_assets",
{
"userId": user_id,
"url": url,
"filename": file.filename or name,
"url": upload.url,
"filename": file.filename or upload.object_key,
"contentType": file.content_type or "application/octet-stream",
"size": total,
"size": upload.size,
"objectKey": upload.object_key,
},
)
return {"ok": True, "url": url}
return {"ok": True, "url": upload.url, "objectKey": upload.object_key}
@app.post("/api/pay", response_model=None)
@@ -1535,6 +1526,11 @@ async def create_service_lead(payload: ServiceLeadPayload, request: Request) ->
@app.get("/api/downloads/{filename}")
async def download_asset(filename: str) -> FastAPIResponse:
async def redirect_to_minio(body: bytes, content_type: str) -> RedirectResponse:
object_key = f"{settings.minio_upload_prefix}/downloads/{filename}"
asset = await upload_bytes_to_minio(body, object_key=object_key, content_type=content_type)
return RedirectResponse(asset.url, status_code=307)
if filename == "nomad-china-playbook.pdf":
body = (
"%PDF-1.4\n"
@@ -1547,11 +1543,7 @@ async def download_asset(filename: str) -> FastAPIResponse:
"xref\n0 5\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n0000000204 00000 n \n"
"trailer << /Root 1 0 R /Size 5 >>\nstartxref\n322\n%%EOF\n"
).encode("latin-1")
return FastAPIResponse(
body,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
return await redirect_to_minio(body, "application/pdf")
if filename == "nomadcna-app-demo.zip":
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
@@ -1593,11 +1585,7 @@ async def download_asset(filename: str) -> FastAPIResponse:
""",
)
body = buffer.getvalue()
return FastAPIResponse(
body,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
return await redirect_to_minio(body, "application/zip")
raise HTTPException(status_code=404, detail="下载文件不存在")
@@ -1611,9 +1599,25 @@ async def list_meetups() -> dict[str, Any]:
async def create_meetup(payload: dict[str, Any]) -> dict[str, Any]:
if payload.get("date") and "T" not in str(payload["date"]):
payload["date"] = f"{str(payload['date'])[:10]} 00:00:00.000Z"
mode = str(payload.get("mode") or "offline").lower()
if mode not in {"offline", "online", "hybrid"}:
mode = "offline"
access_level = str(payload.get("accessLevel") or "public").lower()
if access_level not in {"public", "members", "vip", "hosts"}:
access_level = "public"
payload["mode"] = mode
payload["accessLevel"] = access_level
payload.setdefault("status", "pending")
payload.setdefault("rsvpCount", 0)
payload.setdefault("attendees", [])
payload.setdefault("maxAttendees", 0)
payload.setdefault("organizer", "NomadCNA")
payload.setdefault("meetingProvider", "mirotalk" if mode != "offline" else "")
payload.setdefault("mirotalkRoom", "")
payload.setdefault("meetingUrl", "")
payload.setdefault("replayUrl", "")
if not isinstance(payload.get("features"), list):
payload["features"] = []
record = await pb.create_record("meetups", payload)
await create_notification(
title=f"新活动已提交:{payload.get('city', '同城')} · {payload.get('venue', '活动地点')}",

133
backend/minio_storage.py Normal file
View File

@@ -0,0 +1,133 @@
from __future__ import annotations
import asyncio
import io
import json
import tempfile
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from fastapi import HTTPException, UploadFile
from minio import Minio
from .settings import get_settings
@dataclass
class MinioUploadResult:
url: str
object_key: str
size: int
def _client() -> Minio:
settings = get_settings()
endpoint = settings.minio_endpoint
if settings.minio_port and ":" not in endpoint:
endpoint = f"{endpoint}:{settings.minio_port}"
return Minio(
endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_use_ssl,
region=settings.minio_region,
)
def _object_key(filename: str, purpose: str) -> str:
settings = get_settings()
suffix = Path(filename or "upload.bin").suffix.lower() or ".bin"
day = datetime.now(timezone.utc).strftime("%Y/%m/%d")
prefix = settings.minio_upload_prefix or "uploads"
purpose_part = (purpose or "uploads").replace("/", "-")[:40]
return f"{prefix}/{purpose_part}/{day}/{uuid.uuid4().hex}{suffix}"
async def _ensure_bucket(client: Minio, bucket: str) -> None:
exists = await asyncio.to_thread(client.bucket_exists, bucket)
if not exists:
await asyncio.to_thread(client.make_bucket, bucket)
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": [f"arn:aws:s3:::{bucket}/*"],
}
],
}
await asyncio.to_thread(client.set_bucket_policy, bucket, json.dumps(policy))
async def upload_file_to_minio(
file: UploadFile,
*,
purpose: str,
max_size: int,
) -> MinioUploadResult:
settings = get_settings()
if not settings.minio_enabled:
raise HTTPException(status_code=503, detail="MinIO 存储未启用")
total = 0
with tempfile.SpooledTemporaryFile(max_size=8 * 1024 * 1024) as tmp:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > max_size:
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_size // 1024 // 1024}MB")
tmp.write(chunk)
tmp.seek(0)
client = _client()
object_key = _object_key(file.filename or "upload.bin", purpose)
await _ensure_bucket(client, settings.minio_bucket)
await asyncio.to_thread(
client.put_object,
settings.minio_bucket,
object_key,
tmp,
total,
content_type=file.content_type or "application/octet-stream",
metadata={"Cache-Control": "public, max-age=31536000, immutable"},
)
return MinioUploadResult(
url=f"{settings.minio_public_url}/{object_key}",
object_key=object_key,
size=total,
)
async def upload_bytes_to_minio(
data: bytes,
*,
object_key: str,
content_type: str,
) -> MinioUploadResult:
settings = get_settings()
if not settings.minio_enabled:
raise HTTPException(status_code=503, detail="MinIO 存储未启用")
client = _client()
await _ensure_bucket(client, settings.minio_bucket)
await asyncio.to_thread(
client.put_object,
settings.minio_bucket,
object_key,
io.BytesIO(data),
len(data),
content_type=content_type,
metadata={"Cache-Control": "public, max-age=31536000, immutable"},
)
return MinioUploadResult(
url=f"{settings.minio_public_url}/{object_key}",
object_key=object_key,
size=len(data),
)

View File

@@ -5,3 +5,4 @@ python-multipart>=0.0.9
PyJWT>=2.8
passlib[bcrypt]>=1.7
pydantic>=2.7
minio>=7.2

View File

@@ -129,10 +129,11 @@ CITIES = [
MEETUPS = [
{"city": "大理", "emoji": "🏯", "date": "2026-07-15", "time": "14:00", "venue": "大理古城人民路咖啡馆", "address": "大理市大理古城人民路中段", "description": "苍山脚下,洱海边,与大理的慢生活游民们一起喝咖啡、晒太阳、聊人生", "rsvpCount": 12, "gradientFrom": "#3b82f6", "gradientTo": "#4f46e5", "attendees": [{"name": "林晓", "photo": "https://i.pravatar.cc/150?img=1"}, {"name": "张明", "photo": "https://i.pravatar.cc/150?img=2"}], "isUpcoming": True},
{"city": "成都", "emoji": "🐼", "date": "2026-07-18", "time": "19:00", "venue": "方所书店", "address": "成都市锦江区中纱帽街8号远洋太古里", "description": "天府之国游民聚会,在文艺书店里品茶聊天,探讨成都的慢生活与远程工作可能性", "rsvpCount": 9, "gradientFrom": "#22c55e", "gradientTo": "#10b981", "attendees": [{"name": "杨帆", "photo": "https://i.pravatar.cc/150?img=6"}], "isUpcoming": True},
{"city": "深圳", "emoji": "🌃", "date": "2026-07-20", "time": "19:30", "venue": "WeWork 卓越前海壹号", "address": "深圳市南山区前海深港合作区卓越前海壹号", "description": "深圳数字游民线下见面会,探讨创业、远程工作与湾区发展机遇", "rsvpCount": 15, "gradientFrom": "#ff4d4f", "gradientTo": "#ff7a45", "attendees": [{"name": "李娜", "photo": "https://i.pravatar.cc/150?img=10"}], "isUpcoming": True},
{"city": "杭州", "emoji": "", "date": "2026-05-25", "time": "18:00", "venue": "梦想小镇·良仓咖啡", "address": "杭州市余杭区梦想小镇互联网村", "description": "西湖畔数字游民聚会,交流产品与运营经验", "rsvpCount": 11, "gradientFrom": "#06b6d4", "gradientTo": "#0d9488", "attendees": [], "isUpcoming": False},
{"city": "线上", "emoji": "💻", "date": "2026-07-09", "time": "20:00", "venue": "MiroTalk 多人视频房", "address": "线上活动", "description": "围绕远程工作的节奏、异地协作和旅居规划做一场开放圆桌", "rsvpCount": 36, "gradientFrom": "#0f766e", "gradientTo": "#2563eb", "attendees": [{"name": "林晓", "photo": "https://i.pravatar.cc/150?img=1"}, {"name": "张明", "photo": "https://i.pravatar.cc/150?img=2"}], "isUpcoming": True, "mode": "online", "maxAttendees": 80, "organizer": "NomadCNA", "accessLevel": "members", "meetingProvider": "mirotalk", "mirotalkRoom": "nomadcna-remote-work-open-office", "meetingUrl": "", "replayUrl": "", "features": ["group_video", "screen_share", "whiteboard", "host_controls"]},
{"city": "大理", "emoji": "🏯", "date": "2026-07-15", "time": "14:00", "venue": "大理古城人民路咖啡馆", "address": "大理市大理古城人民路中段", "description": "苍山脚下,洱海边,与大理的慢生活游民们一起喝咖啡、晒太阳、聊人生", "rsvpCount": 12, "gradientFrom": "#3b82f6", "gradientTo": "#4f46e5", "attendees": [{"name": "林晓", "photo": "https://i.pravatar.cc/150?img=3"}, {"name": "张明", "photo": "https://i.pravatar.cc/150?img=4"}], "isUpcoming": True, "mode": "hybrid", "maxAttendees": 32, "organizer": "大理城市主理人", "accessLevel": "vip", "meetingProvider": "mirotalk", "mirotalkRoom": "nomadcna-dali-slow-life-roundtable", "meetingUrl": "", "replayUrl": "", "features": ["group_video", "screen_share", "whiteboard", "recording", "host_controls"]},
{"city": "成都", "emoji": "🐼", "date": "2026-07-18", "time": "19:00", "venue": "方所书店", "address": "成都市锦江区中纱帽街8号远洋太古里", "description": "天府之国游民聚会,在文艺书店里品茶聊天,探讨成都的慢生活与远程工作可能性", "rsvpCount": 9, "gradientFrom": "#22c55e", "gradientTo": "#10b981", "attendees": [{"name": "杨帆", "photo": "https://i.pravatar.cc/150?img=6"}], "isUpcoming": True, "mode": "offline", "maxAttendees": 20, "organizer": "成都游民小组", "accessLevel": "public", "meetingProvider": "", "mirotalkRoom": "", "meetingUrl": "", "replayUrl": "", "features": []},
{"city": "深圳", "emoji": "🌃", "date": "2026-07-20", "time": "19:30", "venue": "WeWork 卓越前海壹号", "address": "深圳市南山区前海深港合作区卓越前海壹号", "description": "深圳数字游民线下见面会,探讨创业、远程工作与湾区发展机遇", "rsvpCount": 15, "gradientFrom": "#ff4d4f", "gradientTo": "#ff7a45", "attendees": [{"name": "李娜", "photo": "https://i.pravatar.cc/150?img=10"}], "isUpcoming": True, "mode": "hybrid", "maxAttendees": 48, "organizer": "深圳湾区主理人", "accessLevel": "members", "meetingProvider": "mirotalk", "mirotalkRoom": "nomadcna-shenzhen-maker-night", "meetingUrl": "", "replayUrl": "", "features": ["group_video", "screen_share", "breakout", "host_controls"]},
{"city": "杭州", "emoji": "", "date": "2026-05-25", "time": "18:00", "venue": "梦想小镇·良仓咖啡", "address": "杭州市余杭区梦想小镇互联网村", "description": "西湖畔数字游民聚会,交流产品与运营经验。", "rsvpCount": 11, "gradientFrom": "#06b6d4", "gradientTo": "#0d9488", "attendees": [], "isUpcoming": False, "mode": "offline", "maxAttendees": 24, "organizer": "杭州产品游民", "accessLevel": "public", "meetingProvider": "", "mirotalkRoom": "", "meetingUrl": "", "replayUrl": "", "features": []},
]

View File

@@ -60,12 +60,12 @@ class Settings:
self.pb_admin_email = (
os.getenv("PB_ADMIN_EMAIL")
or os.getenv("POCKETBASE_EMAIL")
or "admin@nomadcna.local"
or "xiaoshuang.eric@gmail.com"
)
self.pb_admin_password = (
os.getenv("PB_ADMIN_PASSWORD")
or os.getenv("POCKETBASE_PASSWORD")
or "NomadCNA123456"
or "Xiao4669805@"
)
self.cookie_name = os.getenv("API_COOKIE_NAME", "pb_session")
self.frontend_origins = _split_csv(
@@ -75,6 +75,19 @@ class Settings:
)
)
self.upload_dir = Path(os.getenv("UPLOAD_DIR", "backend/uploads"))
self.minio_enabled = _bool_env("MINIO_ENABLED", "true")
self.minio_endpoint = os.getenv("MINIO_ENDPOINT", "127.0.0.1:9100")
self.minio_port = int(os.getenv("MINIO_PORT", "0") or "0")
self.minio_use_ssl = _bool_env("MINIO_USE_SSL", "false")
self.minio_bucket = os.getenv("MINIO_BUCKET", "hackrobot")
self.minio_access_key = os.getenv("MINIO_ACCESS_KEY", "nomadro")
self.minio_secret_key = os.getenv("MINIO_SECRET_KEY", "Xiao4669805@")
self.minio_region = os.getenv("MINIO_REGION", "china")
self.minio_public_url = (
os.getenv("MINIO_PUBLIC_URL")
or f"https://minio.nomadro.cn/{self.minio_bucket}"
).rstrip("/")
self.minio_upload_prefix = os.getenv("MINIO_UPLOAD_PREFIX", "joins").strip("/").strip()
self.app_env = os.getenv("APP_ENV", "development").strip().lower()
self.dev_auto_pay = _bool_env("DEV_AUTO_PAY", "false")
self.site_id = os.getenv("SITE_ID") or os.getenv("NEXT_PUBLIC_SITE_ID") or "meetup"
@@ -93,7 +106,7 @@ class Settings:
or os.getenv("BASE_URL")
or self.base_url
).rstrip("/")
self.email_auth_password = os.getenv("EMAIL_AUTH_PASSWORD", "NomadCNAEmailLogin2026!")
self.email_auth_password = os.getenv("EMAIL_AUTH_PASSWORD", "12345678")
self.google_client_id = os.getenv("GOOGLE_CLIENT_ID") or os.getenv("NEXT_PUBLIC_GOOGLE_CLIENT_ID") or ""
self.payment_provider = os.getenv("PAYMENT_PROVIDER", "zpay").lower()
self.payment_default_amount = int(os.getenv("PAYMENT_DEFAULT_AMOUNT", "100") or "100")