feat: initial MVP - FastAPI backend + Vue3 frontend + docker-compose

- backend: FastAPI + SQLAlchemy 2.0(async) + asyncpg + Alembic
- 7 API routes: auth/me/articles/sources/bookmarks/subscriptions/admin
- models: User/Source/Article/Bookmark/Subscription/ApiToken
- services: RSS fetcher (feedparser) + Tencent TMT translator with quota + cache + local NLLB fallback
- workers: APScheduler + asyncio pipeline (fetch -> dedupe -> insert -> translate)
- seed scripts: create_user, seed_sources (5 RSS: Reuters/BBC/Al Jazeera/NHK/DW)
- frontend: Vue 3 + Vite + Naive UI + Pinia + vue-router
- pages: Login, Feed (24h), ArticleDetail, Sources, Bookmarks, AdminSources
- deploy: docker-compose (postgres/redis/api/worker/frontend/caddy)
- docs: README, DEPLOY, architecture, acceptance
This commit is contained in:
Mavis
2026-06-07 21:51:01 +08:00
commit 60b062daf2
81 changed files with 5540 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""命令行脚本集合。"""

View File

@@ -0,0 +1,56 @@
"""创建用户(默认 owner)。"""
from __future__ import annotations
import argparse
import asyncio
import sys
from getpass import getpass
from sqlalchemy import select
from app.core.security import hash_password
from app.database import AsyncSessionLocal
from app.models.user import User, UserRole
async def main(username: str, password: str, email: str | None, role: UserRole) -> int:
async with AsyncSessionLocal() as session:
exists = (await session.execute(select(User).where(User.username == username))).scalar_one_or_none()
if exists:
print(f"user '{username}' already exists (id={exists.id})", file=sys.stderr)
return 1
u = User(
username=username,
email=email,
password_hash=hash_password(password),
role=role,
is_active=True,
)
session.add(u)
await session.commit()
await session.refresh(u)
print(f"created user id={u.id} username={u.username} role={u.role.value}")
return 0
def cli() -> None:
p = argparse.ArgumentParser()
p.add_argument("--username", required=True)
p.add_argument("--password", default=None, help="缺省则交互输入")
p.add_argument("--email", default=None)
p.add_argument("--role", choices=["owner", "member"], default="member")
args = p.parse_args()
password = args.password
if not password:
pw1 = getpass("password: ")
pw2 = getpass("password (again): ")
if pw1 != pw2 or len(pw1) < 6:
print("passwords differ or too short", file=sys.stderr)
sys.exit(2)
password = pw1
rc = asyncio.run(main(args.username, password, args.email, UserRole(args.role)))
sys.exit(rc)
if __name__ == "__main__":
cli()

View File

@@ -0,0 +1,114 @@
"""种子:导入 MVP 5 源。
- Reuters World
- BBC World
- Al Jazeera
- NHK World
- DW
RSS 链接为公开 feed,实际链接可能变更;若 fetch 失败,先看 /admin/health。
"""
from __future__ import annotations
import asyncio
import sys
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from app.database import AsyncSessionLocal
from app.models.source import Source, SourceKind
SEEDS = [
{
"name": "Reuters World",
"slug": "reuters-world",
"kind": SourceKind.RSS,
"url": "https://feeds.reuters.com/Reuters/worldNews",
"region": "global",
"language_src": "en",
"priority": 90,
"fetch_interval_min": 30,
"translate_to": "zh",
"enabled": True,
},
{
"name": "BBC World",
"slug": "bbc-world",
"kind": SourceKind.RSS,
"url": "https://feeds.bbci.co.uk/news/world/rss.xml",
"region": "global",
"language_src": "en",
"priority": 85,
"fetch_interval_min": 30,
"translate_to": "zh",
"enabled": True,
},
{
"name": "Al Jazeera",
"slug": "aljazeera",
"kind": SourceKind.RSS,
"url": "https://www.aljazeera.com/xml/rss/all.xml",
"region": "mena",
"language_src": "en",
"priority": 80,
"fetch_interval_min": 45,
"translate_to": "zh",
"enabled": True,
},
{
"name": "NHK World",
"slug": "nhk-world",
"kind": SourceKind.RSS,
"url": "https://www3.nhk.or.jp/rss/news/cat0.xml",
"region": "asia",
"language_src": "en",
"priority": 70,
"fetch_interval_min": 60,
"translate_to": "zh",
"enabled": True,
},
{
"name": "DW (Deutsche Welle)",
"slug": "dw",
"kind": SourceKind.RSS,
"url": "https://rss.dw.com/xml/rss-en-all",
"region": "eu",
"language_src": "en",
"priority": 70,
"fetch_interval_min": 60,
"translate_to": "zh",
"enabled": True,
},
]
async def main() -> int:
async with AsyncSessionLocal() as session:
inserted = 0
for row in SEEDS:
stmt = (
pg_insert(Source)
.values(**row)
.on_conflict_do_nothing(index_elements=["slug"])
.returning(Source.id)
)
try:
r = await session.execute(stmt)
rid = r.scalar_one_or_none()
if rid is not None:
inserted += 1
print(f" + {row['slug']} (id={rid})")
else:
print(f" = {row['slug']} (already exists)")
except IntegrityError as e:
print(f" ! {row['slug']}: {e}", file=sys.stderr)
await session.rollback()
await session.commit()
print(f"seeded {inserted} new source(s)")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))