rebuild(stage-15): complete governance and handoff
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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预发布验证的条件,但未经用户最终确认不会替换正式容器。
|
||||
@@ -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/预发布环境执行;正式容器保持不变。
|
||||
@@ -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时必须停止合并。
|
||||
- 新功能先更新规格、数据边界和固定案例,再写代码;删除功能先确认历史数据、导出和回退。
|
||||
@@ -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`。
|
||||
@@ -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契约。
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user