457 lines
18 KiB
Python
457 lines
18 KiB
Python
"""
|
|
芽说英语 (SpeakSprout) - 自建跨平台多端同步服务
|
|
"""
|
|
import os
|
|
import json
|
|
import uuid
|
|
import datetime
|
|
import logging
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
import jwt
|
|
import bcrypt
|
|
import aiosqlite
|
|
from fastapi import FastAPI, HTTPException, Depends, Header, status
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel, Field
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
logger = logging.getLogger("speaksprout_sync")
|
|
|
|
DATABASE_PATH = os.getenv("DATABASE_PATH", "./data/speaksprout_sync.db")
|
|
JWT_SECRET = os.getenv("JWT_SECRET", "speaksprout-secure-sync-secret-key-2026-v1-production-secure")
|
|
JWT_ALGORITHM = "HS256"
|
|
JWT_EXPIRATION_DAYS = 60
|
|
|
|
app = FastAPI(
|
|
title="芽说英语 · SpeakSprout 同步服务",
|
|
description="支持 Android / iOS / macOS 三端学习进度与艾宾浩斯复习的自建同步服务",
|
|
version="1.0.0"
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# ----------------- 数据契约 (DTO) -----------------
|
|
class RegisterRequest(BaseModel):
|
|
username: str = Field(..., min_length=3, max_length=50)
|
|
password: str = Field(..., min_length=6)
|
|
device_name: Optional[str] = "Unknown Device"
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
device_name: Optional[str] = "Unknown Device"
|
|
|
|
class LessonProgressDTO(BaseModel):
|
|
active_lesson_id: str
|
|
completed_lesson_ids: List[str] = []
|
|
completed_segment_ids: List[str] = []
|
|
active_step: Optional[str] = "preview"
|
|
streak_days: Optional[int] = 0
|
|
updated_at: str
|
|
|
|
class MasteryItemDTO(BaseModel):
|
|
item_id: str
|
|
checkpoint: int
|
|
status: str
|
|
due_at: str
|
|
successful_reviews: int = 0
|
|
attempts: int = 0
|
|
payload: Optional[Dict[str, Any]] = {}
|
|
updated_at: str
|
|
|
|
class ProfileDTO(BaseModel):
|
|
onboarding_complete: Optional[bool] = True
|
|
goal: Optional[str] = "travel"
|
|
placement: Optional[str] = "A0"
|
|
daily_minutes: Optional[int] = 20
|
|
show_chinese_hints: Optional[bool] = True
|
|
ai_endpoint: Optional[str] = ""
|
|
ai_model: Optional[str] = ""
|
|
ai_provider: Optional[str] = ""
|
|
settings_payload: Optional[Dict[str, Any]] = {}
|
|
updated_at: str
|
|
|
|
class SyncPushRequest(BaseModel):
|
|
client_time: str
|
|
device_name: Optional[str] = None
|
|
progress: Optional[LessonProgressDTO] = None
|
|
mastery_updates: Optional[List[MasteryItemDTO]] = []
|
|
profile: Optional[ProfileDTO] = None
|
|
|
|
# ----------------- 数据库连接与表结构 -----------------
|
|
async def get_db():
|
|
async with aiosqlite.connect(DATABASE_PATH) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
yield db
|
|
|
|
async def init_db():
|
|
db_dir = os.path.dirname(os.path.abspath(DATABASE_PATH))
|
|
if db_dir:
|
|
os.makedirs(db_dir, exist_ok=True)
|
|
async with aiosqlite.connect(DATABASE_PATH) as db:
|
|
await db.execute("""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
device_names TEXT DEFAULT '[]',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
""")
|
|
await db.execute("""
|
|
CREATE TABLE IF NOT EXISTS user_lesson_progress (
|
|
user_id TEXT PRIMARY KEY,
|
|
active_lesson_id TEXT NOT NULL,
|
|
completed_lesson_ids TEXT NOT NULL,
|
|
completed_segment_ids TEXT NOT NULL,
|
|
active_step TEXT NOT NULL DEFAULT 'preview',
|
|
streak_days INTEGER NOT NULL DEFAULT 0,
|
|
client_updated_at TEXT NOT NULL,
|
|
server_updated_at TEXT NOT NULL
|
|
);
|
|
""")
|
|
await db.execute("""
|
|
CREATE TABLE IF NOT EXISTS user_mastery_records (
|
|
user_id TEXT NOT NULL,
|
|
item_id TEXT NOT NULL,
|
|
checkpoint INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL,
|
|
due_at TEXT NOT NULL,
|
|
successful_reviews INTEGER NOT NULL DEFAULT 0,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
payload TEXT NOT NULL DEFAULT '{}',
|
|
client_updated_at TEXT NOT NULL,
|
|
server_updated_at TEXT NOT NULL,
|
|
PRIMARY KEY(user_id, item_id)
|
|
);
|
|
""")
|
|
await db.execute("""
|
|
CREATE TABLE IF NOT EXISTS user_profiles (
|
|
user_id TEXT PRIMARY KEY,
|
|
onboarding_complete INTEGER NOT NULL DEFAULT 1,
|
|
goal TEXT DEFAULT 'travel',
|
|
placement TEXT DEFAULT 'A0',
|
|
daily_minutes INTEGER DEFAULT 20,
|
|
show_chinese_hints INTEGER DEFAULT 1,
|
|
ai_endpoint TEXT DEFAULT '',
|
|
ai_model TEXT DEFAULT '',
|
|
ai_provider TEXT DEFAULT '',
|
|
settings_payload TEXT NOT NULL DEFAULT '{}',
|
|
client_updated_at TEXT NOT NULL,
|
|
server_updated_at TEXT NOT NULL
|
|
);
|
|
""")
|
|
await db.commit()
|
|
logger.info("✅ 数据库表初始化完成: %s", DATABASE_PATH)
|
|
|
|
@app.on_event("startup")
|
|
async def on_startup():
|
|
await init_db()
|
|
|
|
# ----------------- 鉴权逻辑 -----------------
|
|
def create_jwt(user_id: str, username: str) -> str:
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
payload = {
|
|
"sub": user_id,
|
|
"username": username,
|
|
"iat": now,
|
|
"exp": now + datetime.timedelta(days=JWT_EXPIRATION_DAYS)
|
|
}
|
|
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
|
|
|
async def get_current_user(authorization: Optional[str] = Header(None)) -> Dict[str, str]:
|
|
if not authorization or not authorization.startswith("Bearer "):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未提供有效的认证凭证")
|
|
token = authorization.split(" ")[1]
|
|
try:
|
|
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
|
return {"user_id": payload["sub"], "username": payload.get("username", "")}
|
|
except jwt.ExpiredSignatureError:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录凭证已过期,请重新登录")
|
|
except jwt.PyJWTError:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭证")
|
|
|
|
# ----------------- 控制器路由 -----------------
|
|
@app.get("/")
|
|
def root():
|
|
return {
|
|
"app": "芽说英语 · SpeakSprout Sync Service",
|
|
"status": "online",
|
|
"version": "1.0.0",
|
|
"server_time": datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
}
|
|
|
|
@app.get("/api/v1/health")
|
|
def health():
|
|
return {"status": "ok", "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat()}
|
|
|
|
@app.post("/api/v1/auth/register")
|
|
async def register(req: RegisterRequest, db: aiosqlite.Connection = Depends(get_db)):
|
|
clean_username = req.username.strip().lower()
|
|
cursor = await db.execute("SELECT id FROM users WHERE username = ?", (clean_username,))
|
|
if await cursor.fetchone():
|
|
raise HTTPException(status_code=400, detail="该账号已被注册,请直接登录")
|
|
|
|
salt = bcrypt.gensalt()
|
|
pwd_hash = bcrypt.hashpw(req.password.encode('utf-8'), salt).decode('utf-8')
|
|
user_id = f"u_{uuid.uuid4().hex[:12]}"
|
|
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
devices = json.dumps([req.device_name] if req.device_name else [])
|
|
|
|
await db.execute(
|
|
"INSERT INTO users (id, username, password_hash, device_names, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
(user_id, clean_username, pwd_hash, devices, now_iso, now_iso)
|
|
)
|
|
await db.commit()
|
|
token = create_jwt(user_id, clean_username)
|
|
logger.info("🎉 新用户注册: %s (%s)", clean_username, user_id)
|
|
return {
|
|
"code": 0,
|
|
"message": "注册成功",
|
|
"data": {
|
|
"user_id": user_id,
|
|
"username": clean_username,
|
|
"token": token,
|
|
"expires_in": JWT_EXPIRATION_DAYS * 86400
|
|
}
|
|
}
|
|
|
|
@app.post("/api/v1/auth/login")
|
|
async def login(req: LoginRequest, db: aiosqlite.Connection = Depends(get_db)):
|
|
clean_username = req.username.strip().lower()
|
|
cursor = await db.execute("SELECT id, password_hash, device_names FROM users WHERE username = ?", (clean_username,))
|
|
row = await cursor.fetchone()
|
|
if not row or not bcrypt.checkpw(req.password.encode('utf-8'), row["password_hash"].encode('utf-8')):
|
|
raise HTTPException(status_code=400, detail="账号或密码错误")
|
|
|
|
user_id = row["id"]
|
|
try:
|
|
devices = json.loads(row["device_names"])
|
|
if req.device_name and req.device_name not in devices:
|
|
devices.append(req.device_name)
|
|
await db.execute(
|
|
"UPDATE users SET device_names = ?, updated_at = ? WHERE id = ?",
|
|
(json.dumps(devices), datetime.datetime.now(datetime.timezone.utc).isoformat(), user_id)
|
|
)
|
|
await db.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
token = create_jwt(user_id, clean_username)
|
|
logger.info("🔑 用户登录: %s (%s)", clean_username, user_id)
|
|
return {
|
|
"code": 0,
|
|
"message": "登录成功",
|
|
"data": {
|
|
"user_id": user_id,
|
|
"username": clean_username,
|
|
"token": token,
|
|
"expires_in": JWT_EXPIRATION_DAYS * 86400
|
|
}
|
|
}
|
|
|
|
@app.get("/api/v1/sync/pull")
|
|
async def sync_pull(
|
|
since: Optional[str] = None,
|
|
auth: Dict[str, str] = Depends(get_current_user),
|
|
db: aiosqlite.Connection = Depends(get_db)
|
|
):
|
|
user_id = auth["user_id"]
|
|
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
|
|
# 1. 课程进度
|
|
cursor = await db.execute("SELECT * FROM user_lesson_progress WHERE user_id = ?", (user_id,))
|
|
p_row = await cursor.fetchone()
|
|
progress_data = None
|
|
if p_row:
|
|
progress_data = {
|
|
"active_lesson_id": p_row["active_lesson_id"],
|
|
"completed_lesson_ids": json.loads(p_row["completed_lesson_ids"]),
|
|
"completed_segment_ids": json.loads(p_row["completed_segment_ids"]),
|
|
"active_step": p_row["active_step"],
|
|
"streak_days": p_row["streak_days"],
|
|
"updated_at": p_row["server_updated_at"]
|
|
}
|
|
|
|
# 2. 复习掌握项 (增量)
|
|
if since:
|
|
cursor = await db.execute(
|
|
"SELECT * FROM user_mastery_records WHERE user_id = ? AND server_updated_at > ?",
|
|
(user_id, since)
|
|
)
|
|
else:
|
|
cursor = await db.execute("SELECT * FROM user_mastery_records WHERE user_id = ?", (user_id,))
|
|
|
|
m_rows = await cursor.fetchall()
|
|
mastery_list = []
|
|
for r in m_rows:
|
|
mastery_list.append({
|
|
"item_id": r["item_id"],
|
|
"checkpoint": r["checkpoint"],
|
|
"status": r["status"],
|
|
"due_at": r["due_at"],
|
|
"successful_reviews": r["successful_reviews"],
|
|
"attempts": r["attempts"],
|
|
"payload": json.loads(r["payload"]),
|
|
"updated_at": r["server_updated_at"]
|
|
})
|
|
|
|
# 3. 用户画像
|
|
cursor = await db.execute("SELECT * FROM user_profiles WHERE user_id = ?", (user_id,))
|
|
prof_row = await cursor.fetchone()
|
|
profile_data = None
|
|
if prof_row:
|
|
profile_data = {
|
|
"onboarding_complete": bool(prof_row["onboarding_complete"]),
|
|
"goal": prof_row["goal"],
|
|
"placement": prof_row["placement"],
|
|
"daily_minutes": prof_row["daily_minutes"],
|
|
"show_chinese_hints": bool(prof_row["show_chinese_hints"]),
|
|
"ai_endpoint": prof_row["ai_endpoint"],
|
|
"ai_model": prof_row["ai_model"],
|
|
"ai_provider": prof_row["ai_provider"],
|
|
"settings_payload": json.loads(prof_row["settings_payload"]),
|
|
"updated_at": prof_row["server_updated_at"]
|
|
}
|
|
|
|
return {
|
|
"code": 0,
|
|
"message": "拉取成功",
|
|
"data": {
|
|
"server_time": now_iso,
|
|
"progress": progress_data,
|
|
"mastery_updates": mastery_list,
|
|
"profile": profile_data
|
|
}
|
|
}
|
|
|
|
@app.post("/api/v1/sync/push")
|
|
async def sync_push(
|
|
req: SyncPushRequest,
|
|
auth: Dict[str, str] = Depends(get_current_user),
|
|
db: aiosqlite.Connection = Depends(get_db)
|
|
):
|
|
user_id = auth["user_id"]
|
|
server_now = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
|
|
# 1. 合并课程进度 (Union 并集)
|
|
if req.progress:
|
|
p = req.progress
|
|
cursor = await db.execute("SELECT * FROM user_lesson_progress WHERE user_id = ?", (user_id,))
|
|
existing = await cursor.fetchone()
|
|
if existing:
|
|
local_lessons = set(json.loads(existing["completed_lesson_ids"]))
|
|
incoming_lessons = set(p.completed_lesson_ids)
|
|
merged_lessons = list(local_lessons.union(incoming_lessons))
|
|
|
|
local_segments = set(json.loads(existing["completed_segment_ids"]))
|
|
incoming_segments = set(p.completed_segment_ids)
|
|
merged_segments = list(local_segments.union(incoming_segments))
|
|
|
|
active_lesson = p.active_lesson_id if p.updated_at >= existing["client_updated_at"] else existing["active_lesson_id"]
|
|
streak = max(p.streak_days or 0, existing["streak_days"] or 0)
|
|
|
|
await db.execute("""
|
|
UPDATE user_lesson_progress
|
|
SET active_lesson_id = ?, completed_lesson_ids = ?, completed_segment_ids = ?,
|
|
active_step = ?, streak_days = ?, client_updated_at = ?, server_updated_at = ?
|
|
WHERE user_id = ?
|
|
""", (active_lesson, json.dumps(merged_lessons), json.dumps(merged_segments),
|
|
p.active_step or "preview", streak, p.updated_at, server_now, user_id))
|
|
else:
|
|
await db.execute("""
|
|
INSERT INTO user_lesson_progress (user_id, active_lesson_id, completed_lesson_ids, completed_segment_ids,
|
|
active_step, streak_days, client_updated_at, server_updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (user_id, p.active_lesson_id, json.dumps(p.completed_lesson_ids), json.dumps(p.completed_segment_ids),
|
|
p.active_step or "preview", p.streak_days or 0, p.updated_at, server_now))
|
|
|
|
# 2. 合并复习掌握项 (Max Checkpoint + Latest Timestamp)
|
|
updated_mastery_count = 0
|
|
if req.mastery_updates:
|
|
for m in req.mastery_updates:
|
|
cursor = await db.execute(
|
|
"SELECT checkpoint, client_updated_at FROM user_mastery_records WHERE user_id = ? AND item_id = ?",
|
|
(user_id, m.item_id)
|
|
)
|
|
existing_m = await cursor.fetchone()
|
|
|
|
should_update = False
|
|
if not existing_m:
|
|
should_update = True
|
|
else:
|
|
if m.checkpoint > existing_m["checkpoint"]:
|
|
should_update = True
|
|
elif m.checkpoint == existing_m["checkpoint"] and m.updated_at >= existing_m["client_updated_at"]:
|
|
should_update = True
|
|
|
|
if should_update:
|
|
updated_mastery_count += 1
|
|
await db.execute("""
|
|
INSERT INTO user_mastery_records (user_id, item_id, checkpoint, status, due_at,
|
|
successful_reviews, attempts, payload, client_updated_at, server_updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
|
checkpoint = excluded.checkpoint,
|
|
status = excluded.status,
|
|
due_at = excluded.due_at,
|
|
successful_reviews = excluded.successful_reviews,
|
|
attempts = excluded.attempts,
|
|
payload = excluded.payload,
|
|
client_updated_at = excluded.client_updated_at,
|
|
server_updated_at = excluded.server_updated_at
|
|
""", (user_id, m.item_id, m.checkpoint, m.status, m.due_at,
|
|
m.successful_reviews, m.attempts, json.dumps(m.payload or {}), m.updated_at, server_now))
|
|
|
|
# 3. 合并用户配置 (LWW)
|
|
if req.profile:
|
|
prof = req.profile
|
|
cursor = await db.execute("SELECT client_updated_at FROM user_profiles WHERE user_id = ?", (user_id,))
|
|
existing_prof = await cursor.fetchone()
|
|
|
|
if not existing_prof or prof.updated_at >= existing_prof["client_updated_at"]:
|
|
await db.execute("""
|
|
INSERT INTO user_profiles (user_id, onboarding_complete, goal, placement, daily_minutes,
|
|
show_chinese_hints, ai_endpoint, ai_model, ai_provider, settings_payload,
|
|
client_updated_at, server_updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
onboarding_complete = excluded.onboarding_complete,
|
|
goal = excluded.goal,
|
|
placement = excluded.placement,
|
|
daily_minutes = excluded.daily_minutes,
|
|
show_chinese_hints = excluded.show_chinese_hints,
|
|
ai_endpoint = excluded.ai_endpoint,
|
|
ai_model = excluded.ai_model,
|
|
ai_provider = excluded.ai_provider,
|
|
settings_payload = excluded.settings_payload,
|
|
client_updated_at = excluded.client_updated_at,
|
|
server_updated_at = excluded.server_updated_at
|
|
""", (user_id, 1 if prof.onboarding_complete else 0, prof.goal or "travel", prof.placement or "A0",
|
|
prof.daily_minutes or 20, 1 if prof.show_chinese_hints else 0, prof.ai_endpoint or "",
|
|
prof.ai_model or "", prof.ai_provider or "", json.dumps(prof.settings_payload or {}),
|
|
prof.updated_at, server_now))
|
|
|
|
await db.commit()
|
|
logger.info("🔄 数据推送完成 [user=%s]: 词汇=%d", user_id, updated_mastery_count)
|
|
return {
|
|
"code": 0,
|
|
"message": "同步成功",
|
|
"data": {
|
|
"server_time": server_now,
|
|
"synced_mastery_count": updated_mastery_count
|
|
}
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True)
|