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

@@ -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",