added a user system

This commit is contained in:
2026-08-30 17:07:55 +08:00
parent 0b85a2ed57
commit 637101bcdb
13 changed files with 1082 additions and 20 deletions

View File

@@ -1,10 +1,11 @@
import json
import sqlite3
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
import uuid
from .auth import hash_password, hash_token, new_token, verify_password
from .models import SentenceBoundary, SentenceBoundaryDocument
from .store import normalize_video_hash
@@ -70,6 +71,31 @@ class VideoRepository:
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
nickname TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS enrollments (
user_id TEXT NOT NULL,
video_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, video_hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dub_shares (
share_id TEXT PRIMARY KEY,
video_hash TEXT NOT NULL,
@@ -114,6 +140,24 @@ class VideoRepository:
"ALTER TABLE dub_share_segments ADD COLUMN score_details TEXT NOT NULL DEFAULT '{}'"
)
with self._connect() as connection:
attempt_columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(attempts)")
}
if "user_id" not in attempt_columns:
connection.execute("ALTER TABLE attempts ADD COLUMN user_id TEXT NOT NULL DEFAULT ''")
connection.execute("CREATE INDEX IF NOT EXISTS idx_attempts_user ON attempts(user_id)")
with self._connect() as connection:
dub_columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(dub_shares)")
}
if "user_id" not in dub_columns:
connection.execute("ALTER TABLE dub_shares ADD COLUMN user_id TEXT NOT NULL DEFAULT ''")
connection.execute("CREATE INDEX IF NOT EXISTS idx_dub_shares_user ON dub_shares(user_id)")
with self._connect() as connection:
video_columns = {
row["name"]
@@ -612,6 +656,7 @@ class VideoRepository:
video_hash: str,
title: str,
segments: List[Dict[str, Any]],
user_id: str,
) -> Optional[Dict[str, Any]]:
normalized_hash = normalize_video_hash(video_hash)
if self.get_video(normalized_hash) is None:
@@ -621,10 +666,10 @@ class VideoRepository:
with self._connect() as connection:
connection.execute(
"""
INSERT INTO dub_shares (share_id, video_hash, title, created_at)
VALUES (?, ?, ?, ?)
INSERT INTO dub_shares (share_id, video_hash, title, user_id, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(share_id, normalized_hash, title[:200], now),
(share_id, normalized_hash, title[:200], user_id, now),
)
connection.executemany(
"""
@@ -684,14 +729,15 @@ class VideoRepository:
sentence_index: int,
result: Dict[str, Any],
audio_filename: Optional[str],
user_id: str,
) -> None:
with self._connect() as connection:
connection.execute(
"""
INSERT INTO attempts (
attempt_id, video_hash, sentence_index, result_json,
audio_filename, created_at
) VALUES (?, ?, ?, ?, ?, ?)
audio_filename, user_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
attempt_id,
@@ -699,10 +745,193 @@ class VideoRepository:
sentence_index,
json.dumps(result, ensure_ascii=False),
audio_filename,
user_id,
utc_now(),
),
)
def create_user(self, *, username: str, password: str, nickname: Optional[str]) -> Optional[Dict[str, Any]]:
user_id = uuid.uuid4().hex
now = utc_now()
try:
with self._connect() as connection:
row = connection.execute(
"""
INSERT INTO users (id, username, nickname, password_hash, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(
user_id,
username.strip().lower(),
(nickname or username).strip()[:80],
hash_password(password),
now,
),
).fetchone()
except sqlite3.IntegrityError:
return None
return self.get_user_by_id(user_id)
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM users WHERE username = ? COLLATE NOCASE",
(username.strip(),),
).fetchone()
return dict(row) if row else None
def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
with self._connect() as connection:
row = connection.execute(
"SELECT id, username, nickname, created_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
return dict(row) if row else None
def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]:
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM users WHERE username = ? COLLATE NOCASE",
(username.strip(),),
).fetchone()
if row is None or not verify_password(password, row["password_hash"]):
return None
return self.get_user_by_id(row["id"])
def create_session(self, user_id: str) -> str:
token = new_token()
now = datetime.now(timezone.utc)
with self._connect() as connection:
connection.execute(
"""
INSERT INTO auth_tokens (token_hash, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)
""",
(
hash_token(token),
user_id,
now.isoformat(),
(now + timedelta(days=30)).isoformat(),
),
)
return token
def resolve_session(self, token: str) -> Optional[Dict[str, Any]]:
with self._connect() as connection:
row = connection.execute(
"""
SELECT u.id, u.username, u.nickname, u.created_at
FROM auth_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ? AND t.expires_at > ?
""",
(hash_token(token), datetime.now(timezone.utc).isoformat()),
).fetchone()
return dict(row) if row else None
def delete_session(self, token: str) -> None:
with self._connect() as connection:
connection.execute(
"DELETE FROM auth_tokens WHERE token_hash = ?",
(hash_token(token),),
)
def list_courses(self, user_id: str) -> List[Dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute(
"""
SELECT v.*, COUNT(s.sentence_index) AS sentence_count,
CASE WHEN e.user_id IS NULL THEN 0 ELSE 1 END AS enrolled
FROM videos v
LEFT JOIN sentences s ON s.video_hash = v.video_hash
LEFT JOIN enrollments e ON e.video_hash = v.video_hash AND e.user_id = ?
WHERE v.status = 'ready'
GROUP BY v.video_hash
ORDER BY e.created_at DESC, v.created_at DESC
""",
(user_id,),
).fetchall()
return [dict(row) for row in rows]
def enroll_course(self, user_id: str, video_hash: str) -> bool:
normalized_hash = normalize_video_hash(video_hash)
with self._connect() as connection:
video = connection.execute(
"SELECT 1 FROM videos WHERE video_hash = ? AND status = 'ready'",
(normalized_hash,),
).fetchone()
if video is None:
return False
connection.execute(
"""
INSERT OR IGNORE INTO enrollments (user_id, video_hash, created_at)
VALUES (?, ?, ?)
""",
(user_id, normalized_hash, utc_now()),
)
return True
def unenroll_course(self, user_id: str, video_hash: str) -> bool:
normalized_hash = normalize_video_hash(video_hash)
with self._connect() as connection:
cursor = connection.execute(
"DELETE FROM enrollments WHERE user_id = ? AND video_hash = ?",
(user_id, normalized_hash),
)
return cursor.rowcount > 0
def list_user_results(self, user_id: str) -> List[Dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute(
"""
SELECT a.attempt_id, a.video_hash, a.sentence_index, a.result_json, a.created_at,
v.title AS course_title, s.text AS sentence_text
FROM attempts a
JOIN videos v ON v.video_hash = a.video_hash
LEFT JOIN sentences s
ON s.video_hash = a.video_hash AND s.sentence_index = a.sentence_index
WHERE a.user_id = ?
ORDER BY a.created_at DESC
LIMIT 500
""",
(user_id,),
).fetchall()
results = []
for row in rows:
payload = json.loads(row["result_json"] or "{}")
results.append(
{
"attempt_id": row["attempt_id"],
"video_hash": row["video_hash"],
"course_title": row["course_title"],
"sentence_index": row["sentence_index"],
"sentence_text": row["sentence_text"] or "",
"overall_score": float(payload.get("overall_score", 0)),
"passed": bool(payload.get("passed", False)),
"created_at": row["created_at"],
}
)
return results
def list_user_dub_shares(self, user_id: str) -> List[Dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute(
"""
SELECT d.share_id, d.video_hash, d.title, d.created_at,
v.title AS course_title,
COUNT(g.id) AS segment_count,
COALESCE(AVG(NULLIF(g.overall_score, 0)), 0) AS average_score
FROM dub_shares d
JOIN videos v ON v.video_hash = d.video_hash
LEFT JOIN dub_share_segments g ON g.share_id = d.share_id
WHERE d.user_id = ?
GROUP BY d.share_id
ORDER BY d.created_at DESC
LIMIT 500
""",
(user_id,),
).fetchall()
return [dict(row) for row in rows]
def delete_video(self, video_hash: str) -> Optional[Dict[str, Any]]:
video = self.get_video(video_hash)
if video is None: