refactor: centralize runtime configuration and API access policy
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
The application intentionally keeps a small deployment footprint: one Python process, one
|
||||||
|
SQLite database, and a build-free browser client. The internal boundaries are nevertheless
|
||||||
|
explicit so new features do not bypass account isolation or data-quality rules.
|
||||||
|
|
||||||
|
## Backend boundaries
|
||||||
|
|
||||||
|
- `server.py`: application services and HTTP request/response wiring.
|
||||||
|
- `api_access.py`: the single authorization policy for authenticated, member, and admin APIs.
|
||||||
|
- `app_config.py`: runtime paths, local environment loading, and shared input validation.
|
||||||
|
- `database.py`: SQLite schema, migrations, and persistence operations.
|
||||||
|
- `tushare_client.py` and `realtime_aggregator.py`: external market-data adapters.
|
||||||
|
- `sentiment_engine.py`, `screener.py`, and `heaven_engine.py`: deterministic domain logic.
|
||||||
|
- `mentor_agent.py`, `heaven_agent.py`, and `llm_strategy.py`: bounded LLM adapters.
|
||||||
|
|
||||||
|
## Data ownership
|
||||||
|
|
||||||
|
Public market snapshots, stock factors, built-in strategies, limit-up reasons, seat aliases,
|
||||||
|
and sector-element mappings are shared. Only administrators can modify shared knowledge.
|
||||||
|
|
||||||
|
Watchlists, review notes, custom strategies, screener runs, mentor conversations, birth data,
|
||||||
|
alerts, trading journals, and assistant conversations are owned by a user ID and must be
|
||||||
|
queried with that ID. LLM features additionally require active membership.
|
||||||
|
|
||||||
|
## Data integrity
|
||||||
|
|
||||||
|
Production reads never synthesize market prices. A failed live request may use the latest real
|
||||||
|
snapshot at or before the requested date. When no real snapshot exists, the API reports that
|
||||||
|
the data is unavailable. Demo builders remain test fixtures only.
|
||||||
|
|
||||||
|
## Change contract
|
||||||
|
|
||||||
|
New endpoints must be added to `api_access.required_role` when they need member or admin
|
||||||
|
access. New user-owned tables must include `user_id`, an ownership index, and cross-account
|
||||||
|
tests. API payload compatibility is protected by the Python and Playwright suites.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
AccessRole = Literal["authenticated", "member", "admin"]
|
||||||
|
|
||||||
|
MEMBER_GET_PATHS = frozenset(
|
||||||
|
{
|
||||||
|
"/api/screener/setup",
|
||||||
|
"/api/mentors/setup",
|
||||||
|
"/api/mentors/messages",
|
||||||
|
"/api/heaven/setup",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
MEMBER_POST_PATHS = frozenset(
|
||||||
|
{
|
||||||
|
"/api/screener/sync",
|
||||||
|
"/api/screener/compile",
|
||||||
|
"/api/screener/strategies",
|
||||||
|
"/api/screener/run",
|
||||||
|
"/api/mentors/chat",
|
||||||
|
"/api/heaven/hexagram",
|
||||||
|
"/api/heaven/personal",
|
||||||
|
"/api/heaven/interpret",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ADMIN_POST_PATHS = frozenset(
|
||||||
|
{
|
||||||
|
"/api/backfill",
|
||||||
|
"/api/reasons",
|
||||||
|
"/api/seat-aliases",
|
||||||
|
"/api/heaven/sector-phases",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def required_role(method: str, path: str) -> AccessRole:
|
||||||
|
method = method.upper()
|
||||||
|
if path.startswith("/api/admin/"):
|
||||||
|
return "admin"
|
||||||
|
if method == "GET" and path in MEMBER_GET_PATHS:
|
||||||
|
return "member"
|
||||||
|
if method == "POST":
|
||||||
|
if path in ADMIN_POST_PATHS:
|
||||||
|
return "admin"
|
||||||
|
if path in MEMBER_POST_PATHS:
|
||||||
|
return "member"
|
||||||
|
if method == "DELETE":
|
||||||
|
if re.fullmatch(r"/api/heaven/sector-phases/.+", path):
|
||||||
|
return "admin"
|
||||||
|
if path == "/api/mentors/messages" or re.fullmatch(
|
||||||
|
r"/api/screener/strategies/\d+", path
|
||||||
|
):
|
||||||
|
return "member"
|
||||||
|
return "authenticated"
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
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().parent
|
||||||
|
STATIC_DIR = APP_DIR / "static"
|
||||||
|
DATA_DIR = APP_DIR / "data"
|
||||||
|
ENV_FILE = APP_DIR / ".env"
|
||||||
|
MENTOR_SKILLS_DIR = APP_DIR / "游资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)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import calendar
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -13,10 +12,29 @@ from datetime import date, datetime, timedelta, timezone
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
|
from api_access import required_role
|
||||||
|
from app_config import (
|
||||||
|
DATA_DIR,
|
||||||
|
MENTOR_SKILLS_DIR,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
SESSION_MAX_AGE,
|
||||||
|
STATIC_DIR,
|
||||||
|
TOKEN_PATTERN,
|
||||||
|
USERNAME_PATTERN,
|
||||||
|
add_months as _add_months,
|
||||||
|
load_local_env,
|
||||||
|
membership_boundary as _membership_boundary,
|
||||||
|
normalize_date,
|
||||||
|
parse_iso_datetime as _parse_iso_datetime,
|
||||||
|
remove_local_env,
|
||||||
|
save_local_env,
|
||||||
|
tushare_code,
|
||||||
|
validate_stock_code,
|
||||||
|
validate_text,
|
||||||
|
)
|
||||||
from database import ReviewDatabase
|
from database import ReviewDatabase
|
||||||
from heaven_agent import HeavenAgentError, interpret_heaven
|
from heaven_agent import HeavenAgentError, interpret_heaven
|
||||||
from heaven_engine import (
|
from heaven_engine import (
|
||||||
@@ -47,15 +65,6 @@ from sentiment_engine import (
|
|||||||
from tushare_client import TushareClient, TushareError
|
from tushare_client import TushareClient, TushareError
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
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
|
|
||||||
LEGACY_SECRET_KEYS = {
|
LEGACY_SECRET_KEYS = {
|
||||||
"TUSHARE_TOKEN",
|
"TUSHARE_TOKEN",
|
||||||
"LLM_API_KEY",
|
"LLM_API_KEY",
|
||||||
@@ -87,46 +96,6 @@ THS_SEARCH_TYPES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardService:
|
class DashboardService:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
load_local_env()
|
load_local_env()
|
||||||
@@ -3369,12 +3338,7 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
if parsed.path.startswith("/api/") and not self.require_auth():
|
if parsed.path.startswith("/api/") and not self.require_auth():
|
||||||
return
|
return
|
||||||
if parsed.path.startswith("/api/admin/") and not self.require_admin():
|
if not self.require_access("GET", parsed.path):
|
||||||
return
|
|
||||||
if parsed.path in {
|
|
||||||
"/api/screener/setup", "/api/mentors/setup", "/api/mentors/messages",
|
|
||||||
"/api/heaven/setup",
|
|
||||||
} and not self.require_member():
|
|
||||||
return
|
return
|
||||||
if parsed.path == "/api/admin/settings":
|
if parsed.path == "/api/admin/settings":
|
||||||
self.send_json(
|
self.send_json(
|
||||||
@@ -3570,15 +3534,7 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
if not self.require_auth() or not self.require_csrf():
|
if not self.require_auth() or not self.require_csrf():
|
||||||
return
|
return
|
||||||
if parsed.path.startswith("/api/admin/") and not self.require_admin():
|
if not self.require_access("POST", parsed.path):
|
||||||
return
|
|
||||||
if parsed.path == "/api/backfill" and not self.require_admin():
|
|
||||||
return
|
|
||||||
if parsed.path in {
|
|
||||||
"/api/screener/sync", "/api/screener/compile", "/api/screener/strategies",
|
|
||||||
"/api/screener/run", "/api/mentors/chat", "/api/heaven/hexagram",
|
|
||||||
"/api/heaven/personal", "/api/heaven/interpret", "/api/heaven/sector-phases",
|
|
||||||
} and not self.require_member():
|
|
||||||
return
|
return
|
||||||
if parsed.path == "/api/auth/logout":
|
if parsed.path == "/api/auth/logout":
|
||||||
self.auth_logout()
|
self.auth_logout()
|
||||||
@@ -3608,18 +3564,12 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
self.save_note()
|
self.save_note()
|
||||||
return
|
return
|
||||||
if parsed.path == "/api/reasons":
|
if parsed.path == "/api/reasons":
|
||||||
if not self.require_admin():
|
|
||||||
return
|
|
||||||
self.save_reason()
|
self.save_reason()
|
||||||
return
|
return
|
||||||
if parsed.path == "/api/seat-aliases":
|
if parsed.path == "/api/seat-aliases":
|
||||||
if not self.require_admin():
|
|
||||||
return
|
|
||||||
self.save_seat_alias()
|
self.save_seat_alias()
|
||||||
return
|
return
|
||||||
if parsed.path == "/api/heaven/sector-phases":
|
if parsed.path == "/api/heaven/sector-phases":
|
||||||
if not self.require_admin():
|
|
||||||
return
|
|
||||||
self.save_sector_phase_override()
|
self.save_sector_phase_override()
|
||||||
return
|
return
|
||||||
if parsed.path == "/api/backfill":
|
if parsed.path == "/api/backfill":
|
||||||
@@ -3655,13 +3605,13 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
if not self.require_auth() or not self.require_csrf():
|
if not self.require_auth() or not self.require_csrf():
|
||||||
return
|
return
|
||||||
|
if not self.require_access("DELETE", parsed.path):
|
||||||
|
return
|
||||||
if parsed.path == "/api/account/birth-profile":
|
if parsed.path == "/api/account/birth-profile":
|
||||||
deleted = SERVICE.database.delete_user_birth_profile(SERVICE.current_user_id)
|
deleted = SERVICE.database.delete_user_birth_profile(SERVICE.current_user_id)
|
||||||
self.send_json({"ok": True, "deleted": deleted})
|
self.send_json({"ok": True, "deleted": deleted})
|
||||||
return
|
return
|
||||||
if parsed.path == "/api/mentors/messages":
|
if parsed.path == "/api/mentors/messages":
|
||||||
if not self.require_member():
|
|
||||||
return
|
|
||||||
query = parse_qs(parsed.query)
|
query = parse_qs(parsed.query)
|
||||||
try:
|
try:
|
||||||
deleted = SERVICE.clear_mentor_messages(
|
deleted = SERVICE.clear_mentor_messages(
|
||||||
@@ -3674,8 +3624,6 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
strategy_match = re.fullmatch(r"/api/screener/strategies/(\d+)", parsed.path)
|
strategy_match = re.fullmatch(r"/api/screener/strategies/(\d+)", parsed.path)
|
||||||
if strategy_match:
|
if strategy_match:
|
||||||
if not self.require_member():
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
result = SERVICE.delete_screener_strategy(int(strategy_match.group(1)))
|
result = SERVICE.delete_screener_strategy(int(strategy_match.group(1)))
|
||||||
self.send_json({"ok": True, **result})
|
self.send_json({"ok": True, **result})
|
||||||
@@ -3698,8 +3646,6 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path)
|
sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path)
|
||||||
if sector_phase_match:
|
if sector_phase_match:
|
||||||
if not self.require_admin():
|
|
||||||
return
|
|
||||||
name = unquote(sector_phase_match.group(1)).strip()
|
name = unquote(sector_phase_match.group(1)).strip()
|
||||||
deleted = SERVICE.database.delete_sector_phase_override(name)
|
deleted = SERVICE.database.delete_sector_phase_override(name)
|
||||||
self.send_json({"ok": True, "deleted": deleted})
|
self.send_json({"ok": True, "deleted": deleted})
|
||||||
@@ -3842,6 +3788,14 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def require_access(self, method: str, path: str) -> bool:
|
||||||
|
role = required_role(method, path)
|
||||||
|
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:
|
def session_cookie(self, value: str, clear: bool = False) -> str:
|
||||||
max_age = 0 if clear else SESSION_MAX_AGE
|
max_age = 0 if clear else SESSION_MAX_AGE
|
||||||
cookie = (
|
cookie = (
|
||||||
@@ -4151,75 +4105,6 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
print(f"[{self.log_date_time_string()}] {format_string % args}")
|
print(f"[{self.log_date_time_string()}] {format_string % args}")
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="Xiaobai stock review web application")
|
parser = argparse.ArgumentParser(description="Xiaobai stock review web application")
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from api_access import required_role
|
||||||
|
|
||||||
|
|
||||||
|
class ApiAccessPolicyTests(unittest.TestCase):
|
||||||
|
def test_member_workspaces_are_consistently_protected(self):
|
||||||
|
cases = {
|
||||||
|
("GET", "/api/screener/setup"): "member",
|
||||||
|
("GET", "/api/mentors/messages"): "member",
|
||||||
|
("GET", "/api/heaven/setup"): "member",
|
||||||
|
("POST", "/api/screener/run"): "member",
|
||||||
|
("POST", "/api/mentors/chat"): "member",
|
||||||
|
("POST", "/api/heaven/interpret"): "member",
|
||||||
|
("DELETE", "/api/screener/strategies/42"): "member",
|
||||||
|
("DELETE", "/api/mentors/messages"): "member",
|
||||||
|
}
|
||||||
|
for (method, path), role in cases.items():
|
||||||
|
with self.subTest(method=method, path=path):
|
||||||
|
self.assertEqual(required_role(method, path), role)
|
||||||
|
|
||||||
|
def test_shared_knowledge_mutations_require_admin(self):
|
||||||
|
cases = (
|
||||||
|
("POST", "/api/reasons"),
|
||||||
|
("POST", "/api/seat-aliases"),
|
||||||
|
("POST", "/api/heaven/sector-phases"),
|
||||||
|
("DELETE", "/api/heaven/sector-phases/油气开采"),
|
||||||
|
("POST", "/api/backfill"),
|
||||||
|
("GET", "/api/admin/settings"),
|
||||||
|
)
|
||||||
|
for method, path in cases:
|
||||||
|
with self.subTest(method=method, path=path):
|
||||||
|
self.assertEqual(required_role(method, path), "admin")
|
||||||
|
|
||||||
|
def test_personal_market_data_routes_need_login_only(self):
|
||||||
|
cases = (
|
||||||
|
("GET", "/api/dashboard"),
|
||||||
|
("GET", "/api/watchlist"),
|
||||||
|
("POST", "/api/notes"),
|
||||||
|
("DELETE", "/api/notes/3"),
|
||||||
|
)
|
||||||
|
for method, path in cases:
|
||||||
|
with self.subTest(method=method, path=path):
|
||||||
|
self.assertEqual(required_role(method, path), "authenticated")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user