s
This commit is contained in:
@@ -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 = {
|
||||
|
||||
223
backend/main.py
223
backend/main.py
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user