Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/scut-senior/api/migrations/0014_byok_cross_device.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-- BYOK 跨设备:凭据作用域从“登录会话”改为“用户(GitHub 账号)”。
-- 同一账号在任何设备登录(新 auth_session_id)都能解密同一把 BYOK。
--
-- 安全说明:AES-256-GCM 的 AAD 由绑定 user_id+auth_session_id+provider_id
-- 改为绑定 user_id+provider_id(见 credentials._credential_aad),因此旧密文
-- 无法再解密。故这里不迁移旧密文,用户需在任一设备重新保存一次 API Key。

DROP TRIGGER IF EXISTS delete_credentials_when_session_revoked;
DROP INDEX IF EXISTS idx_model_credentials_expiry;
DROP TABLE IF EXISTS model_credentials;

CREATE TABLE model_credentials (
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL CHECK (
provider_id IN ('openrouter', 'deepseek', 'siliconflow', 'zhipu')
),
ciphertext BLOB NOT NULL CHECK (length(ciphertext) > 16),
nonce BLOB NOT NULL CHECK (length(nonce) = 12),
algorithm TEXT NOT NULL CHECK (algorithm = 'AES-256-GCM'),
key_version INTEGER NOT NULL CHECK (key_version > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
PRIMARY KEY (user_id, provider_id),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_model_credentials_expiry
ON model_credentials (expires_at);
11 changes: 11 additions & 0 deletions apps/scut-senior/api/migrations/0015_user_preferences.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- 个人中心偏好:随 GitHub 账号(user_id)维护,跨设备同步。
-- 存 theme_mode / accent_theme / answer_mode / tone 等键值对,值以文本存储。

CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT NOT NULL,
preference_key TEXT NOT NULL,
preference_value TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, preference_key),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,9 @@ def _build_structured_request(
),
},
],
# 详细模式 + 公式 + 附录引用很容易超过 2048 token(线上实测被截断);
# 8192 与 BYOK 目录默认值对齐,只影响实际生成量。
"max_tokens": 8192,
# 推理模型会把一部分输出预算用于 reasoning;16384 为正文和推理
# 同时留出空间,只影响实际生成量。
"max_tokens": 16384,
"temperature": 0.2,
}

Expand Down
151 changes: 73 additions & 78 deletions apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@


HISTORY_TTL = timedelta(days=30)
# User-scoped BYOK credentials are independent of any login session, so they
# survive re-login on another device. Give them a fixed lifetime rather than a
# session-bound one; a user re-saves / rotates before it lapses.
BYOK_CREDENTIAL_LIFETIME_DAYS = 365
PRIVATE_DIRECTORY_MODE = 0o700
PRIVATE_FILE_MODE = 0o600

Expand Down Expand Up @@ -678,6 +682,44 @@ def delete_account(self, user_id: str) -> dict[str, int]:
connection.close()
return counts

def get_user_preferences(self, user_id: str) -> dict[str, str]:
normalized_user_id = str(UUID(str(user_id)))
with self._connect() as connection:
rows = connection.execute(
"""
SELECT preference_key, preference_value
FROM user_preferences
WHERE user_id = ?
ORDER BY preference_key
""",
(normalized_user_id,),
).fetchall()
return {row["preference_key"]: row["preference_value"] for row in rows}

def set_user_preference(self, user_id: str, key: str, value: str) -> None:
normalized_user_id = str(UUID(str(user_id)))
now = self._now().isoformat()
with self._connect() as connection:
connection.execute(
"""
INSERT INTO user_preferences (
user_id, preference_key, preference_value, updated_at
) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, preference_key) DO UPDATE SET
preference_value = excluded.preference_value,
updated_at = excluded.updated_at
""",
(normalized_user_id, key, value, now),
)

def delete_user_preference(self, user_id: str, key: str) -> None:
normalized_user_id = str(UUID(str(user_id)))
with self._connect() as connection:
connection.execute(
"DELETE FROM user_preferences WHERE user_id = ? AND preference_key = ?",
(normalized_user_id, key),
)

