From 59243fe96e684cb98c59aa3a17a2996cc27bc1e4 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Thu, 8 Oct 2026 16:40:50 +0800 Subject: [PATCH] refactor: remove obsolete schema fallbacks and stale project guidance Require the initialized database schema instead of silently bypassing quotas, flow ownership and delivery history when tables are missing. Keep all data migrations and legacy-record behavior. Remove the unused delivery reader and pre-migration worker test. Add missing schema regression tests. Discover all shipped JavaScript in CI and validate examples without interpreting Python caches as workflows. Move the standalone SDK sample out of server scripts and exclude generated build/coverage files. Replace stale single-merchant and deployment narratives with current product boundaries and reusable verification guidance. --- .dockerignore | 7 + .github/workflows/ci.yml | 32 ++--- .gitignore | 7 +- PRODUCT.md | 38 +++-- README.md | 2 +- docs/acceptance.md | 175 ++++-------------------- docs/releases/0.8.0.md | 2 +- examples/sdk/README.md | 16 +++ {scripts => examples/sdk}/welcome.py | 0 examples/workflows/validate_examples.py | 13 +- extore/card_entitlements.py | 126 +++++------------ extore/card_tracking.py | 29 ++-- extore/db.py | 5 + extore/files.py | 15 +- extore/proxy_routes.py | 35 ++--- extore/storage.py | 29 ++-- extore/task_flow.py | 11 +- extore/text_cards.py | 6 +- extore/worker.py | 26 +--- pyproject.toml | 6 +- tests/test_initialized_schema.py | 65 +++++++++ tests/test_task_flow_native_worker.py | 10 -- tests/test_task_flow_schema.py | 24 ++++ 23 files changed, 269 insertions(+), 410 deletions(-) create mode 100644 examples/sdk/README.md rename {scripts => examples/sdk}/welcome.py (100%) create mode 100644 tests/test_initialized_schema.py diff --git a/.dockerignore b/.dockerignore index 318caef..e13eb77 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,10 @@ output .playwright-cli .pytest_cache __pycache__ +.ruff_cache +dist +build +*.egg-info +.coverage +.coverage.* +htmlcov diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9db515c..8541cc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,34 +25,18 @@ jobs: sudo meson install -C "$RUNNER_TEMP/extore-bubblewrap-build" /usr/bin/bwrap --version - run: uv sync --frozen --python 3.12 - - run: uv run ruff check extore scripts tests - - run: uv run ruff format --check extore scripts tests + - run: uv run ruff check extore scripts tests examples + - run: uv run ruff format --check extore scripts tests examples - run: uv run ruff check --config pyproject.toml processors/official/extore_processors processors/official/tests - run: uv run ruff format --check --config pyproject.toml processors/official/extore_processors processors/official/tests - run: uv run pytest -q - - run: uv run python -m compileall -q extore scripts + - run: uv run python -m compileall -q extore scripts examples - run: uv run python examples/workflows/validate_examples.py - - run: node --check extore/static/account.js - - run: node --check extore/static/vendor/qrcodegen.js - - run: node --check extore/static/totp-qr.js - - run: node --check extore/static/device-login.js - - run: node --check extore/static/pipeline-authorizations.js - - run: | - for file in extore/static/{task-flow,task-flow-editor,text-cards,batch-redemption,proxy-routing,proxy-config}.js; do node --check "$file"; done - - run: node --check extore/static/app.js - - run: node --check extore/static/progress-board.js - - run: node --check extore/static/worker-identity.js - - run: node --check extore/static/preferences.js - - run: node --check extore/static/products.js - - run: node --check extore/static/processor-contribution.js - - run: node --check extore/static/commerce-connect.js - - run: node --check extore/static/cards.js - - run: node --check extore/static/webmcp.js - - run: node --check extore/static/product-export.js - - run: node --check extore/static/customer-motion.js - - run: node --check extore/static/source-link.js - - run: node --check extore/static/cli-prompts.js - - run: node --check extore/static/owner-cli.js + - name: Check all shipped JavaScript + run: | + while IFS= read -r -d '' file; do + node --check "$file" + done < <(find extore/static -type f -name '*.js' -print0) - run: node --test tests/*.test.cjs - run: uv run pytest -q processors/official/tests - run: uv build -q diff --git a/.gitignore b/.gitignore index 1faf04e..c7f8447 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,10 @@ __pycache__/ output/ .playwright-cli/ .impeccable/config.local.json - dist/ - +build/ +*.egg-info/ +.coverage +.coverage.* +htmlcov/ .ruff_cache/ diff --git a/PRODUCT.md b/PRODUCT.md index f918754..a407b80 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -5,41 +5,35 @@ web ## Stack -Python FastAPI、SQLite、原生 HTML/CSS/JavaScript。根据脚本 SDK 需求选择,部署目标为用户指定的 archczy,域名 extore.lmm.best。 +Python FastAPI、SQLite、原生 HTML/CSS/JavaScript。Linux 上运行 API 和 worker;部署地址由配置决定,不绑定开发者的服务器。 ## Users -单个商家、通过商品管理链接获得指定权限的店长与处理人员,以及持有卡密的顾客。顾客无需注册。 +平台管理员、各店店主、获得商品权限的管理者与人类或 AI 处理者,以及持卡顾客。一个实例可以服务多个独立店铺。顾客无需注册。 ## Product Purpose -顾客输入卡密、填写商品参数,完成一次兑换,领取交付内容或查看服务状态。支付、外部订单和支付平台交付职责归另一网站。 +顾客用卡密提交需求、查看处理状态、领取交付内容。商家按商品管理卡密和任务。支付、销售订单与实际售价由外部商城负责;Extore 不收款,规格价格只是参考价。 ## Operating Context -队列按商品分开,人和 AI 通过同一套权限接口处理任务;AI 可使用原生 WebMCP。自动处理使用固定版本的官方白名单处理器,或独立外部服务的签名 webhook / 回调。管理者通过限一个商品、可撤销、有期限及登录次数限制的管理链接工作。 +商品、卡密、任务、附件、配置与授权按店铺隔离。人和 AI 使用相同的权限规则。浏览器、CLI、Python SDK 和原生 WebMCP 是同一业务的入口,不是各自独立的权限体系。 -## Capabilities and Constraints -SQLite。安全随机且唯一的卡密、一次兑换、可选重复查看或一次查看、可核实后重试。商品可公开或仅持卡可见;队列商品自定义输入参数与多个交付输出字段,支持多语言名称、URL 与 Markdown 教程。服务商品仅显示状态。任务创建时保存输入输出与处理步骤快照;队列商品可调整新任务的字段,已有任务及其重试按原快照处理。自动商品发行卡密后仍冻结字段结构与处理器,以及处理方式、交付和查看规则;字段名称与说明可改善。商家用 Passkey 登录,支持多个 Passkey;首次密码引导注册,SSH 命令恢复。 - -商品支持多个规格,每个规格有稳定 ID、名称、说明、可选参考价、币种、属性与启用状态。参考价仅供外部商城配置参考,Extore 只负责兑换与交付,不收款。发行卡密时选定规格并保存快照,顾客持卡兑换对应规格,无需自行选规格;已发行规格可以停用,不能删除。卡密支持批次、到期时间、尾号定位、商品和规格统计及不含秘密的时间线。剩余只统计未兑换且未到期的卡密,与原顾客可重试卡密分别计数;销售状态归支付平台。卡密原文只在本次发行返回,可整批或单条复制与下载,历史查询不恢复原文。 +平台管理员使用 Passkey;首次密码只用于平台初始化。店主使用已验证邮箱与密码,可启用 TOTP 或 Passkey。平台恢复命令不重置店主账号。完整规则见[多店与账号](docs/shops.md)。 -`file` 输入接收顾客材料,`file` 输出交付处理者上传的附件,每个文件最多 20 MiB。上传与下载限定卡密、任务、字段和对应权限,附件作为下载文件提供;文件不在站点执行,也不是处理器代码上传入口。交付附件同样遵循查看规则和销毁操作,已下载到顾客设备的副本不能撤回。 - -等待页显示实际任务步骤、处理说明、百分比和商品队列排位。纸条与反向滚动虚线仅作装饰,可由用户暂停,并遵循减少动态设置、页面隐藏及离屏暂停。同一任务轮询更新状态,保持已有动画。商品可配置联系邮箱,「催一下」用预填任务信息的 `mailto` 链接打开用户邮件应用,由用户自行发送,网站不自动发邮件。 +## Capabilities and Constraints +卡密绑定发行时的商品、规格和适用配置。已有卡密不继承以后新增的权限或修改额度。普通兑换、文本卡和可选任务流程共存;旧数据通过启动迁移继续使用,不在业务处理中猜测数据库是否缺表。 -主题和语言默认自动,可分别选择浅色/深色与中文/英文。自动主题实时跟随系统;自动语言按浏览器偏好顺序选择中文或英文,无匹配时回退中文。手动偏好保存在本浏览器。 +任务保存输入、输出与处理步骤快照。支持附件、人工处理、受审核的固定处理器和外部处理服务。处理失败或外部结果不明确时,需要核实,不自动宣称交付成功。修改交付使用卡密冻结的修改额度,不能重新计算为销售库存。 -商品管理链接可分别授权队列查看、处理、放行重试、展示与顾客参数编辑、自动发货配置、卡密管理、事件管理与向下委派。自动发货配置须同时获得商品编辑权限,可读写签名密钥、官方处理器配置和重试规则,具有控制该商品外部发货的能力;仅负责商品展示编辑的管理者不获此权限。店长可完全管理指定商品;子链接的权限必须比父链接少,期限不超过父链接,父链接失效则后代也失效。链接不能访问全店设置或管理商家 Passkey。 +处理器代码来自固定提交的官方子模块,不接受代码上传、任意仓库、命令或镜像配置。离线处理器需要 bubblewrap 和 libseccomp 隔离,并受资源限制;隔离不可用时拒绝运行,不退回普通进程。隔离不替代源码审核。复杂或联网任务由经过授权的外部执行器处理。见[运行指南](docs/getting-started.md)和[工作流开发](docs/workflow-development.md)。 -管理链接 `max_uses` 默认 1,限制成功建立新登录的次数,次数耗尽不注销已有会话。子链接次数上限不能超过父链接设定上限。商家管理全店会话;商品管理者可管理自己的会话,拥有向下委派权限时还可管理授权商品的下级会话。登录、链接使用、委派、退出和注销保留审计记录;会话列表显示设备、地址、登录与活动时间,不显示授权凭证。 +管理链接和设备授权有明确的商品范围、权限与有效期。向下委派只能缩小权限。授权撤销影响后续处理;秘密不进入公开提示词或普通查询。见[协议](docs/protocol.md)。 -自动处理器来自公开仓库 TokenNotIncluded/extore-processors,在 processors/official 子模块固定到审核过的提交。仅支持官方预设及其代码定义的输入输出;商家不能安装任意程序、上传代码或配置 Git 地址。固定代码按 worker 权限运行,不提供恶意代码沙箱。 -销毁立即撤除应用内交付内容和相关参数;无法撤回他人已下载的内容。 +卡密原文只在发行时返回。领取和销毁遵循商品规则及各版本的权限。销毁清除应用内对应内容,不能撤回外部副本,也不承诺擦除备份。 ## Brand Commitments -品牌名称为 Extore · 兑所,采用几何 E Logo 与撕纸折线、分段虚线。浅色温白,暗色中性灰黑。首页两张有柔和阴影和轻透视的纸卡,左纸内展示公开商品,右纸输入卡密;桌面点击放大焦点纸卡,手机原生左右滑动且默认兑换卡。卡密验证成功以短暂撕纸过渡进入填写页,减少动态或动画不可用时直接切换。 +名称为 Extore · 兑所。采用几何 E 标记、撕纸边缘与分段虚线。主题和语言默认跟随系统;动态效果遵循减少动态设置。视觉与交互规范只在 [DESIGN.md](DESIGN.md) 维护,不在此重复尺寸、颜色和动画时长。 ## Product Principles -- 兑换一次,重试不等于重新发货。 -- 成功必须有可信处理结果,未知状态等待核实。 -- 非公开商品只向持有效卡密的顾客展示。 -- 顾客专注领取,管理者按商品和权限工作,商家管理全店。 -- 店长可以委派更小的权限,不能越过自己的商品、权限或期限。 +- 兑换、重试和修改交付各有边界,不把重试当作新销售。 +- 成功必须有可信结果;测试通过不等于真实部署、邮件送达或付款成功。 +- 每次操作都检查实际店铺、商品和权限,不信任页面隐藏或客户端声明。 +- 旧数据升级保留业务记录;数据库结构错误必须暴露,不能静默跳过安全检查。 diff --git a/README.md b/README.md index 0b72143..74f0d92 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ SMTP 凭据、TOTP 密钥与店铺处理器配置加密保存。店主可以回 | [0.10.0 更新说明](https://github.com/TokenNotIncluded/extore/blob/main/docs/releases/0.10.0.md) | 自定义卡密属性、交付后修改权益、历史版本领取与轮次保护 | | [0.8.2 更新说明](https://github.com/TokenNotIncluded/extore/blob/main/docs/releases/0.8.2.md) | 清空回收站与彻底删除商品;保留已有卡密、任务和交付 | | [0.8.1 更新说明](https://github.com/TokenNotIncluded/extore/blob/main/docs/releases/0.8.1.md) | 商品删除、回收站恢复与独立删除权限;保留旧卡密和任务 | -| [验收记录](https://github.com/TokenNotIncluded/extore/blob/main/docs/acceptance.md) | 已记录的验证结果、对接边界与限制 | +| [验证指南](https://github.com/TokenNotIncluded/extore/blob/main/docs/acceptance.md) | 当前检查方法、数据升级与真实验收边界 | | [产品定义](https://github.com/TokenNotIncluded/extore/blob/main/PRODUCT.md) · [设计说明](https://github.com/TokenNotIncluded/extore/blob/main/DESIGN.md) | 项目范围、交互和视觉原则 | 服务运行后,`/docs` 提供交互式 OpenAPI 文档。普通事件和既有回调保持 **v1**;流程私有 Worker 使用独立的 **v2** 签名与节点范围。 diff --git a/docs/acceptance.md b/docs/acceptance.md index 2e8c8b3..c789a5e 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -1,162 +1,45 @@ -# 验收记录 +# 验证指南 -以下记录针对各自标注的版本和日期,保留当时范围与未验收事项。早期的单商家、首次密码和脚本说明不代表当前多店版本;当前行为见[多店与账号](shops.md)及[运行指南](getting-started.md)。新增功能的代码通过检查不等同于生产部署、邮件送达或真实付款完成。 +此页说明如何验证当前代码,不汇总已经过时的测试数量、服务器状态或临时验收账号。版本变化见 [releases](releases/),历史验收记录可从 Git 历史查阅。当前部署步骤见[运行指南](getting-started.md),账号规则见[多店与账号](shops.md)。 -## 0.6.2 参考价验证(2026-10-07) +## 代码检查 -- 商品规格、卡密统计、浏览器与 CLI 商品资料、WebMCP 与 OpenAPI 字段说明统一标为参考价;价格字段、精确小数与币种继续兼容,SQLite schema 不变。 -- Extore 负责卡密兑换与交付,参考价供外部商城配置参考;实际售价、支付和订单由商家在外部平台管理。 -- 主项目 Python 1314 项、Node 400 项通过;Ruff、格式、编译、CLI 版本及差异检查通过。这些本地结果不代替 CI、PyPI 发布或生产部署证据。 +先取得主仓库固定的处理器子模块,再使用锁定依赖: -## 0.6.1 邮件修复验证(2026-10-07) - -- 主项目 Python 1314 项通过,其中邮件专项 124 项;Ruff、格式、编译与 CLI 版本检查通过。 -- 可靠 SMTP 5xx 立即终止当前邮件并清除正文,4xx、断线及不明确响应保留有限重试;认证和发送策略错误不将邮箱全局标记为无效。 -- 真实标准库的 EHLO 返回拒绝、发送后 QUIT/连接关闭 I/O 错误、原 TLS/发送异常优先和过期 worker 租约均有回归覆盖。已被服务商接受的邮件不会因关闭连接的 I/O 错误重复发送。 -- SMTP 服务商接受邮件不代表最终送达;异步退信和地址压制仍由服务商处理,本地测试不证明真实邮件投递。 - -## 0.6.0 本地验证(2026-10-07) - -- 主项目 Python 1244 项、Node 400 项、处理器 34 项通过;处理器另有 74 个子测例,不重复计入独立测试总数。 -- Ruff 检查与 107 个文件的格式检查、Python 编译、CLI 帮助及版本检查通过。文档另校验 24 段 JSON、1 段 JSON Lines、115 条 CLI 示例和 79 个本地链接;SDK 步骤示例分别验证空计划、商家已有计划与已有完成项。 -- 本机 `http://127.0.0.1:8018` 的首页经过真实浏览器查看并留存截图。这项结果只证明本地页面,不能替代公网部署验收。 -- SMTP 通过测试用 TLS 服务验证,未验证真实邮件送达;未验证真实硬件 Passkey、付款提供商或实际付款交易。付款适配默认关闭,提供商尚未选定。 - -本节只记录本地结果,不作为仓库 CI、PyPI 发布或生产部署完成的证据;这些步骤应在完成后分别补充。 - -## 0.6.0 发布与部署(2026-10-07) - -- 发布代码固定为 `eddbc211eb5405db997f15a5f49f4cec25dd1da6`;[对应 CI](https://github.com/TokenNotIncluded/extore/actions/runs/37506427310) 完整通过。处理器子仓库固定为 `41f9c82f84dd0d77e1a7fa6d5fd80e8ed996b0f0`。 -- 使用 `uv build -q` 构建并发布 [PyPI 0.6.0](https://pypi.org/project/extore/0.6.0/);公开下载的 wheel 和源码分发包 SHA-256 均与本地发布产物一致,独立安装的 CLI 返回 `0.6.0`。 -- `archczy` 原生包升级为 `0.6.0-1`,SQLite schema 从 9 升为 11;API 与 worker 均为 active/running,验收时重启次数为 0,数据库完整性正常且无外键错误。 -- 服务启动前,24 张旧表的旧列、已有附件、发行密钥与环境文件严格核对一致;受保护的迁移快照再次复核通过。快照保存在 `/var/backups/extore/20261007-0.6.0-1` 及其 `migrated` 子目录,新密文密钥与数据库一起备份,目录 0700、文件 0600。 -- 上线后出现并发管理操作,包括会话变更、认证重置与店铺禁用;这些身份状态变化单独记录且没有回滚。17 张旧业务表仍与升级前投影完全一致,不将运行中的身份数据声称为完全不变。 -- 公网 HTTPS、健康检查和 OpenAPI 正常;19 个静态文件逐 SHA-256 与固定代码归档一致。真实浏览器访问登录页后点击右上角「兑换与领取」成功返回首页,公开商品显示在卡片内,无 JavaScript 错误。 -- 本次部署没有登录测试账号、消耗真实管理链接或卡密、修改任务、支付或配置邮箱;真实邮件、硬件 Passkey 与付款仍须另行验收。 - -## 0.2.0 更新(2026-10-06) - -- 首页空状态改为「暂无公开商品」,采用分段虚线与简化折线撕纸边缘。桌面 1440 × 1000、手机 390 × 844 检查,管理链接权限选择可在手机操作。 -- 队列按商品分开,管理员先选商品,管理链接固定商品;跨商品批处理整批拒绝。 -- 商品管理链接支持 8 项权限和店长完整商品管理,子链接严格缩权且不能超过父级期限。展示编辑与自动发货配置分别授权,展示编辑者无法读取签名密钥。 -- 后端 101 项测试、WebMCP 52 项 Node 测试、领取页面上下文 6 项 Node 测试通过,共 159 项。Ruff、Python 编译、JavaScript 语法与 0.2.0 wheel 构建通过。 -- Chromium 154 开启 `--enable-features=WebMCP`,确认实际使用 `document.modelContext`。真实原生工具完成商品创建、制卡、卡密验证、参数提交、店长创建下级链接、处理者完成人工交付、顾客查看及销毁。连续工具调用保持同一上下文注册稳定。 -- 浏览器中注册两个独立虚拟验证器(internal / USB),分别独立登录成功;密码禁用。未进行真实手机或硬件 Passkey 验收。 -- 上级管理链接撤销后,店长与处理者的原生工具调用均返回 `forbidden`,下级会话立即失效。 -- 上述业务操作使用本地隔离数据库,没有向生产增加测试商品、卡密或 Passkey。 -- UI 检测器缺少 HTML 解析依赖而降级为正则检查;渲染结果另经浏览器检查,不将降级扫描视为完整界面检测。 - -以下为初始化版本 0.1.0 的历史验收。 - -## 已验证 +```sh +git submodule update --init --recursive +uv sync --frozen --python 3.12 +uv run ruff check extore scripts tests examples +uv run ruff format --check extore scripts tests examples +uv run pytest -q +node --test tests/*.test.cjs +uv run pytest -q processors/official/tests +uv run python examples/workflows/validate_examples.py +uv build -q +uv run extore --help +uv run extore --version +``` -- Python 3.12 与 3.14 均运行核心测试;并发兑换仅产生一个任务和请求事件。 -- 卡密哈希存储、非公开商品、输入验证、批量事务回滚。 -- 重复查看、原子一次领取、销毁清理内容及参数。 -- 失败重试与次数限制,未知外部交付状态须商家核实。 -- 员工限商品授权、领取所有权、权限撤销。 -- 签名回调、过期签名、nonce 重放、旧尝试和终态覆盖拒绝。 -- 制卡 API 的持久化幂等与加密响应。 -- Python 脚本真实子进程输入、进度和成功结果。 -- Webhook 持久化重试、过期请求取消、私有网络地址拒绝。 -- 浏览器完整流程:卡密 → 参数 → 脚本发货 → 查看内容。 -- Chromium 虚拟验证器完成 Passkey 注册与重新登录;注册后密码请求 HTTP 403。没有真实硬件 / 手机 Passkey 验收。 -- 桌面 1280 × 720、手机 390 × 844 截图检查;手机无横向溢出。 -- Docker 镜像构建与容器内应用加载,Compose 配置校验。 +完整检查和处理器隔离环境的准备方法以 [CI 工作流](../.github/workflows/ci.yml)为准。JavaScript 语法检查自动遍历 `extore/static`,包括随项目提供的第三方脚本,不维护第二份文件清单。 -## 对接边界 +报告必须标注代码提交、Python 版本、检查命令和结果。跳过或失败的测试单独说明;不能用旧版本的通过数量代替当前结果。没有处理器隔离条件的本机测试,不能证明生产隔离执行正常。 -真实支付平台、真实商品提供商、真实外部 Webhook 往返尚未对接。现有测试不能证明第三方发货、扣款或结算成功。 +## 数据库与旧数据 -脚本不是不可信代码沙箱。只支持单商家、单 worker;当前脚本逐个执行。后台使用中文,顾客主要流程及参数支持中英切换。商品图片使用商家填写的 HTTPS URL,不包含上传存储服务。 +API 和 worker 启动时先完成 `extore.db.init()`。数据迁移保留旧卡密、任务、授权与交付。业务处理只使用完整结构;缺表或缺列必须报错,不能按零用量、无历史交付或无授权规则继续运行。 -已成功的一次领取遇到响应丢失可能无法恢复。销毁删除应用内内容,不撤回外部副本,不清理历史备份,不承诺物理擦除。 +在隔离数据库中验证首次初始化、重复初始化和旧结构升级。检查原有数据、发行密钥与加密密钥未被替换,并确认 `PRAGMA integrity_check` 和 `PRAGMA foreign_key_check` 的结果。不能通过删除生产数据让迁移测试通过。 -## 2026-10-06 生产部署 +## 业务与权限 -- 服务器:SSH alias `archczy`;公网域名 [extore.lmm.best](https://extore.lmm.best)。 -- 运行代码:`375b43f71b37e58cd9ceb90be1b67dd748519aa2`,原生包 `extore 0.1.0-2`。之后的验收文档提交不改变运行代码。 -- 源码归档 SHA-256:`63c2b105b65c7cb9b66b98079bed991578fd634e42073b2f2d937181fadb58c5`,服务器构建前校验一致。 -- [代码 CI](https://github.com/TokenNotIncluded/extore/actions/runs/37447234414) 通过:23 项测试、Ruff 检查与格式、Python 编译、前端语法及 wheel 构建。 -- `extore-api`、`extore-worker` enabled + active/running,验收时 `NRestarts=0`。数据目录 0700,数据库、发行响应密钥和首次密码文件 0600。 -- `pacman -Qkk extore`:1505 文件,0 被修改。SQLite `integrity_check=ok`。 -- HTTPS 证书有效至 2027-01-04;现有 `certbot-renew.timer` enabled,增加仅针对本域名的 nginx 重载 hook。 -- 公网 GET / HEAD 首页与 `/health` 均 200;浏览器静态资源加载成功,无应用错误。首页无公开商品,连续点击 Logo 5 次可进入首次密码登录页面。 -- 公网浏览器真实流程:临时私有卡密 → 输入参数 → 生产 worker 执行脚本 → 成功领取 → 销毁后不可领取。 -- 通过公开 HTTPS 调用 Python SDK 签名回调,更新进度、成功交付、领取及销毁均通过。公网 TLS 固定 IP 发起 webhook 请求正常到达测试目标(测试目标不接收 POST,预期返回 405);没有真实商家平台通知接收验收。 -- 验收商品、卡密、任务和临时脚本已清除;生产库商品、卡密、任务计数均为 0。首次密码保留供商家自行注册 Passkey,没有在生产注册测试者 Passkey。 -- 原有 `msg.lmm.best` 与 `donate.lmm.best` 部署后均返回 200。 +使用隔离测试店铺验证卡密发行、兑换、参数提交、处理、领取、重试、修改交付与销毁。检查并发兑换、重复请求、过期请求和响应丢失,不只检查第一次请求成功。 -商家在自己的终端执行: +至少覆盖跨店与跨商品拒绝、权限撤销、旧任务快照、一次领取、附件容量、处理超时及服务重启后的恢复。数据库事务、配额和任务隔离不能因兼容分支而被跳过。 -```sh -ssh archczy 'sudo cat /var/lib/extore/bootstrap-password.txt' -``` +桌面与手机分别检查登录、兑换、队列和领取页面。检查键盘操作、减少动态设置、页面隐藏后的轮询和动画行为。没有原生 WebMCP 的浏览器应保持普通页面可用;模拟接口的测试不能代替原生浏览器验证。 -不要将密码粘贴到聊天。打开 `/admin`,输入首次密码并注册自己的 Passkey。注册成功后密码登录禁用,服务器上的首次密码文件删除。丢失所有 Passkey 时: +## 部署与外部服务 -```sh -ssh -t archczy 'sudo -u extore extore-admin reset-auth' -``` +代码检查、构建、发布、部署和真实业务验收是不同结果,分别记录。部署报告注明实际运行提交与版本、服务状态、健康检查和升级前后的数据核对结果,不把历史服务器名称写成通用操作命令。 -维护升级应在目标服务器重新构建原生包,再用 pacman 更新;系统 Python 大版本改变后也须重建该包。CLI 通过 `extore-admin` 加载 `/etc/extore/extore.env`,无需激活虚拟环境或使用打包时的路径。 - - -## 0.2.0 生产更新(2026-10-06) - -- 运行代码:`e4ce8a1b9f766cb9bb9c1602b5e365e7ed76c1e3`;Arch 原生包 `extore 0.2.0-1`,在目标 Python 3.14.7 构建。后续仅验收文档提交不改变运行代码。 -- 源码 SHA-256:`293d460bde5182a20f50e8c46e8fbded7e8df944553ee60e5c290909d18e47a0`,服务器构建已校验。[代码 CI](https://github.com/TokenNotIncluded/extore/actions/runs/37454866120) 全部通过。 -- 升级前受保护备份:`/var/backups/extore/20261006-0.2.0`。数据库既有字段逐表指纹与发行密钥、配置文件校验一致;保留商家已注册的 1 个 Passkey,密码登录仍禁用。生产商品、卡密、任务、管理链接均无测试数据。 -- SQLite 结构版本升到 2,`integrity_check=ok`;`pacman -Qkk extore` 为 1507 个文件、0 被修改。 -- API 与 worker enabled、active/running,`NRestarts=0`。数据目录 0700、数据库与发行密钥 0600。 -- HTTPS 首页、健康检查与 WebMCP 静态资源 200;公网 JS、CSS、虚线 SVG 与提交内容逐字节一致。 -- 开启 WebMCP 的真实 Chromium 在公网确认 `document.modelContext` 与首页 5 个原生工具,实际执行公开商品查询;连续点击 Logo 5 次进入 Passkey 登录页,匿名后台只注册上下文和导航工具。 -- 未启用 WebMCP 的 Chromium 正常显示兑换表单,未伪造浏览器 API。生产未执行 Passkey 注册或重置。 - -## 0.3.0 代码与生产更新(2026-10-06) - -本节记录 0.3.0。前面的首次密码、任意本地脚本、清空测试数据等内容属于当时的历史记录,不代表当前配置或执行方式。 - -- 运行代码:`03a774cb038db2b7d125db99c162029bccfd5bd5`;Arch 原生包 `extore 0.3.0-1`,目标运行环境 CPython 3.14.7。 -- [主代码 CI](https://github.com/TokenNotIncluded/extore/actions/runs/37464387047) 成功,提交与生产运行代码一致:主项目 Python 261 项、Node 138 项、预设处理器 23 项,共 422 项。处理器测试中的 52 个子测例不重复计入总数。Ruff、Python 编译、前端语法与 wheel 构建通过。 -- [主仓库](https://github.com/TokenNotIncluded/extore) 与[预设处理器仓库](https://github.com/TokenNotIncluded/extore-processors) 均已公开。`processors/official` 子模块固定到 `7b4303aeeaf947373cd97e2885a60790218c8257`,仅运行白名单中的 `resource_link` 和 `personalized_text`;商家不能指定任意程序、上传代码或配置其他 Git 地址。 -- 新增商品输入与结构化输出定义、仅状态的服务交付、按商品的人与 AI 共用队列、39 项原生 WebMCP 工具,以及卡密批次、尾号、到期和统计追踪。界面已提供全部/单张卡密复制与三种管理链接操作。 -- 本地隔离环境已验证队列和预设处理器的原生工具交付闭环;领取与销毁期间的页面上下文竞态回归测试通过。这些证据不替代本次真实生产闭环验收。 -- 源码归档 SHA-256:`2e145873275e3f49f19984c9d1e0b085f3014f4be637615ade6e826c77141f7f`,服务器构建前校验一致。 -- 升级前受保护备份:`/var/backups/extore/20261006-0.3.0`。既有核心数据逐字段指纹、发行密钥与原环境配置还原校验一致;保留商家已注册的 1 个 Passkey,未注册或重置生产认证。 -- 升级时保留 1 个商品、10 张卡密、1 个管理链接、0 个任务。这些是商家创建的真实数据,未作为测试数据清理。SQLite 结构版本为 4,`integrity_check=ok`。 -- pacman 升级移除的旧 `EXTORE_SCRIPTS` 配置行已按原字节恢复;环境文件因此与新版默认配置不同,该遗留变量不会恢复任意脚本执行。`pacman -Qkk extore` 检查 1519 个文件、0 被修改。 -- API 与 worker 为 active/running,验收时 `NRestarts=0`;服务器本地 `/health` 返回 200。 - -本次公网页面与静态资源、浏览器界面及真实生产新闭环尚待补充验收。真实手机/硬件 Passkey、真实外部商品提供商与支付平台往返仍未验收。 - -## 0.4.0 生产更新与文件交付闭环(2026-10-06) - -本节为当前运行验收,替代前文的待补充生产闭环状态。仅修改和部署 Extore,没有部署或修改上游商城。 - -- 运行代码:`202f1e9f79783b723e24363e6111661e62ef79fd`;原生包 `extore 0.4.0-2`,在 archczy 的 CPython 3.14.7 构建。后续验收文档提交不改变运行程序。 -- [代码 CI](https://github.com/TokenNotIncluded/extore/actions/runs/37477559545) 全部通过:主项目 Python 436 项、Node 215 项、预设处理器 24 项,共 675 项;54 个处理器子测例不重复计数。Ruff、格式、编译、前端语法和 wheel 构建通过。 -- 预设处理器子模块为 `92911e9f418d21a892297023f02c6f5325bd115a`;[处理器 CI](https://github.com/TokenNotIncluded/extore-processors/actions/runs/37474976242) 通过。两个仓库均公开,仍只运行预设白名单预设。 -- 源码归档 SHA-256:`f09a11e70201671e1a7805c2adca34b9b28785a3ae9e6ebe16f9f379af749523`,服务器原生构建前校验一致。 -- 受保护备份:`/var/backups/extore/20261006-0.4.0` 与 `/var/backups/extore/20261006-0.4.0-2`;后一份包含已经上传的材料及会话。升级前后既有字段逐表指纹、发行密钥和环境文件字节一致,保留 1 个商家 Passkey,没有初始化、注册或重置生产认证。 -- SQLite 结构版本 7,`integrity_check=ok`。两个服务 active/running,`NRestarts=0`;数据目录 0700、发行密钥 0600。`pacman -Qkk` 检查 1545 个文件,仅环境配置与包默认值不同:保留原字节及更严格的 0600 权限;程序文件无修改。 -- HTTPS 首页、健康检查与源码元数据均 200;主要 JS/CSS 公网字节与运行提交一致。右上角 GitHub 源码与缓存 Star 数可见(验收时 0),失败时不伪造数量。 -- 桌面纸卡具有阴影和透视,点击切换焦点;公开商品实际位于左侧纸卡。设置 390 × 844 视口验证手机左右切换,检查无页面横向溢出,随后复原视口。深色为黑灰背景。 -- 顾客等待页使用真实步骤进度与商品内排位。实际显示过第 2 位、前方 1 个任务,以及 25%、50%、75% 的步骤更新和消息;上方虚线正向、下方反向,三个循环动画均为 running;暂停按钮将动画变为 paused。商品后台提供商家催办邮箱,填写后才显示 mailto 快捷入口,未替商家假定邮箱地址或自动发送邮件。 -- 商品资料一键复制在生产浏览器验证,包含规格、输入、输出、步骤及未兑换卡密统计;不含卡密原文、管理凭证或发货密钥。未兑换库存不等于上游未售库存。 -- 管理链接创建页可登录次数为必填,默认 1。原有有效会话迁移后继续使用,原链接计数为 1/1;不带会话的新客户端登录实际返回 409,未发放新会话。会话与审计页面可访问;旧会话没有迁移前未曾采集的 IP/UA,不伪造历史来源。 -- 六个新浏览器并发登录单次链接的端点回归:仅一个成功、一个会话、一条消耗审计;其余 409。会话自身/后代范围、不能操作上级或同级、会话撤销保留记录及最后会话注销释放未完成队列任务均由自动测试验证,没有注销商家的真实会话。 -- 实际 Chromium 使用 `document.modelContext`,队列上下文注册 14 个适用工具且无注册错误,全部页面共定义 47 个工具。通过原生 `getTools()` / `executeTool(RegisteredTool, JSON.stringify(args))` 成功执行上下文读取、任务领取、步骤绑定、进度、材料读取、Word/PPT 文件上传及完成交付,没有使用伪造浏览器 API。 -- 首次真实材料读取暴露精确任务过滤漏用的问题:已修复为先按 `job_id` 过滤再应用 limit,并增加同商品第二任务及跨商品边界回归。AI 上传与完成按目标任务输出快照验证,旧任务不继承当前商品新增必填字段,结构不同的批处理拒绝。 -- 在商家既有 1 个无参数排队任务基础上,新建 1 个材料任务;使用商家已给出的卡密,未购买新卡密或生成额外库存。旧任务交付明确标注的演示回执,不伪造真实研究结果;新任务上传合成 CSV,由 AI 读取并汇总,再交付一页 Word 与三页 PPT。顾客主动查看并下载两份文件,下载字节与生成文件相同且 ZIP CRC 通过。 -- 输入材料 SHA-256:`8b5d53d2a41fb247f23ba24f835e83f8ca3183d3ec1f6699c58799a21dc48520`(361 B);Word:`6081633f860a14916f9a6768c91406a4fb808206da56fc965ba12c1f484daff0`(13059 B);PPT:`fee13a94e55ebe9b59532002c2ddab0ee009264c7874aea8c1d437f29c41b8b9`(27364 B)。文件为合成数据演示与验收模板,非正式研究成果;没有伪造文献或实验。 -- 完成后线上 2 个任务均 succeeded / 100%,10 张卡密中 2 张 used、8 张 ready,保存 1 个输入文件、2 个输出文件。交付保持可查看,没有执行永久销毁。 -- 上传请求仅两个文件接口放宽到 21 MiB,其他接口维持 256 KiB。实际发送两个 512 KiB 的无权限测试请求,均通过反向代理后由应用返回 404/401,未写入文件;20 MiB 单文件、100 MiB 单卡及一次下载/销毁等边界由自动测试验证。 - -### 上游与持续运行边界 - -- 用户要求跳过购买并提供卡密,因此本次验证覆盖兑换至文件交付,没有声称支付或上游发卡闭环成功。上游尝试下单曾返回「此账号无权执行该商店操作」,没有取得订单/卡密;没有资金流水证据,不据此断言未扣款。 -- 上游应在下单前明确不可购买的原因;是否为卖家购买自己的商品需要以实际账户和服务端日志确认,不能仅凭错误文本定论。商品描述应渲染 Markdown。 -- 上游规格应有稳定 SKU 标识、独立价格、按规格粘贴卡密补库存;商品销售库存为各规格未售库存之和。发卡及订单须保留规格标识。Extore 已支持对应规格与未兑换卡密统计,但不能把兑换状态直接当作销售库存或替上游更新价格。 -- AI 队列验证由本次已获商品权限的 AI 代理实际执行。Extore 不自带持续唤醒的通用 AI 模型服务;长期流水线仍需商家连接常驻代理或受审核的外部处理服务。 -- 真实手机/硬件 Passkey、真实商品提供商与支付平台完整往返仍未验收。UI 检测器缺解析依赖,降级正则扫描不替代本次浏览器界面检查。 +SMTP 接受不等于邮件最终送达;Webhook 返回不等于外部业务已完成;页面配置成功不等于付款成功。真实邮件、硬件 Passkey、外部处理服务和支付平台往返,只在实际完成后记录。不要把卡密、私密链接、密码或密钥写进仓库、日志或验收报告。 diff --git a/docs/releases/0.8.0.md b/docs/releases/0.8.0.md index eb4c0e2..1157282 100644 --- a/docs/releases/0.8.0.md +++ b/docs/releases/0.8.0.md @@ -1,6 +1,6 @@ # 0.8.0 更新说明 -本轮把一次兑换扩展为可选的分步任务,并保留既有商品、卡密、Webhook v1 和认证授权流程。本文说明代码接口与兼容性;发布、部署和真实业务验收分别记录在[验收记录](../acceptance.md)。 +本轮把一次兑换扩展为可选的分步任务,并保留既有商品、卡密、Webhook v1 和认证授权流程。本文说明代码接口与兼容性;发布、部署和真实业务验收须按[验证指南](../acceptance.md)分别核实。 - **商品任务流程**:输入、处理、展示、结束节点,有限分支、前序结果引用、显式开始和时限。图与执行配置按发行卡密冻结;当前顾客视图和处理权限不暴露整图私密数据,旧 epoch / attempt 不能覆盖新节点。 - **文本库存**:按商品规格粘贴或导入 UTF-8 文本,每个非空行生成随机卡密并绑定文本;同次重复行去重,整批额度不足不创建半批。原卡和对应表只在发行时返回。 diff --git a/examples/sdk/README.md b/examples/sdk/README.md new file mode 100644 index 0000000..cd8200e --- /dev/null +++ b/examples/sdk/README.md @@ -0,0 +1,16 @@ +# External worker SDK example + +`welcome.py` shows the reviewed external worker stdin/stdout protocol. It is not +a selectable website processor and does not start a queue consumer. + +Run it from the repository root with a task envelope on standard input: + +```sh +uv run python examples/sdk/welcome.py < task.json +``` + +The envelope must follow the [SDK task contract](../../docs/python-sdk.md), with a +`name` input parameter. This example writes a progress update and a welcome +message. Use the [worker instructions](../../docs/private-worker.md) for a live +queue and the [processor guide](../../docs/processor-contributing.md) for reviewed +built-in processors. diff --git a/scripts/welcome.py b/examples/sdk/welcome.py similarity index 100% rename from scripts/welcome.py rename to examples/sdk/welcome.py diff --git a/examples/workflows/validate_examples.py b/examples/workflows/validate_examples.py index 953e6bb..92524f4 100644 --- a/examples/workflows/validate_examples.py +++ b/examples/workflows/validate_examples.py @@ -6,12 +6,13 @@ from extore.task_flow_definition import validate_definition -def main(): - root = Path(__file__).parent - for directory in sorted(root.iterdir()): - if not directory.is_dir(): - continue - definition = json.loads((directory / "flow.json").read_text(encoding="utf-8")) +def main(root=Path(__file__).parent): + definitions = sorted(root.glob("*/flow.json")) + if not definitions: + raise ValueError("No workflow examples found") + for path in definitions: + directory = path.parent + definition = json.loads(path.read_text(encoding="utf-8")) product = json.loads((directory / "product.json").read_text(encoding="utf-8")) canonical = validate_definition(definition, product) assert canonical is not None diff --git a/extore/card_entitlements.py b/extore/card_entitlements.py index 29980f2..c415261 100644 --- a/extore/card_entitlements.py +++ b/extore/card_entitlements.py @@ -7,7 +7,6 @@ import hashlib import hmac import json -import sqlite3 import time from .card_tracking import card_expired @@ -55,10 +54,6 @@ def _value(row, name, default=None): return row[name] if name in row.keys() else default -def _missing_schema(error): - return "no such table" in str(error) or "no such column" in str(error) - - def validate_capacity(attributes, policy): if not policy: return 0 @@ -84,15 +79,10 @@ def freeze_card(c, card_id, attributes, policy): def _record(c, card_id): - try: - return c.execute( - "SELECT attributes,policy FROM card_entitlements WHERE card_id=?", - (card_id,), - ).fetchone() - except sqlite3.OperationalError as error: - if not _missing_schema(error): - raise - return None + return c.execute( + "SELECT attributes,policy FROM card_entitlements WHERE card_id=?", + (card_id,), + ).fetchone() def card_attributes(c, card): @@ -122,21 +112,6 @@ def revision_view(row): } -def _versions(c, row): - try: - return [ - dict(item) - for item in c.execute( - "SELECT * FROM job_delivery_versions WHERE job_id=? ORDER BY revision", - (row["id"],), - ) - ] - except sqlite3.OperationalError as error: - if not _missing_schema(error): - raise - return [] - - def _current_delivery(row): return { "job_id": row["id"], @@ -155,20 +130,15 @@ def delivery_view(c, row): from .service import job_product # Polling never materializes historical document bodies in Python memory. - try: - result = [ - dict(item) - for item in c.execute( - "SELECT v.revision,v.attempt,v.created,v.revealed,EXISTS(SELECT 1 FROM job_files f " - "WHERE f.job_id=v.job_id AND f.attempt=v.attempt AND f.kind='output' AND f.bound=1 AND f.content IS NOT NULL) AS has_files " - "FROM job_delivery_versions v WHERE v.job_id=? ORDER BY v.revision", - (row["id"],), - ) - ] - except sqlite3.OperationalError as error: - if not _missing_schema(error): - raise - result = [] + result = [ + dict(item) + for item in c.execute( + "SELECT v.revision,v.attempt,v.created,v.revealed,EXISTS(SELECT 1 FROM job_files f " + "WHERE f.job_id=v.job_id AND f.attempt=v.attempt AND f.kind='output' AND f.bound=1 AND f.content IS NOT NULL) AS has_files " + "FROM job_delivery_versions v WHERE v.job_id=? ORDER BY v.revision", + (row["id"],), + ) + ] for item in result: item["revealed"] = bool(item["revealed"]) item["has_files"] = bool(item["has_files"]) @@ -194,49 +164,34 @@ def last_delivery(c, row): if row["state"] == "succeeded": current = _current_delivery(row) return {key: current[key] for key in ("revision", "attempt", "created")} - try: - saved = c.execute( - "SELECT revision,attempt,created FROM job_delivery_versions WHERE job_id=? ORDER BY revision DESC LIMIT 1", - (row["id"],), - ).fetchone() - except sqlite3.OperationalError as error: - if not _missing_schema(error): - raise - saved = None + saved = c.execute( + "SELECT revision,attempt,created FROM job_delivery_versions WHERE job_id=? ORDER BY revision DESC LIMIT 1", + (row["id"],), + ).fetchone() return dict(saved) if saved else None def has_previous_delivery(c, row): - try: - return bool( - c.execute( - "SELECT 1 FROM job_delivery_versions WHERE job_id=? LIMIT 1", - (row["id"],), - ).fetchone() - ) - except sqlite3.OperationalError as error: - if not _missing_schema(error): - raise - return False + return bool( + c.execute( + "SELECT 1 FROM job_delivery_versions WHERE job_id=? LIMIT 1", + (row["id"],), + ).fetchone() + ) def preserved_output_ids(c, row): # bind_outputs only binds selected outputs, and deletes all other drafts. # This retention query is not download authority: downloads still check the # archived JSON's exact field/file reference independently. - try: - return { - item[0] - for item in c.execute( - "SELECT id FROM job_files f WHERE f.job_id=? AND f.kind='output' AND f.bound=1 " - "AND EXISTS(SELECT 1 FROM job_delivery_versions v WHERE v.job_id=f.job_id AND v.attempt=f.attempt AND v.result_json IS NOT NULL)", - (row["id"],), - ) - } - except sqlite3.OperationalError as error: - if not _missing_schema(error): - raise - return set() + return { + item[0] + for item in c.execute( + "SELECT id FROM job_files f WHERE f.job_id=? AND f.kind='output' AND f.bound=1 " + "AND EXISTS(SELECT 1 FROM job_delivery_versions v WHERE v.job_id=f.job_id AND v.attempt=f.attempt AND v.result_json IS NOT NULL)", + (row["id"],), + ) + } def file_delivery(c, row, item, revision=None): @@ -346,15 +301,10 @@ def select_delivery(c, row, revision=None): current = _current_delivery(row) if row["state"] == "succeeded" else None if current and (revision is None or revision == current["revision"]): return current - try: - saved = c.execute( - "SELECT * FROM job_delivery_versions WHERE job_id=? AND (? IS NULL OR revision=?) ORDER BY revision DESC LIMIT 1", - (row["id"], revision, revision), - ).fetchone() - except sqlite3.OperationalError as error: - if not _missing_schema(error): - raise - saved = None + saved = c.execute( + "SELECT * FROM job_delivery_versions WHERE job_id=? AND (? IS NULL OR revision=?) ORDER BY revision DESC LIMIT 1", + (row["id"], revision, revision), + ).fetchone() if not saved: fail( "尚无可领取内容" if revision is None else "交付版本不存在", @@ -380,12 +330,6 @@ def mark_revealed(c, row, delivery): def allocated_bytes(c, shop_id=None, card_id=None): """Historical text and revision suggestions share normal storage quotas.""" - tables = { - item[0] - for item in c.execute("SELECT name FROM sqlite_master WHERE type='table'") - } - if "job_delivery_versions" not in tables: - return 0 scope = " AND (? IS NULL OR p.shop_id=?) AND (? IS NULL OR j.card_id=?)" values = (shop_id, shop_id, card_id, card_id) saved = c.execute( diff --git a/extore/card_tracking.py b/extore/card_tracking.py index 72334aa..d76666c 100644 --- a/extore/card_tracking.py +++ b/extore/card_tracking.py @@ -177,23 +177,18 @@ def record_issue( "INSERT INTO card_meta(card_id,batch_id,code_suffix,expires,variant_id,variant_snapshot) VALUES (?,?,?,?,?,?)", [(cid, batch_id, suffix, expires, variant_id, frozen) for cid, suffix in rows], ) - # Issuance tools on pre-migration minimal databases keep their legacy SKU - # snapshot. Normal installations always freeze the explicit null policy too. - if c.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='card_entitlements'" - ).fetchone(): - from .card_entitlements import freeze_card - from .variants import validate_attributes - - config = json.loads( - c.execute("SELECT config FROM products WHERE id=?", (pid,)).fetchone()[0] - ) - effective = validate_attributes( - {**snapshot.get("attributes", {}), **(attributes or {})} - ) - policy = config.get("revision_policy") - for cid, _ in rows: - freeze_card(c, cid, effective, policy) + from .card_entitlements import freeze_card + from .variants import validate_attributes + + config = json.loads( + c.execute("SELECT config FROM products WHERE id=?", (pid,)).fetchone()[0] + ) + effective = validate_attributes( + {**snapshot.get("attributes", {}), **(attributes or {})} + ) + policy = config.get("revision_policy") + for cid, _ in rows: + freeze_card(c, cid, effective, policy) return batch_id diff --git a/extore/db.py b/extore/db.py index dd91e3c..77a2d5f 100644 --- a/extore/db.py +++ b/extore/db.py @@ -48,6 +48,11 @@ def db(): def init(): + """Complete migrations before serving requests or starting workers. + + Runtime code requires the full schema; missing tables must not silently + disable authorization, retention, or quota checks. + """ DATA.mkdir(parents=True, exist_ok=True, mode=0o700) with db() as c: c.executescript(SCHEMA) diff --git a/extore/files.py b/extore/files.py index 93d1100..239cee3 100644 --- a/extore/files.py +++ b/extore/files.py @@ -123,15 +123,12 @@ def _descriptor(row): def _with_flow_scope(c, row): - if c.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_flow_files'" - ).fetchone(): - scope = c.execute( - "SELECT flow_epoch,node_id FROM task_flow_files WHERE file_id=?", - (row["id"],), - ).fetchone() - if scope: - return {**dict(row), **dict(scope)} + scope = c.execute( + "SELECT flow_epoch,node_id FROM task_flow_files WHERE file_id=?", + (row["id"],), + ).fetchone() + if scope: + return {**dict(row), **dict(scope)} return row diff --git a/extore/proxy_routes.py b/extore/proxy_routes.py index c89428c..4356d4d 100644 --- a/extore/proxy_routes.py +++ b/extore/proxy_routes.py @@ -99,15 +99,6 @@ def init_schema(c): ) -def _has_schema(c): - return ( - c.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='proxy_routes'" - ).fetchone() - is not None - ) - - def _encode(raw): return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") @@ -530,14 +521,10 @@ def default_issuer_route(c, shop_id, routed=None): ensure_shop_issuer(c, shop_id) if routed is False and ORIGIN.startswith("https:"): fail("新卡必须使用本店发行标识", 409) - row = ( - c.execute( - "SELECT * FROM proxy_routes WHERE shop_id=? AND default_issuer=1 AND identity_id IS NOT NULL", - (shop_id,), - ).fetchone() - if _has_schema(c) - else None - ) + row = c.execute( + "SELECT * FROM proxy_routes WHERE shop_id=? AND default_issuer=1 AND identity_id IS NOT NULL", + (shop_id,), + ).fetchone() if row is None: if routed is True or ORIGIN.startswith("https:"): fail("请先配置本店的签名发行路由", 409) @@ -612,14 +599,10 @@ def unwrap_local_code(c, code): return code try: parsed = parse_routed_code(code) - row = ( - c.execute( - "SELECT * FROM proxy_routes WHERE route_id=? AND enabled=1 AND identity_id IS NOT NULL", - (parsed["route_id"],), - ).fetchone() - if _has_schema(c) - else None - ) + row = c.execute( + "SELECT * FROM proxy_routes WHERE route_id=? AND enabled=1 AND identity_id IS NOT NULL", + (parsed["route_id"],), + ).fetchone() if ( row is None or row["origin"] != canonical_origin(ORIGIN) @@ -650,8 +633,6 @@ def unwrap_local_code(c, code): @router.get("/api/proxy/routes") def public_routes(): with db() as c: - if not _has_schema(c): - return [] unique = {} for row in c.execute( "SELECT routes.* FROM proxy_routes routes JOIN shops ON shops.id=routes.shop_id WHERE routes.enabled=1 AND shops.enabled=1 ORDER BY routes.created,routes.route_id" diff --git a/extore/storage.py b/extore/storage.py index 7bf1faf..87666b8 100644 --- a/extore/storage.py +++ b/extore/storage.py @@ -67,29 +67,22 @@ def require_disk_space(additional, *, pending=0): def _flow_allocated(c, shop_id=None): - tables = { - row[0] for row in c.execute("SELECT name FROM sqlite_master WHERE type='table'") - } - if "card_task_flows" not in tables: - return 0 total = c.execute( "SELECT COALESCE(SUM(length(snapshot_ciphertext)),0) FROM card_task_flows " "WHERE (? IS NULL OR shop_id=?)", (shop_id, shop_id), ).fetchone()[0] - if "task_flow_steps" in tables: - total += c.execute( - "SELECT COALESCE(SUM(length(s.payload_ciphertext)),0) FROM task_flow_steps s " - "JOIN jobs j ON j.id=s.job_id JOIN card_task_flows f ON f.card_id=j.card_id " - "WHERE (? IS NULL OR f.shop_id=?)", - (shop_id, shop_id), - ).fetchone()[0] - if "task_flow_dispatches" in tables: - total += c.execute( - "SELECT COALESCE(SUM(length(payload_ciphertext)),0) FROM task_flow_dispatches " - "WHERE (? IS NULL OR shop_id=?)", - (shop_id, shop_id), - ).fetchone()[0] + total += c.execute( + "SELECT COALESCE(SUM(length(s.payload_ciphertext)),0) FROM task_flow_steps s " + "JOIN jobs j ON j.id=s.job_id JOIN card_task_flows f ON f.card_id=j.card_id " + "WHERE (? IS NULL OR f.shop_id=?)", + (shop_id, shop_id), + ).fetchone()[0] + total += c.execute( + "SELECT COALESCE(SUM(length(payload_ciphertext)),0) FROM task_flow_dispatches " + "WHERE (? IS NULL OR shop_id=?)", + (shop_id, shop_id), + ).fetchone()[0] return total diff --git a/extore/task_flow.py b/extore/task_flow.py index 04a4b27..5166476 100644 --- a/extore/task_flow.py +++ b/extore/task_flow.py @@ -36,13 +36,10 @@ def _quota(c, ciphertext, product_id, *, job_id=None, old_bytes=0): "ON jobs.card_id=card_task_flows.card_id WHERE jobs.id=?", (job_id,), ).fetchone()[0] - if c.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_flow_dispatches'" - ).fetchone(): - total += c.execute( - "SELECT COALESCE(SUM(length(payload_ciphertext)),0) FROM task_flow_dispatches WHERE job_id=?", - (job_id,), - ).fetchone()[0] + total += c.execute( + "SELECT COALESCE(SUM(length(payload_ciphertext)),0) FROM task_flow_dispatches WHERE job_id=?", + (job_id,), + ).fetchone()[0] if total + additional > MAX_RUN_CIPHERTEXT_BYTES: fail("任务流程文字存储达到上限,请使用附件", 413) if additional > 0: diff --git a/extore/text_cards.py b/extore/text_cards.py index e051eea..f614457 100644 --- a/extore/text_cards.py +++ b/extore/text_cards.py @@ -268,11 +268,7 @@ def discard_assignment(c, card_id): def allocated_bytes(c, shop_id=None): - """Include text stock in shared quotas, while old databases remain readable.""" - if not c.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='text_card_payloads'" - ).fetchone(): - return 0 + """Include issued text stock in shared storage quotas.""" if shop_id is None: return c.execute( "SELECT COALESCE(SUM(size),0) FROM text_card_payloads" diff --git a/extore/worker.py b/extore/worker.py index a6a73c2..c493781 100644 --- a/extore/worker.py +++ b/extore/worker.py @@ -18,22 +18,6 @@ from .variants import card_variant -def _has_table(c, name): - return ( - c.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,) - ).fetchone() - is not None - ) - - -def _flow_exclusion(c): - # Compatibility with existing databases/tests before the additive migration. - if not _has_table(c, "card_task_flows"): - return "" - return " AND NOT EXISTS (SELECT 1 FROM card_task_flows WHERE card_task_flows.card_id=jobs.card_id)" - - def _check_script_execution(c, row, processor_id, *, require_input=False): """Recheck live authority without putting private configuration in a task.""" current = job(c, row["id"]) @@ -211,8 +195,7 @@ async def outbox_once(): not r or r["state"] not in ("queued", "processing") or r["attempt"] != payload["data"]["attempt"] - or _has_table(c, "card_task_flows") - and c.execute( + or c.execute( "SELECT 1 FROM card_task_flows WHERE card_id=?", (r["card_id"],) ).fetchone() is not None @@ -267,8 +250,7 @@ async def execute_script(row, p, task_flow_epoch=None): ): raise ValueError("处理任务已失效") is_flow = ( - _has_table(c, "card_task_flows") - and c.execute( + c.execute( "SELECT 1 FROM card_task_flows WHERE card_id=?", (trusted_job["card_id"],), ).fetchone() @@ -524,7 +506,7 @@ async def watch_authority(): async def job_once(): with db() as c: - exclusion = _flow_exclusion(c) + exclusion = " AND NOT EXISTS (SELECT 1 FROM card_task_flows WHERE card_task_flows.card_id=jobs.card_id)" # A crash/timeout may already have performed external effects. Never retry silently. expired = c.execute( "SELECT * FROM jobs WHERE state='processing' AND lease