feat: 完善芽说英语品牌Logo与图标配置,完成跨平台云端同步服务开发与自动化部署

This commit is contained in:
shen
2026-09-16 09:15:53 +08:00
parent 293b838341
commit eda5dfce62
66 changed files with 3564 additions and 80 deletions
+456
View File
@@ -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)