def export_account_data(self, user_id: str) -> dict[str, object]:
"""导出本人数据:历史、贡献与临时材料元数据。

Expand Down Expand Up @@ -1157,7 +1199,6 @@ def session_is_active(self, user_id: UUID, auth_session_id: UUID) -> bool:
def _stored_model_credential(row: sqlite3.Row) -> StoredModelCredential:
return StoredModelCredential(
user_id=UUID(row["user_id"]),
auth_session_id=UUID(row["auth_session_id"]),
provider_id=row["provider_id"],
ciphertext=bytes(row["ciphertext"]),
nonce=bytes(row["nonce"]),
Expand All @@ -1167,64 +1208,43 @@ def _stored_model_credential(row: sqlite3.Row) -> StoredModelCredential:
updated_at=datetime.fromisoformat(row["updated_at"]),
)

def list_model_credentials(
self, user_id: UUID, auth_session_id: UUID
) -> list[StoredModelCredential]:
def list_model_credentials(self, user_id: UUID) -> list[StoredModelCredential]:
self.cleanup_auth_records()
now = self._now().isoformat()
with self._connect() as connection:
rows = connection.execute(
"""
SELECT c.user_id, c.auth_session_id, c.provider_id,
c.ciphertext, c.nonce, c.algorithm, c.key_version,
c.expires_at, c.updated_at
FROM model_credentials AS c
JOIN auth_sessions AS s
ON s.auth_session_id = c.auth_session_id
AND s.user_id = c.user_id
WHERE c.user_id = ? AND c.auth_session_id = ?
AND c.expires_at > ?
AND s.revoked_at IS NULL AND s.expires_at > ?
ORDER BY c.provider_id
SELECT user_id, provider_id, ciphertext, nonce, algorithm,
key_version, expires_at, updated_at
FROM model_credentials
WHERE user_id = ? AND expires_at > ?
ORDER BY provider_id
""",
(str(user_id), str(auth_session_id), now, now),
(str(user_id), now),
).fetchall()
return [self._stored_model_credential(row) for row in rows]

def get_model_credential(
self, user_id: UUID, auth_session_id: UUID, provider_id: str
self, user_id: UUID, provider_id: str
) -> StoredModelCredential | None:
self.cleanup_auth_records()
now = self._now().isoformat()
with self._connect() as connection:
row = connection.execute(
"""
SELECT c.user_id, c.auth_session_id, c.provider_id,
c.ciphertext, c.nonce, c.algorithm, c.key_version,
c.expires_at, c.updated_at
FROM model_credentials AS c
JOIN auth_sessions AS s
ON s.auth_session_id = c.auth_session_id
AND s.user_id = c.user_id
WHERE c.user_id = ? AND c.auth_session_id = ?
AND c.provider_id = ? AND c.expires_at > ?
AND s.revoked_at IS NULL AND s.expires_at > ?
SELECT user_id, provider_id, ciphertext, nonce, algorithm,
key_version, expires_at, updated_at
FROM model_credentials
WHERE user_id = ? AND provider_id = ? AND expires_at > ?
""",
(
str(user_id),
str(auth_session_id),
provider_id,
now,
now,
),
(str(user_id), provider_id, now),
).fetchone()
return self._stored_model_credential(row) if row is not None else None

def upsert_model_credential(
self,
*,
user_id: UUID,
auth_session_id: UUID,
provider_id: str,
ciphertext: bytes,
nonce: bytes,
Expand All @@ -1239,32 +1259,19 @@ def upsert_model_credential(
raise ValueError("invalid credential key version")
now_value = self._now()
now = now_value.isoformat()
# Per-user credentials live for a year from the write; they are no
# longer bound to a (7-day) login session, so they survive re-login on
# another device. The active-session check lives in the credential
# manager, not here.
expires_at = (now_value + timedelta(days=BYOK_CREDENTIAL_LIFETIME_DAYS)).isoformat()
with self._connect() as connection:
# Serialize the active-session check with credential replacement.
# If replacement wins, a later revoke trigger deletes the row; if
# revoke wins, this check fails and no late ciphertext is written.
connection.execute("BEGIN IMMEDIATE")
session = connection.execute(
"""
SELECT expires_at FROM auth_sessions
WHERE auth_session_id = ? AND user_id = ?
AND revoked_at IS NULL AND expires_at > ?
""",
(str(auth_session_id), str(user_id), now),
).fetchone()
if session is None:
raise AuthRequired()
expires_at = datetime.fromisoformat(session["expires_at"])
if expires_at <= now_value:
raise AuthRequired()
connection.execute(
"""
INSERT INTO model_credentials (
auth_session_id, user_id, provider_id, ciphertext, nonce,
algorithm, key_version,
created_at, updated_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(auth_session_id, provider_id) DO UPDATE SET
user_id, provider_id, ciphertext, nonce, algorithm,
key_version, created_at, updated_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, provider_id) DO UPDATE SET
ciphertext = excluded.ciphertext,
nonce = excluded.nonce,
algorithm = excluded.algorithm,
Expand All @@ -1273,7 +1280,6 @@ def upsert_model_credential(
expires_at = excluded.expires_at
""",
(
str(auth_session_id),
str(user_id),
provider_id,
sqlite3.Binary(ciphertext),
Expand All @@ -1282,43 +1288,32 @@ def upsert_model_credential(
key_version,
now,
now,
expires_at.isoformat(),
expires_at,
),
)
row = connection.execute(
"""
SELECT user_id, auth_session_id, provider_id, ciphertext,
nonce, algorithm, key_version, expires_at, updated_at
SELECT user_id, provider_id, ciphertext, nonce, algorithm,
key_version, expires_at, updated_at
FROM model_credentials
WHERE auth_session_id = ? AND provider_id = ?
WHERE user_id = ? AND provider_id = ?
""",
(str(auth_session_id), provider_id),
(str(user_id), provider_id),
).fetchone()
if row is None:
raise RuntimeError("model credential was not persisted")
return self._stored_model_credential(row)

def delete_model_credential(
self, user_id: UUID, auth_session_id: UUID, provider_id: str
self, user_id: UUID, provider_id: str
) -> bool:
now = self._now().isoformat()
with self._connect() as connection:
active = connection.execute(
"""
SELECT 1 FROM auth_sessions
WHERE auth_session_id = ? AND user_id = ?
AND revoked_at IS NULL AND expires_at > ?
""",
(str(auth_session_id), str(user_id), now),
).fetchone()
if active is None:
raise AuthRequired()
cursor = connection.execute(
"""
DELETE FROM model_credentials
WHERE auth_session_id = ? AND user_id = ? AND provider_id = ?
WHERE user_id = ? AND provider_id = ?
""",
(str(auth_session_id), str(user_id), provider_id),
(str(user_id), provider_id),
)
return cursor.rowcount == 1

Expand Down Expand Up @@ -1538,7 +1533,7 @@ def get_conversation(
FROM workflow_runs
WHERE conversation_id = ? AND user_id = ?
AND run_status NOT IN ('created', 'running')
ORDER BY created_at DESC, workflow_run_id DESC
ORDER BY created_at ASC, workflow_run_id ASC
""",
(str(conversation_id), user_id),
).fetchall()
Expand Down
4 changes: 2 additions & 2 deletions apps/scut-senior/api/src/scut_senior_api/byok_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def as_public_dict(self) -> dict[str, object]:
# DeepSeek is a reasoning model: its thinking consumes part of
# the token budget, so a small max_tokens can return an empty
# final ``content``. Keep headroom for reasoning + answer.
default_max_tokens=8192,
default_max_tokens=16384,
),
),
),
Expand All @@ -112,7 +112,7 @@ def as_public_dict(self) -> dict[str, object]:
company="DeepSeek",
display_name="DeepSeek V4 Flash",
# Same reasoning-model note as the OpenRouter DeepSeek route.
default_max_tokens=8192,
default_max_tokens=16384,
),
),
),
Expand Down
9 changes: 9 additions & 0 deletions apps/scut-senior/api/src/scut_senior_api/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,15 @@ class AccountDeletionSummary(ContractModel):
deleted_at: datetime


