rebuild(stage-15): complete governance and handoff

This commit is contained in:
leefer
2026-07-30 08:46:12 +08:00
parent fa7a8dde06
commit d1b3b977d2
14 changed files with 498 additions and 13 deletions
+7 -2
View File
@@ -12,6 +12,7 @@ from backend.database.migrations import MIGRATIONS, MigrationRunner
from backend.http.errors import install_error_handlers
from backend.http.request_context import install_request_context
from backend.http.router import api_router
from backend.http.security import install_security_headers
from backend.jobs.screener import run_screener_scheduler
logger = logging.getLogger(__name__)
@@ -58,6 +59,7 @@ def create_application(settings: Settings | None = None) -> FastAPI:
application.state.settings = runtime
application.state.container = container
install_request_context(application)
install_security_headers(application)
install_error_handlers(application)
application.include_router(api_router, prefix="/api")
if runtime.frontend_dist_directory.joinpath("index.html").is_file():
@@ -69,7 +71,10 @@ def create_application(settings: Settings | None = None) -> FastAPI:
raise HTTPException(status_code=404)
candidate = frontend_root.joinpath(requested_path).resolve()
if candidate.is_relative_to(frontend_root) and candidate.is_file():
return FileResponse(candidate)
return FileResponse(frontend_root / "index.html")
response = FileResponse(candidate)
if candidate.parent.name == "assets":
response.headers["Cache-Control"] = "public,max-age=31536000,immutable"
return response
return FileResponse(frontend_root / "index.html", headers={"Cache-Control": "no-cache"})
return application
@@ -20,6 +20,12 @@ class DatabaseStatusRepository:
try:
with self._database.read() as connection:
connection.execute("SELECT 1").fetchone()
schema_exists = connection.execute(
"""SELECT 1 FROM sqlite_master
WHERE type='table' AND name='schema_migrations'"""
).fetchone()
if schema_exists is None:
return DatabaseStatus(available=True, schema_version=0)
row = connection.execute(
"SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations"
).fetchone()
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi import FastAPI, Request, Response
def install_security_headers(application: FastAPI) -> None:
@application.middleware("http")
async def security_headers(request: Request, call_next) -> Response:
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
if request.app.state.settings.environment == "production":
response.headers["Content-Security-Policy"] = (
"default-src 'self'; base-uri 'self'; frame-ancestors 'none'; "
"form-action 'self'; img-src 'self' data:; font-src 'self'; "
"style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'"
)
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
return response
+40 -6
View File
@@ -5,19 +5,53 @@ import uuid
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from zoneinfo import ZoneInfo
from backend.database.connection import Database
from backend.errors import BusinessError
from backend.features.accounts.model_pool import ModelPoolService
from backend.features.accounts.models import Principal
from backend.features.accounts.service import MembershipService
from backend.llm.provider import OpenAICompatibleClient, ProviderFailure
from backend.llm.repository import LLMRepository
SHANGHAI = ZoneInfo("Asia/Shanghai")
class PrincipalUser(Protocol):
id: int
class PrincipalAccess(Protocol):
user: PrincipalUser
class MembershipViewAccess(Protocol):
quota_exempt: bool
daily_limit: int
class MembershipAccess(Protocol):
def view_for(self, principal: PrincipalAccess) -> MembershipViewAccess: ...
def can_use_smart_features(self, principal: PrincipalAccess) -> bool: ...
class ModelRecordAccess(Protocol):
id: int
base_url: str
model_identifier: str
class ModelRuntimeAccess(Protocol):
primary: ModelRecordAccess | None
fallback: ModelRecordAccess | None
class ModelPoolAccess(Protocol):
def runtime_config(self) -> ModelRuntimeAccess: ...
def decrypt_api_key(self, record: ModelRecordAccess) -> str: ...
class LLMGatewayError(RuntimeError):
def __init__(self, code: str, message: str, *, partial: bool = False) -> None:
super().__init__(message)
@@ -60,8 +94,8 @@ class LLMGateway:
self,
database: Database,
repository: LLMRepository,
memberships: MembershipService,
model_pool: ModelPoolService,
memberships: MembershipAccess,
model_pool: ModelPoolAccess,
provider: OpenAICompatibleClient | None = None,
) -> None:
self._database = database
@@ -72,7 +106,7 @@ class LLMGateway:
def prepare(
self,
principal: Principal,
principal: PrincipalAccess,
*,
feature: str,
prompt_version: str,
+47
View File
@@ -0,0 +1,47 @@
# 阶段 15 总验收
## 产品覆盖
- 《小白复盘完整产品规格说明书》第25节85个固定案例已逐项映射,见`../../final/spec-coverage.md`
- 权限、账号隔离、算法、数据质量、日期、失败语义由单元/API测试验证;布局、交互、主题、响应式和移动端由真实Playwright流程与阶段截图验证。
- 16个工作区、策略跟踪内部页、账户/会员/系统管理及全局搜索、弹窗、主题、提醒均有可达证据。
## 减法结果
- 旧运行时代码:55个文件、61,793行;重建运行时代码:206个职责文件、25,151行。
- 运行时代码净减少36,642行,约59.3%;迁移/备份工具、测试和文档未混入运行时比较。
- 唯一浏览器API、数据网关、LLM网关、弹窗Host、设计令牌和移动规则均通过扫描。
- LLM网关原有3个账户领域具体类型反向导入已改为最小Protocol,未增加第二套服务。
- 超过章程建议行数的算法、Provider和CSS已逐项登记保留原因及拆分触发条件,见`../../final/subtraction-audit.md`
## 维护与回退演练
- 已在真实迁移库副本执行schema 10 -> 9 -> 10,完整性及外键检查通过;明确验证数据回退只能在副本执行。
- 演练发现并修复`tools.database status`对已有migration误报未知版本的问题;状态查询现在只读,真实副本返回`schema_version=10`
- 人工维护入口、常见改动路径、数据源标准、故障定位与复杂度红线见`../../final/maintenance-guide.md`
- NAS切换、观察和回退动作见`../../final/cutover-checklist.md`;正式容器未变更。
## 安全与性能
- npm生产依赖审计:0个已知漏洞。
- Python `requirements.txt`审计:0个已知漏洞。
- 生产响应统一设置CSP、`nosniff`、拒绝Frame、Permissions Policy、Referrer PolicyHTTPS增加HSTS。
- CSP保留Vue动态宽度样式所需的`style-src 'unsafe-inline'`,脚本仍只允许同源。
- 静态哈希资源长期缓存,SPA入口不缓存;未知API不被SPA接管。
- 真实迁移库副本市场摘要中位数20.90ms、P95 23.55ms;前端生产JS 279.23KB、CSS 89.25KB(未压缩)。
## 最终门禁
- Ruff:通过。
- pytest99项通过。
- Vue TypeScript:通过。
- Vitest3个文件、7项通过。
- Vite生产构建:通过。
- Playwright17项通过,单worker,最后一轮耗时约1分钟。
- `git diff --check`和已知敏感值扫描:通过。
- Docker:本机未安装,未虚报实构建;列为NAS切换前阻断项。
## 结论
从零重建、数据迁移工具、备份恢复、结构治理、移动端、性能安全和人工维护资产已经完成。
当前代码具备进入独立NAS预发布验证的条件,但未经用户最终确认不会替换正式容器。
+36
View File
@@ -0,0 +1,36 @@
# NAS切换清单
此清单是最终人工确认前的准备资产,不授权当前任务停止或替换正式容器。
## 切换前阻断项
- [ ] NAS或预发布机实际执行`docker compose config``docker compose build`
- [ ] 验证容器以非root、只读根文件系统启动,`/app/data`可写,健康检查稳定。
- [ ] 验证容器重启后账号、行情、自选、复盘、会员、模型、问天和选股记录不丢失。
- [ ] 在NAS受控目录完成数据库、环境密钥和私有Skill整套备份,并做一次独立恢复。
- [ ] 为新容器分配临时端口,完成真实数据的日间/夜间、1080P/4K/390px抽验。
- [ ] 用户明确确认最终切换窗口。
## 切换步骤
1. 记录旧镜像、容器配置、端口和旧数据库校验和,创建最终一致性备份。
2. 暂停旧系统写入,不删除旧容器和数据卷。
3. 对最终只读旧库运行`tools.legacy_migration`到全新目标,核对报告和外键完整性。
4. 用固定`APP_ENCRYPTION_KEY`和新数据卷启动新容器临时端口。
5. 抽验真实账号登录、共享快照、私有记录、会员/模型、问天、选股和LLM一次成功调用。
6. 切换反向代理或宿主端口;观察健康、5xx、数据库写入和后台任务至少一个完整刷新周期。
7. 只有观察期通过后才归档旧容器;旧数据库和旧镜像按回退周期保留。
## 回退条件与动作
出现登录失败、私有数据错绑、数据库完整性失败、关键行情不可读、后台任务持续失败或无法恢复的5xx时立即回退。
1. 停止新容器写入并保存故障数据库副本和日志。
2. 将流量切回旧容器;不要对新数据库自动执行migration降级。
3. 若需用旧镜像读取新时期数据,只能恢复升级前与旧镜像匹配的整套备份。
4. 修复后重新从最新只读旧库执行迁移,不在失败目标库上手工改表。
## 当前状态
代码、真实旧库副本迁移、备份恢复、SPA生产形态、性能、安全和全量浏览器回归均已完成。
本机没有Docker,以上6个切换前阻断项仍需在NAS/预发布环境执行;正式容器保持不变。
+69
View File
@@ -0,0 +1,69 @@
# 人工维护手册
## 从哪里开始
产品行为先查`../../../docs/product/小白复盘-完整产品规格说明书.md`,迁移约束查
`../../../docs/migration/重建迁移章程.md`。新代码根目录只有`next/`;旧根目录代码不得作为新运行时依赖。
请求主链为:页面 -> `shared/api` -> HTTP Route -> Service -> Repository/DataGateway/LLMGateway。
`bootstrap/container.py`是唯一依赖组装点,页面注册在`workspaceRegistry.ts`,数据库版本在
`database/migrations/registry.py`按连续编号登记。
## 常见改动
| 改动 | 修改入口 | 必须补的测试 |
|---|---|---|
| 页面字段/布局 | 对应`frontend/src/pages`与领域CSS | 日夜、1080P、4K、390px |
| 新市场页面 | 页面+`workspaceRegistry.ts`+市场Route/Service | 空态、缺失、日期、移动入口 |
| 数据源字段 | Provider契约+DataGateway策略 | 来源、单位、新鲜度、覆盖率、失败关闭 |
| 情绪/竞价/选股公式 | 纯计算模块+版本字段 | 固定样本、边界、缺失与确定性 |
| 新策略 | `config/screener-strategies.json` | 因子覆盖、无信号、数据不足、顺序稳定 |
| 新思维模型 | 公开或私有Skill目录 | 目录发现、等级、私密性、上下文路由 |
| LLM功能 | 业务Prompt+唯一`LLMGateway` | 会员、配额、回退、中断、去重 |
| 私有记录 | 领域Repository每条SQL带`user_id` | 甲乙账号交叉读写删 |
| 数据表变更 | 新增连续migration | 前进、失败原子性、回退、旧库升级 |
不要在页面直接`fetch`、在Feature直接访问外网、在Controller计算评分、为单页创建第二个弹窗或
把颜色/间距写成新字面值。确需新共享抽象时,至少应有两个真实调用方或消除一个高风险全局出口。
## 数据源维护
- Tushare:交易日、目录、日线、正式盘后事件和财务基础。
- iFinD:实时K线、分时、动态竞价和已授权补充字段。
- 东方财富:仅展示分时兜底,不进入正式计算。
- 腾讯:已登记但无正式消费者前不创建空Provider。
- 每个保存结果都要带来源、实际日期、观察时间、用途、复权、单位、新鲜度和覆盖率。
- 供应商失败不得返回模拟数据;跨源替代必须在DataSourcePolicy显式登记并向用户显示实际日期。
## 日常命令
```powershell
cd next
.\.venv\Scripts\python.exe -m tools.database status
.\.venv\Scripts\python.exe -m ruff check backend tools tests
.\.venv\Scripts\python.exe -m pytest
cd frontend
npm.cmd run check
npm.cmd run test
npm.cmd run build
cd ..
npx.cmd playwright test
```
数据库`status`只读查询,不执行升级。应用启动时执行有序前进migration;显式回退只允许在副本演练。
生产代码回退不自动降级数据,需恢复升级前整套备份。
## 故障定位
1. 先看`/api/health`和结构化日志的`request_id`,不要从前端提示猜供应商原因。
2. 行情问题查快照的实际日期、来源、覆盖率和质量门,再查Provider;不要直接加兜底。
3. LLM问题查`llm_requests``llm_attempts`,确认失败发生在首字前还是首字后。
4. 私有数据问题用两个测试账号交叉验证SQL所有权,不只检查页面隐藏。
5. 视觉问题先确认Shell、令牌和领域CSS归属,不追加`override/final-fix`覆盖层。
6. migration失败先停写并恢复备份,不编辑已执行migration的签名或SQL。
## 复杂度红线
- 页面容器超过300行、业务Service超过400行、CSS超过400行时必须复核职责并记录原因。
- 同一行为出现第二个算法、API客户端、外部数据出口、LLM入口或弹窗Host时必须停止合并。
- 新功能先更新规格、数据边界和固定案例,再写代码;删除功能先确认历史数据、导出和回退。
+136
View File
@@ -0,0 +1,136 @@
# 产品规格覆盖矩阵
本矩阵以《小白复盘完整产品规格说明书》第25节的85个固定案例为唯一编号源。
“自动”表示确定性单元/API测试;“浏览器”表示Playwright交互、响应式断言和阶段截图共同验收。
两者都不是仅检查页面能否打开。
## 账户、权限与隔离
| 编号 | 证据 | 状态 |
|---|---|---|
| A01 | `test_first_account_is_admin_and_second_is_regular_user` | 自动通过 |
| A02 | 同A01及`stage4.spec.js`普通账号流程 | 自动+浏览器通过 |
| A03 | `test_membership_and_admin_are_independent_dimensions` | 自动通过 |
| A04 | 同A03,管理员智能权限与会员标识分别断言 | 自动通过 |
| A05 | `stage4.spec.js`非会员完整锁定结构 | 浏览器通过 |
| A06 | `test_watchlist_repository_isolates_accounts` | 自动通过 |
| A07 | `test_private_review_records_are_strictly_scoped_and_daily_notes_upsert` | 自动通过 |
| A08 | `test_non_admin_cannot_read_or_write_system_credentials` | 自动通过 |
| A09 | `test_csrf_password_change_and_other_session_revocation` | 自动通过 |
| A10 | `stage4.spec.js`账户五项菜单与弹窗 | 浏览器通过 |
## 日期与数据真相
| 编号 | 证据 | 状态 |
|---|---|---|
| D01 | `test_latest_daily_chart_drops_empty_premarket_bar`及交易上下文样本 | 自动通过 |
| D02 | `test_trade_context_keeps_real_snapshot_date` | 自动通过 |
| D03 | iFinD时间戳规范化测试及`stage5.spec.js`摘要时间 | 自动+浏览器通过 |
| D04 | 市场快照最终态测试及`stage5.spec.js` | 自动+浏览器通过 |
| D05 | 请求日、实际日分离的市场上下文测试 | 自动通过 |
| D06 | `test_trade_context_keeps_real_snapshot_date`的沿用快照断言 | 自动通过 |
| D07 | `stage4.spec.js`首次无快照空态 | 浏览器通过 |
| D08 | `stage4.spec.js`后台刷新保持当前工作区 | 浏览器通过 |
| D09 | `stage6.spec.js`默认情绪周期与最新日期 | 浏览器通过 |
## 图表与详情
| 编号 | 证据 | 状态 |
|---|---|---|
| C01 | `test_latest_daily_chart_drops_empty_premarket_bar` | 自动通过 |
| C02 | `stage5.spec.js`历史页面日期与最新悬浮行情分离 | 浏览器通过 |
| C03 | `test_minute_chart_contract_has_real_session_bounds_and_hides_source` | 自动通过 |
| C04 | `MarketChart.vue`实体/影线分段及阶段5截图 | 浏览器通过 |
| C05 | 主题令牌加载态及阶段5夜间截图 | 浏览器通过 |
| C06 | `stage5.spec.js`个股详情完整入口 | 浏览器通过 |
| C07 | `stage5.spec.js`非个股详情边界 | 浏览器通过 |
| C08 | 图表前收契约测试及详情摘要浏览器断言 | 自动+浏览器通过 |
## 情绪与市场页面
| 编号 | 证据 | 状态 |
|---|---|---|
| M01 | `test_sentiment_has_all_weighted_components_and_extreme_risk_cap` | 自动通过 |
| M02 | `test_sentiment_phase_transitions_require_confirmed_recovery_and_fermentation` | 自动通过 |
| M03 | 同M02的连续发酵确认断言 | 自动通过 |
| M04 | `stage6.spec.js`当前阶段警示文本 | 浏览器通过 |
| M05 | `stage6.spec.js`明细范围、整页滚动和末行可达 | 浏览器通过 |
| M06 | `stage6.spec.js`低行数股池固定状态栏 | 浏览器通过 |
| M07 | `stage7.spec.js`天梯展开、收起、滚动与等宽格 | 浏览器通过 |
| M08 | `stage6.spec.js`动态板高与涨停表现布局 | 浏览器通过 |
| M09 | `test_sector_members_are_normalized_and_persistently_cached``stage7.spec.js` | 自动+浏览器通过 |
## 竞价、题材、热榜与龙虎榜
| 编号 | 证据 | 状态 |
|---|---|---|
| X01 | `test_auction_lifecycle_uses_live_snapshot_then_archives_925_result` | 自动通过 |
| X02 | `test_observing_without_dynamic_data_never_disguises_previous_archive` | 自动通过 |
| X03 | X01的9:26选定态断言 | 自动通过 |
| X04 | `test_auction_keeps_market_cores_and_isolates_real_limit_price` | 自动通过 |
| X05 | 同X04的新规ST 10%涨跌幅样本 | 自动通过 |
| X06 | 同X04的市场核心超限保留断言 | 自动通过 |
| X07 | 竞价成交额单一聚合结果及`stage8.spec.js`摘要/柱图 | 自动+浏览器通过 |
| X08 | `stage8.spec.js`题材悬浮统一预览与无固定K线模块 | 浏览器通过 |
| X09 | `test_popularity_preserves_single_source_without_false_consensus`及阶段8空态 | 自动+浏览器通过 |
| X10 | `test_dragon_list_distinguishes_missing_and_unclassified_seats` | 自动通过 |
| X11 | `stage8.spec.js`左名录右详情、无弹窗 | 浏览器通过 |
## 智能选股
| 编号 | 证据 | 状态 |
|---|---|---|
| S01 | `test_curated_strategies_run_independently_of_emotion_phase` | 自动通过 |
| S02 | `test_missing_required_factor_and_complete_no_match_are_distinct` | 自动通过 |
| S03 | 同S02的数据不足分支 | 自动通过 |
| S04 | `test_formula_is_deterministic_and_best_value_scores_first`及幂等运行测试 | 自动通过 |
| S05 | 按策略标识持久化运行及`stage9.spec.js`模式切换 | 自动+浏览器通过 |
| S06 | 多策略独立运行Repository测试及策略面板切换 | 自动+浏览器通过 |
| S07 | `stage9.spec.js`三模式独立结果容器 | 浏览器通过 |
| S08 | `stage9.spec.js`未执行/无信号状态 | 浏览器通过 |
| S09 | 自定义公式确定性引擎测试;LLM仅编译受控公式 | 自动通过 |
| S10 | `test_formula_weights_must_total_one_hundred_percent` | 自动通过 |
| S11 | `test_running_a_strategy_never_creates_tracking_rows` | 自动通过 |
| S12 | `stage9.spec.js`手动加入及入选价 | 自动+浏览器通过 |
| S13 | `test_custom_strategies_and_tracks_are_account_isolated` | 自动通过 |
| S14 | `test_tracking_statistics_and_milestone_events_are_persistent_and_idempotent` | 自动通过 |
## 问师、问天与LLM
| 编号 | 证据 | 状态 |
|---|---|---|
| L01 | `test_stream_accumulator_does_not_repeat_final_snapshot` | 自动通过 |
| L02 | `test_main_failure_before_first_delta_falls_back_and_counts_once` | 自动通过 |
| L03 | `test_failure_after_first_delta_keeps_partial_and_never_falls_back` | 自动通过 |
| L04 | `test_context_profiles_are_distinct_and_dragon_data_is_question_driven` | 自动通过 |
| L05 | 阶段10、11、12三个非会员同结构锁定E2E | 浏览器通过 |
| W01 | `stage11.spec.js`观势初始空态 | 浏览器通过 |
| W02 | `test_small_sector_with_five_of_five_quotes_passes_all_gates` | 自动通过 |
| W03 | `test_manual_objective_sector_value_recomputes_without_overwriting_valid_data` | 自动通过 |
| W04 | `test_missing_one_formal_index_fails_closed` | 自动通过 |
| W05 | `test_daily_fortune_is_created_once_even_before_interpretation` | 自动通过 |
| W06 | `heartTiming.test.ts``stage11.spec.js` | 自动+浏览器通过 |
| W07 | `test_each_heart_cast_appends_exactly_one_line` | 自动通过 |
| W08 | `test_hexagram_is_deterministic_and_contains_only_six_lines`及阶段11页面断言 | 自动+浏览器通过 |
| W09 | 阶段11夜间观心截图和可读性断言 | 浏览器通过 |
## 复盘、主题与响应式
| 编号 | 证据 | 状态 |
|---|---|---|
| R01 | `stage12.spec.js`交易日志保存、Toast与无异常弹窗 | 浏览器通过 |
| R02 | `test_trade_summary_uses_only_realized_fields_and_keeps_zero_in_denominator` | 自动通过 |
| R03 | `test_private_review_records_are_strictly_scoped_and_daily_notes_upsert` | 自动通过 |
| R04 | `stage12.spec.js`历史展开、收起与滚动 | 浏览器通过 |
| U01 | 阶段4至13日夜切换E2E,无白闪/控制台错误 | 浏览器通过 |
| U02 | 阶段6的1920×1080整页滚动与明细可达 | 浏览器通过 |
| U03 | 阶段11观气/观心滚动与背景断言 | 浏览器通过 |
| U04 | 阶段4至13的3840×2160截图及密度验收 | 浏览器通过 |
| U05 | `stage13.spec.js`390×844、五入口与44px触控目标 | 浏览器通过 |
| U06 | `stage13.spec.js`320px 16工作区、纵向记录/局部横滚 | 浏览器通过 |
## 汇总
- 固定案例:85/85已有可追踪证据。
- 确定性、权限、隔离和失败语义均由自动测试覆盖;视觉案例同时保留真实浏览器截图。
- Docker镜像构建、NAS容器重启与正式端口切换不属于85个页面案例,但仍是切换前阻断项,见`cutover-checklist.md`
+60
View File
@@ -0,0 +1,60 @@
# 减法与结构审计
## 结论
重建版运行时代码共206个源码文件、25,151行;旧版运行时代码共55个文件、61,793行。
在功能完整迁移并增加移动端后,新运行时代码减少36,642行,约59.3%。文件数量增加来自按职责拆分,
不再由`server.py``app.js``styles.css`和覆盖样式承载整个系统。
一次性迁移、备份、测试与文档不计入运行时代码比较;旧系统仍保留作回退资产,但新系统没有运行时导入。
## 唯一出口
| 能力 | 权威入口 | 审计结果 |
|---|---|---|
| 浏览器请求 | `frontend/src/shared/api/client.ts` | 全站仅此文件直接调用`fetch` |
| 外部行情 | `backend/data/gateway.py`+Provider | 业务Feature不直接访问外网 |
| LLM | `backend/llm/gateway.py` | 问师、问天、复盘助手、自定义公式共用 |
| 弹窗 | `DialogHost.vue`+UI Store | 无页面级第二套全局弹窗 |
| 设计令牌 | `frontend/src/shared/styles/tokens.css` | 主题颜色、字号、间距集中 |
| 移动规则 | `frontend/src/shared/styles/mobile.css` | 不复制移动页面或API |
| 数据演进 | `backend/database/migrations/` | 10个有序、带签名migration |
阶段15发现并删除了LLM网关对账户领域具体类的3个反向导入,改为网关内部最小Protocol;
组合根仍注入原服务,没有第二套权限或模型逻辑。基础数据层、数据库层没有反向依赖业务Feature。
## 大文件复核
章程的400行是维护预警线,不是机械拆分指标。以下超线文件均已逐项复核:
| 文件 | 行数 | 保留原因 | 必须拆分的触发条件 |
|---|---:|---|---|
| `data/providers/tushare.py` | 657 | 单一Provider的端点映射和统一传输 | 新增第二种协议或重复传输实现 |
| `data/gateway.py` | 546 | 唯一数据策略、质量门和图表规范化 | 新增非行情领域或独立生命周期 |
| `market/insights/auction.py` | 519 | 一个完整竞价确定性算法 | 新算法出现独立输入/版本/测试 |
| `market/insights/service.py` | 490 | 四类洞察的薄编排,计算已在子模块 | 任一洞察编排可独立部署/调度 |
| `data/repository.py` | 486 | 共享行情领域的版本化存取 | 出现第二数据库或跨域私有写入 |
| `data/heaven.py` | 485 | 问势专用市场输入适配 | 观气/观心开始依赖行情时 |
| `screener/repository.py` | 448 | 选股、运行和跟踪的同事务存取 | 跟踪获得独立生命周期/任务 |
| `screener/technical.py` | 434 | 纯技术因子计算 | 新因子族能形成独立输入契约 |
| `mobile.css` | 537 | 唯一跨页移动规则,按断点组织 | 出现页面冲突或超过650行 |
| 4个领域CSS | 403-415 | 单一领域且仅略超阈值 | 新增覆盖层或跨领域选择器 |
| `tools/legacy_migration.py` | 629 | 一次性离线适配,不进入运行时 | 新增第二旧版本或需要常驻运行 |
当前强拆这些文件只会增加接口、跳转和空抽象,不能减少业务复杂度,因此未为满足行数制造目录。
## 删除与未继承
- 未复制旧`server.py``database.py``app.js`、15,465行旧样式或8,570行覆盖样式。
- 未保留用户自主LLM配置、模拟行情、公开网页参与正式计算、策略自动跟踪和手动执行盘后策略。
- 未复制旧原始因子表到新运行时;正式因子由唯一数据网关和盘后任务重建。
- 未引入ORM、通用CRUD、缓存框架、消息队列或第二套前端状态库。
- iFinD、Tushare、东方财富与腾讯职责固定;未做无来源标识的静默多源兜底。
## 依赖与安全
- npm生产依赖审计:0个已知漏洞。
- Python生产依赖审计:0个已知漏洞。
- 已知令牌、密码片段、私有数据目录和备份归档未进入Git。
- 生产响应增加CSP、`nosniff`、拒绝Frame、权限策略、Referrer策略和HTTPS HSTS。
- 生产静态哈希资源长期缓存,SPA入口`no-cache`API错误仍使用统一JSON契约。
+14
View File
@@ -1,5 +1,6 @@
import pytest
from backend.database import MIGRATIONS, Database, MigrationRunner
from tools.database import main
@@ -14,6 +15,19 @@ def test_status_uses_configured_data_directory(tmp_path, monkeypatch, capsys) ->
assert (tmp_path / "xiaobai.db").exists()
def test_status_reads_an_existing_schema_without_mutating_history(
tmp_path, monkeypatch, capsys
) -> None:
database_path = tmp_path / "existing.db"
MigrationRunner(Database(database_path)).upgrade(MIGRATIONS)
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("APP_DATABASE_PATH", str(database_path))
assert main(["status"]) == 0
assert capsys.readouterr().out.strip() == "available=true schema_version=10"
def test_downgrade_requires_explicit_confirmation(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("APP_DATA_DIR", str(tmp_path))
+34 -2
View File
@@ -1,3 +1,8 @@
import asyncio
from dataclasses import replace
import httpx
from cryptography.fernet import Fernet
from fastapi import Query
from backend.bootstrap.application import create_application
@@ -17,6 +22,8 @@ def test_health_reports_runtime_environment(tmp_path) -> None:
"components": {"process": "ok", "database": "ok"},
}
assert len(response.headers["X-Request-ID"]) == 32
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["X-Frame-Options"] == "DENY"
def test_unknown_failure_uses_safe_error_contract(tmp_path) -> None:
@@ -81,13 +88,38 @@ def test_production_frontend_serves_spa_without_capturing_api_404(tmp_path) -> N
settings.frontend_dist_directory.joinpath("index.html").write_text(
"<main>application</main>", encoding="utf-8"
)
settings.frontend_dist_directory.joinpath("asset.js").write_text(
settings.frontend_dist_directory.joinpath("assets").mkdir()
settings.frontend_dist_directory.joinpath("assets", "asset.js").write_text(
"window.ready=true", encoding="utf-8"
)
application = create_application(settings)
assert request(application, "/review/history").text == "<main>application</main>"
assert request(application, "/asset.js").text == "window.ready=true"
asset_response = request(application, "/assets/asset.js")
assert asset_response.text == "window.ready=true"
assert asset_response.headers["Cache-Control"] == "public,max-age=31536000,immutable"
api_response = request(application, "/api/unknown")
assert api_response.status_code == 404
assert api_response.json()["error"]["code"] == "not_found"
def test_production_security_headers_are_strict_on_https(tmp_path) -> None:
settings = Settings.for_test(tmp_path)
settings = replace(
settings,
environment="production",
encryption_key=Fernet.generate_key().decode("ascii"),
)
application = create_application(settings)
async def get():
transport = httpx.ASGITransport(app=application)
async with application.router.lifespan_context(application):
async with httpx.AsyncClient(
transport=transport, base_url="https://testserver"
) as client:
return await client.get("/api/health")
response = asyncio.run(get())
assert "default-src 'self'" in response.headers["Content-Security-Policy"]
assert response.headers["Strict-Transport-Security"].startswith("max-age=31536000")
+20 -1
View File
@@ -21,7 +21,7 @@ from backend.data.policy import DataPolicyError, DataSourcePolicy
from backend.data.providers.ifind import IfindProvider
from backend.data.providers.tushare import TushareProvider
from backend.data.repository import MarketRepository
from backend.data.sentiment import calculate_sentiment
from backend.data.sentiment import _phase, calculate_sentiment
from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner
from backend.features.market.snapshot import build_snapshot
@@ -403,6 +403,25 @@ def test_sentiment_has_all_weighted_components_and_extreme_risk_cap() -> None:
}
def test_sentiment_phase_transitions_require_confirmed_recovery_and_fermentation() -> None:
phase, _reason = _phase(
{"phase": "冰点"}, 28, 3, 40, 45, 50, "修复", False
)
assert phase == "冰点"
phase, _reason = _phase(
{"phase": "修复", "fermentation_signal_count": 0},
55,
8,
60,
55,
60,
"发酵",
False,
)
assert phase == "修复"
def test_incomplete_daily_snapshot_is_rejected_without_overwriting(tmp_path) -> None:
database = Database(tmp_path / "sync.db")
MigrationRunner(database).upgrade(MIGRATIONS)
-1
View File
@@ -26,7 +26,6 @@ def main(arguments: Sequence[str] | None = None) -> int:
runner = MigrationRunner(database)
if parsed.command == "status":
runner.upgrade(())
status = DatabaseStatusRepository(database).get()
print(f"available={str(status.available).lower()} schema_version={status.schema_version}")
return 0