migration: preserve startup accounts and system slice
This commit is contained in:
+2
-128
@@ -1,129 +1,3 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility imports for code that still uses the original configuration module."""
|
||||||
|
|
||||||
import calendar
|
from backend.bootstrap.config import * # noqa: F401,F403
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
APP_DIR = Path(__file__).resolve().parent
|
|
||||||
STATIC_DIR = APP_DIR / "static"
|
|
||||||
DATA_DIR = APP_DIR / "data"
|
|
||||||
ENV_FILE = APP_DIR / ".env"
|
|
||||||
MENTOR_SKILLS_DIR = APP_DIR / "游资skills"
|
|
||||||
PRIVATE_MENTOR_SKILLS_DIR = DATA_DIR / "private-mentor-skills"
|
|
||||||
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,128}$")
|
|
||||||
USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}$")
|
|
||||||
SESSION_COOKIE = "xiaobai_session"
|
|
||||||
SESSION_MAX_AGE = 30 * 24 * 60 * 60
|
|
||||||
|
|
||||||
|
|
||||||
def load_local_env() -> None:
|
|
||||||
if not ENV_FILE.exists():
|
|
||||||
return
|
|
||||||
for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
|
||||||
continue
|
|
||||||
key, value = line.split("=", 1)
|
|
||||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
|
||||||
|
|
||||||
|
|
||||||
def save_local_env(updates: dict[str, str]) -> None:
|
|
||||||
values: dict[str, str] = {}
|
|
||||||
if ENV_FILE.exists():
|
|
||||||
for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
|
||||||
if "=" in raw_line and not raw_line.lstrip().startswith("#"):
|
|
||||||
key, value = raw_line.split("=", 1)
|
|
||||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
||||||
values.update(updates)
|
|
||||||
ENV_FILE.write_text(
|
|
||||||
"".join(f"{key}={value}\n" for key, value in values.items()),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_local_env(keys: set[str]) -> None:
|
|
||||||
if not ENV_FILE.exists():
|
|
||||||
return
|
|
||||||
kept = []
|
|
||||||
for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
|
||||||
if "=" in raw_line and not raw_line.lstrip().startswith("#"):
|
|
||||||
key = raw_line.split("=", 1)[0].strip()
|
|
||||||
if key in keys:
|
|
||||||
continue
|
|
||||||
kept.append(raw_line)
|
|
||||||
ENV_FILE.write_text("".join(f"{line}\n" for line in kept), encoding="utf-8")
|
|
||||||
for key in keys:
|
|
||||||
os.environ.pop(key, None)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_date(value: str) -> str:
|
|
||||||
compact = value.replace("-", "").strip()
|
|
||||||
try:
|
|
||||||
parsed = datetime.strptime(compact, "%Y%m%d")
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("日期格式应为 YYYY-MM-DD。") from exc
|
|
||||||
if parsed.date() > date.today():
|
|
||||||
raise ValueError("不能查询未来日期。")
|
|
||||||
return parsed.strftime("%Y%m%d")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_stock_code(value: str) -> str:
|
|
||||||
code = value.strip()
|
|
||||||
if not re.fullmatch(r"\d{6}", code):
|
|
||||||
raise ValueError("股票代码应为 6 位数字。")
|
|
||||||
return code
|
|
||||||
|
|
||||||
|
|
||||||
def tushare_code(code: str) -> str:
|
|
||||||
if code.startswith(("4", "8", "9")):
|
|
||||||
suffix = "BJ"
|
|
||||||
elif code.startswith("6"):
|
|
||||||
suffix = "SH"
|
|
||||||
else:
|
|
||||||
suffix = "SZ"
|
|
||||||
return f"{code}.{suffix}"
|
|
||||||
|
|
||||||
|
|
||||||
def validate_text(value: Any, label: str, maximum: int, required: bool = False) -> str:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
if required and not text:
|
|
||||||
raise ValueError(f"{label}不能为空。")
|
|
||||||
if len(text) > maximum:
|
|
||||||
raise ValueError(f"{label}不能超过 {maximum} 个字符。")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def parse_iso_datetime(value: Any) -> datetime | None:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(text)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def membership_boundary(value: Any, end: bool) -> str | None:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
day = datetime.strptime(text, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("会员日期格式应为 YYYY-MM-DD。") from exc
|
|
||||||
if end:
|
|
||||||
day += timedelta(days=1)
|
|
||||||
return day.isoformat(timespec="seconds")
|
|
||||||
|
|
||||||
|
|
||||||
def add_months(value: datetime, months: int) -> datetime:
|
|
||||||
month_index = value.year * 12 + value.month - 1 + months
|
|
||||||
year, zero_based_month = divmod(month_index, 12)
|
|
||||||
month = zero_based_month + 1
|
|
||||||
day = min(value.day, calendar.monthrange(year, month)[1])
|
|
||||||
return value.replace(year=year, month=month, day=day)
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,23 @@
|
|||||||
from .container import ApplicationContainer, build_application_container
|
|
||||||
from .settings import RuntimeSettings, load_runtime_settings
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ApplicationContainer",
|
"ApplicationContainer",
|
||||||
"RuntimeSettings",
|
"RuntimeSettings",
|
||||||
"build_application_container",
|
"build_application_container",
|
||||||
"load_runtime_settings",
|
"load_runtime_settings",
|
||||||
|
"main",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
if name in {"ApplicationContainer", "build_application_container"}:
|
||||||
|
from . import container
|
||||||
|
|
||||||
|
return getattr(container, name)
|
||||||
|
if name in {"RuntimeSettings", "load_runtime_settings"}:
|
||||||
|
from . import settings
|
||||||
|
|
||||||
|
return getattr(settings, name)
|
||||||
|
if name == "main":
|
||||||
|
from .runtime import main
|
||||||
|
|
||||||
|
return main
|
||||||
|
raise AttributeError(name)
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
APP_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
STATIC_DIR = APP_DIR / "static"
|
||||||
|
DATA_DIR = APP_DIR / "data"
|
||||||
|
ENV_FILE = APP_DIR / ".env"
|
||||||
|
MENTOR_SKILLS_DIR = APP_DIR / "游资skills"
|
||||||
|
PRIVATE_MENTOR_SKILLS_DIR = DATA_DIR / "private-mentor-skills"
|
||||||
|
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,128}$")
|
||||||
|
USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}$")
|
||||||
|
SESSION_COOKIE = "xiaobai_session"
|
||||||
|
SESSION_MAX_AGE = 30 * 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def load_local_env() -> None:
|
||||||
|
if not ENV_FILE.exists():
|
||||||
|
return
|
||||||
|
for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||||
|
|
||||||
|
|
||||||
|
def save_local_env(updates: dict[str, str]) -> None:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
if ENV_FILE.exists():
|
||||||
|
for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
||||||
|
if "=" in raw_line and not raw_line.lstrip().startswith("#"):
|
||||||
|
key, value = raw_line.split("=", 1)
|
||||||
|
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
values.update(updates)
|
||||||
|
ENV_FILE.write_text(
|
||||||
|
"".join(f"{key}={value}\n" for key, value in values.items()),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_local_env(keys: set[str]) -> None:
|
||||||
|
if not ENV_FILE.exists():
|
||||||
|
return
|
||||||
|
kept = []
|
||||||
|
for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
||||||
|
if "=" in raw_line and not raw_line.lstrip().startswith("#"):
|
||||||
|
key = raw_line.split("=", 1)[0].strip()
|
||||||
|
if key in keys:
|
||||||
|
continue
|
||||||
|
kept.append(raw_line)
|
||||||
|
ENV_FILE.write_text("".join(f"{line}\n" for line in kept), encoding="utf-8")
|
||||||
|
for key in keys:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_date(value: str) -> str:
|
||||||
|
compact = value.replace("-", "").strip()
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(compact, "%Y%m%d")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("日期格式应为 YYYY-MM-DD。") from exc
|
||||||
|
if parsed.date() > date.today():
|
||||||
|
raise ValueError("不能查询未来日期。")
|
||||||
|
return parsed.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_stock_code(value: str) -> str:
|
||||||
|
code = value.strip()
|
||||||
|
if not re.fullmatch(r"\d{6}", code):
|
||||||
|
raise ValueError("股票代码应为 6 位数字。")
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def tushare_code(code: str) -> str:
|
||||||
|
if code.startswith(("4", "8", "9")):
|
||||||
|
suffix = "BJ"
|
||||||
|
elif code.startswith("6"):
|
||||||
|
suffix = "SH"
|
||||||
|
else:
|
||||||
|
suffix = "SZ"
|
||||||
|
return f"{code}.{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_text(value: Any, label: str, maximum: int, required: bool = False) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if required and not text:
|
||||||
|
raise ValueError(f"{label}不能为空。")
|
||||||
|
if len(text) > maximum:
|
||||||
|
raise ValueError(f"{label}不能超过 {maximum} 个字符。")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iso_datetime(value: Any) -> datetime | None:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def membership_boundary(value: Any, end: bool) -> str | None:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
day = datetime.strptime(text, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("会员日期格式应为 YYYY-MM-DD。") from exc
|
||||||
|
if end:
|
||||||
|
day += timedelta(days=1)
|
||||||
|
return day.isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def add_months(value: datetime, months: int) -> datetime:
|
||||||
|
month_index = value.year * 12 + value.month - 1 + months
|
||||||
|
year, zero_based_month = divmod(month_index, 12)
|
||||||
|
month = zero_based_month + 1
|
||||||
|
day = min(value.day, calendar.monthrange(year, month)[1])
|
||||||
|
return value.replace(year=year, month=month, day=day)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def main(handler_class: type[Any] | None = None, service: Any | None = None) -> None:
|
||||||
|
if handler_class is None or service is None:
|
||||||
|
from backend.application import RequestHandler, SERVICE
|
||||||
|
|
||||||
|
handler_class = handler_class or RequestHandler
|
||||||
|
service = service or SERVICE
|
||||||
|
parser = argparse.ArgumentParser(description="Xiaobai stock review web application")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8765)
|
||||||
|
args = parser.parse_args()
|
||||||
|
server = ThreadingHTTPServer((args.host, args.port), handler_class)
|
||||||
|
print(f"Xiaobai Review Web is running at http://{args.host}:{args.port}")
|
||||||
|
print("Press Ctrl+C to stop.")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
service._background_stop.set()
|
||||||
|
server.server_close()
|
||||||
@@ -4,8 +4,8 @@ import os
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
from app_config import load_local_env, save_local_env
|
from backend.bootstrap.config import load_local_env, save_local_env
|
||||||
from security import SecretVault
|
from backend.features.accounts.security import SecretVault
|
||||||
|
|
||||||
|
|
||||||
def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]:
|
def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app_config import APP_DIR
|
from backend.bootstrap.config import APP_DIR
|
||||||
from backend.data.contracts import DataUsage, DatasetContract, ProviderContract
|
from backend.data.contracts import DataUsage, DatasetContract, ProviderContract
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from app_config import APP_DIR
|
from backend.bootstrap.config import APP_DIR
|
||||||
from backend.data.contracts import DataUsage
|
from backend.data.contracts import DataUsage
|
||||||
from backend.data.policy import DataPolicyError, DataSourcePolicy
|
from backend.data.policy import DataPolicyError, DataSourcePolicy
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
__all__ = [
|
||||||
|
"AccountHttpMixin",
|
||||||
|
"AccountService",
|
||||||
|
"SecretVault",
|
||||||
|
"hash_password",
|
||||||
|
"token_hash",
|
||||||
|
"verify_password",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
if name == "AccountHttpMixin":
|
||||||
|
from .http import AccountHttpMixin
|
||||||
|
|
||||||
|
return AccountHttpMixin
|
||||||
|
if name == "AccountService":
|
||||||
|
from .service import AccountService
|
||||||
|
|
||||||
|
return AccountService
|
||||||
|
if name in {"SecretVault", "hash_password", "token_hash", "verify_password"}:
|
||||||
|
from . import security
|
||||||
|
|
||||||
|
return getattr(security, name)
|
||||||
|
raise AttributeError(name)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
|
||||||
|
class AccountHttpMixin:
|
||||||
|
def auth_register(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self.read_json_body()
|
||||||
|
result = self.application_service.register_account(
|
||||||
|
str(body.get("username") or ""),
|
||||||
|
str(body.get("password") or ""),
|
||||||
|
)
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"authenticated": True,
|
||||||
|
"user": result["user"],
|
||||||
|
"csrf_token": result["csrf_token"],
|
||||||
|
},
|
||||||
|
HTTPStatus.CREATED,
|
||||||
|
{"Set-Cookie": self.session_cookie(result["session_token"])},
|
||||||
|
)
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
def auth_login(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self.read_json_body()
|
||||||
|
result = self.application_service.login_account(
|
||||||
|
str(body.get("username") or ""),
|
||||||
|
str(body.get("password") or ""),
|
||||||
|
)
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"authenticated": True,
|
||||||
|
"user": result["user"],
|
||||||
|
"csrf_token": result["csrf_token"],
|
||||||
|
},
|
||||||
|
headers={"Set-Cookie": self.session_cookie(result["session_token"])},
|
||||||
|
)
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
|
def auth_me(self) -> None:
|
||||||
|
service = self.application_service
|
||||||
|
if not self.require_auth(send_error=False):
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"authenticated": False,
|
||||||
|
"registration_required": service.database.count_users() == 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"authenticated": True,
|
||||||
|
"user": {
|
||||||
|
"id": int(self.auth_user["id"]),
|
||||||
|
"username": str(self.auth_user["username"]),
|
||||||
|
"role": str(self.auth_user.get("role") or "user"),
|
||||||
|
"membership": service.membership(),
|
||||||
|
},
|
||||||
|
"csrf_token": str(self.auth_user["csrf_token"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def auth_logout(self) -> None:
|
||||||
|
raw_token = self.session_token()
|
||||||
|
if raw_token:
|
||||||
|
from backend.features.accounts.security import token_hash
|
||||||
|
|
||||||
|
self.application_service.database.delete_session(token_hash(raw_token))
|
||||||
|
self.send_json(
|
||||||
|
{"ok": True},
|
||||||
|
headers={"Set-Cookie": self.session_cookie("", clear=True)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_birth_profile(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self.read_json_body()
|
||||||
|
personal = self.application_service.save_birth_profile(body)
|
||||||
|
self.send_json({"ok": True, "personal": personal})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
def change_password(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self.read_json_body()
|
||||||
|
current = str(body.get("current_password") or "")
|
||||||
|
new = str(body.get("new_password") or "")
|
||||||
|
confirmation = str(body.get("confirm_password") or "")
|
||||||
|
if new != confirmation:
|
||||||
|
raise ValueError("两次输入的新密码不一致。")
|
||||||
|
self.application_service.change_password(current, new)
|
||||||
|
self.send_json({"ok": True})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
def save_membership(self) -> None:
|
||||||
|
try:
|
||||||
|
service = self.application_service
|
||||||
|
service.update_membership(self.read_json_body())
|
||||||
|
self.send_json({"ok": True, "users": service.admin_users()})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class AccountRepositoryMixin:
|
||||||
|
"""Original SQLite account persistence methods, moved without query changes."""
|
||||||
|
|
||||||
|
def count_users(self) -> int:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute("SELECT COUNT(*) AS total FROM users").fetchone()
|
||||||
|
return int(row["total"] if row else 0)
|
||||||
|
|
||||||
|
def first_user_id(self) -> int:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute("SELECT MIN(id) AS id FROM users").fetchone()
|
||||||
|
return int(row["id"] or 0) if row else 0
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password_salt: str,
|
||||||
|
password_hash: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
try:
|
||||||
|
with self.connect() as connection:
|
||||||
|
role = "admin" if int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) == 0 else "user"
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users
|
||||||
|
(username, password_salt, password_hash, role, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(username, password_salt, password_hash, role, now, now),
|
||||||
|
)
|
||||||
|
user_id = int(cursor.lastrowid)
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
raise ValueError("该账号名已被使用。") from exc
|
||||||
|
return {"id": user_id, "username": username, "role": role, "created_at": now}
|
||||||
|
|
||||||
|
def user_by_username(self, username: str) -> dict[str, Any] | None:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, password_salt, password_hash, role, llm_mode,
|
||||||
|
membership_status, membership_plan, membership_starts_at,
|
||||||
|
membership_expires_at, created_at
|
||||||
|
FROM users WHERE username = ? COLLATE NOCASE
|
||||||
|
""",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def user_password(self, user_id: int) -> dict[str, str] | None:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT password_salt, password_hash FROM users WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def update_user_password(self, user_id: int, password_salt: str, password_hash: str) -> bool:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"UPDATE users SET password_salt = ?, password_hash = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(password_salt, password_hash, now, user_id),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def delete_user(self, user_id: int) -> bool:
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
self,
|
||||||
|
session_hash: str,
|
||||||
|
user_id: int,
|
||||||
|
csrf_token: str,
|
||||||
|
expires_at: str,
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute("DELETE FROM user_sessions WHERE expires_at <= ?", (now,))
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_sessions
|
||||||
|
(token_hash, user_id, csrf_token, expires_at, created_at, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(session_hash, user_id, csrf_token, expires_at, now, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
def session_user(self, session_hash: str) -> dict[str, Any] | None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status,
|
||||||
|
u.membership_plan, u.membership_starts_at, u.membership_expires_at,
|
||||||
|
u.created_at, s.csrf_token, s.expires_at
|
||||||
|
FROM user_sessions AS s
|
||||||
|
JOIN users AS u ON u.id = s.user_id
|
||||||
|
WHERE s.token_hash = ? AND s.expires_at > ?
|
||||||
|
""",
|
||||||
|
(session_hash, now),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE user_sessions SET last_seen_at = ? WHERE token_hash = ?",
|
||||||
|
(now, session_hash),
|
||||||
|
)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def delete_session(self, session_hash: str) -> bool:
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"DELETE FROM user_sessions WHERE token_hash = ?",
|
||||||
|
(session_hash,),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def get_user_credentials(self, user_id: int) -> str:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT encrypted_payload FROM user_credentials WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return str(row["encrypted_payload"]) if row else ""
|
||||||
|
|
||||||
|
def save_user_credentials(self, user_id: int, encrypted_payload: str) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_credentials (user_id, encrypted_payload, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
encrypted_payload = excluded.encrypted_payload,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(user_id, encrypted_payload, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_user_credentials(self) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT user_id, encrypted_payload FROM user_credentials ORDER BY user_id"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def user_access(self, user_id: int) -> dict[str, Any] | None:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, role, llm_mode, membership_status, membership_plan,
|
||||||
|
membership_starts_at, membership_expires_at, created_at
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def list_users(self) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, role, llm_mode, membership_status, membership_plan,
|
||||||
|
membership_starts_at, membership_expires_at, created_at
|
||||||
|
FROM users ORDER BY id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def update_user_llm_mode(self, user_id: int, mode: str) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE users SET llm_mode = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(mode, now, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_membership(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
status: str,
|
||||||
|
plan: str,
|
||||||
|
starts_at: str | None,
|
||||||
|
expires_at: str | None,
|
||||||
|
) -> bool:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET membership_status = ?, membership_plan = ?,
|
||||||
|
membership_starts_at = ?, membership_expires_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(status, plan, starts_at, expires_at, now, user_id),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def get_user_birth_profile(self, user_id: int) -> str:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT encrypted_payload FROM user_birth_profiles WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return str(row["encrypted_payload"]) if row else ""
|
||||||
|
|
||||||
|
def save_user_birth_profile(self, user_id: int, encrypted_payload: str) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_birth_profiles (user_id, encrypted_payload, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
encrypted_payload = excluded.encrypted_payload,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(user_id, encrypted_payload, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_user_birth_profile(self, user_id: int) -> bool:
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"DELETE FROM user_birth_profiles WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
|
||||||
|
PASSWORD_SCRYPT_N = 2**14
|
||||||
|
PASSWORD_SCRYPT_R = 8
|
||||||
|
PASSWORD_SCRYPT_P = 1
|
||||||
|
|
||||||
|
|
||||||
|
class SecretVault:
|
||||||
|
def __init__(self, key: str) -> None:
|
||||||
|
try:
|
||||||
|
self._fernet = Fernet(key.encode("ascii"))
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise ValueError("APP_ENCRYPTION_KEY 格式无效。") from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_key() -> str:
|
||||||
|
return Fernet.generate_key().decode("ascii")
|
||||||
|
|
||||||
|
def encrypt_json(self, payload: dict[str, Any]) -> str:
|
||||||
|
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
return self._fernet.encrypt(raw).decode("ascii")
|
||||||
|
|
||||||
|
def decrypt_json(self, token: str) -> dict[str, Any]:
|
||||||
|
if not token:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
payload = json.loads(self._fernet.decrypt(token.encode("ascii")).decode("utf-8"))
|
||||||
|
except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ValueError("账号加密数据无法解密,请检查 APP_ENCRYPTION_KEY。") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("账号加密数据格式无效。")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
||||||
|
raw_salt = salt or os.urandom(16)
|
||||||
|
digest = hashlib.scrypt(
|
||||||
|
password.encode("utf-8"),
|
||||||
|
salt=raw_salt,
|
||||||
|
n=PASSWORD_SCRYPT_N,
|
||||||
|
r=PASSWORD_SCRYPT_R,
|
||||||
|
p=PASSWORD_SCRYPT_P,
|
||||||
|
dklen=32,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
base64.urlsafe_b64encode(raw_salt).decode("ascii"),
|
||||||
|
base64.urlsafe_b64encode(digest).decode("ascii"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, salt_text: str, expected_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
|
||||||
|
_, actual_hash = hash_password(password, salt)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(actual_hash, expected_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def token_hash(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.bootstrap.config import (
|
||||||
|
SESSION_MAX_AGE,
|
||||||
|
USERNAME_PATTERN,
|
||||||
|
add_months,
|
||||||
|
normalize_date,
|
||||||
|
parse_iso_datetime,
|
||||||
|
)
|
||||||
|
from backend.features.accounts.security import (
|
||||||
|
SecretVault,
|
||||||
|
hash_password,
|
||||||
|
token_hash,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountService:
|
||||||
|
"""Preserved account, session, membership and birth-profile behavior."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
database: Any,
|
||||||
|
vault: SecretVault,
|
||||||
|
current_user_supplier: Callable[[], int],
|
||||||
|
access_supplier: Callable[[], dict[str, Any]],
|
||||||
|
bind_user: Callable[[int], None],
|
||||||
|
personal_field_builder: Callable[..., dict[str, Any]],
|
||||||
|
auth_lock: threading.Lock,
|
||||||
|
) -> None:
|
||||||
|
self.database = database
|
||||||
|
self.vault = vault
|
||||||
|
self.current_user_supplier = current_user_supplier
|
||||||
|
self.access_supplier = access_supplier
|
||||||
|
self.bind_user = bind_user
|
||||||
|
self.personal_field_builder = personal_field_builder
|
||||||
|
self.auth_lock = auth_lock
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_user_id(self) -> int:
|
||||||
|
return int(self.current_user_supplier())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def membership_for_access(access: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
starts = parse_iso_datetime(access.get("membership_starts_at"))
|
||||||
|
expires = parse_iso_datetime(access.get("membership_expires_at"))
|
||||||
|
subscribed = (
|
||||||
|
access.get("membership_status") == "active"
|
||||||
|
and (not starts or starts <= now)
|
||||||
|
and (not expires or expires > now)
|
||||||
|
)
|
||||||
|
is_admin = str(access.get("role")) == "admin"
|
||||||
|
active = is_admin or subscribed
|
||||||
|
remaining_seconds = None
|
||||||
|
if expires:
|
||||||
|
remaining_seconds = max(0, int((expires - now).total_seconds()))
|
||||||
|
return {
|
||||||
|
"active": active,
|
||||||
|
"subscribed": subscribed,
|
||||||
|
"status": "active" if subscribed else str(access.get("membership_status") or "inactive"),
|
||||||
|
"plan": str(access.get("membership_plan") or ""),
|
||||||
|
"starts_at": str(access.get("membership_starts_at") or ""),
|
||||||
|
"expires_at": str(access.get("membership_expires_at") or ""),
|
||||||
|
"is_admin": is_admin,
|
||||||
|
"remaining_seconds": remaining_seconds,
|
||||||
|
"remaining_days": None if remaining_seconds is None else (remaining_seconds + 86399) // 86400,
|
||||||
|
}
|
||||||
|
|
||||||
|
def membership(self) -> dict[str, Any]:
|
||||||
|
access = self.access_supplier() or self.database.user_access(self.current_user_id) or {}
|
||||||
|
return self.membership_for_access(access)
|
||||||
|
|
||||||
|
def register(self, username: str, password: str) -> dict[str, Any]:
|
||||||
|
username = username.strip()
|
||||||
|
self.validate_input(username, password)
|
||||||
|
with self.auth_lock:
|
||||||
|
salt, password_digest = hash_password(password)
|
||||||
|
user = self.database.create_user(username, salt, password_digest)
|
||||||
|
return self.create_session(user)
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> dict[str, Any]:
|
||||||
|
username = username.strip()
|
||||||
|
if not username or not password:
|
||||||
|
raise ValueError("账号名和密码不能为空。")
|
||||||
|
user = self.database.user_by_username(username)
|
||||||
|
if not user or not verify_password(
|
||||||
|
password,
|
||||||
|
str(user.get("password_salt") or ""),
|
||||||
|
str(user.get("password_hash") or ""),
|
||||||
|
):
|
||||||
|
raise ValueError("账号名或密码不正确。")
|
||||||
|
return self.create_session(user)
|
||||||
|
|
||||||
|
def change_password(self, current_password: str, new_password: str) -> None:
|
||||||
|
current_password = str(current_password or "")
|
||||||
|
access = self.database.user_access(self.current_user_id)
|
||||||
|
self.validate_input(str(access["username"]), new_password)
|
||||||
|
credentials = self.database.user_password(self.current_user_id)
|
||||||
|
if not credentials or not verify_password(
|
||||||
|
current_password,
|
||||||
|
str(credentials.get("password_salt") or ""),
|
||||||
|
str(credentials.get("password_hash") or ""),
|
||||||
|
):
|
||||||
|
raise ValueError("当前密码不正确。")
|
||||||
|
salt, digest = hash_password(new_password)
|
||||||
|
if not self.database.update_user_password(self.current_user_id, salt, digest):
|
||||||
|
raise ValueError("账号不存在。")
|
||||||
|
|
||||||
|
def create_session(self, user: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
session_token = secrets.token_urlsafe(32)
|
||||||
|
csrf_token = secrets.token_urlsafe(24)
|
||||||
|
expires = datetime.now(timezone.utc) + timedelta(seconds=SESSION_MAX_AGE)
|
||||||
|
self.database.create_session(
|
||||||
|
token_hash(session_token),
|
||||||
|
int(user["id"]),
|
||||||
|
csrf_token,
|
||||||
|
expires.isoformat(timespec="seconds"),
|
||||||
|
)
|
||||||
|
self.bind_user(int(user["id"]))
|
||||||
|
access = self.database.user_access(int(user["id"])) or {}
|
||||||
|
return {
|
||||||
|
"user": {
|
||||||
|
"id": int(user["id"]),
|
||||||
|
"username": str(user["username"]),
|
||||||
|
"role": str(access.get("role") or "user"),
|
||||||
|
"membership": self.membership(),
|
||||||
|
},
|
||||||
|
"session_token": session_token,
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_input(username: str, password: str) -> None:
|
||||||
|
if not USERNAME_PATTERN.fullmatch(username):
|
||||||
|
raise ValueError("账号名应为 3 至 30 位中文、字母、数字、下划线或连字符。")
|
||||||
|
if len(password) < 8 or len(password) > 128:
|
||||||
|
raise ValueError("密码长度应为 8 至 128 位。")
|
||||||
|
if password.isalpha() or password.isdigit():
|
||||||
|
raise ValueError("密码应同时包含字母、数字或符号中的至少两类。")
|
||||||
|
|
||||||
|
def save_birth_profile(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
birth_datetime = str(payload.get("birth_datetime") or "").strip()
|
||||||
|
gender = str(payload.get("gender") or "unspecified").strip()
|
||||||
|
current_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
|
||||||
|
personal = self.personal_field_builder(birth_datetime, gender, current_date)
|
||||||
|
encrypted = self.vault.encrypt_json(
|
||||||
|
{"birth_datetime": birth_datetime, "gender": gender}
|
||||||
|
)
|
||||||
|
self.database.save_user_birth_profile(self.current_user_id, encrypted)
|
||||||
|
return self.public_personal_profile(personal)
|
||||||
|
|
||||||
|
def stored_birth_profile(self) -> dict[str, str] | None:
|
||||||
|
encrypted = self.database.get_user_birth_profile(self.current_user_id)
|
||||||
|
if not encrypted:
|
||||||
|
return None
|
||||||
|
payload = self.vault.decrypt_json(encrypted)
|
||||||
|
birth_datetime = str(payload.get("birth_datetime") or "").strip()
|
||||||
|
if not birth_datetime:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"birth_datetime": birth_datetime,
|
||||||
|
"gender": str(payload.get("gender") or "unspecified"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def personal_field(
|
||||||
|
self,
|
||||||
|
current_date: str,
|
||||||
|
current_field: dict[str, Any],
|
||||||
|
public: bool = False,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
stored = self.stored_birth_profile()
|
||||||
|
if not stored:
|
||||||
|
return None
|
||||||
|
personal = self.personal_field_builder(
|
||||||
|
stored["birth_datetime"],
|
||||||
|
stored["gender"],
|
||||||
|
current_date,
|
||||||
|
current_field,
|
||||||
|
)
|
||||||
|
if public:
|
||||||
|
return self.public_personal_profile(personal)
|
||||||
|
personal.pop("birth", None)
|
||||||
|
return personal
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def public_personal_profile(personal: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
allowed = {
|
||||||
|
"day_master",
|
||||||
|
"ten_god_tendency",
|
||||||
|
"element_balance",
|
||||||
|
"balance_tendency",
|
||||||
|
"current",
|
||||||
|
"notice",
|
||||||
|
}
|
||||||
|
return {key: value for key, value in personal.items() if key in allowed}
|
||||||
|
|
||||||
|
def update_membership(self, payload: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
user_id = int(payload.get("user_id"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("会员账号不正确。") from exc
|
||||||
|
status = str(payload.get("status") or "inactive")
|
||||||
|
if status not in {"active", "inactive", "suspended"}:
|
||||||
|
raise ValueError("会员状态不正确。")
|
||||||
|
access = self.database.user_access(user_id)
|
||||||
|
if not access:
|
||||||
|
raise ValueError("用户不存在。")
|
||||||
|
starts_at = None
|
||||||
|
expires_at = None
|
||||||
|
plan = ""
|
||||||
|
if status == "active":
|
||||||
|
duration = str(payload.get("duration") or "").strip()
|
||||||
|
durations = {
|
||||||
|
"1_month": (1, "1个月"),
|
||||||
|
"3_months": (3, "3个月"),
|
||||||
|
"12_months": (12, "12个月"),
|
||||||
|
"3_years": (36, "3年"),
|
||||||
|
"permanent": (0, "永久"),
|
||||||
|
}
|
||||||
|
if duration not in durations:
|
||||||
|
raise ValueError("请选择会员开通时长。")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
existing_start = parse_iso_datetime(access.get("membership_starts_at"))
|
||||||
|
existing_expiry = parse_iso_datetime(access.get("membership_expires_at"))
|
||||||
|
starts = existing_start if existing_start and existing_start <= now else now
|
||||||
|
months, plan = durations[duration]
|
||||||
|
starts_at = starts.isoformat(timespec="seconds")
|
||||||
|
if months:
|
||||||
|
renewal_base = existing_expiry if existing_expiry and existing_expiry > now else now
|
||||||
|
expires_at = add_months(renewal_base, months).isoformat(timespec="seconds")
|
||||||
|
if not self.database.update_membership(
|
||||||
|
user_id, status, plan, starts_at, expires_at
|
||||||
|
):
|
||||||
|
raise ValueError("用户不存在。")
|
||||||
|
|
||||||
|
def admin_users(
|
||||||
|
self, usage_supplier: Callable[[int], int]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
rows = []
|
||||||
|
for user in self.database.list_users():
|
||||||
|
membership = self.membership_for_access(user)
|
||||||
|
used = usage_supplier(int(user["id"])) if membership["active"] else 0
|
||||||
|
rows.append({
|
||||||
|
**user,
|
||||||
|
"membership_active": membership["active"],
|
||||||
|
"membership_subscribed": membership["subscribed"],
|
||||||
|
"used_today": used,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
@@ -4,7 +4,7 @@ import secrets
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app_config import validate_text
|
from backend.bootstrap.config import validate_text
|
||||||
from backend.database.repositories import AlertRepository
|
from backend.database.repositories import AlertRepository
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app_config import normalize_date, validate_stock_code, validate_text
|
from backend.bootstrap.config import normalize_date, validate_stock_code, validate_text
|
||||||
from backend.database.repositories import TradeJournalRepository
|
from backend.database.repositories import TradeJournalRepository
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .http import SystemHttpMixin
|
||||||
|
|
||||||
|
__all__ = ["SystemHttpMixin"]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
|
||||||
|
class SystemHttpMixin:
|
||||||
|
def save_system_settings(self) -> None:
|
||||||
|
try:
|
||||||
|
result = self.application_service.save_system_settings(self.read_json_body())
|
||||||
|
self.send_json({"ok": True, **result})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_system_llm_settings(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self.read_json_body()
|
||||||
|
result = self.application_service.test_system_llm_profile(
|
||||||
|
str(body.get("model_id") or ""), body.get("profile") or {}
|
||||||
|
)
|
||||||
|
self.send_json({"ok": True, "result": result})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
def start_background_refresh(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self.read_json_body(allow_empty=True)
|
||||||
|
started = self.application_service.request_background_sync(
|
||||||
|
str(body.get("trade_date") or date.today().isoformat())
|
||||||
|
)
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"started": started,
|
||||||
|
"message": "后台刷新已开始" if started else "已有后台刷新任务正在运行",
|
||||||
|
},
|
||||||
|
HTTPStatus.ACCEPTED,
|
||||||
|
)
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSettingsRepositoryMixin:
|
||||||
|
"""Original encrypted system-setting persistence methods."""
|
||||||
|
|
||||||
|
def get_system_setting(self, key: str) -> str:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT encrypted_payload FROM system_settings WHERE setting_key = ?",
|
||||||
|
(key,),
|
||||||
|
).fetchone()
|
||||||
|
return str(row["encrypted_payload"]) if row else ""
|
||||||
|
|
||||||
|
def save_system_setting(self, key: str, encrypted_payload: str) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO system_settings (setting_key, encrypted_payload, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(setting_key) DO UPDATE SET
|
||||||
|
encrypted_payload = excluded.encrypted_payload,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(key, encrypted_payload, now),
|
||||||
|
)
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
from .context import correlation_id
|
from .context import correlation_id
|
||||||
from .errors import normalize_error_payload
|
from .errors import normalize_error_payload
|
||||||
|
from .handler import HttpTransportMixin
|
||||||
from .router import AccessRole, ApiRoute, ApiRouteRegistry, RouteRegistryError
|
from .router import AccessRole, ApiRoute, ApiRouteRegistry, RouteRegistryError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AccessRole", "ApiRoute", "ApiRouteRegistry", "RouteRegistryError",
|
"AccessRole", "ApiRoute", "ApiRouteRegistry", "RouteRegistryError",
|
||||||
"correlation_id", "normalize_error_payload",
|
"HttpTransportMixin", "correlation_id", "normalize_error_payload",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import secrets
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from backend.bootstrap.config import SESSION_COOKIE, SESSION_MAX_AGE, STATIC_DIR
|
||||||
|
from backend.features.accounts.security import token_hash
|
||||||
|
from backend.http.context import correlation_id
|
||||||
|
from backend.http.errors import normalize_error_payload
|
||||||
|
|
||||||
|
|
||||||
|
class HttpTransportMixin:
|
||||||
|
"""Original HTTP transport, static-file, session and access behavior."""
|
||||||
|
|
||||||
|
application_service: Any
|
||||||
|
route_registry: Any
|
||||||
|
|
||||||
|
def session_token(self) -> str:
|
||||||
|
cookie = SimpleCookie()
|
||||||
|
try:
|
||||||
|
cookie.load(self.headers.get("Cookie", ""))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
morsel = cookie.get(SESSION_COOKIE)
|
||||||
|
return morsel.value if morsel else ""
|
||||||
|
|
||||||
|
def require_auth(self, send_error: bool = True) -> bool:
|
||||||
|
raw_token = self.session_token()
|
||||||
|
service = self.application_service
|
||||||
|
user = service.database.session_user(token_hash(raw_token)) if raw_token else None
|
||||||
|
if not user:
|
||||||
|
if send_error:
|
||||||
|
self.send_json({"error": "请先登录。"}, HTTPStatus.UNAUTHORIZED)
|
||||||
|
return False
|
||||||
|
self.auth_user = user
|
||||||
|
service.bind_user(int(user["id"]))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def require_csrf(self) -> bool:
|
||||||
|
supplied = self.headers.get("X-CSRF-Token", "")
|
||||||
|
expected = str(getattr(self, "auth_user", {}).get("csrf_token") or "")
|
||||||
|
if not supplied or not secrets.compare_digest(supplied, expected):
|
||||||
|
self.send_json({"error": "请求校验失败,请刷新页面后重试。"}, HTTPStatus.FORBIDDEN)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def require_admin(self) -> bool:
|
||||||
|
if str(getattr(self, "auth_user", {}).get("role") or "user") != "admin":
|
||||||
|
self.send_json({"error": "需要管理员权限。"}, HTTPStatus.FORBIDDEN)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def require_member(self) -> bool:
|
||||||
|
if self.application_service.membership()["active"]:
|
||||||
|
return True
|
||||||
|
self.send_json(
|
||||||
|
{"error": "该功能仅对有效会员开放,请联系管理员开通会员。", "code": "membership_required"},
|
||||||
|
HTTPStatus.FORBIDDEN,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def require_access(self, method: str, path: str) -> bool:
|
||||||
|
route = self.route_registry.resolve(method, path)
|
||||||
|
if route is None:
|
||||||
|
self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
|
||||||
|
return False
|
||||||
|
role = route.access
|
||||||
|
if role == "public":
|
||||||
|
return True
|
||||||
|
if role == "admin":
|
||||||
|
return self.require_admin()
|
||||||
|
if role == "member":
|
||||||
|
return self.require_member()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def session_cookie(self, value: str, clear: bool = False) -> str:
|
||||||
|
max_age = 0 if clear else SESSION_MAX_AGE
|
||||||
|
cookie = (
|
||||||
|
f"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
|
||||||
|
)
|
||||||
|
if self.headers.get("X-Forwarded-Proto", "").lower() == "https":
|
||||||
|
cookie += "; Secure"
|
||||||
|
return cookie
|
||||||
|
|
||||||
|
def read_json_body(self, allow_empty: bool = False) -> dict[str, Any]:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length == 0 and allow_empty:
|
||||||
|
return {}
|
||||||
|
if length <= 0 or length > 65536:
|
||||||
|
raise ValueError("请求内容为空或过大。")
|
||||||
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
|
||||||
|
def serve_static(self, request_path: str) -> None:
|
||||||
|
relative = unquote(request_path).lstrip("/") or "index.html"
|
||||||
|
candidate = (STATIC_DIR / relative).resolve()
|
||||||
|
try:
|
||||||
|
candidate.relative_to(STATIC_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
|
self.send_error(HTTPStatus.FORBIDDEN)
|
||||||
|
return
|
||||||
|
if not candidate.is_file():
|
||||||
|
candidate = STATIC_DIR / "index.html"
|
||||||
|
try:
|
||||||
|
content = candidate.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
|
||||||
|
if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}:
|
||||||
|
content_type += "; charset=utf-8"
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(content)))
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(content)
|
||||||
|
|
||||||
|
def send_json(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
status: HTTPStatus = HTTPStatus.OK,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
request_id = getattr(self, "_correlation_id", "")
|
||||||
|
if not request_id:
|
||||||
|
request_id = correlation_id(self.headers.get("X-Request-ID", ""))
|
||||||
|
self._correlation_id = request_id
|
||||||
|
payload = normalize_error_payload(payload, status, request_id)
|
||||||
|
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(content)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Request-ID", request_id)
|
||||||
|
for name, value in (headers or {}).items():
|
||||||
|
self.send_header(name, value)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(content)
|
||||||
|
|
||||||
|
def log_message(self, format_string: str, *args: Any) -> None:
|
||||||
|
print(f"[{self.log_date_time_string()}] {format_string % args}")
|
||||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, cast
|
from typing import Literal, cast
|
||||||
|
|
||||||
from app_config import APP_DIR
|
from backend.bootstrap.config import APP_DIR
|
||||||
|
|
||||||
|
|
||||||
AccessRole = Literal["public", "authenticated", "member", "admin"]
|
AccessRole = Literal["public", "authenticated", "member", "admin"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app_config import APP_DIR
|
from backend.bootstrap.config import APP_DIR
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
+3
-250
@@ -7,6 +7,8 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory
|
from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory
|
||||||
|
from backend.features.accounts.repository import AccountRepositoryMixin
|
||||||
|
from backend.features.system.repository import SystemSettingsRepositoryMixin
|
||||||
|
|
||||||
|
|
||||||
def _optional_float(value: Any) -> float | None:
|
def _optional_float(value: Any) -> float | None:
|
||||||
@@ -18,7 +20,7 @@ def _optional_float(value: Any) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ReviewDatabase:
|
class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin):
|
||||||
def __init__(self, path: Path) -> None:
|
def __init__(self, path: Path) -> None:
|
||||||
self.path = path
|
self.path = path
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -647,225 +649,6 @@ class ReviewDatabase:
|
|||||||
)
|
)
|
||||||
MigrationRunner().apply(connection, MIGRATIONS)
|
MigrationRunner().apply(connection, MIGRATIONS)
|
||||||
|
|
||||||
def count_users(self) -> int:
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute("SELECT COUNT(*) AS total FROM users").fetchone()
|
|
||||||
return int(row["total"] if row else 0)
|
|
||||||
|
|
||||||
def first_user_id(self) -> int:
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute("SELECT MIN(id) AS id FROM users").fetchone()
|
|
||||||
return int(row["id"] or 0) if row else 0
|
|
||||||
|
|
||||||
def create_user(
|
|
||||||
self,
|
|
||||||
username: str,
|
|
||||||
password_salt: str,
|
|
||||||
password_hash: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
||||||
try:
|
|
||||||
with self.connect() as connection:
|
|
||||||
role = "admin" if int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) == 0 else "user"
|
|
||||||
cursor = connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO users
|
|
||||||
(username, password_salt, password_hash, role, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(username, password_salt, password_hash, role, now, now),
|
|
||||||
)
|
|
||||||
user_id = int(cursor.lastrowid)
|
|
||||||
except sqlite3.IntegrityError as exc:
|
|
||||||
raise ValueError("该账号名已被使用。") from exc
|
|
||||||
return {"id": user_id, "username": username, "role": role, "created_at": now}
|
|
||||||
|
|
||||||
def user_by_username(self, username: str) -> dict[str, Any] | None:
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, username, password_salt, password_hash, role, llm_mode,
|
|
||||||
membership_status, membership_plan, membership_starts_at,
|
|
||||||
membership_expires_at, created_at
|
|
||||||
FROM users WHERE username = ? COLLATE NOCASE
|
|
||||||
""",
|
|
||||||
(username,),
|
|
||||||
).fetchone()
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
def user_password(self, user_id: int) -> dict[str, str] | None:
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT password_salt, password_hash FROM users WHERE id = ?",
|
|
||||||
(user_id,),
|
|
||||||
).fetchone()
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
def update_user_password(self, user_id: int, password_salt: str, password_hash: str) -> bool:
|
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
||||||
with self.connect() as connection:
|
|
||||||
cursor = connection.execute(
|
|
||||||
"UPDATE users SET password_salt = ?, password_hash = ?, updated_at = ? WHERE id = ?",
|
|
||||||
(password_salt, password_hash, now, user_id),
|
|
||||||
)
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|
||||||
def delete_user(self, user_id: int) -> bool:
|
|
||||||
with self.connect() as connection:
|
|
||||||
cursor = connection.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|
||||||
def create_session(
|
|
||||||
self,
|
|
||||||
session_hash: str,
|
|
||||||
user_id: int,
|
|
||||||
csrf_token: str,
|
|
||||||
expires_at: str,
|
|
||||||
) -> None:
|
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.execute("DELETE FROM user_sessions WHERE expires_at <= ?", (now,))
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO user_sessions
|
|
||||||
(token_hash, user_id, csrf_token, expires_at, created_at, last_seen_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(session_hash, user_id, csrf_token, expires_at, now, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
def session_user(self, session_hash: str) -> dict[str, Any] | None:
|
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status,
|
|
||||||
u.membership_plan, u.membership_starts_at, u.membership_expires_at,
|
|
||||||
u.created_at, s.csrf_token, s.expires_at
|
|
||||||
FROM user_sessions AS s
|
|
||||||
JOIN users AS u ON u.id = s.user_id
|
|
||||||
WHERE s.token_hash = ? AND s.expires_at > ?
|
|
||||||
""",
|
|
||||||
(session_hash, now),
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
connection.execute(
|
|
||||||
"UPDATE user_sessions SET last_seen_at = ? WHERE token_hash = ?",
|
|
||||||
(now, session_hash),
|
|
||||||
)
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
def delete_session(self, session_hash: str) -> bool:
|
|
||||||
with self.connect() as connection:
|
|
||||||
cursor = connection.execute(
|
|
||||||
"DELETE FROM user_sessions WHERE token_hash = ?",
|
|
||||||
(session_hash,),
|
|
||||||
)
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|
||||||
def get_user_credentials(self, user_id: int) -> str:
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT encrypted_payload FROM user_credentials WHERE user_id = ?",
|
|
||||||
(user_id,),
|
|
||||||
).fetchone()
|
|
||||||
return str(row["encrypted_payload"]) if row else ""
|
|
||||||
|
|
||||||
def save_user_credentials(self, user_id: int, encrypted_payload: str) -> None:
|
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO user_credentials (user_id, encrypted_payload, updated_at)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
|
||||||
encrypted_payload = excluded.encrypted_payload,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(user_id, encrypted_payload, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_user_credentials(self) -> list[dict[str, Any]]:
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT user_id, encrypted_payload FROM user_credentials ORDER BY user_id"
|
|
||||||
).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
def get_system_setting(self, key: str) -> str:
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT encrypted_payload FROM system_settings WHERE setting_key = ?",
|
|
||||||
(key,),
|
|
||||||
).fetchone()
|
|
||||||
return str(row["encrypted_payload"]) if row else ""
|
|
||||||
|
|
||||||
def save_system_setting(self, key: str, encrypted_payload: str) -> None:
|
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO system_settings (setting_key, encrypted_payload, updated_at)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(setting_key) DO UPDATE SET
|
|
||||||
encrypted_payload = excluded.encrypted_payload,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(key, encrypted_payload, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
def user_access(self, user_id: int) -> dict[str, Any] | None:
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, username, role, llm_mode, membership_status, membership_plan,
|
|
||||||
membership_starts_at, membership_expires_at, created_at
|
|
||||||
FROM users WHERE id = ?
|
|
||||||
""",
|
|
||||||
(user_id,),
|
|
||||||
).fetchone()
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
def list_users(self) -> list[dict[str, Any]]:
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, username, role, llm_mode, membership_status, membership_plan,
|
|
||||||
membership_starts_at, membership_expires_at, created_at
|
|
||||||
FROM users ORDER BY id
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
def update_user_llm_mode(self, user_id: int, mode: str) -> None:
|
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"UPDATE users SET llm_mode = ?, updated_at = ? WHERE id = ?",
|
|
||||||
(mode, now, user_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_membership(
|
|
||||||
self,
|
|
||||||
user_id: int,
|
|
||||||
status: str,
|
|
||||||
plan: str,
|
|
||||||
starts_at: str | None,
|
|
||||||
expires_at: str | None,
|
|
||||||
) -> bool:
|
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
||||||
with self.connect() as connection:
|
|
||||||
cursor = connection.execute(
|
|
||||||
"""
|
|
||||||
UPDATE users
|
|
||||||
SET membership_status = ?, membership_plan = ?,
|
|
||||||
membership_starts_at = ?, membership_expires_at = ?, updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(status, plan, starts_at, expires_at, now, user_id),
|
|
||||||
)
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|
||||||
def record_llm_usage(
|
def record_llm_usage(
|
||||||
self,
|
self,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -907,36 +690,6 @@ class ReviewDatabase:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return int(row["total"] if row else 0)
|
return int(row["total"] if row else 0)
|
||||||
|
|
||||||
def get_user_birth_profile(self, user_id: int) -> str:
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT encrypted_payload FROM user_birth_profiles WHERE user_id = ?",
|
|
||||||
(user_id,),
|
|
||||||
).fetchone()
|
|
||||||
return str(row["encrypted_payload"]) if row else ""
|
|
||||||
|
|
||||||
def save_user_birth_profile(self, user_id: int, encrypted_payload: str) -> None:
|
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO user_birth_profiles (user_id, encrypted_payload, updated_at)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
|
||||||
encrypted_payload = excluded.encrypted_payload,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(user_id, encrypted_payload, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete_user_birth_profile(self, user_id: int) -> bool:
|
|
||||||
with self.connect() as connection:
|
|
||||||
cursor = connection.execute(
|
|
||||||
"DELETE FROM user_birth_profiles WHERE user_id = ?",
|
|
||||||
(user_id,),
|
|
||||||
)
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|
||||||
def get_snapshot(self, trade_date: str) -> dict[str, Any] | None:
|
def get_snapshot(self, trade_date: str) -> dict[str, Any] | None:
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
|
|||||||
+2
-70
@@ -1,71 +1,3 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility imports for the preserved account security API."""
|
||||||
|
|
||||||
import base64
|
from backend.features.accounts.security import * # noqa: F401,F403
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
|
||||||
|
|
||||||
|
|
||||||
PASSWORD_SCRYPT_N = 2**14
|
|
||||||
PASSWORD_SCRYPT_R = 8
|
|
||||||
PASSWORD_SCRYPT_P = 1
|
|
||||||
|
|
||||||
|
|
||||||
class SecretVault:
|
|
||||||
def __init__(self, key: str) -> None:
|
|
||||||
try:
|
|
||||||
self._fernet = Fernet(key.encode("ascii"))
|
|
||||||
except (ValueError, TypeError) as exc:
|
|
||||||
raise ValueError("APP_ENCRYPTION_KEY 格式无效。") from exc
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def generate_key() -> str:
|
|
||||||
return Fernet.generate_key().decode("ascii")
|
|
||||||
|
|
||||||
def encrypt_json(self, payload: dict[str, Any]) -> str:
|
|
||||||
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
||||||
return self._fernet.encrypt(raw).decode("ascii")
|
|
||||||
|
|
||||||
def decrypt_json(self, token: str) -> dict[str, Any]:
|
|
||||||
if not token:
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
payload = json.loads(self._fernet.decrypt(token.encode("ascii")).decode("utf-8"))
|
|
||||||
except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise ValueError("账号加密数据无法解密,请检查 APP_ENCRYPTION_KEY。") from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise ValueError("账号加密数据格式无效。")
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
|
||||||
raw_salt = salt or os.urandom(16)
|
|
||||||
digest = hashlib.scrypt(
|
|
||||||
password.encode("utf-8"),
|
|
||||||
salt=raw_salt,
|
|
||||||
n=PASSWORD_SCRYPT_N,
|
|
||||||
r=PASSWORD_SCRYPT_R,
|
|
||||||
p=PASSWORD_SCRYPT_P,
|
|
||||||
dklen=32,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
base64.urlsafe_b64encode(raw_salt).decode("ascii"),
|
|
||||||
base64.urlsafe_b64encode(digest).decode("ascii"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(password: str, salt_text: str, expected_hash: str) -> bool:
|
|
||||||
try:
|
|
||||||
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
|
|
||||||
_, actual_hash = hash_password(password, salt)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False
|
|
||||||
return hmac.compare_digest(actual_hash, expected_hash)
|
|
||||||
|
|
||||||
|
|
||||||
def token_hash(token: str) -> str:
|
|
||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import sqlite3
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from database import ReviewDatabase
|
from database import ReviewDatabase
|
||||||
from security import hash_password, verify_password
|
from backend.features.accounts.security import hash_password, verify_password
|
||||||
|
|
||||||
|
|
||||||
class AccountAccessTests(unittest.TestCase):
|
class AccountAccessTests(unittest.TestCase):
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
def test_server_has_no_direct_runtime_tushare_construction(self) -> None:
|
def test_server_has_no_direct_runtime_tushare_construction(self) -> None:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
source = (Path(__file__).resolve().parents[1] / "server.py").read_text(encoding="utf-8")
|
source = (
|
||||||
|
Path(__file__).resolve().parents[1] / "backend" / "application.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
self.assertEqual(source.count("TushareClient(self.token)"), 1)
|
self.assertEqual(source.count("TushareClient(self.token)"), 1)
|
||||||
self.assertIn("return gateway.tushare()", source)
|
self.assertIn("return gateway.tushare()", source)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from tushare_client import _sector_coverage_issue
|
|||||||
|
|
||||||
|
|
||||||
def load_method(name: str):
|
def load_method(name: str):
|
||||||
source = Path("server.py").read_text(encoding="utf-8")
|
source = Path("backend/application.py").read_text(encoding="utf-8")
|
||||||
tree = ast.parse(source)
|
tree = ast.parse(source)
|
||||||
dashboard_service = next(
|
dashboard_service = next(
|
||||||
node for node in tree.body
|
node for node in tree.body
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class MentorSkillRegistryTests(unittest.TestCase):
|
|||||||
self.assertTrue(all(item.quality_total == 6 for item in skills))
|
self.assertTrue(all(item.quality_total == 6 for item in skills))
|
||||||
|
|
||||||
def test_server_applies_private_guard_to_every_mentor_entry_point(self):
|
def test_server_applies_private_guard_to_every_mentor_entry_point(self):
|
||||||
source = (ROOT / "server.py").read_text(encoding="utf-8")
|
source = (ROOT / "backend" / "application.py").read_text(encoding="utf-8")
|
||||||
mentor_section = source[source.index(" def mentor_setup"):source.index(" def _heaven_manual_schema")]
|
mentor_section = source[source.index(" def mentor_setup"):source.index(" def _heaven_manual_schema")]
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
mentor_section.count('include_private=self.membership()["is_admin"]'),
|
mentor_section.count('include_private=self.membership()["is_admin"]'),
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import server
|
||||||
|
from backend.application import DashboardService, RequestHandler
|
||||||
|
from backend.bootstrap.config import APP_DIR, STATIC_DIR
|
||||||
|
from backend.features.accounts.repository import AccountRepositoryMixin
|
||||||
|
from backend.features.accounts.security import SecretVault, token_hash
|
||||||
|
from backend.features.accounts.service import AccountService
|
||||||
|
from backend.http.handler import HttpTransportMixin
|
||||||
|
from database import ReviewDatabase
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSliceStructureTests(unittest.TestCase):
|
||||||
|
def test_original_entrypoint_exports_canonical_runtime(self) -> None:
|
||||||
|
self.assertIs(server.DashboardService, DashboardService)
|
||||||
|
self.assertIs(server.RequestHandler, RequestHandler)
|
||||||
|
self.assertIs(server.SERVICE, RequestHandler.application_service)
|
||||||
|
|
||||||
|
def test_runtime_paths_still_point_at_app_root(self) -> None:
|
||||||
|
self.assertEqual(APP_DIR, Path(__file__).resolve().parents[1])
|
||||||
|
self.assertEqual(STATIC_DIR, APP_DIR / "static")
|
||||||
|
|
||||||
|
def test_account_persistence_and_http_transport_have_single_owners(self) -> None:
|
||||||
|
for method in (
|
||||||
|
"create_user",
|
||||||
|
"session_user",
|
||||||
|
"update_membership",
|
||||||
|
"save_user_birth_profile",
|
||||||
|
):
|
||||||
|
self.assertNotIn(method, ReviewDatabase.__dict__)
|
||||||
|
self.assertIn(method, AccountRepositoryMixin.__dict__)
|
||||||
|
for method in (
|
||||||
|
"require_auth",
|
||||||
|
"require_csrf",
|
||||||
|
"require_access",
|
||||||
|
"serve_static",
|
||||||
|
"send_json",
|
||||||
|
):
|
||||||
|
self.assertNotIn(method, RequestHandler.__dict__)
|
||||||
|
self.assertIn(method, HttpTransportMixin.__dict__)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSliceBehaviorTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.database = ReviewDatabase(Path(self.temporary.name) / "review.db")
|
||||||
|
self.vault = SecretVault(SecretVault.generate_key())
|
||||||
|
self.context: dict[str, object] = {"user_id": 0, "access": {}}
|
||||||
|
|
||||||
|
def bind_user(user_id: int) -> None:
|
||||||
|
self.context["user_id"] = user_id
|
||||||
|
self.context["access"] = self.database.user_access(user_id) or {}
|
||||||
|
|
||||||
|
self.service = AccountService(
|
||||||
|
database=self.database,
|
||||||
|
vault=self.vault,
|
||||||
|
current_user_supplier=lambda: int(self.context["user_id"]),
|
||||||
|
access_supplier=lambda: dict(self.context["access"]),
|
||||||
|
bind_user=bind_user,
|
||||||
|
personal_field_builder=lambda *args: {
|
||||||
|
"birth": "private",
|
||||||
|
"day_master": "甲木",
|
||||||
|
"current": {"trade_date": args[2]},
|
||||||
|
"notice": "test",
|
||||||
|
},
|
||||||
|
auth_lock=threading.Lock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def test_register_login_session_and_password_contract(self) -> None:
|
||||||
|
registered = self.service.register("owner_01", "Password123")
|
||||||
|
self.assertEqual(registered["user"]["role"], "admin")
|
||||||
|
self.assertTrue(registered["user"]["membership"]["active"])
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self.database.session_user(token_hash(registered["session_token"]))
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "账号名或密码不正确"):
|
||||||
|
self.service.login("owner_01", "wrong-password")
|
||||||
|
|
||||||
|
self.service.change_password("Password123", "NewPassword456")
|
||||||
|
logged_in = self.service.login("owner_01", "NewPassword456")
|
||||||
|
self.assertEqual(logged_in["user"]["id"], registered["user"]["id"])
|
||||||
|
|
||||||
|
def test_membership_and_birth_profile_remain_account_scoped(self) -> None:
|
||||||
|
owner = self.service.register("owner_02", "Password123")
|
||||||
|
other = self.database.create_user("other_02", "salt", "hash")
|
||||||
|
self.service.update_membership(
|
||||||
|
{"user_id": other["id"], "status": "active", "duration": "3_months"}
|
||||||
|
)
|
||||||
|
other_access = self.database.user_access(other["id"])
|
||||||
|
self.assertEqual(other_access["membership_plan"], "3个月")
|
||||||
|
self.assertTrue(AccountService.membership_for_access(other_access)["subscribed"])
|
||||||
|
|
||||||
|
personal = self.service.save_birth_profile(
|
||||||
|
{
|
||||||
|
"birth_datetime": "1990-01-01 08:30",
|
||||||
|
"gender": "male",
|
||||||
|
"trade_date": "2026-07-30",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertNotIn("birth", personal)
|
||||||
|
self.assertEqual(personal["day_master"], "甲木")
|
||||||
|
self.assertTrue(self.database.get_user_birth_profile(owner["user"]["id"]))
|
||||||
|
self.assertEqual(self.database.get_user_birth_profile(other["id"]), "")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -90,8 +90,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
"moneyflow": {},
|
"moneyflow": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("server.datetime", FixedMarketDatetime), patch(
|
with patch("backend.application.datetime", FixedMarketDatetime), patch(
|
||||||
"server.TushareClient", RealtimeClientStub
|
"backend.application.TushareClient", RealtimeClientStub
|
||||||
):
|
):
|
||||||
result = self.service._prepare_stock_detail(cached, "002141", today)
|
result = self.service._prepare_stock_detail(cached, "002141", today)
|
||||||
|
|
||||||
@@ -112,8 +112,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
"stock": {"code": "002141", "price": 10, "change": 1.2},
|
"stock": {"code": "002141", "price": 10, "change": 1.2},
|
||||||
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
|
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
|
||||||
}
|
}
|
||||||
with patch("server.datetime", FixedMarketDatetime), patch(
|
with patch("backend.application.datetime", FixedMarketDatetime), patch(
|
||||||
"server.TushareClient", RealtimeClientStub
|
"backend.application.TushareClient", RealtimeClientStub
|
||||||
):
|
):
|
||||||
result = self.service._prepare_stock_detail(payload, "002141", historical)
|
result = self.service._prepare_stock_detail(payload, "002141", historical)
|
||||||
|
|
||||||
@@ -151,8 +151,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
with patch("server.datetime", FixedPreopenDatetime), patch(
|
with patch("backend.application.datetime", FixedPreopenDatetime), patch(
|
||||||
"server.TushareClient", RealtimeClientStub
|
"backend.application.TushareClient", RealtimeClientStub
|
||||||
):
|
):
|
||||||
result = self.service._prepare_stock_detail(payload, "002141", today)
|
result = self.service._prepare_stock_detail(payload, "002141", today)
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ def _role(method: str, path: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def build() -> dict:
|
def build() -> dict:
|
||||||
text = (ROOT / "server.py").read_text(encoding="utf-8")
|
text = (ROOT / "backend" / "application.py").read_text(encoding="utf-8")
|
||||||
method_matches = list(re.finditer(r"^ def do_(GET|POST|DELETE)\(", text, re.MULTILINE))
|
method_matches = list(re.finditer(r"^ def do_(GET|POST|DELETE)\(", text, re.MULTILINE))
|
||||||
routes = []
|
routes = []
|
||||||
for index, match in enumerate(method_matches):
|
for index, match in enumerate(method_matches):
|
||||||
|
|||||||
Reference in New Issue
Block a user