class AccountPreferencesUpdate(ContractModel):
"""个人中心偏好:键值对,随 GitHub 账号跨设备同步。

键为 theme_mode / accent_theme / answer_mode / tone 等;值为字符串。
"""

preferences: dict[str, str]


class AccountExportContribution(ContractModel):
contribution_id: UUID
course_id: str
Expand Down
31 changes: 31 additions & 0 deletions apps/scut-senior/api/src/scut_senior_api/course_availability.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@


RetrievalAvailability = Literal["fixture", "local_corpus", "unavailable"]
CourseCategory = Literal["enabled", "not_enabled", "no_data"]


def course_category(data_backed: bool, enabled: bool) -> CourseCategory:
"""Single canonical category shared by the course list and the plugin panel.

``no_data`` when the configured retrieval adapter cannot serve the course
right now (no active local-corpus data, no fixture coverage). Otherwise
``enabled`` when the user has its plugin loaded, else ``not_enabled``.
Keeping one derivation here is what stops the two surfaces from drifting.
"""
if not data_backed:
return "no_data"
return "enabled" if enabled else "not_enabled"


def course_plugin_data_backed(retrieval_mode: str, state: str) -> bool:
"""Whether a plugin-registry ``state`` implies the retrieval adapter has data.

``derive_course_plugin_states`` only reports ``active`` for a served
local-corpus course and ``fixture_only`` for fixture coverage, so the data
fact is mode-dependent: local_corpus sees ``active`` as backed, while the
fixture profile sees ``fixture_only`` as backed.
"""
if retrieval_mode == "local_corpus":
return state == "active"
return state == "fixture_only"


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -39,6 +66,10 @@ def as_public_dict(self) -> dict[str, object]:
"retrieval_available": self.retrieval_available,
"plugin_loaded": self.plugin_loaded,
"selectable": self.selectable,
# Single collapsed truth for the UI: ``selectable`` PLUS the
# category both surfaces (course picker and plugin panel) render.
"usable": self.selectable,
"category": course_category(self.retrieval_available, self.plugin_loaded),
}


Expand Down
Loading
Loading