From d1b3b977d2482e6186b83fd706e0ed7beae704ef Mon Sep 17 00:00:00 2001 From: leefer Date: Thu, 30 Jul 2026 08:46:12 +0800 Subject: [PATCH] rebuild(stage-15): complete governance and handoff --- docs/migration/重建迁移章程.md | 6 +- next/backend/bootstrap/application.py | 9 +- next/backend/database/repositories/status.py | 6 + next/backend/http/security.py | 24 ++++ next/backend/llm/gateway.py | 46 ++++++- next/docs/evidence/stage-15/acceptance.md | 47 +++++++ next/docs/final/cutover-checklist.md | 36 +++++ next/docs/final/maintenance-guide.md | 69 ++++++++++ next/docs/final/spec-coverage.md | 136 +++++++++++++++++++ next/docs/final/subtraction-audit.md | 60 ++++++++ next/tests/test_database_tool.py | 14 ++ next/tests/test_health.py | 36 ++++- next/tests/test_market_data.py | 21 ++- next/tools/database.py | 1 - 14 files changed, 498 insertions(+), 13 deletions(-) create mode 100644 next/backend/http/security.py create mode 100644 next/docs/evidence/stage-15/acceptance.md create mode 100644 next/docs/final/cutover-checklist.md create mode 100644 next/docs/final/maintenance-guide.md create mode 100644 next/docs/final/spec-coverage.md create mode 100644 next/docs/final/subtraction-audit.md 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