feat: 完善芽说英语品牌Logo与图标配置,完成跨平台云端同步服务开发与自动化部署
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
芽说英语 (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)
|
||||
Reference in New Issue
Block a user