This commit is contained in:
root
2026-06-07 18:06:52 +08:00
parent 9de2465793
commit a1daa040ef
33 changed files with 1917 additions and 439 deletions

View File

@@ -333,6 +333,13 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = {
number("durationMinutes", only_int=True),
number("sortOrder", only_int=True),
text("status", max=40),
text("authorName", max=120),
text("authorEmail", max=255),
text("submittedBy", max=80),
text("reviewStatus", max=40),
text("reviewNote", max=1000),
date("reviewedAt"),
json_field("submissionMeta"),
],
"city_details": [
text("citySlug", required=True, max=120),
@@ -359,8 +366,26 @@ async def collection_exists(name: str) -> bool:
return False
async def ensure_collection_fields(name: str, fields: list[dict[str, Any]]) -> None:
collection = await pb.request("GET", f"/api/collections/{name}", admin=True)
field_key = "fields" if "fields" in collection else "schema"
current_fields = collection.get(field_key) or []
current_names = {field.get("name") for field in current_fields}
missing_fields = [field for field in fields if field.get("name") not in current_names]
if not missing_fields:
return
await pb.request(
"PATCH",
f"/api/collections/{collection.get('id') or name}",
admin=True,
json={field_key: [*current_fields, *missing_fields]},
)
print(f"collection updated: {name} +{len(missing_fields)} fields")
async def create_collection(name: str, fields: list[dict[str, Any]]) -> None:
if await collection_exists(name):
await ensure_collection_fields(name, fields)
print(f"collection exists: {name}")
return
payload = {

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
import asyncio
import io
import re
import secrets
import uuid
import zipfile
from datetime import datetime, timedelta, timezone
from html import escape
from pathlib import Path
@@ -119,6 +122,19 @@ class ServiceLeadPayload(BaseModel):
note: str = ""
class ContentSubmissionPayload(BaseModel):
type: str
title: str
subtitle: str = ""
description: str = ""
coverImage: str = ""
mediaUrl: str = ""
authorName: str = ""
authorEmail: str = ""
citySlugs: list[str] = Field(default_factory=list)
chapters: list[dict[str, Any]] = Field(default_factory=list)
class NotificationCreatePayload(BaseModel):
title: str
body: str = ""
@@ -324,6 +340,52 @@ def utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000Z")
def clean_submission_text(value: Any, limit: int, *, multiline: bool = False) -> str:
text = str(value or "").strip()
if not multiline:
text = re.sub(r"\s+", " ", text)
else:
text = "\n".join(line.strip() for line in text.splitlines()).strip()
return text[:limit]
def slugify_content_title(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug[:70].strip("-") or "submission"
def validate_submission_url(value: str, field: str) -> str:
url = str(value or "").strip()
if not url:
raise HTTPException(status_code=400, detail=f"缺少{field}")
if len(url) > 500:
raise HTTPException(status_code=400, detail=f"{field}链接过长")
if not (url.startswith("https://") or url.startswith("http://") or url.startswith("/")):
raise HTTPException(status_code=400, detail=f"{field}必须是有效链接")
return url
def normalize_submission_chapters(chapters: list[dict[str, Any]], content_type: str) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for chapter in chapters[:30]:
title = clean_submission_text(chapter.get("title", ""), 160)
if not title:
continue
item: dict[str, Any] = {"title": title}
if content_type == "video":
time = clean_submission_text(chapter.get("time", "") or chapter.get("minutes", ""), 20)
if time:
item["time"] = time
else:
try:
minutes = int(chapter.get("minutes") or 8)
except Exception:
minutes = 8
item["minutes"] = max(1, min(minutes, 999))
normalized.append(item)
return normalized
def parse_order_id(order_id: str) -> tuple[str, str]:
if "_order_" not in order_id:
return "", "unknown"
@@ -845,13 +907,28 @@ async def join_status(request: Request) -> dict[str, Any]:
@app.post("/api/upload-media")
async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[str, Any]:
if file.size and file.size > 20 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过 20MB")
purpose = (request.query_params.get("purpose") or "").lower()
is_content_upload = purpose in {"content", "ebook", "video", "submission"}
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
content = await file.read()
target.write_bytes(content)
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}"
user_id = ""
_, user = await current_user(request)
@@ -863,7 +940,7 @@ async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[s
"url": url,
"filename": file.filename or name,
"contentType": file.content_type or "application/octet-stream",
"size": len(content),
"size": total,
},
)
return {"ok": True, "url": url}
@@ -1041,7 +1118,7 @@ async def city_detail(slug: str) -> dict[str, Any]:
detail = await pb.first_record("city_details", filter=f'citySlug={pb_quote(slug)}')
content = await pb.list_records(
"content_items",
filter=f'citySlugs ?~ {pb_quote(slug)}',
filter=f'citySlugs ?~ {pb_quote(slug)} && status="published"',
per_page=20,
sort="sortOrder",
)
@@ -1063,11 +1140,102 @@ async def home_content() -> dict[str, Any]:
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
@app.post("/api/content/submissions")
async def submit_content(payload: ContentSubmissionPayload, request: Request) -> dict[str, Any]:
content_type = clean_submission_text(payload.type, 20).lower()
if content_type not in {"ebook", "video"}:
raise HTTPException(status_code=400, detail="只支持电子书或访谈视频投稿")
title = clean_submission_text(payload.title, 255)
subtitle = clean_submission_text(payload.subtitle, 500) or ("社区电子书投稿" if content_type == "ebook" else "社区访谈视频投稿")
description = clean_submission_text(payload.description, 2000, multiline=True)
if not title:
raise HTTPException(status_code=400, detail="缺少标题")
if not description:
raise HTTPException(status_code=400, detail="缺少内容简介")
cover_image = validate_submission_url(payload.coverImage, "封面图")
media_url = validate_submission_url(payload.mediaUrl, "内容文件")
_, user = await current_user(request)
author_name = clean_submission_text(payload.authorName or (user or {}).get("name") or "", 120)
author_email = clean_submission_text(payload.authorEmail or (user or {}).get("email") or "", 255).lower()
if "@" not in author_email or "." not in author_email.split("@")[-1]:
raise HTTPException(status_code=400, detail="请填写有效联系邮箱,便于审核沟通")
slug = f"ugc-{content_type}-{slugify_content_title(title)}-{uuid.uuid4().hex[:8]}"
target_url = f"/ebooks/{slug}" if content_type == "ebook" else f"/videos/{slug}"
cta_label = "查看电子书" if content_type == "ebook" else "观看访谈"
chapters = normalize_submission_chapters(payload.chapters, content_type)
city_slugs = [clean_submission_text(x, 80) for x in payload.citySlugs[:12] if clean_submission_text(x, 80)]
submitted_by = str((user or {}).get("id") or "")
submitted_at = utc_now()
base_record: dict[str, Any] = {
"slug": slug,
"type": content_type,
"title": title,
"titleEn": "",
"subtitle": subtitle,
"subtitleEn": "",
"description": description,
"descriptionEn": "",
"coverImage": cover_image,
"mediaUrl": media_url,
"targetUrl": target_url,
"ctaLabel": cta_label,
"ctaLabelEn": "View ebook" if content_type == "ebook" else "Watch interview",
"citySlugs": city_slugs,
"relatedProfiles": [submitted_by] if submitted_by else [],
"chapters": chapters,
"durationMinutes": 0,
"sortOrder": 1000,
"status": "pending",
}
record_payload = {
**base_record,
"authorName": author_name,
"authorEmail": author_email,
"submittedBy": submitted_by,
"reviewStatus": "pending",
"reviewNote": "",
"submissionMeta": {
"source": "ugc",
"submittedAt": submitted_at,
"ip": client_ip_from_request(request),
"userAgent": request.headers.get("user-agent", "")[:300],
},
}
try:
record = await pb.create_record("content_items", record_payload)
except PocketBaseError:
record = await pb.create_record("content_items", base_record)
await create_notification(
title="新的社区内容待审核",
body=f"{author_name or author_email} 提交了《{title}》,类型:{'电子书' if content_type == 'ebook' else '访谈视频'}",
category="system",
priority="high",
audience="review",
action_label="查看消息",
action_url="/messages",
entity_type="content_submission",
entity_id=record.get("id", ""),
icon="📝",
metadata={"slug": slug, "type": content_type, "status": "pending"},
)
return {"ok": True, "status": "pending", "record": public_record(record)}
@app.get("/api/content/{slug}")
async def content_detail(slug: str) -> dict[str, Any]:
async def content_detail(slug: str, request: Request) -> dict[str, Any]:
item = await pb.first_record("content_items", filter=f'slug={pb_quote(slug)}')
if not item:
raise HTTPException(status_code=404, detail="内容不存在")
status = str(item.get("status") or "published")
if status != "published":
_, user = await current_user(request)
submitted_by = str(item.get("submittedBy") or "")
if not (user and submitted_by and user.get("id") == submitted_by):
raise HTTPException(status_code=404, detail="内容不存在")
cities: list[dict[str, Any]] = []
for city_slug in item.get("citySlugs") or []:
city = await pb.first_record("cities", filter=f'slug={pb_quote(city_slug)}')
@@ -1385,7 +1553,46 @@ async def download_asset(filename: str) -> FastAPIResponse:
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
if filename == "nomadcna-app-demo.zip":
body = b"NomadCNA App demo package. Replace with real installer artifact later.\n"
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr(
"README.txt",
"\n".join(
[
"Nomadro Web App Package",
"",
"Open Nomadro-Web-App.html in a browser or visit https://nomadro.cn/zh/digital.",
"The web app can be installed from Chrome, Edge, Safari, and other modern browsers.",
"",
]
),
)
archive.writestr(
"Nomadro-Web-App.html",
"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="0; url=https://nomadro.cn/zh/digital" />
<title>Nomadro Web App</title>
<style>
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#fafafa;color:#111827}
main{max-width:560px;padding:32px;text-align:center}
a{display:inline-flex;margin-top:16px;border-radius:999px;background:#ff4d4f;color:#fff;padding:12px 20px;text-decoration:none;font-weight:700}
</style>
</head>
<body>
<main>
<h1>Nomadro Web App</h1>
<p>正在打开数字游民指南。如果没有自动跳转,请点击下方按钮。</p>
<a href="https://nomadro.cn/zh/digital">打开 Nomadro</a>
</main>
</body>
</html>
""",
)
body = buffer.getvalue()
return FastAPIResponse(
body,
media_type="application/zip",

View File

@@ -168,7 +168,7 @@ CONTENT_ITEMS = [
"description": "这本电子书把城市决策拆成 7 个步骤:预算、气候、网络、工作空间、社群、住宿和长期身份。每一章都关联本平台真实城市数据,后续可根据你的收藏城市自动生成个性化版本。",
"descriptionEn": "A seven-step playbook connected to live city data: budget, climate, internet, workspace, community, housing, and long-stay planning.",
"coverImage": "https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=1200",
"mediaUrl": "http://127.0.0.1:8000/api/downloads/nomad-china-playbook.pdf",
"mediaUrl": "/api/downloads/nomad-china-playbook.pdf",
"targetUrl": "/ebooks/nomad-china-playbook",
"ctaLabel": "查看电子书",
"ctaLabelEn": "View ebook",
@@ -218,7 +218,7 @@ CONTENT_ITEMS = [
"description": "App 落地页提供 Windows/Android/iOS 下载入口。本地 MVP 先提供模拟下载和功能预览,后续可接真实安装包、版本更新和用户设备同步。",
"descriptionEn": "The app landing page provides downloads and feature previews.",
"coverImage": "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=1200",
"mediaUrl": "http://127.0.0.1:8000/api/downloads/nomadcna-app-demo.zip",
"mediaUrl": "/api/downloads/nomadcna-app-demo.zip",
"targetUrl": "/app",
"ctaLabel": "下载 App",
"ctaLabelEn": "Download app",