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:
1
backend/app/scripts/__init__.py
Normal file
1
backend/app/scripts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""命令行脚本集合。"""
|
||||
56
backend/app/scripts/create_user.py
Normal file
56
backend/app/scripts/create_user.py
Normal 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()
|
||||
114
backend/app/scripts/seed_sources.py
Normal file
114
backend/app/scripts/seed_sources.py
Normal 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()))
|
||||
Reference in New Issue
Block a user