diff --git a/docs/migration/重建迁移章程.md b/docs/migration/重建迁移章程.md
index 5f7d46c..b424928 100644
--- a/docs/migration/重建迁移章程.md
+++ b/docs/migration/重建迁移章程.md
@@ -125,7 +125,7 @@ HTTP/后台任务 -> 业务服务 -> Repository/DataGateway/LLMGateway -> 基础
| 12 | 我的复盘、提醒和复盘助手 | 私有数据、汇总和弹窗验收 | 已完成 |
| 13 | 全站移动端重组和无障碍 | 320/390/430/768/横屏验收 | 已完成 |
| 14 | 数据迁移、Docker、备份恢复和性能安全 | 旧库副本迁移、回滚演练 | 已完成 |
-| 15 | 全量验收、减法审计和切换准备 | 规格覆盖矩阵、最终报告 | 未开始 |
+| 15 | 全量验收、减法审计和切换准备 | 规格覆盖矩阵、最终报告 | 已完成 |
阶段编号不会因为上下文压缩重新规划。只有发现产品规格自身矛盾时,才记录决策并调整阶段内容;不得通过新增阶段掩盖未完成工作。
@@ -260,4 +260,8 @@ HTTP/后台任务 -> 业务服务 -> Repository/DataGateway/LLMGateway -> 基础
- 生产前端改为FastAPI同端口提供,SPA深链接与API 404边界通过测试;新容器定义采用多阶段构建、非root、只读根文件系统、持久卷和健康检查。开发机没有Docker,实际镜像构建和容器重启恢复列为NAS切换前阻断验收项。
- 阶段14门禁:Ruff、96项pytest、Vue类型检查、7项Vitest、Vite生产构建及17项Playwright全部通过;真实迁移库副本行情摘要读取P95为23.55ms。证据位于`next/docs/evidence/stage-14/`。
- 新系统进入阶段15:执行规格覆盖矩阵、减法审计、性能安全总验收、人工维护演练和最终切换准备;阶段15不自动切换NAS生产容器。
+- 阶段15已完成:85个固定产品案例逐项映射到确定性/API或真实浏览器证据;重建运行时代码由旧版61,793行收敛为25,151行,减少约59.3%,未复制旧巨型文件和CSS覆盖层。
+- 结构审计消除LLM网关对账户具体类的反向依赖;数据库状态命令修复为真正只读且能识别已有migration。统一生产安全头、静态缓存边界、维护手册和NAS切换/回退清单已交付。
+- 阶段15门禁:Ruff、99项pytest、Vue类型检查、7项Vitest、Vite生产构建和17项Playwright全部通过;npm与Python生产依赖已知漏洞均为0,敏感值扫描通过。
+- 新系统代码迁移工作完成。正式NAS切换仍被Docker实构建、容器重启恢复和用户切换窗口确认三类人工条件阻断,不在本阶段自动执行。
- 生产切换明确保留为最终人工确认项。
diff --git a/next/backend/bootstrap/application.py b/next/backend/bootstrap/application.py
index 60c4a40..2f53877 100644
--- a/next/backend/bootstrap/application.py
+++ b/next/backend/bootstrap/application.py
@@ -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
diff --git a/next/backend/database/repositories/status.py b/next/backend/database/repositories/status.py
index 895b0ac..18c965f 100644
--- a/next/backend/database/repositories/status.py
+++ b/next/backend/database/repositories/status.py
@@ -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()
diff --git a/next/backend/http/security.py b/next/backend/http/security.py
new file mode 100644
index 0000000..51fabf4
--- /dev/null
+++ b/next/backend/http/security.py
@@ -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
diff --git a/next/backend/llm/gateway.py b/next/backend/llm/gateway.py
index eb4100e..c8dac6f 100644
--- a/next/backend/llm/gateway.py
+++ b/next/backend/llm/gateway.py
@@ -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,
diff --git a/next/docs/evidence/stage-15/acceptance.md b/next/docs/evidence/stage-15/acceptance.md
new file mode 100644
index 0000000..581b4ad
--- /dev/null
+++ b/next/docs/evidence/stage-15/acceptance.md
@@ -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 Policy;HTTPS增加HSTS。
+- CSP保留Vue动态宽度样式所需的`style-src 'unsafe-inline'`,脚本仍只允许同源。
+- 静态哈希资源长期缓存,SPA入口不缓存;未知API不被SPA接管。
+- 真实迁移库副本市场摘要中位数20.90ms、P95 23.55ms;前端生产JS 279.23KB、CSS 89.25KB(未压缩)。
+
+## 最终门禁
+
+- Ruff:通过。
+- pytest:99项通过。
+- Vue TypeScript:通过。
+- Vitest:3个文件、7项通过。
+- Vite生产构建:通过。
+- Playwright:17项通过,单worker,最后一轮耗时约1分钟。
+- `git diff --check`和已知敏感值扫描:通过。
+- Docker:本机未安装,未虚报实构建;列为NAS切换前阻断项。
+
+## 结论
+
+从零重建、数据迁移工具、备份恢复、结构治理、移动端、性能安全和人工维护资产已经完成。
+当前代码具备进入独立NAS预发布验证的条件,但未经用户最终确认不会替换正式容器。
diff --git a/next/docs/final/cutover-checklist.md b/next/docs/final/cutover-checklist.md
new file mode 100644
index 0000000..ffd9fcf
--- /dev/null
+++ b/next/docs/final/cutover-checklist.md
@@ -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/预发布环境执行;正式容器保持不变。
diff --git a/next/docs/final/maintenance-guide.md b/next/docs/final/maintenance-guide.md
new file mode 100644
index 0000000..ad647a9
--- /dev/null
+++ b/next/docs/final/maintenance-guide.md
@@ -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时必须停止合并。
+- 新功能先更新规格、数据边界和固定案例,再写代码;删除功能先确认历史数据、导出和回退。
diff --git a/next/docs/final/spec-coverage.md b/next/docs/final/spec-coverage.md
new file mode 100644
index 0000000..d53f6b7
--- /dev/null
+++ b/next/docs/final/spec-coverage.md
@@ -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`。
diff --git a/next/docs/final/subtraction-audit.md b/next/docs/final/subtraction-audit.md
new file mode 100644
index 0000000..ccb0e20
--- /dev/null
+++ b/next/docs/final/subtraction-audit.md
@@ -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契约。
diff --git a/next/tests/test_database_tool.py b/next/tests/test_database_tool.py
index 7612e9d..cc67ef7 100644
--- a/next/tests/test_database_tool.py
+++ b/next/tests/test_database_tool.py
@@ -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))
diff --git a/next/tests/test_health.py b/next/tests/test_health.py
index 1e2e06a..3ca9362 100644
--- a/next/tests/test_health.py
+++ b/next/tests/test_health.py
@@ -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(
"application", 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 == "application"
- 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")
diff --git a/next/tests/test_market_data.py b/next/tests/test_market_data.py
index 629dfe1..2374428 100644
--- a/next/tests/test_market_data.py
+++ b/next/tests/test_market_data.py
@@ -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)
diff --git a/next/tools/database.py b/next/tools/database.py
index cfc0cc6..2499179 100644
--- a/next/tools/database.py
+++ b/next/tools/database.py
@@ -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