diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..439c13f9 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,36 @@ +name: Deploy + +on: + push: + branches: [main, master] + workflow_dispatch: # 允许手动触发 + +jobs: + deploy: + name: Deploy to Cloudflare Workers + runs-on: ubuntu-latest + # 需要在 GitHub 仓库 Settings → Secrets 配置: + # CLOUDFLARE_API_TOKEN (Workers 编辑 + KV 编辑权限) + # CLOUDFLARE_ACCOUNT_ID (可选;Token 可锁定到单 account) + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + Test (deploy 前最后一道防线) + run: | + npm run lint + npm test + + - name: Deploy to Cloudflare Workers + run: npm run deploy:safe + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..35e88766 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Test + +on: + push: + branches: [main, master, 'refactor/**'] + pull_request: + branches: [main, master] + +jobs: + test: + name: Lint + Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Type check (JSDoc + // @ts-check) + run: npm run lint + + - name: Run unit tests + run: npm test diff --git a/.gitignore b/.gitignore index e87b81e8..157d6575 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,20 @@ +node_modules/ .wrangler/ .codeflicker/ -package-lock.json +dist/ +coverage/ +.DS_Store + +# 本地调研与草稿,不入库 +优化文档/ +CHANGELOG-TEST.md +docs/ + +# AI 工具本地配置 +.codex/ +.agents/ + +# 环境变量 +.env +.env.* +.dev.vars diff --git a/README.md b/README.md index e9c8dc69..b75b20f0 100644 --- a/README.md +++ b/README.md @@ -1,273 +1,183 @@ -# SubsTracker - 订阅管理与提醒系统 +# SubsTracker — 订阅管理与提醒系统 -基于 Cloudflare Workers 的轻量级订阅管理系统,帮助你轻松跟踪各类订阅服务的到期时间,并通过 Telegram、Webhook 等多渠道发送及时提醒。 +[![Deploy with Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/wangwangit/SubsTracker) -> 🎉 项目说明: -> - 原有稳定版本代码已保留在 **`legacy-v1`** 分支(可随时回看/回滚) -> - 从现在开始,**`main` 分支由 AI 托管持续迭代**(功能优化、体验升级、问题修复) -> - 欢迎大家直接试用 `main` 分支,遇到问题就提 Issue —— 我会让 AI 第一时间跟进修改 👻 - -![image](https://github.com/user-attachments/assets/22ff1592-7836-4f73-aa13-24e9d43d7064) +基于 Cloudflare Workers 的轻量级订阅管理系统。跟踪所有订阅服务的到期时间,通过 Telegram、Bark、Webhook 等 9 种渠道发送可靠的多档位提醒,并提供完整的发送日志用于自助排查。 --- ## ✨ 功能特色 -### 🎯 核心功能 -- **订阅管理**:添加、编辑、删除各类订阅服务 -- **智能提醒**:自定义提前提醒天数,自动续订计算 -- **农历显示**:支持农历日期显示,可控制开关 -- **状态管理**:订阅启用/停用,过期状态自动识别 -- **财务追踪**:记录订阅费用,完整的支付历史和统计分析 -- **手动续订**:支持自定义金额、周期和备注 -- **仪表盘**:可视化展示月度/年度支出,支出趋势和分类统计 - -### 📱 多渠道通知 -- **Telegram**:支持 Telegram Bot 通知 -- **NotifyX**:集成 NotifyX 推送服务 -- **Webhook 通知**:支持自定义 Webhook 推送 -- **企业微信机器人**:支持企业微信群机器人通知 -- **邮件通知**:基于 Resend 的邮件服务 -- **Bark**:支持 iOS Bark 推送 -- **Server酱**:支持 Server酱 3 推送 -- **PushPlus**:支持 PushPlus 推送 - -### 🌙 农历功能 -- **农历转换**:支持 1900-2100 年农历转换 -- **智能显示**:列表和编辑页面可控制农历显示 -- **通知集成**:通知消息中可包含农历信息 - -### 🎨 用户体验 -- **响应式设计**:适配桌面端和移动端 -- **备注优化**:长备注自动截断,悬停显示完整内容 -- **实时预览**:日期选择时实时显示对应农历 -- **外观风格**:支持浅色模式、深色模式、跟随系统 - -### 💰 财务管理 -- **订阅金额追踪**:支持多币种记录 -- **汇率换算**:支持动态汇率、固定汇率 -- **智能仪表盘**: - - 📊 月度/年度支出统计,环比趋势分析 - - 💳 活跃订阅数量,月均支出计算 - - 📅 最近7天支付记录,即将续费提醒 - - 📈 按类型/分类的支出排行和占比 -- **支付历史管理**: - - 📝 完整支付记录,支持编辑/删除 - - 🕒 精确显示计费周期 - - 📊 累计支出和支付次数统计 - - 🔄 删除支付记录时自动回退订阅周期 -- **高级续订功能**: - - 💵 自定义续订金额 - - 📅 选择续订日期(支持回溯) - - 🔢 批量续订多个周期 - - 📝 添加续订备注 - - 👁️ 实时预览新的到期日期 - ---- - -## 🧰 环境准备 - -### 1) 下载项目到本地(必须) +### 🎯 订阅管理 -本项目采用 Wrangler 本地部署模式,不是 Cloudflare Dashboard 直接连接 GitHub 自动部署。 -请先将项目下载到本地: +- **CRUD**:添加、编辑、删除、启用/停用各类订阅服务 +- **多档位提醒**:每订阅独立设置 N 条规则,支持"到期前 7/3/1 天 + 当天 + 到期后每 X 小时重复直到续费" +- **自动续订**:到期后自动推进到期日并写入支付记录 +- **手动续订**:自定义金额、日期、周期数、备注 +- **支付历史**:完整记录、可编辑/删除(删除时自动回退订阅周期) +- **农历支持**:1900-2100 年农历转换,可按农历周期续订 -```bash -git clone https://github.com/wangwangit/SubsTracker.git -cd SubsTracker -``` +### 📱 多渠道通知(9 种) -> ⚠️ 必须进入包含 **package.json** 的项目目录后才能执行之后的 **npm install**。 +| 渠道 | 状态 | 配置项 | +|------|------|--------| +| Telegram | ✅ MarkdownV2 + 失败降级纯文本 | Bot Token + Chat ID | +| NotifyX | ✅ | API Key | +| Webhook | ✅ 支持自定义 Header 与消息模板 | URL + 模板(含 `{{title}} {{content}} {{daysRemaining}}` 等) | +| 企业微信机器人 | ✅ text/markdown + @ 提醒 | Webhook URL | +| Resend 邮件 | ✅ HTML 模板 | API Key + 收发邮箱 | +| Bark(iOS) | ✅ 支持自建服务器 | Server + Device Key | +| Gotify | ✅ 自托管 | Server URL + App Token | +| Server酱 | ✅ Server酱 3 | SendKey | +| PushPlus | ✅ Topic + Channel | Token | -### 2) 安装 Node.js / npm +### 📊 可观测性 -如果你电脑里没有 `npm`: +- **通知历史页** `/admin/notify-logs`:每条发送(成功 / 失败)都有记录,可按订阅、渠道、状态、时间筛选 +- **调度执行日志**:每次 Cron 触发的链路日志(命中/去重/发送/续订计数 + 失败原因),可在通知历史页折叠预览 +- **`/debug` 时区诊断**:登录后访问,显示 UTC 时间、用户 TZ 时间、当前是否在通知窗口 -- 前往官网下载安装: -- 推荐安装 LTS 版本(安装后自动包含 npm) +### 💰 财务管理 -安装后验证: +- 多币种(CNY / USD / HKD / TWD / JPY / EUR / GBP / KRW / TRY)+ 动态汇率换算 +- 仪表盘:月度/年度支出 + 环比 + 即将到期 + 未来 7 天续费 + 按类型/分类排行 -```bash -node -v -npm -v -``` +### 🔐 时区与通知时段 -### 3) 获取 Cloudflare API Token +- 配置项 `TIMEZONE` 默认 `Asia/Shanghai`,是所有时间判断与展示的真相源 +- `NOTIFICATION_HOURS` 是按 `TIMEZONE` 解释的"小时数组",例如 `["08", "20"]` +- 留空 = 全天可发(仍受 Cron 每小时触发限制) +- `*` 或 `ALL` 等同于留空 -1. 打开 Cloudflare Dashboard → **My Profile** → **API Tokens** -2. 点击 **Create Token** -3. **强烈推荐**使用 Edit Cloudflare Workers 模版(Edit Cloudflare Workers) -4. 权限至少包含: - - Workers Scripts: Edit - - Workers KV Storage: Edit -5. Account Resources 选择你的目标账号 -6. 创建后复制 Token +--- -![image-20260227170420115](https://img.996007.icu/file/1772183075773_20260227170427274.png) +## 🚀 部署 -> ⚠️ Token 只显示一次,请妥善保存;泄露后请立刻删除重建。 +### 一键按钮 ---- +点击页面顶部 **Deploy with Cloudflare** 按钮,Cloudflare 会自动 fork 仓库并跑 `wrangler deploy`。需要在 Dashboard 的 Worker Settings 里关联 KV 命名空间。 -## 🚀 部署方式(推荐) +### 命令行部署(推荐) ```bash +git clone https://github.com/wangwangit/SubsTracker.git +cd SubsTracker npm install + +# 设置 Token +# Linux/macOS: +export CLOUDFLARE_API_TOKEN=你的token # Windows PowerShell: $env:CLOUDFLARE_API_TOKEN="你的token" + npm run deploy:safe ``` -`deploy:safe` 会自动执行: -1. `npm run setup` - - 检查是否已有 `SUBSCRIPTIONS_KV` / `SUBSCRIPTIONS_KV_PREVIEW` - - 若存在则复用原 ID - - 若不存在则自动创建 - - 自动回写 `wrangler.toml` -2. `npm run deploy` - - 执行部署到 Cloudflare Workers +`deploy:safe` 自动执行: +1. `npm run setup` — 检测/创建 `SUBSCRIPTIONS_KV` + `SUBSCRIPTIONS_KV_PREVIEW`,自动写入 `wrangler.toml` +2. `npm run deploy` — `wrangler deploy` -![image-20260227170513582](https://img.996007.icu/file/1772183123590_20260227170513797.png) +### 默认凭据 -如果你是 Windows CMD: +部署后首次登录: +- 用户名:`admin` +- 密码:`password` -```bat -set CLOUDFLARE_API_TOKEN=你的token -npm run deploy:safe -``` +**首次登录后请立即在系统配置中修改密码。** + +### 忘记密码 + +到 Cloudflare Dashboard → Workers → KV → `SUBSCRIPTIONS_KV` → 编辑 `config` 这条记录的 JSON 中 `ADMIN_PASSWORD` 字段。 --- -## 🔄 已部署版本升级(保留原数据) +## 🔄 升级到当前版本 -可以直接升级,且会优先复用原 KV: +如果你已经在使用旧版本,直接执行: ```bash git pull npm install -# Windows PowerShell: -$env:CLOUDFLARE_API_TOKEN="你的token" npm run deploy:safe ``` -如需备份(可选): +第一次访问任意页面时,KV 数据会**自动迁移**到新结构(多 Key 拆分、提醒规则、可观测性日志)。旧 `subscriptions` 数据自动备份保留 7 天,可回滚。详见 [`docs/MIGRATION.md`](docs/MIGRATION.md)。 -```bash -npx wrangler kv key get --binding=SUBSCRIPTIONS_KV --env="" --remote config > backup-config.json -npx wrangler kv key get --binding=SUBSCRIPTIONS_KV --env="" --remote subscriptions > backup-subscriptions.json -``` +> ⚠️ **如果你之前按 UTC 配置过 `NOTIFICATION_HOURS`**:升级后该字段改按你设置的 `TIMEZONE` 解释。请到配置页根据底部"实时预览"重新调整。 --- -## 🔐 首次部署登录说明 +## 🛠 开发 -部署完成后,访问你的 Worker 域名: - -- 默认用户名:`admin` -- 默认密码:`password` - -首次登录后请立即在系统配置中修改账号密码。 - -## 忘记密码 -请前往CloudFlare的KV管理页面,修改KV SUBSCRIPTIONS_KV 下面的config中的内容即可! - ---- - -## 🔧 通知渠道配置 - -### Telegram -- **Bot Token**: 从 [@BotFather](https://t.me/BotFather) 获取 -- **Chat ID**: 从 [@userinfobot](https://t.me/userinfobot) 获取 - -### NotifyX -- **API Key**: 从 [NotifyX 官网](https://www.notifyx.cn/) 获取 - -### 企业微信机器人 -- **推送 URL**: 参考 [官方文档](https://developer.work.weixin.qq.com/document/path/91770) 获取 - -### Webhook 通知 -- **推送 URL**: 例如 `https://your-service.com/hooks/notify` -- 支持自定义请求方法、请求头与消息模板 -- **模板占位符**:`{{title}}`、`{{content}}`、`{{tags}}`、`{{tagsLine}}`、`{{timestamp}}`、`{{formattedMessage}}` - -### Bark(iOS 推送) -- **服务器地址**:默认 `https://api.day.app`,也可用自建服务器 -- **设备 Key**:在 Bark App 内复制 -- **历史记录**:勾选“保存推送”后可保留推送历史 - -### Server酱 -- **SendKey**:从 [Server酱官网](https://sct.ftqq.com/) 获取 -- 使用 Server酱 3 接口发送 Markdown 格式通知 +```bash +npm install +npm test # 跑 170+ 条单元测试 +npm run lint # tsc 类型检查(用 JSDoc + // @ts-check) +npm run test:watch # watch 模式 -### PushPlus -- **Token**:从 [PushPlus 官网](https://www.pushplus.plus/) 获取 -- **Topic**:可选,配置后可发送到指定群组 -- **Channel**:可选,可在系统配置中选择默认、微信公众号、邮件、短信或 Webhook 渠道 +# 本地启动 dev 环境(独立的 miniflare KV,不影响生产数据) +npx wrangler dev --config wrangler.dev.toml --local +# 浏览器打开 http://127.0.0.1:8787,admin/password +``` -### 邮件通知 (Resend) -- **API Key**: 从 [Resend 官方教程](https://developers.cloudflare.com/workers/tutorials/send-emails-with-resend/) 获取 -- **发件人邮箱**: 需为 Resend 已验证域名邮箱 -- **收件人邮箱**: 接收通知的邮箱 +源码结构: -### 🔔 通知时间与时区说明 -- 后端调度与计算统一使用 **UTC** -- `notificationHours` 按 **UTC 小时**解释 -- 留空表示全天允许发送 -- 前端页面时间按“当前设备时区”显示 +``` +src/ +├── index.js # Worker 入口(fetch + scheduled) +├── app.js # Hono 应用装配 +├── core/ # 时间 / 农历 / 货币 / 认证 +├── data/ # KV 仓库 + 自动迁移 +├── services/ # 调度器 + 通知(9 渠道适配器) +├── api/ # 路由 + handler + 中间件 +└── views/ # HTML 页面(text-import) + +public/ # Workers Assets 静态资源 +└── js/lib/ # 共享前端库 + +tests/ # Vitest + workers-pool +docs/ # 文档(MIGRATION / ARCHITECTURE) +``` -### 🔐 第三方 API 安全调用 -- `POST /api/notify/{token}` 可触发系统通知 -- 令牌也支持 `Authorization: Bearer ` 或 `?token=` -- 未配置或令牌不匹配时接口会拒绝请求 +详细架构请见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。 --- -## 🛠 常见问题排查 - -### `Authentication error [code: 10000]` -通常是本地 Wrangler 状态/缓存或 Token 权限问题。 - -可按顺序处理: +## 🔧 第三方 API 通知 ```bash -# PowerShell 重新设置 token -$env:CLOUDFLARE_API_TOKEN="你的token" -npm run deploy:safe +curl -X POST https://your-domain.workers.dev/api/notify/YOUR_TOKEN \ + -H "Content-Type: application/json" \ + -d '{"title":"自定义标题","content":"消息正文","tags":["可选","标签"]}' ``` -若仍报错,清理本地 Wrangler 缓存后重试: - -- Windows: `C:\Users\<你的用户名>\AppData\Roaming\xdg.config\.wrangler\` - -删除目录后,重新设置 token 再执行部署。 +也可用 `Authorization: Bearer YOUR_TOKEN` 或 `?token=YOUR_TOKEN`。 --- -## 欢迎关注我的公众号 - -![39d8d5a902fa1eee6cbbbc8a0dcff4b](https://github.com/user-attachments/assets/96bae085-4299-4377-9958-9a3a11294efc) +## 🛠 常见问题 ---- - -## 赞助 +### "为什么没收到通知?" -本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助:EdgeOne 提供长期有效的免费套餐,包含不限量流量和请求,覆盖中国大陆节点,且无超额收费。 +1. 登录后访问 `/admin/notify-logs`,按订阅 / 状态 / 时间筛选——若有"failed"行,展开看具体错误 +2. 访问 `/debug`,看"时区诊断"区块——确认当前是否在通知窗口 +3. 如果"在窗口内但 sched_log status=ok 且 sentCount=0",说明本次没命中任何提醒规则——检查订阅的"提醒规则"配置 -[[Best Asian CDN, Edge, and Secure Solutions - Tencent EdgeOne](https://edgeone.ai/?from=github)] +### Authentication error [code: 10000] -[![image](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png) +通常是 Wrangler 缓存或 Token 权限问题。重新设置 Token 后重试,仍报错则清理 `.wrangler/` 目录后再来。 --- -## 🤝 贡献 +## 🤝 贡献 / 协议 -欢迎贡献代码、报告问题或提出新功能建议。 +PR 欢迎,issue 也欢迎。代码风格:JSDoc 中文注释 + Vitest 单测。 +MIT License。 -## 📜 许可证 +--- -MIT License +## 关注作者 -## Star History +![image](https://github.com/user-attachments/assets/96bae085-4299-4377-9958-9a3a11294efc) -[![Star History Chart](https://api.star-history.com/svg?repos=wangwangit/SubsTracker&type=Date)](https://www.star-history.com/#wangwangit/SubsTracker&Date) +CDN 加速由 Tencent EdgeOne 赞助。 diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 00000000..65c992f4 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "checkJs": false, + "allowJs": true, + "noEmit": true, + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*.js"], + "exclude": ["node_modules", "dist", ".wrangler", "public", "tests"] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..7c4dd072 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3650 @@ +{ + "name": "subscription-manager", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "subscription-manager", + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "hono": "^4.6.14" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.5.40", + "@cloudflare/workers-types": "^4.20241218.0", + "typescript": "^5.7.2", + "vitest": "~2.1.9", + "wrangler": "^3.99.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/vitest-pool-workers": { + "version": "0.5.41", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.5.41.tgz", + "integrity": "sha512-J0uYmOKJgyo/az5nV8QHlR6xQ+HHB6S65tOEutkvUPbuPDbFlBPRT+XHJhSTNNvZGeM1t2qZIzxp0WGmXLtNlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "birpc": "0.2.14", + "cjs-module-lexer": "^1.2.3", + "devalue": "^4.3.0", + "esbuild": "0.17.19", + "miniflare": "3.20241230.0", + "semver": "^7.5.1", + "wrangler": "3.100.0", + "zod": "^3.22.3" + }, + "peerDependencies": { + "@vitest/runner": "2.0.x - 2.1.x", + "@vitest/snapshot": "2.0.x - 2.1.x", + "vitest": "2.0.x - 2.1.x" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/ohash": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", + "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/unenv": { + "name": "unenv-nightly", + "version": "2.0.0-20241218-183400-5d6aec3", + "resolved": "https://registry.npmjs.org/unenv-nightly/-/unenv-nightly-2.0.0-20241218-183400-5d6aec3.tgz", + "integrity": "sha512-7Xpi29CJRbOV1/IrC03DawMJ0hloklDLq/cigSe+J2jkcC+iDres2Cy0r4ltj5f0x7DqsaGaB4/dLuCPPFZnZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "mlly": "^1.7.3", + "ohash": "^1.1.4", + "pathe": "^1.1.2", + "ufo": "^1.5.4" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": { + "version": "3.100.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.100.0.tgz", + "integrity": "sha512-+nsZK374Xnp2BEQQuB/18pnObgsOey0AHVlg75pAdwNaKAmB2aa0/E5rFb7i89DiiwFYoZMz3cARY1UKcm/WQQ==", + "deprecated": "Downgrade to 3.99.0", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@esbuild-plugins/node-globals-polyfill": "^0.2.3", + "@esbuild-plugins/node-modules-polyfill": "^0.2.2", + "blake3-wasm": "^2.1.5", + "chokidar": "^4.0.1", + "date-fns": "^4.1.0", + "esbuild": "0.17.19", + "itty-time": "^1.0.6", + "miniflare": "3.20241230.0", + "nanoid": "^3.3.3", + "path-to-regexp": "^6.3.0", + "resolve": "^1.22.8", + "selfsigned": "^2.0.1", + "source-map": "^0.6.1", + "unenv": "npm:unenv-nightly@2.0.0-20241218-183400-5d6aec3", + "workerd": "1.20241230.0", + "xxhash-wasm": "^1.0.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20241230.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20241230.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20241230.0.tgz", + "integrity": "sha512-BZHLg4bbhNQoaY1Uan81O3FV/zcmWueC55juhnaI7NAobiQth9RppadPNpxNAmS9fK2mR5z8xrwMQSQrHmztyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20241230.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20241230.0.tgz", + "integrity": "sha512-lllxycj7EzYoJ0VOJh8M3palUgoonVrILnzGrgsworgWlIpgjfXGS7b41tEGCw6AxSxL9prmTIGtfSPUvn/rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20241230.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20241230.0.tgz", + "integrity": "sha512-Y3mHcW0KghOmWdNZyHYpEOG4Ba/ga8tht5vj1a+WXfagEjMO8Y98XhZUlCaYa9yB7Wh5jVcK5LM2jlO/BLgqpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20241230.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20241230.0.tgz", + "integrity": "sha512-IAjhsWPlHzhhkJ6I49sDG6XfMnhPvv0szKGXxTWQK/IWMrbGdHm4RSfNKBSoLQm67jGMIzbmcrX9UIkms27Y1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20241230.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20241230.0.tgz", + "integrity": "sha512-y5SPIk9iOb2gz+yWtHxoeMnjPnkYQswiCJ480oHC6zexnJLlKTpcmBCjDH1nWCT4pQi8F25gaH8thgElf4NvXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260524.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260524.1.tgz", + "integrity": "sha512-9o939wce6hAlfNAIG4W58bySW7twgkqgPkxmSNrk/DV0eEexjTPyqChGdsQeKZEXJAjxSUFklaebMYJWg1GM0g==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/birpc": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.14.tgz", + "integrity": "sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/capnp-ts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/capnp-ts/-/capnp-ts-0.7.0.tgz", + "integrity": "sha512-XKxXAC3HVPv7r674zP0VC3RTXz+/JKhfyw94ljvF80yynK6VkTnqE3jMuN8b3dUVmmc43TjyxjW4KTsmB3c86g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "tslib": "^2.2.0" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.3.0.tgz", + "integrity": "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.3.tgz", + "integrity": "sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.22", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", + "integrity": "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/itty-time": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/itty-time/-/itty-time-1.0.6.tgz", + "integrity": "sha512-+P8IZaLLBtFv8hCkIjcymZOp4UJ+xW6bSlQsXGqrkmJh7vSiMFSlNne0mCYagEE0N7HDNR5jJBRxwN0oYv61Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20241230.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20241230.0.tgz", + "integrity": "sha512-ZtWNoNAIj5Q0Vb3B4SPEKr7DDmVG8a0Stsp/AuRkYXoJniA5hsbKjFNIGhTXGMIHVP5bvDrKJWt/POIDGfpiKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "^8.8.0", + "acorn-walk": "^8.2.0", + "capnp-ts": "^0.7.0", + "exit-hook": "^2.2.1", + "glob-to-regexp": "^0.4.1", + "stoppable": "^1.1.0", + "undici": "^5.28.4", + "workerd": "1.20241230.0", + "ws": "^8.18.0", + "youch": "^3.2.2", + "zod": "^3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-inject/node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup-plugin-inject/node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/rollup-pluginutils/node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/unenv/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20241230.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20241230.0.tgz", + "integrity": "sha512-EgixXP0JGXGq6J9lz17TKIZtfNDUvJNG+cl9paPMfZuYWT920fFpBx+K04YmnbQRLnglsivF1GT9pxh1yrlWhg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20241230.0", + "@cloudflare/workerd-darwin-arm64": "1.20241230.0", + "@cloudflare/workerd-linux-64": "1.20241230.0", + "@cloudflare/workerd-linux-arm64": "1.20241230.0", + "@cloudflare/workerd-windows-64": "1.20241230.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/wrangler/node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/wrangler/node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/wrangler/node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index a7e35277..baef4ece 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,34 @@ { "name": "subscription-manager", - "version": "2.0.0", - "description": "订阅管理系统 - 基于CloudFlare Workers", + "version": "3.0.0", + "description": "订阅管理系统 - 基于 Cloudflare Workers + Hono", "main": "src/index.js", + "type": "module", "scripts": { "build": "echo 'No build step required for Workers runtime'", "setup": "node scripts/setup-kv.js", "deploy": "npx wrangler deploy --env=\"\"", - "deploy:safe": "npm run setup && npm run deploy" + "deploy:safe": "npm run setup && npm run deploy", + "lint": "tsc --noEmit -p jsconfig.json", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "hono": "^4.6.14" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.5.40", + "@cloudflare/workers-types": "^4.20241218.0", + "typescript": "^5.7.2", + "vitest": "~2.1.9", + "wrangler": "^3.99.0" }, "keywords": [ "cloudflare-workers", "subscription-management", "notification", - "lunar-calendar" + "lunar-calendar", + "hono" ], "license": "MIT" } diff --git a/public/README.md b/public/README.md new file mode 100644 index 00000000..b9008959 --- /dev/null +++ b/public/README.md @@ -0,0 +1,15 @@ +# public/ + +Workers Assets 静态资源目录。客户端 JS / CSS / 独立 HTML 页面放在这里, +由 wrangler 自动打包并通过 ASSETS binding 服务到根路径下: + +- `public/js/lib/*.js` → 浏览器访问 `/js/lib/.js` +- `public/js/pages/*.js` → 浏览器访问 `/js/pages/.js` +- `public/css/*.css` → 浏览器访问 `/css/.css` + +注意事项: + +- 既有页面(`adminPage.html` / `configPage.html` 等)由 `src/views/` 下的 + text-import 提供,浏览器侧无须改动。新功能的客户端 JS 优先放进本目录, + 避免污染已有 HTML。 +- 不要把敏感信息放进 `public/`(公网可读)。 diff --git a/public/js/lib/api-client.js b/public/js/lib/api-client.js new file mode 100644 index 00000000..c8b7afb6 --- /dev/null +++ b/public/js/lib/api-client.js @@ -0,0 +1,67 @@ +/** + * 简易 API 客户端 + * + * 用法(浏览器全局): + * + * const r = await ApiClient.get('/api/notification-logs'); + * + * 所有方法都返回解析后的 JSON;HTTP 非 2xx 会抛出含 status / body 的 Error。 + * 自动带 Cookie(凭着站内 SameSite=Strict 的 token)。 + */ +(function (root) { + 'use strict'; + + async function request(method, url, body) { + /** @type {RequestInit} */ + const init = { + method, + credentials: 'same-origin', + headers: { Accept: 'application/json' } + }; + if (body !== undefined) { + init.headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + const res = await fetch(url, init); + let data = null; + try { + data = await res.json(); + } catch { + // 非 JSON 响应,保留 null + } + if (!res.ok) { + const err = new Error((data && data.message) || ('HTTP ' + res.status)); + // @ts-ignore + err.status = res.status; + // @ts-ignore + err.body = data; + throw err; + } + return data; + } + + /** + * 简易查询字符串构造(None / undefined 字段过滤掉)。 + * + * @param {Record} params + * @returns {string} + */ + function qs(params) { + if (!params) return ''; + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === '') continue; + usp.append(k, String(v)); + } + const s = usp.toString(); + return s ? '?' + s : ''; + } + + root.ApiClient = { + get: (url, params) => request('GET', url + qs(params)), + post: (url, body) => request('POST', url, body), + put: (url, body) => request('PUT', url, body), + delete: (url) => request('DELETE', url), + qs + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/src/api/admin.js b/src/api/admin.js index ac421867..869e39d3 100644 --- a/src/api/admin.js +++ b/src/api/admin.js @@ -1,7 +1,7 @@ import { getConfig } from '../data/config.js'; import { verifyJWT } from '../core/auth.js'; import { getCookieValue } from './utils.js'; -import { loginPage, adminPage, configPage, dashboardPage } from '../views/pages.js'; +import { loginPage, adminPage, configPage, dashboardPage, notifyLogsPage } from '../views/pages.js'; async function handleAdminRequest(request, env) { try { @@ -38,6 +38,12 @@ async function handleAdminRequest(request, env) { }); } + if (pathname === '/admin/notify-logs') { + return new Response(notifyLogsPage, { + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); + } + return new Response(adminPage, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); diff --git a/src/api/debug.js b/src/api/debug.js index 1adff08f..074687e0 100644 --- a/src/api/debug.js +++ b/src/api/debug.js @@ -1,59 +1,139 @@ +// @ts-check +/** + * 调试页(仅登录后可见) + * + * 用途: + * - 检查 KV 绑定、配置完整性、JWT 密钥状态 + * - 新增"时区诊断"区块,直观展示 UTC vs 用户 TZ 的当前小时差异 + * 这是 #91 / #52 / #166 类问题的自助排查入口 + * + */ import { getConfig } from '../data/config.js'; +import { + getNowInTimezone, + formatTimezoneDisplay, + getTimezoneOffset +} from '../core/time.js'; +import * as schedLogs from '../data/scheduler-logs.repo.js'; +/** 简单 HTML 转义,防止配置中的字符串污染页面 */ +function esc(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * @param {Request} request + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + */ async function handleDebug(request, env) { try { const url = new URL(request.url); + + // 子路由:导出最近 N 条调度日志(JSON) + if (url.searchParams.get('export') === 'sched_logs') { + const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') || 50))); + const logs = await schedLogs.getRecent(env, limit); + return new Response(JSON.stringify(logs, null, 2), { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Disposition': `attachment; filename="scheduler-logs-${Date.now()}.json"` + } + }); + } + const config = await getConfig(env); + const tz = config.TIMEZONE || 'UTC'; + const now = getNowInTimezone(tz); + + const notificationHours = Array.isArray(config.NOTIFICATION_HOURS) + ? config.NOTIFICATION_HOURS.map((h) => String(h).padStart(2, '0')) + : []; + const inWindow = + notificationHours.length === 0 || + notificationHours.includes('*') || + notificationHours.includes('ALL') || + notificationHours.includes(now.hourString); + const debugInfo = { - timestamp: new Date().toISOString(), + timestamp: now.utc.toISOString(), pathname: url.pathname, kvBinding: !!env.SUBSCRIPTIONS_KV, configExists: !!config, adminUsername: config.ADMIN_USERNAME, hasJwtSecret: !!config.JWT_SECRET, - jwtSecretLength: config.JWT_SECRET ? config.JWT_SECRET.length : 0 + jwtSecretLength: config.JWT_SECRET ? config.JWT_SECRET.length : 0, + timezone: tz, + timezoneDisplay: formatTimezoneDisplay(tz), + timezoneOffsetHours: getTimezoneOffset(tz), + utcIso: now.utc.toISOString(), + localIso: now.isoLocal, + currentHour: now.hourString, + configuredHours: notificationHours, + inNotificationWindow: inWindow }; - return new Response(` - - + return new Response( + ` + - 调试信息 + + 调试信息 - SubsTracker

系统调试信息

+
-

基本信息

-

时间: ${debugInfo.timestamp}

-

路径: ${debugInfo.pathname}

-

KV绑定: ${debugInfo.kvBinding ? '✓' : '✗'}

+

基本

+
UTC 时间${esc(debugInfo.timestamp)}
+
访问路径${esc(debugInfo.pathname)}
+
KV 绑定${debugInfo.kvBinding ? '✓ 已绑定' : '✗ 未绑定'}
+
配置可读${debugInfo.configExists ? '✓' : '✗'}
+
管理员用户名${esc(debugInfo.adminUsername || '(未设置)')}
+
JWT 密钥${debugInfo.hasJwtSecret ? `✓ 已设置 (${debugInfo.jwtSecretLength} 字符)` : '✗ 缺失'}
-

配置信息

-

配置存在: ${debugInfo.configExists ? '✓' : '✗'}

-

管理员用户名: ${debugInfo.adminUsername}

-

JWT密钥: ${debugInfo.hasJwtSecret ? '✓' : '✗'} (长度: ${debugInfo.jwtSecretLength})

+

时区诊断

+
配置的时区${esc(debugInfo.timezoneDisplay)}
+
时区偏移UTC${debugInfo.timezoneOffsetHours >= 0 ? '+' : ''}${debugInfo.timezoneOffsetHours} 小时
+
当前 UTC${esc(debugInfo.utcIso)}
+
当前用户本地时间${esc(debugInfo.localIso)}
+
用于通知时段判断的小时${esc(debugInfo.currentHour)}
+
配置的通知小时(用户 TZ)${notificationHours.length === 0 ? '空(默认全天发送)' : `${esc(notificationHours.join(', '))}`}
+
现在是否允许发送${debugInfo.inNotificationWindow ? '✓ 在窗口内' : '✗ 不在窗口内'}
-

解决方案

-

1. 确保KV命名空间已正确绑定为 SUBSCRIPTIONS_KV

-

2. 尝试访问 / 进行登录

-

3. 如果仍有问题,请检查Cloudflare Workers日志

+

提示

+

1. 如果时区诊断中"当前小时"与你预期不符,请检查配置中的 TIMEZONE 是否与你所在地匹配。

+

2. 本版本 NOTIFICATION_HOURS 按你配置的时区解释(不再是 UTC)。例如想让北京时间 8 点收到通知,TIMEZONE=Asia/Shanghai 时填 08

+

3. 详细发送记录请前往后台"通知历史"页(后续版本提供)。

+

4. 返回管理后台

+

5. 📥 导出最近 50 条调度执行日志(JSON)

-`, { - headers: { 'Content-Type': 'text/html; charset=utf-8' } - }); +`, + { headers: { 'Content-Type': 'text/html; charset=utf-8' } } + ); } catch (error) { - return new Response(`调试页面错误: ${error.message}`, { + return new Response(`调试页面错误: ${error && error.message ? error.message : error}`, { status: 500, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); diff --git a/src/api/handlers/auth.js b/src/api/handlers/auth.js index 3215fe9a..f0b1665b 100644 --- a/src/api/handlers/auth.js +++ b/src/api/handlers/auth.js @@ -2,11 +2,52 @@ import { generateJWT, verifyJWT } from '../../core/auth.js'; import { getConfig } from '../../data/config.js'; import { getCookieValue } from '../utils.js'; +const MAX_LOGIN_ATTEMPTS = 5; +const LOCKOUT_SECONDS = 300; // 5 分钟 + +async function checkRateLimit(env, ip) { + const key = `login_attempts:${ip}`; + const raw = await env.SUBSCRIPTIONS_KV.get(key); + const attempts = raw ? parseInt(raw, 10) : 0; + return attempts >= MAX_LOGIN_ATTEMPTS; +} + +async function recordFailedAttempt(env, ip) { + const key = `login_attempts:${ip}`; + const raw = await env.SUBSCRIPTIONS_KV.get(key); + const attempts = (raw ? parseInt(raw, 10) : 0) + 1; + await env.SUBSCRIPTIONS_KV.put(key, String(attempts), { expirationTtl: LOCKOUT_SECONDS }); + return attempts; +} + +async function clearAttempts(env, ip) { + await env.SUBSCRIPTIONS_KV.delete(`login_attempts:${ip}`); +} + async function handleLogin(request, env) { + const ip = request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || 'unknown'; + + if (await checkRateLimit(env, ip)) { + return new Response( + JSON.stringify({ success: false, message: '登录尝试过多,请 5 分钟后再试' }), + { status: 429, headers: { 'Content-Type': 'application/json' } } + ); + } + + let body; + try { + body = await request.json(); + } catch (e) { + return new Response( + JSON.stringify({ success: false, message: '请求格式错误' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + const config = await getConfig(env); - const body = await request.json(); if (body.username === config.ADMIN_USERNAME && body.password === config.ADMIN_PASSWORD) { + await clearAttempts(env, ip); const token = await generateJWT(body.username, config.JWT_SECRET); return new Response( @@ -14,15 +55,21 @@ async function handleLogin(request, env) { { headers: { 'Content-Type': 'application/json', - 'Set-Cookie': 'token=' + token + '; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400' + 'Set-Cookie': 'token=' + token + '; HttpOnly; Secure; Path=/; SameSite=Strict; Max-Age=86400' } } ); } + const attempts = await recordFailedAttempt(env, ip); + const remaining = MAX_LOGIN_ATTEMPTS - attempts; + const message = remaining > 0 + ? `用户名或密码错误(还可尝试 ${remaining} 次)` + : '登录尝试过多,请 5 分钟后再试'; + return new Response( - JSON.stringify({ success: false, message: '用户名或密码错误' }), - { headers: { 'Content-Type': 'application/json' } } + JSON.stringify({ success: false, message }), + { status: remaining > 0 ? 200 : 429, headers: { 'Content-Type': 'application/json' } } ); } @@ -31,7 +78,7 @@ function handleLogout() { status: 302, headers: { 'Location': '/', - 'Set-Cookie': 'token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0' + 'Set-Cookie': 'token=; HttpOnly; Secure; Path=/; SameSite=Strict; Max-Age=0' } }); } diff --git a/src/api/handlers/dashboard.js b/src/api/handlers/dashboard.js index 441dfecf..322c65fe 100644 --- a/src/api/handlers/dashboard.js +++ b/src/api/handlers/dashboard.js @@ -1,21 +1,59 @@ +// 注:dashboard 业务逻辑较多,此处不启用 // @ts-check(依赖外部 currency 模块的复杂返回类型) +/** + * 仪表盘统计 handler + * + * 改动: + * - 用户时区从 config.TIMEZONE 读取(不再硬编码 'UTC') + * - schedulerStatus / schedulerStatusHistory 从新的 scheduler-logs.repo 取 + * 旧 'scheduler_status' / 'scheduler_status_history' 已废弃(迁移会清掉) + * + */ import { getAllSubscriptions } from '../../data/subscriptions.js'; -import { getDynamicRates, calculateMonthlyExpense, calculateYearlyExpense, getRecentPayments, getUpcomingRenewals, getExpenseByType, getExpenseByCategory } from '../../core/currency.js'; +import { + getDynamicRates, + calculateMonthlyExpense, + calculateYearlyExpense, + getRecentPayments, + getUpcomingRenewals, + getExpenseByType, + getExpenseByCategory +} from '../../core/currency.js'; import { getCurrentTimeInTimezone, MS_PER_DAY } from '../../core/time.js'; +import * as schedulerLogsRepo from '../../data/scheduler-logs.repo.js'; async function handleDashboardStats(env, config) { try { const subscriptions = await getAllSubscriptions(env); - const timezone = 'UTC'; + const timezone = (config && config.TIMEZONE) || 'UTC'; + /** 本次:从结构化日志库读最新调度状态 */ let schedulerStatus = null; let schedulerStatusHistory = []; try { - const rawSchedulerStatus = await env.SUBSCRIPTIONS_KV.get('scheduler_status'); - schedulerStatus = rawSchedulerStatus ? JSON.parse(rawSchedulerStatus) : null; - const rawSchedulerStatusHistory = await env.SUBSCRIPTIONS_KV.get('scheduler_status_history'); - schedulerStatusHistory = rawSchedulerStatusHistory ? JSON.parse(rawSchedulerStatusHistory) : []; + const recent = await schedulerLogsRepo.getRecent(env, 10); + schedulerStatusHistory = recent; + // 兼容老前端字段:转一份扁平结构 + if (recent.length > 0) { + const head = recent[0]; + schedulerStatus = { + lastRunAt: head.startedAt, + timezone: head.timezone, + currentHour: head.currentHour, + configuredHours: head.configuredHours, + shouldNotifyThisHour: head.inWindow, + checkedSubscriptions: head.checkedCount, + activeSubscriptions: head.checkedCount, + expiringMatched: head.matchedCount, + dedupeSkipped: head.dedupedCount, + updatedSubscriptions: head.autoRenewedCount, + sent: head.sentCount > 0, + reason: head.reason, + status: head.status, + extra: head.extra + }; + } } catch (error) { - console.error('读取定时任务状态失败:', error); + console.error('读取调度日志失败:', error); } const rates = await getDynamicRates(env); @@ -26,10 +64,10 @@ async function handleDashboardStats(env, config) { const expenseByType = getExpenseByType(subscriptions, timezone, rates); const expenseByCategory = getExpenseByCategory(subscriptions, timezone, rates); - const activeSubscriptions = subscriptions.filter(s => s.isActive); + const activeSubscriptions = subscriptions.filter((s) => s.isActive); const now = getCurrentTimeInTimezone(timezone); const sevenDaysLater = new Date(now.getTime() + 7 * MS_PER_DAY); - const expiringSoon = activeSubscriptions.filter(s => { + const expiringSoon = activeSubscriptions.filter((s) => { const expiryDate = new Date(s.expiryDate); return expiryDate >= now && expiryDate <= sevenDaysLater; }).length; @@ -50,7 +88,9 @@ async function handleDashboardStats(env, config) { expenseByType, expenseByCategory, schedulerStatus, - schedulerStatusHistory + schedulerStatusHistory, + /** 新增:用户时区(前端可据此显示) */ + timezone } }), { headers: { 'Content-Type': 'application/json' } } @@ -58,7 +98,10 @@ async function handleDashboardStats(env, config) { } catch (error) { console.error('获取仪表盘统计失败:', error); return new Response( - JSON.stringify({ success: false, message: '获取统计数据失败: ' + error.message }), + JSON.stringify({ + success: false, + message: '获取统计数据失败: ' + (error && error.message ? error.message : error) + }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } diff --git a/src/api/handlers/extras.js b/src/api/handlers/extras.js new file mode 100644 index 00000000..92102801 --- /dev/null +++ b/src/api/handlers/extras.js @@ -0,0 +1,205 @@ +// @ts-check +/** + * 提醒规则 / 通知日志 / 调度日志 路由 + * + * 接口表(路径前缀 /api): + * GET /subscriptions/:id/reminders 列出某订阅的提醒规则 + * POST /subscriptions/:id/reminders 新增一条规则 + * PUT /subscriptions/:id/reminders/:ruleId 更新一条规则 + * DELETE /subscriptions/:id/reminders/:ruleId 删除一条规则 + * + * GET /notification-logs?subId=&channel=&status=&since=&limit= + * 查询通知日志 + * GET /scheduler-logs?limit=N 查询调度执行日志 + * + * 鉴权:与既有客户端约定一致——需要登录(cookie token),由 router.js 在 routes 之前 + * 统一校验。本文件被 router.js 调用,不再单独校验。 + * + */ + +import * as remindersRepo from '../../data/reminders.repo.js'; +import * as notifyLogsRepo from '../../data/notification-logs.repo.js'; +import * as schedLogsRepo from '../../data/scheduler-logs.repo.js'; +import { getCategories, addCategory } from '../../data/categories.js'; +import { getNextFireTime } from '../../services/notify/reminder-engine.js'; + +export const VERSION = '3.0.0'; + +/** 标准 JSON 响应 */ +function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' } + }); +} + +/** + * 处理提醒规则 / 通知日志 / 调度日志相关的 新 API。 + * 返回 null 表示路径不匹配,由调用方继续转给下一组路由。 + * + * @param {Request} request + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} path 已剥离 /api 前缀的路径 + */ +export async function handleExtraRoutes(request, env, path) { + const method = request.method; + + // /subscriptions/:id/reminders[/:ruleId] + const remMatch = path.match(/^\/subscriptions\/([^/]+)\/reminders(?:\/([^/]+))?\/?$/); + if (remMatch) { + const [, subId, ruleId] = remMatch; + return handleReminderRoute(request, env, method, subId, ruleId); + } + + // /subscriptions/:id/next-reminder + const nrMatch = path.match(/^\/subscriptions\/([^/]+)\/next-reminder\/?$/); + if (nrMatch && method === 'GET') { + const [, subId] = nrMatch; + const { getSubscription } = await import('../../data/subscriptions.js'); + const sub = await getSubscription(subId, env); + if (!sub) return json({ success: false, message: '订阅不存在' }, 404); + const rules = await remindersRepo.listForSubscription(env, subId); + const nowIso = new Date().toISOString(); + const times = rules + .map((r) => ({ ruleId: r.id, type: r.type, value: r.value, unit: r.unit, nextFireTime: getNextFireTime(r, sub.expiryDate, nowIso) })) + .filter((t) => t.nextFireTime !== null) + .sort((a, b) => new Date(a.nextFireTime).getTime() - new Date(b.nextFireTime).getTime()); + return json({ success: true, nextReminder: times[0] || null, allUpcoming: times }); + } + + // /notification-logs + if (path === '/notification-logs' && method === 'GET') { + return handleNotifyLogsList(request, env); + } + + // /scheduler-logs + if (path === '/scheduler-logs' && method === 'GET') { + return handleSchedLogsList(request, env); + } + + // /version + if (path === '/version' && method === 'GET') { + return json({ success: true, version: VERSION }); + } + + // /categories + if (path === '/categories') { + if (method === 'GET') { + return json({ success: true, categories: await getCategories(env) }); + } + if (method === 'POST') { + let body; + try { body = await request.json(); } catch { return json({ success: false, message: '请求体不是合法 JSON' }, 400); } + const name = body && body.name; + if (!name || !name.trim()) return json({ success: false, message: '分类名不能为空' }, 400); + await addCategory(env, name); + return json({ success: true }); + } + } + + return null; +} + +/** + * @param {Request} request + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} method + * @param {string} subId + * @param {string} [ruleId] + */ +async function handleReminderRoute(request, env, method, subId, ruleId) { + // GET /subscriptions/:id/reminders + if (method === 'GET' && !ruleId) { + const list = await remindersRepo.listForSubscription(env, subId); + return json({ success: true, rules: list }); + } + + // POST /subscriptions/:id/reminders + if (method === 'POST' && !ruleId) { + let body; + try { + body = await request.json(); + } catch { + return json({ success: false, message: '请求体不是合法 JSON' }, 400); + } + if (body && body.preset === true) { + // 一次性应用智能预设(覆盖现有规则) + const presets = remindersRepo.defaultPresetRules(); + await remindersRepo.replaceForSubscription(env, subId, presets); + return json({ success: true, rules: presets }); + } + const rule = await remindersRepo.addRule(env, subId, body || {}); + return json({ success: true, rule }); + } + + // PUT /subscriptions/:id/reminders(不带 ruleId)→ 整体替换规则列表 + if (method === 'PUT' && !ruleId) { + let body; + try { + body = await request.json(); + } catch { + return json({ success: false, message: '请求体不是合法 JSON' }, 400); + } + const rules = Array.isArray(body && body.rules) ? body.rules : []; + await remindersRepo.replaceForSubscription(env, subId, rules); + const saved = await remindersRepo.listForSubscription(env, subId); + return json({ success: true, rules: saved }); + } + + // PUT /subscriptions/:id/reminders/:ruleId + if (method === 'PUT' && ruleId) { + let body; + try { + body = await request.json(); + } catch { + return json({ success: false, message: '请求体不是合法 JSON' }, 400); + } + const updated = await remindersRepo.updateRule(env, subId, ruleId, body || {}); + if (!updated) return json({ success: false, message: '规则不存在' }, 404); + return json({ success: true, rule: updated }); + } + + // DELETE /subscriptions/:id/reminders/:ruleId + if (method === 'DELETE' && ruleId) { + const ok = await remindersRepo.deleteRule(env, subId, ruleId); + if (!ok) return json({ success: false, message: '规则不存在' }, 404); + return json({ success: true }); + } + + return json({ success: false, message: 'Method Not Allowed' }, 405); +} + +/** + * GET /api/notification-logs?subId=&channel=&status=&since=&limit= + * + * @param {Request} request + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + */ +async function handleNotifyLogsList(request, env) { + const url = new URL(request.url); + const filter = { + subId: url.searchParams.get('subId') || undefined, + channel: url.searchParams.get('channel') || undefined, + status: + /** @type {'success'|'failed'|undefined} */ + (url.searchParams.get('status') || undefined), + since: url.searchParams.get('since') || undefined, + until: url.searchParams.get('until') || undefined, + limit: Number(url.searchParams.get('limit') || 100) + }; + const logs = await notifyLogsRepo.query(env, filter); + return json({ success: true, logs }); +} + +/** + * GET /api/scheduler-logs?limit=N + * + * @param {Request} request + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + */ +async function handleSchedLogsList(request, env) { + const url = new URL(request.url); + const limit = Number(url.searchParams.get('limit') || 20); + const logs = await schedLogsRepo.getRecent(env, limit); + return json({ success: true, logs }); +} diff --git a/src/api/handlers/subscriptions.js b/src/api/handlers/subscriptions.js index 501150c8..dac848d1 100644 --- a/src/api/handlers/subscriptions.js +++ b/src/api/handlers/subscriptions.js @@ -13,6 +13,7 @@ import { getConfig } from '../../data/config.js'; import { sendNotificationToAllChannels } from '../../services/notify/index.js'; import { lunarCalendar } from '../../core/lunar.js'; import { formatTimeInTimezone, formatTimezoneDisplay } from '../../core/time.js'; +import { formatAmount } from '../../core/currency-format.js'; import { extractTagsFromSubscriptions } from '../utils.js'; async function testSingleSubscriptionNotification(id, env) { @@ -40,13 +41,8 @@ async function testSingleSubscriptionNotification(id, env) { const calendarType = subscription.useLunar ? '农历' : '公历'; const autoRenewText = subscription.autoRenew ? '是' : '否'; - const currencySymbols = { - CNY: '¥', USD: '$', HKD: 'HK$', TWD: 'NT$', - JPY: '¥', EUR: '€', GBP: '£', KRW: '₩', TRY: '₺' - }; - const amountConfigured = subscription.amount !== null && subscription.amount !== undefined && !Number.isNaN(Number(subscription.amount)); - const amountCurrency = currencySymbols[subscription.currency || 'CNY'] || '¥'; - const amountText = amountConfigured ? `\n金额: ${amountCurrency}${Number(subscription.amount).toFixed(2)}/周期` : ''; + const formattedAmount = formatAmount(subscription.amount, subscription.currency || 'CNY'); + const amountText = formattedAmount ? `\n金额: ${formattedAmount}/周期` : ''; const categoryText = subscription.category ? subscription.category : '未分类'; @@ -62,6 +58,7 @@ async function testSingleSubscriptionNotification(id, env) { const tags = extractTagsFromSubscriptions([subscription]); const notifyResult = await sendNotificationToAllChannels(title, commonContent, config, '[手动测试]', { + env, subId: id, ruleId: 'manual-test', metadata: { tags } }); @@ -100,6 +97,21 @@ async function handleSubscriptions(request, env, path) { if (method === 'POST') { const subscription = await request.json(); const result = await createSubscription(subscription, env); + // 本次:创建成功后写入提醒规则 + if (result.success && result.subscription) { + try { + const remindersRepo = await import('../../data/reminders.repo.js'); + const incoming = Array.isArray(subscription.reminderRules) + ? subscription.reminderRules + : null; + const rules = incoming && incoming.length > 0 + ? incoming.map(remindersRepo.normalizeRule) + : remindersRepo.defaultPresetRules(); + await remindersRepo.replaceForSubscription(env, result.subscription.id, rules); + } catch (err) { + console.error('[subscriptions] 写入提醒规则失败(订阅本身已创建):', err); + } + } return new Response(JSON.stringify(result), { status: result.success ? 201 : 400, headers: { 'Content-Type': 'application/json' } diff --git a/src/api/router.js b/src/api/router.js index 813fd647..52c98c37 100644 --- a/src/api/router.js +++ b/src/api/router.js @@ -5,6 +5,7 @@ import { handleThirdPartyNotify } from './handlers/notify.js'; import { handleSubscriptions } from './handlers/subscriptions.js'; import { getConfig } from '../data/config.js'; import { handleTestNotification } from './handlers/test-notification.js'; +import { handleExtraRoutes } from "./handlers/extras.js"; async function handleApiRequest(request, env) { const url = new URL(request.url); @@ -42,6 +43,10 @@ async function handleApiRequest(request, env) { return handleTestNotification(request, env); } + // 新增路由:提醒规则 / 通知日志 / 调度日志(提醒规则 / 通知日志 / 调度日志) + const extraResponse = await handleExtraRoutes(request, env, path); + if (extraResponse) return extraResponse; + const subscriptionResponse = await handleSubscriptions(request, env, path); if (subscriptionResponse) return subscriptionResponse; diff --git a/src/app.js b/src/app.js new file mode 100644 index 00000000..4ed17d73 --- /dev/null +++ b/src/app.js @@ -0,0 +1,109 @@ +// @ts-check +/** + * Hono 应用装配 + * + * 设计目标: + * - 用 Hono 实现路由分发与中间件管线 + * - 引入中间件:迁移检查 / 日志 / 认证 / 错误处理 + * - 路由路径、方法、响应结构与既有客户端严格兼容 + * 现有前端代码无需改动即可继续工作 + * + * 落地策略: + * - 现阶段(Task 7):Hono 充当"外壳路由器",把请求转发给现有 handler + * 后续 Task 可逐个把 handler 改成 Hono 原生写法,但当前优先保证不破坏。 + * + */ + +import { Hono } from 'hono'; + +import { handleApiRequest } from './api/router.js'; +import { handleAdminRequest, handleLoginPage } from './api/admin.js'; +import { handleDebug } from './api/debug.js'; +import { getUserFromRequest } from './api/handlers/auth.js'; +import { ensureMigrations } from './data/migrate.js'; + +/** + * @typedef {{ SUBSCRIPTIONS_KV: KVNamespace }} Bindings + */ + +/** @type {Hono<{ Bindings: Bindings }>} */ +const app = new Hono(); + +// ───────────────────────────────────────────────────────────── +// 全局中间件:迁移检查(首次访问透明触发) +// ───────────────────────────────────────────────────────────── +app.use('*', async (c, next) => { + try { + await ensureMigrations(c.env); + } catch (err) { + console.error('[app] 迁移失败,回退继续处理请求:', err); + } + await next(); +}); + +// ───────────────────────────────────────────────────────────── +// 全局错误兜底 +// ───────────────────────────────────────────────────────────── +app.onError((err, c) => { + console.error('[app] 未捕获异常:', err && err.stack ? err.stack : err); + // 统一的错误格式 + return c.json( + { + success: false, + message: err && err.message ? err.message : '服务异常', + code: 'internal_error' + }, + 500 + ); +}); + +// ───────────────────────────────────────────────────────────── +// 路由:根路径 +// 已登录跳 /admin;未登录返回登录页 +// ───────────────────────────────────────────────────────────── +app.get('/', async (c) => { + const { user } = await getUserFromRequest(c.req.raw, c.env); + if (user) return c.redirect('/admin'); + return handleLoginPage(); +}); + +// ───────────────────────────────────────────────────────────── +// 路由:/debug(必须登录) +// ───────────────────────────────────────────────────────────── +app.all('/debug', async (c) => { + const { user } = await getUserFromRequest(c.req.raw, c.env); + if (!user) { + return new Response('未授权访问', { + status: 401, + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }); + } + return handleDebug(c.req.raw, c.env); +}); + +// ───────────────────────────────────────────────────────────── +// 路由:/api/*(认证由 handler 内部处理,与既有客户端约定一致) +// ───────────────────────────────────────────────────────────── +app.all('/api/*', async (c) => { + return handleApiRequest(c.req.raw, c.env); +}); + +// ───────────────────────────────────────────────────────────── +// 路由:/admin/* +// ───────────────────────────────────────────────────────────── +app.all('/admin/*', async (c) => { + return handleAdminRequest(c.req.raw, c.env); +}); + +app.get('/admin', async (c) => { + return handleAdminRequest(c.req.raw, c.env); +}); + +// ───────────────────────────────────────────────────────────── +// 兜底:其他路径返回登录页((兜底行为)) +// ───────────────────────────────────────────────────────────── +app.all('*', async () => { + return handleLoginPage(); +}); + +export default app; diff --git a/src/core/currency-format.js b/src/core/currency-format.js new file mode 100644 index 00000000..f291e80e --- /dev/null +++ b/src/core/currency-format.js @@ -0,0 +1,57 @@ +// @ts-check +/** + * 货币格式化共享工具 + * + * 早期版本曾在不同代码路径(添加表单 / 列表 / 通知正文 / 历史 / 第三方触发) + * 中重复定义了 currencySymbols,且偶尔默认回退 ¥(CNY 符号)造成币种与展示不一致。 + * + * 统一使用本模块: + * formatAmount(123.45, 'USD') → '$123.45' + * formatAmount(123.45, 'JPY') → 'JP¥123.45' (JPY 与 CNY 同符号但加前缀以避免歧义) + * formatAmount(null, 'USD') → '' + * + */ + +/** + * 币种 → 符号(前缀)映射。 + * JPY 故意使用 'JP¥' 前缀以避免与 CNY 的 ¥ 混淆。 + */ +export const CURRENCY_SYMBOLS = { + CNY: '¥', + USD: '$', + HKD: 'HK$', + TWD: 'NT$', + JPY: 'JP¥', + EUR: '€', + GBP: '£', + KRW: '₩', + TRY: '₺' +}; + +/** + * 取币种符号,未知币种回退到币种代码本身。 + * + * @param {string} [currency='CNY'] + * @returns {string} + */ +export function getCurrencySymbol(currency = 'CNY') { + const code = String(currency || 'CNY').toUpperCase(); + return CURRENCY_SYMBOLS[code] || code + ' '; +} + +/** + * 格式化金额字符串,amount 为空时返回空串。 + * + * @param {number|string|null|undefined} amount + * @param {string} [currency='CNY'] + * @param {{ withDecimal?: boolean }} [opts] + * @returns {string} + */ +export function formatAmount(amount, currency = 'CNY', opts = {}) { + if (amount === null || amount === undefined || amount === '') return ''; + const n = Number(amount); + if (Number.isNaN(n)) return ''; + const sym = getCurrencySymbol(currency); + const fixed = opts.withDecimal === false ? String(Math.round(n)) : n.toFixed(2); + return sym + fixed; +} diff --git a/src/core/time.js b/src/core/time.js index c9e51892..23635d66 100644 --- a/src/core/time.js +++ b/src/core/time.js @@ -1,119 +1,501 @@ -// 时间与时区工具 -const MS_PER_HOUR = 1000 * 60 * 60; -const MS_PER_DAY = MS_PER_HOUR * 24; +// @ts-check +/** + * 时区核心模块 + * + * ── 设计原则 ──────────────────────────────────────────────── + * 1. 数据存储层:所有日期一律 ISO 8601 UTC 字符串(如 "2026-05-24T17:30:00.000Z") + * 2. 业务逻辑层:判断"通知时段""剩余天数"前先把 UTC 时刻转到用户配置的时区下取 + * 年/月/日/时;本模块是这层的"唯一真相源" + * 3. 展示层:所有面向用户的日期显示都走 formatLocalDate / formatTimezoneDisplay + * + * ── 关键设计 ──────────────────────────────────────────── + * - 旧 getCurrentTimeInTimezone() 只 `return new Date()`,把"当前 UTC 时刻" + * 伪装成"用户本地时间"对象返回;调用方把它当作时区相关 Date 用,导致 + * 严重误用(#52 / #91 / #166)。本版本改为: + * - 保留 getCurrentTimeInTimezone(tz) 作为兼容 wrapper(返回原生 Date 即 UTC 时刻) + * - 新增 getNowInTimezone(tz) 返回结构体 {utc, parts, hourString, isoLocal} + * 强制调用方显式选择"我要的是 UTC 时刻"还是"用户 TZ 下的字段" + * - 新增 getDaysBetween(fromIso, toIso, tz) 基于"用户 TZ 各自零点"算整天数差, + * 修复"凌晨 0–8 点创建订阅默认日期变前一天"的 #166 + * - 所有公开函数 JSDoc 标注 + 中文用途说明,从此可被 // @ts-check 守护 + * + */ -function getCurrentTimeInTimezone(timezone = 'UTC') { +/** 一小时的毫秒数 */ +export const MS_PER_HOUR = 1000 * 60 * 60; +/** 一天的毫秒数 */ +export const MS_PER_DAY = MS_PER_HOUR * 24; + +/** + * @typedef {Object} TimezoneDateParts 时区下的日期分量 + * @property {number} year 年(4 位整数) + * @property {number} month 月(1-12) + * @property {number} day 日(1-31) + * @property {number} hour 时(0-23) + * @property {number} minute 分(0-59) + * @property {number} second 秒(0-59) + */ + +/** + * @typedef {Object} TimezoneNow 当前时刻在某时区下的完整快照 + * @property {Date} utc 原生 Date(UTC 时刻,等价于 new Date()) + * @property {TimezoneDateParts} parts 该时刻在 timezone 下的年月日时分秒 + * @property {string} hourString parts.hour 的两位字符串(如 "08"),调度器对比通知时段直接用它 + * @property {string} isoLocal "YYYY-MM-DDTHH:mm:ss" 本地表示(不带时区后缀,用于展示) + * @property {string} timezone 实际生效的时区(无效时回退 'UTC') + */ + +/** + * 判断字符串是否为 IANA 合法时区。 + * + * @param {string} timezone + * @returns {boolean} + */ +export function isValidTimezone(timezone) { + if (typeof timezone !== 'string' || timezone.trim() === '') return false; try { - return new Date(); - } catch (error) { - console.error(`时区转换错误: ${error.message}`); - return new Date(); + new Intl.DateTimeFormat('en-US', { timeZone: timezone }); + return true; + } catch { + return false; } } -function getTimestampInTimezone(timezone = 'UTC') { - return getCurrentTimeInTimezone(timezone).getTime(); +/** + * 兜底获取一个安全可用的时区字符串。 + * + * @param {string=} timezone 用户传入的时区 + * @returns {string} 合法 IANA 时区,非法时返回 'UTC' + */ +function safeTimezone(timezone) { + if (timezone && isValidTimezone(timezone)) return timezone; + return 'UTC'; } -function convertUTCToTimezone(utcTime, timezone = 'UTC') { - try { - return new Date(utcTime); - } catch (error) { - console.error(`时区转换错误: ${error.message}`); - return new Date(utcTime); +/** + * 将一个 Date / ISO 字符串 / 时间戳分解为目标时区下的年月日时分秒。 + * + * 内部用 Intl.DateTimeFormat(en-US 12h=false)解析,无 DST/夏令时手算坑。 + * + * @param {Date | string | number} date + * @param {string} [timezone='UTC'] + * @returns {TimezoneDateParts} + */ +export function getTimezoneDateParts(date, timezone = 'UTC') { + const tz = safeTimezone(timezone); + const d = date instanceof Date ? date : new Date(date); + if (Number.isNaN(d.getTime())) { + // 无效输入,返回当前时间作为兜底 + return getTimezoneDateParts(new Date(), tz); } -} -function getTimezoneDateParts(date, timezone = 'UTC') { try { const formatter = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, + timeZone: tz, hour12: false, - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit' + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' }); - const parts = formatter.formatToParts(date); + const parts = formatter.formatToParts(d); const pick = (type) => { - const part = parts.find(item => item.type === type); + const part = parts.find((item) => item.type === type); return part ? Number(part.value) : 0; }; + let hour = pick('hour'); + // Intl 在某些 runtime 把 24 显示为 0/24 不一致,归一化到 0–23 + if (hour === 24) hour = 0; return { year: pick('year'), month: pick('month'), day: pick('day'), - hour: pick('hour'), + hour, minute: pick('minute'), second: pick('second') }; - } catch (error) { - console.error(`解析时区(${timezone})失败: ${error.message}`); + } catch { + // 极少数 runtime 不支持该时区,回退 UTC return { - year: date.getUTCFullYear(), - month: date.getUTCMonth() + 1, - day: date.getUTCDate(), - hour: date.getUTCHours(), - minute: date.getUTCMinutes(), - second: date.getUTCSeconds() + year: d.getUTCFullYear(), + month: d.getUTCMonth() + 1, + day: d.getUTCDate(), + hour: d.getUTCHours(), + minute: d.getUTCMinutes(), + second: d.getUTCSeconds() }; } } -function getTimezoneMidnightTimestamp(date, timezone = 'UTC') { - const { year, month, day } = getTimezoneDateParts(date, timezone); - return Date.UTC(year, month - 1, day, 0, 0, 0); +/** + * 获取"当前时刻"在指定时区下的完整快照。 + * + * 业务代码请优先使用本函数而非 `getCurrentTimeInTimezone`, + * 因为本函数明确告诉你: + * - utc:UTC 原生 Date(用于持久化、计算时间差) + * - parts.hour:你设置的时区下的当前小时(用于通知时段判断) + * - hourString:直接拿来和 NOTIFICATION_HOURS 字符串数组比较 + * + * @param {string} [timezone='UTC'] + * @param {Date} [now] 可选注入当前时间(测试用) + * @returns {TimezoneNow} + */ +export function getNowInTimezone(timezone = 'UTC', now) { + const tz = safeTimezone(timezone); + const utc = now instanceof Date ? new Date(utcMillis(now)) : new Date(); + const parts = getTimezoneDateParts(utc, tz); + const hourString = String(parts.hour).padStart(2, '0'); + const isoLocal = formatPartsAsIsoLocal(parts); + return { utc, parts, hourString, isoLocal, timezone: tz }; } -function formatTimeInTimezone(time, timezone = 'UTC', format = 'full') { - try { - const date = new Date(time); +/** + * 获取指定时刻在某时区下的小时(两位字符串)。 + * + * 调度器判断"现在是不是允许发送通知的小时"专用。 + * + * @param {Date | string | number} [date] + * @param {string} [timezone='UTC'] + * @returns {string} "00" – "23" + */ +export function getTimezoneHourString(date, timezone = 'UTC') { + const d = date == null ? new Date() : date; + const parts = getTimezoneDateParts(d, timezone); + return String(parts.hour).padStart(2, '0'); +} + +/** + * 计算 from → to 在指定时区下"跨过几个本地零点"的整天数。 + * + * 例: + * from = "2026-05-24T16:00:00Z" to = "2026-05-25T16:00:00Z" tz=UTC + * → 1 天 + * + * from = "2026-05-24T16:00:00Z" to = "2026-05-25T16:00:00Z" tz=Asia/Shanghai + * → 1 天(本地 24:00 → 次日 00:00) + * + * from = "2026-05-24T20:00:00Z" to = "2026-05-24T22:00:00Z" tz=Asia/Shanghai + * → 0 天(本地 04:00 → 06:00 同一天) + * + * 当 to < from 时返回负数。 + * + * @param {Date | string | number} from + * @param {Date | string | number} to + * @param {string} [timezone='UTC'] + * @returns {number} + */ +export function getDaysBetween(from, to, timezone = 'UTC') { + const tz = safeTimezone(timezone); + const fromMid = getTimezoneMidnightTimestamp(from, tz); + const toMid = getTimezoneMidnightTimestamp(to, tz); + return Math.round((toMid - fromMid) / MS_PER_DAY); +} + +/** + * 计算指定时刻在某时区下的"零点"对应的 UTC 时间戳。 + * + * 例:date=2026-05-24T15:30:00Z, tz=Asia/Shanghai → 2026-05-24 23:30 北京时间 + * → 该日北京零点 = 2026-05-24T16:00:00Z (因为北京 00:00 = UTC 前一天 16:00) + * → 返回 1748015200000 + * + * @param {Date | string | number} date + * @param {string} [timezone='UTC'] + * @returns {number} UTC ms 时间戳 + */ +export function getTimezoneMidnightTimestamp(date, timezone = 'UTC') { + const tz = safeTimezone(timezone); + const { year, month, day } = getTimezoneDateParts(date, tz); + // 通过反推:tz 下 (year,month,day) 0:00 对应的 UTC 时刻 + // 算法:构造一个临时 UTC 时刻 t0 = Date.UTC(y,m-1,d), 求它在 tz 下的偏移分钟数 offsetMin, + // 则 tz 下零点的 UTC ms = t0 - offsetMin*60_000 + const t0 = Date.UTC(year, month - 1, day, 0, 0, 0); + const probeParts = getTimezoneDateParts(new Date(t0), tz); + const probeAsUtc = Date.UTC( + probeParts.year, + probeParts.month - 1, + probeParts.day, + probeParts.hour, + probeParts.minute, + probeParts.second + ); + const offsetMs = probeAsUtc - t0; + return t0 - offsetMs; +} + +/** + * 把日期分量拼成 "YYYY-MM-DDTHH:mm:ss" 本地表示。 + * + * @param {TimezoneDateParts} parts + * @returns {string} + */ +function formatPartsAsIsoLocal(parts) { + const pad = (n) => String(n).padStart(2, '0'); + return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}:${pad(parts.second)}`; +} + +/** + * 把日期分量拼成 "YYYY-MM-DD"。 + * + * @param {{ year: number, month: number, day: number }} parts + * @returns {string} + */ +function formatPartsAsDateOnly(parts) { + const pad = (n) => String(n).padStart(2, '0'); + return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}`; +} + +/** + * 把 Date 转成 UTC ms 整数(兼容 Date 与时间戳)。 + * + * @param {Date | number} d + * @returns {number} + */ +function utcMillis(d) { + return d instanceof Date ? d.getTime() : Number(d); +} + +/** + * 根据目标时区下的本地日期分量,反推对应的 UTC 时间戳。 + * + * @param {{ year: number, month: number, day: number, hour?: number, minute?: number, second?: number }} parts + * @param {string} [timezone='UTC'] + * @returns {number} + */ +export function getTimestampForTimezoneParts(parts, timezone = 'UTC') { + const tz = safeTimezone(timezone); + const year = Number(parts.year); + const month = Number(parts.month); + const day = Number(parts.day); + const hour = Number(parts.hour || 0); + const minute = Number(parts.minute || 0); + const second = Number(parts.second || 0); + + const probe = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + if ( + Number.isNaN(probe.getTime()) || + probe.getUTCFullYear() !== year || + probe.getUTCMonth() + 1 !== month || + probe.getUTCDate() !== day || + probe.getUTCHours() !== hour || + probe.getUTCMinutes() !== minute || + probe.getUTCSeconds() !== second + ) { + return Number.NaN; + } + const t0 = probe.getTime(); + const probeParts = getTimezoneDateParts(probe, tz); + const probeAsUtc = Date.UTC( + probeParts.year, + probeParts.month - 1, + probeParts.day, + probeParts.hour, + probeParts.minute, + probeParts.second + ); + const offsetMs = probeAsUtc - t0; + return t0 - offsetMs; +} + +/** + * 以指定时区的本地零点解释 "YYYY-MM-DD" 日期输入。 + * + * 若输入是完整 ISO / 时间戳,则保持其绝对时刻语义直接解析。 + * + * @param {Date | string | number} value + * @param {string} [timezone='UTC'] + * @returns {Date} + */ +export function parseDateInputInTimezone(value, timezone = 'UTC') { + if (value instanceof Date) return new Date(value.getTime()); + if (typeof value === 'string') { + const trimmed = value.trim(); + const match = trimmed.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); + if (match) { + const ts = getTimestampForTimezoneParts( + { + year: Number(match[1]), + month: Number(match[2]), + day: Number(match[3]), + hour: 0, + minute: 0, + second: 0 + }, + timezone + ); + return new Date(ts); + } + } + return new Date(value); +} + +/** + * 把某个时刻格式化为指定时区下的日期输入值 "YYYY-MM-DD"。 + * + * @param {Date | string | number} value + * @param {string} [timezone='UTC'] + * @returns {string} + */ +export function formatDateInputInTimezone(value, timezone = 'UTC') { + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) return ''; + return formatPartsAsDateOnly(getTimezoneDateParts(d, timezone)); +} + +/** + * 获取指定时区下"今天"的 YYYY-MM-DD。 + * + * @param {string} [timezone='UTC'] + * @param {Date} [now] + * @returns {string} + */ +export function getTodayDateStringInTimezone(timezone = 'UTC', now) { + const current = getNowInTimezone(timezone, now); + return formatPartsAsDateOnly(current.parts); +} + +/** + * 在指定时区的本地日期语义下增加日/月/年周期,并返回新时刻(本地零点)。 + * + * @param {Date | string | number} value + * @param {number} amount + * @param {'day'|'month'|'year'} unit + * @param {string} [timezone='UTC'] + * @returns {Date} + */ +export function addCalendarPeriodInTimezone(value, amount, unit, timezone = 'UTC') { + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) return new Date(Number.NaN); + + const parts = getTimezoneDateParts(d, timezone); + const temp = new Date(Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0)); + if (unit === 'day') { + temp.setUTCDate(temp.getUTCDate() + amount); + } else if (unit === 'month') { + temp.setUTCMonth(temp.getUTCMonth() + amount); + } else if (unit === 'year') { + temp.setUTCFullYear(temp.getUTCFullYear() + amount); + } + + const ts = getTimestampForTimezoneParts( + { + year: temp.getUTCFullYear(), + month: temp.getUTCMonth() + 1, + day: temp.getUTCDate(), + hour: 0, + minute: 0, + second: 0 + }, + timezone + ); + return new Date(ts); +} + +/** + * 在指定时区下格式化日期。 + * + * 不同 fmt 用于: + * - 'date' → "2026/05/24" + * - 'datetime' → "2026/05/24 17:30:00" + * - 'full'(默认)→ 带星期等本地化完整字符串 + * - 'isoLocal' → "2026-05-24T17:30:00"(无时区后缀) + * + * @param {Date | string | number} time + * @param {string} [timezone='UTC'] + * @param {'date'|'datetime'|'full'|'isoLocal'} [format='full'] + * @returns {string} + */ +export function formatLocalDate(time, timezone = 'UTC', format = 'full') { + const tz = safeTimezone(timezone); + const d = time instanceof Date ? time : new Date(time); + if (Number.isNaN(d.getTime())) return ''; + + if (format === 'isoLocal') { + return formatPartsAsIsoLocal(getTimezoneDateParts(d, tz)); + } + + try { if (format === 'date') { - return date.toLocaleDateString('zh-CN', { - timeZone: timezone, + return d.toLocaleDateString('zh-CN', { + timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }); - } else if (format === 'datetime') { - return date.toLocaleString('zh-CN', { - timeZone: timezone, + } + if (format === 'datetime') { + return d.toLocaleString('zh-CN', { + timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', - second: '2-digit' - }); - } else { - return date.toLocaleString('zh-CN', { - timeZone: timezone + second: '2-digit', + hour12: false }); } - } catch (error) { - console.error(`时间格式化错误: ${error.message}`); - return new Date(time).toISOString(); + return d.toLocaleString('zh-CN', { timeZone: tz }); + } catch { + return d.toISOString(); } } -function getTimezoneOffset(timezone = 'UTC') { +/** + * 同 formatLocalDate,保留原命名以兼容老调用方。 + * + * @param {Date | string | number} time + * @param {string} [timezone='UTC'] + * @param {'date'|'datetime'|'full'|'isoLocal'} [format='full'] + * @returns {string} + */ +export function formatTimeInTimezone(time, timezone = 'UTC', format = 'full') { + return formatLocalDate(time, timezone, format); +} + +/** + * 计算时区相对 UTC 的整小时偏移量(夏令时下取当前时刻偏移)。 + * + * @param {string} [timezone='UTC'] + * @returns {number} 偏移小时数(如 +8 表示 UTC+8) + */ +export function getTimezoneOffset(timezone = 'UTC') { + const tz = safeTimezone(timezone); try { const now = new Date(); - const { year, month, day, hour, minute, second } = getTimezoneDateParts(now, timezone); - const zonedTimestamp = Date.UTC(year, month - 1, day, hour, minute, second); - return Math.round((zonedTimestamp - now.getTime()) / MS_PER_HOUR); - } catch (error) { - console.error(`获取时区偏移量错误: ${error.message}`); + const parts = getTimezoneDateParts(now, tz); + const zoned = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second + ); + // 用 `+ 0` 归一化 -0 为 +0,避免 Object.is 比较时困扰 + return Math.round((zoned - now.getTime()) / MS_PER_HOUR) + 0; + } catch { return 0; } } -function formatTimezoneDisplay(timezone = 'UTC') { +/** + * 生成时区显示文本。 + * + * 例:formatTimezoneDisplay('Asia/Shanghai') → "中国标准时间 (UTC+8)" + * + * @param {string} [timezone='UTC'] + * @returns {string} + */ +export function formatTimezoneDisplay(timezone = 'UTC') { + const tz = safeTimezone(timezone); try { - const offset = getTimezoneOffset(timezone); + const offset = getTimezoneOffset(tz); const offsetStr = offset >= 0 ? `+${offset}` : `${offset}`; - - const timezoneNames = { - 'UTC': '世界标准时间', + const names = { + UTC: '世界标准时间', 'Asia/Shanghai': '中国标准时间', 'Asia/Hong_Kong': '香港时间', 'Asia/Taipei': '台北时间', @@ -132,59 +514,92 @@ function formatTimezoneDisplay(timezone = 'UTC') { 'Australia/Melbourne': '墨尔本时间', 'Pacific/Auckland': '奥克兰时间' }; - - const timezoneName = timezoneNames[timezone] || timezone; - return `${timezoneName} (UTC${offsetStr})`; - } catch (error) { - console.error('格式化时区显示失败:', error); - return timezone; + const cn = names[tz] || tz; + return `${cn} (UTC${offsetStr})`; + } catch { + return tz; } } -function formatBeijingTime(date = new Date(), format = 'full') { - return formatTimeInTimezone(date, 'Asia/Shanghai', format); +/** + * 北京时间快捷格式化函数。 + * + * @param {Date | string | number} [date=new Date()] + * @param {'date'|'datetime'|'full'|'isoLocal'} [format='full'] + * @returns {string} + */ +export function formatBeijingTime(date = new Date(), format = 'full') { + return formatLocalDate(date, 'Asia/Shanghai', format); } -function extractTimezone(request) { - const url = new URL(request.url); - const timezoneParam = url.searchParams.get('timezone'); - - if (timezoneParam) return timezoneParam; +/** + * 从请求中推断时区:query > Header > Accept-Language。 + * + * 注意:本版本前端展示用的是 config.TIMEZONE(用户配置的时区), + * 此函数主要用于 API 兼容场景。 + * + * @param {Request} request + * @returns {string} + */ +export function extractTimezone(request) { + try { + const url = new URL(request.url); + const tzParam = url.searchParams.get('timezone'); + if (tzParam && isValidTimezone(tzParam)) return tzParam; - const timezoneHeader = request.headers.get('X-Timezone'); - if (timezoneHeader) return timezoneHeader; + const tzHeader = request.headers.get('X-Timezone'); + if (tzHeader && isValidTimezone(tzHeader)) return tzHeader; - const acceptLanguage = request.headers.get('Accept-Language'); - if (acceptLanguage) { - if (acceptLanguage.includes('zh')) return 'Asia/Shanghai'; - if (acceptLanguage.includes('en-US')) return 'America/New_York'; - if (acceptLanguage.includes('en-GB')) return 'Europe/London'; + const accept = request.headers.get('Accept-Language') || ''; + if (accept.includes('zh')) return 'Asia/Shanghai'; + if (accept.includes('en-US')) return 'America/New_York'; + if (accept.includes('en-GB')) return 'Europe/London'; + } catch { + /* noop */ } - return 'UTC'; } -function isValidTimezone(timezone) { - try { - new Date().toLocaleString('en-US', { timeZone: timezone }); - return true; - } catch (error) { - return false; - } +// ───────────────────────────────────────────────────────────── +// 兼容层(仅供旧调用方使用,新代码请用上面的 getNowInTimezone) +// ───────────────────────────────────────────────────────────── + +/** + * 兼容老调用:返回当前 UTC 时刻的 Date。 + * + * 老 API 名字误导("InTimezone"),但语义就是"当前时刻"。 + * 后续 Task 会把所有调用方迁移到 getNowInTimezone。 + * + * @param {string} [timezone='UTC'] + * @returns {Date} + */ +export function getCurrentTimeInTimezone(timezone = 'UTC') { + void timezone; // 仅占位保持签名;Date 本身就是 UTC 时刻 + return new Date(); } -export { - MS_PER_HOUR, - MS_PER_DAY, - getCurrentTimeInTimezone, - getTimestampInTimezone, - convertUTCToTimezone, - getTimezoneDateParts, - getTimezoneMidnightTimestamp, - formatTimeInTimezone, - getTimezoneOffset, - formatTimezoneDisplay, - formatBeijingTime, - extractTimezone, - isValidTimezone -}; +/** + * 兼容老调用:返回当前 UTC ms 时间戳。 + * + * @param {string} [timezone='UTC'] + * @returns {number} + */ +export function getTimestampInTimezone(timezone = 'UTC') { + void timezone; + return Date.now(); +} + +/** + * 兼容老调用:把 UTC 时刻"转换到"目标时区。 + * + * 注意:这是个语义陷阱——Date 本身永远是 UTC 时刻(绝对时刻), + * "转到"另一个时区只影响显示,不影响 Date 实例。本函数仅返回原 Date 拷贝。 + * + * @param {Date | string | number} utcTime + * @param {string} [timezone='UTC'] + * @returns {Date} + */ +export function convertUTCToTimezone(utcTime, timezone = 'UTC') { + void timezone; + return utcTime instanceof Date ? new Date(utcTime.getTime()) : new Date(utcTime); +} diff --git a/src/data/categories.js b/src/data/categories.js new file mode 100644 index 00000000..d92e5e35 --- /dev/null +++ b/src/data/categories.js @@ -0,0 +1,26 @@ +import { getKVJson, putKVJson } from './kv.js'; + +const KEY = 'categories'; + +/** + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @returns {Promise} + */ +export async function getCategories(env) { + return (await getKVJson(env, KEY)) || []; +} + +/** + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} category + */ +export async function addCategory(env, category) { + const trimmed = category.trim(); + if (!trimmed) return; + const list = await getCategories(env); + if (!list.includes(trimmed)) { + list.push(trimmed); + list.sort(); + await putKVJson(env, KEY, list); + } +} diff --git a/src/data/config.js b/src/data/config.js index ac33419a..6270fa82 100644 --- a/src/data/config.js +++ b/src/data/config.js @@ -24,7 +24,7 @@ const DEFAULT_CONFIG = { BARK_IS_ARCHIVE: 'false', ENABLED_NOTIFIERS: ['notifyx'], THEME_MODE: 'system', - TIMEZONE: 'UTC', + TIMEZONE: 'Asia/Shanghai', NOTIFICATION_HOURS: [], THIRD_PARTY_API_TOKEN: '', DEBUG_LOGS: false, diff --git a/src/data/migrate.js b/src/data/migrate.js new file mode 100644 index 00000000..a6b78e15 --- /dev/null +++ b/src/data/migrate.js @@ -0,0 +1,276 @@ +// @ts-check +/** + * 自动数据迁移 + * + * ── 设计原则 ──────────────────────────────────────────────── + * 1. **幂等**:每个 step 多次执行结果一致,且 `migrate:{step.id}=done` 标记跳过。 + * 2. **可重入安全**:通过 `migration_lock` Key(60s TTL)防止 Cron 与请求并发触发。 + * 最坏情况是其中一方放弃迁移,另一方完成;不会双写。 + * 3. **可累加**:后续 Task 在 `MIGRATION_STEPS` 数组里追加 step,不影响已运行的步骤。 + * 4. **数据安全**:旧 `subscriptions` 单 Key 在迁移成功后改名为 + * `subscriptions_v2_backup`(7 天 TTL)保留作回滚备份。 + * + * ── KV 标记一览 ───────────────────────────────────────────── + * schema_version = 'v3' ← 数据 schema 版本标识,置上后每次请求秒过 + * migrate:subscriptions_v3 = 'done' ← 每个 step 自己的标记 + * migration_lock = ISO 时间戳 ← TTL 60s 的悲观锁 + * + */ + +import * as subRepo from './subscriptions.repo.js'; +import * as remindersRepo from './reminders.repo.js'; +import * as schedulerLogsRepo from './scheduler-logs.repo.js'; + +/** 当前 schema 版本字符串 */ +export const SCHEMA_VERSION = 'v3'; + +const KEY_SCHEMA_VERSION = 'schema_version'; +const KEY_MIGRATION_LOCK = 'migration_lock'; +const LOCK_TTL_SEC = 60; +const BACKUP_TTL_SEC = 7 * 24 * 3600; + +/** + * @typedef {Object} MigrationStep + * @property {string} id 唯一标识,用于幂等标记 + * @property {string} description 中文描述 + * @property {(env: any) => Promise} run 执行体 + */ + +/** 当前所有 迁移步骤(后续 Task 追加) */ +export const MIGRATION_STEPS = [ + { + id: 'subscriptions_v3', + description: '把单 Key subscriptions 拆成 sub:{id} + sub_index', + run: migrateSubscriptions + }, + { + id: 'reminder_rules_v3', + description: '把订阅自带的 reminderUnit/reminderValue 转成 reminder_rules:{subId}', + run: migrateReminderRules + }, + { + id: 'scheduler_logs_v3', + description: '把旧 scheduler_status_history 合并到 sched_log:{iso}', + run: migrateSchedulerLogs + } +]; + +/** + * 已运行迁移的内存缓存(避免每次请求都查一次 KV)。 + * 同一 isolate 内只查一次。 + */ +let cachedSchemaVersion = /** @type {string|null} */ (null); + +/** + * 入口:确保所有迁移步骤已运行完成。 + * + * 调用方式:在 Worker fetch / scheduled 入口处第一行调用一次。 + * 已迁移过的请求几乎零开销(命中内存缓存或 1 次 KV.get)。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @returns {Promise<{ migrated: boolean, reason?: string, ranSteps?: string[] }>} + */ +export async function ensureMigrations(env) { + if (cachedSchemaVersion === SCHEMA_VERSION) { + return { migrated: false, reason: 'cached' }; + } + + const current = await env.SUBSCRIPTIONS_KV.get(KEY_SCHEMA_VERSION); + if (current === SCHEMA_VERSION) { + cachedSchemaVersion = SCHEMA_VERSION; + return { migrated: false, reason: 'already_v3' }; + } + + // 尝试加锁 + const acquired = await tryAcquireLock(env); + if (!acquired) { + return { migrated: false, reason: 'locked_elsewhere' }; + } + + const ranSteps = []; + try { + for (const step of MIGRATION_STEPS) { + const doneFlag = await env.SUBSCRIPTIONS_KV.get(`migrate:${step.id}`); + if (doneFlag === 'done') continue; + + console.log(`[migrate] 开始执行: ${step.id} - ${step.description}`); + await step.run(env); + await env.SUBSCRIPTIONS_KV.put(`migrate:${step.id}`, 'done'); + console.log(`[migrate] 完成: ${step.id}`); + ranSteps.push(step.id); + } + await env.SUBSCRIPTIONS_KV.put(KEY_SCHEMA_VERSION, SCHEMA_VERSION); + cachedSchemaVersion = SCHEMA_VERSION; + return { migrated: true, ranSteps }; + } catch (err) { + console.error('[migrate] 执行失败,本次不会标记完成,下次请求会重试:', err); + throw err; + } finally { + await releaseLock(env); + } +} + +/** + * 测试用:清除内存缓存,强制下次重新检查 schema_version。 + */ +export function _resetMigrationCache() { + cachedSchemaVersion = null; +} + +/** + * 测试用:检查内存缓存状态。 + */ +export function _getCachedSchemaVersion() { + return cachedSchemaVersion; +} + +// ───────────────────────────────────────────────────────────── +// 锁 helper +// ───────────────────────────────────────────────────────────── + +/** + * 试图获取迁移锁。 + * KV 没有 CAS,只能"先读后写"——存在小窗口竞态,足够大多数场景。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @returns {Promise} + */ +async function tryAcquireLock(env) { + const existing = await env.SUBSCRIPTIONS_KV.get(KEY_MIGRATION_LOCK); + if (existing) return false; + await env.SUBSCRIPTIONS_KV.put(KEY_MIGRATION_LOCK, new Date().toISOString(), { + expirationTtl: LOCK_TTL_SEC + }); + return true; +} + +async function releaseLock(env) { + try { + await env.SUBSCRIPTIONS_KV.delete(KEY_MIGRATION_LOCK); + } catch (err) { + console.warn('[migrate] 释放锁失败(TTL 60s 后会自动释放):', err); + } +} + +// ───────────────────────────────────────────────────────────── +// 各 step 实现 +// ───────────────────────────────────────────────────────────── + +/** + * 迁移:旧 `subscriptions`(JSON 数组)→ `sub:{id}` 多 Key + `sub_index`。 + * + * - 读 `subscriptions`,逐条写 `sub:{id}`,构建索引并写 `sub_index` + * - 旧 `subscriptions` 改名为 `subscriptions_v2_backup`(7 天 TTL)后删除 + * - 旧数据为空或缺失:仅写一个空索引,schema_version 仍写入 + * + * 幂等性: + * - 若 sub_index 已写过且 sub:{id} 已存在,重复执行会覆盖(值相同,影响仅 KV 写次数) + * - 若旧 subscriptions 已被改名,第二次读到 null 视为已迁移,跳过 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + */ +export async function migrateSubscriptions(env) { + const oldRaw = await env.SUBSCRIPTIONS_KV.get('subscriptions'); + + /** @type {any[]} */ + let oldSubs = []; + if (oldRaw) { + try { + const parsed = JSON.parse(oldRaw); + oldSubs = Array.isArray(parsed) ? parsed : []; + } catch (err) { + console.error('[migrate:subscriptions_v3] 旧数据 JSON 解析失败,按空处理:', err); + oldSubs = []; + } + } + + // 写入新结构(即使 oldSubs 为空也要写 sub_index 占位) + if (oldSubs.length > 0) { + await subRepo.replaceAll(env, oldSubs); + } else { + // 现有索引可能已有值(来自第二次执行),不要覆盖 + const existing = await subRepo.listIds(env); + if (existing.length === 0) { + await env.SUBSCRIPTIONS_KV.put('sub_index', '[]'); + } + } + + // 备份旧数据并删除原 Key + if (oldRaw) { + await env.SUBSCRIPTIONS_KV.put('subscriptions_v2_backup', oldRaw, { + expirationTtl: BACKUP_TTL_SEC + }); + await env.SUBSCRIPTIONS_KV.delete('subscriptions'); + } + + console.log(`[migrate:subscriptions_v3] 已迁移 ${oldSubs.length} 条订阅`); +} + +/** + * 迁移:根据每个订阅的 reminderUnit/reminderValue 生成 1 条等价 before_expiry 规则。 + * + * 仅对没有 reminder_rules:{subId} 的订阅写入;如果用户在新版本里已经手动配置过规则, + * 不会被覆盖。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + */ +export async function migrateReminderRules(env) { + const subs = await subRepo.listAll(env); + let count = 0; + for (const sub of subs) { + const existing = await remindersRepo.listForSubscription(env, sub.id); + if (existing.length > 0) continue; + const rule = remindersRepo.legacyFieldToRule(sub); + await remindersRepo.replaceForSubscription(env, sub.id, [rule]); + count++; + } + console.log(`[migrate:reminder_rules_v3] 已为 ${count} 个订阅生成默认提醒规则`); +} + +/** + * 迁移:把旧 scheduler_status_history(早期版本最多保留 20 条调度状态)转写到 sched_log:{iso}。 + * + * 仅作历史记录的最佳努力迁移,结构差异较大,主要保留时间线索。 + * 旧 Key 迁移完保留作回滚备份(让 KV TTL 自然过期清理)。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + */ +export async function migrateSchedulerLogs(env) { + const histRaw = await env.SUBSCRIPTIONS_KV.get('scheduler_status_history'); + if (!histRaw) return; + /** @type {any[]} */ + let history = []; + try { + const parsed = JSON.parse(histRaw); + history = Array.isArray(parsed) ? parsed : []; + } catch { + history = []; + } + + let count = 0; + for (const item of history) { + const startedAt = item.lastRunAt || new Date().toISOString(); + await schedulerLogsRepo.writeLog( + env, + { + startedAt, + finishedAt: startedAt, + timezone: item.timezone || 'UTC', + currentHour: item.currentHour || '00', + configuredHours: Array.isArray(item.configuredHours) ? item.configuredHours : [], + inWindow: !!item.shouldNotifyThisHour, + checkedCount: item.checkedSubscriptions || 0, + matchedCount: item.expiringMatched || 0, + dedupedCount: item.dedupeSkipped || 0, + sentCount: item.sent ? 1 : 0, + autoRenewedCount: item.updatedSubscriptions || 0, + status: item.errorStack ? 'error' : item.sent ? 'ok' : 'skipped', + reason: item.reason || (item.errorStack ? 'legacy_error' : undefined), + extra: { migratedFromV2: true } + }, + { ttlSec: 7 * 24 * 3600 } + ); + count++; + } + console.log(`[migrate:scheduler_logs_v3] 已迁移 ${count} 条历史调度记录`); +} diff --git a/src/data/notification-logs.repo.js b/src/data/notification-logs.repo.js new file mode 100644 index 00000000..10c9946e --- /dev/null +++ b/src/data/notification-logs.repo.js @@ -0,0 +1,169 @@ +// @ts-check +/** + * 通知日志仓库 + * + * 用途:每次发送通知(成功 / 失败)都记录一条;用于"通知历史"页和 + * "为什么没收到通知"自助排查。 + * + * Key 规则: + * notify_log:{ymdh}:{subId}:{ruleId}:{channel} + * ymdh = "YYYYMMDDHH"(UTC,方便按时间区间 list) + * subId = 订阅 ID + * ruleId = 触发的规则 ID(手动触发可填 'manual',第三方 API 可填 'thirdparty') + * channel = 通知渠道(telegram, bark, ...) + * + * 一条 KV 同时还存储进 metadata 里 ymdhmsRand,避免同小时内重复 key 覆盖。 + * + * 默认 TTL:30 天,过期自动清理。失败日志可单独配置更长 TTL(这里统一 30 天即可, + * 后续如需可改 writeLog 接受 ttl 参数)。 + * + */ + +const PREFIX = 'notify_log:'; +const DEFAULT_TTL_SEC = 30 * 24 * 3600; + +/** + * @typedef {Object} NotifyLogEntry + * @property {string} key 完整 KV key + * @property {string} timestamp ISO 时间 + * @property {string} subId + * @property {string|null} ruleId 触发规则;手动 / 第三方为占位字符串 + * @property {string} channel + * @property {'success'|'failed'} status + * @property {string} [title] + * @property {string} [content] + * @property {string} [error] 失败原因 + * @property {any} [raw] 三方原始返回,便于排查 + */ + +/** + * 把 Date 转成 'YYYYMMDDHH' UTC 字符串。 + * + * @param {Date | string | number} date + * @returns {string} + */ +export function ymdhUtc(date) { + const d = date instanceof Date ? date : new Date(date); + if (Number.isNaN(d.getTime())) { + return ymdhUtc(new Date()); + } + const yyyy = String(d.getUTCFullYear()).padStart(4, '0'); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const hh = String(d.getUTCHours()).padStart(2, '0'); + return `${yyyy}${mm}${dd}${hh}`; +} + +/** + * 写入一条通知日志。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {{ + * subId: string, + * ruleId?: string|null, + * channel: string, + * status: 'success'|'failed', + * title?: string, + * content?: string, + * error?: string, + * raw?: any, + * timestamp?: string|Date|number, + * ttlSec?: number + * }} entry + * @returns {Promise} + */ +export async function writeLog(env, entry) { + const ts = entry.timestamp ? new Date(entry.timestamp) : new Date(); + // 增加随机后缀避免同小时同 sub/rule/channel 多次发送相互覆盖 + const rand = Math.floor(ts.getTime() % 100000) + .toString(36) + .padStart(4, '0'); + const ruleId = entry.ruleId || 'none'; + const key = `${PREFIX}${ymdhUtc(ts)}:${entry.subId}:${ruleId}:${entry.channel}:${rand}`; + + const stored = { + timestamp: ts.toISOString(), + subId: entry.subId, + ruleId, + channel: entry.channel, + status: entry.status, + title: entry.title, + content: entry.content, + error: entry.error, + raw: entry.raw + }; + + await env.SUBSCRIPTIONS_KV.put(key, JSON.stringify(stored), { + expirationTtl: Math.max(60, entry.ttlSec || DEFAULT_TTL_SEC) + }); + + return { key, ...stored }; +} + +/** + * 查询通知日志。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {{ + * subId?: string, + * channel?: string, + * status?: 'success'|'failed', + * since?: string|Date|number, + * until?: string|Date|number, + * limit?: number + * }} [filter] + * @returns {Promise} + */ +export async function query(env, filter = {}) { + const limit = Math.min(500, Math.max(1, filter.limit || 100)); + + // KV.list 仅支持前缀,无法按多字段过滤,全部拉到内存再过滤 + const all = []; + let cursor; + do { + const res = await env.SUBSCRIPTIONS_KV.list({ + prefix: PREFIX, + cursor, + limit: 1000 + }); + for (const k of res.keys) all.push(k.name); + cursor = res.list_complete ? undefined : res.cursor; + } while (cursor && all.length < 5000); + + // 按 key 字典序倒序 = 时间倒序(因为 ymdh 在前缀后) + all.sort((a, b) => b.localeCompare(a)); + + const sinceTs = filter.since ? new Date(filter.since).getTime() : 0; + const untilTs = filter.until ? new Date(filter.until).getTime() : Number.POSITIVE_INFINITY; + + const out = []; + for (const key of all) { + if (out.length >= limit) break; + const raw = await env.SUBSCRIPTIONS_KV.get(key); + if (!raw) continue; + try { + const obj = JSON.parse(raw); + if (filter.subId && obj.subId !== filter.subId) continue; + if (filter.channel && obj.channel !== filter.channel) continue; + if (filter.status && obj.status !== filter.status) continue; + const tsMs = new Date(obj.timestamp).getTime(); + if (tsMs < sinceTs || tsMs > untilTs) continue; + out.push({ key, ...obj }); + } catch { + /* skip */ + } + } + + return out; +} + +/** + * 取某订阅最近 N 条日志(仪表盘 / 详情页用)。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} subId + * @param {number} [limit=20] + */ +export async function recentForSubscription(env, subId, limit = 20) { + return query(env, { subId, limit }); +} diff --git a/src/data/reminders.repo.js b/src/data/reminders.repo.js new file mode 100644 index 00000000..cf339bea --- /dev/null +++ b/src/data/reminders.repo.js @@ -0,0 +1,207 @@ +// @ts-check +/** + * 提醒规则仓库 + * + * 数据结构:reminder_rules:{subId} = JSON 数组,每条规则形如: + * { + * id: string, // UUID + * type: 'before_expiry' | 'on_expiry' | 'after_expiry', + * value: number, // 数值(如 7) + * unit: 'days' | 'hours', + * repeatInterval: number | null, // 单位:小时;仅 after_expiry 用 + * repeatUntil: 'renewed' | 'acknowledged' | 'never', + * isEnabled: boolean, + * createdAt: string // ISO + * } + * + * 智能预设(新订阅默认 4 条):到期前 7/3/1 天 + 到期当天。 + * + */ + +const KEY_PREFIX = 'reminder_rules:'; + +/** + * @typedef {Object} ReminderRule + * @property {string} id + * @property {'before_expiry'|'on_expiry'|'after_expiry'} type + * @property {number} value + * @property {'days'|'hours'} unit + * @property {number|null} [repeatInterval] + * @property {'renewed'|'acknowledged'|'never'} [repeatUntil] + * @property {boolean} isEnabled + * @property {string} createdAt + */ + +/** + * 生成新 rule 的默认 id(UUID)。 + * + * @returns {string} + */ +export function makeRuleId() { + return crypto.randomUUID(); +} + +/** + * 智能预设:4 条 — 到期前 7/3/1 天 + 当天。 + * + * @returns {ReminderRule[]} + */ +export function defaultPresetRules() { + const now = new Date().toISOString(); + return [ + { id: makeRuleId(), type: 'before_expiry', value: 7, unit: 'days', repeatInterval: null, repeatUntil: 'renewed', isEnabled: true, createdAt: now }, + { id: makeRuleId(), type: 'before_expiry', value: 3, unit: 'days', repeatInterval: null, repeatUntil: 'renewed', isEnabled: true, createdAt: now }, + { id: makeRuleId(), type: 'before_expiry', value: 1, unit: 'days', repeatInterval: null, repeatUntil: 'renewed', isEnabled: true, createdAt: now }, + { id: makeRuleId(), type: 'on_expiry', value: 0, unit: 'days', repeatInterval: null, repeatUntil: 'renewed', isEnabled: true, createdAt: now } + ]; +} + +/** + * 把旧的 reminderUnit/reminderValue 单点提醒转换为 1 条等价规则。 + * + * @param {{ reminderUnit?: string, reminderValue?: number, reminderDays?: number, reminderHours?: number }} sub + * @returns {ReminderRule} + */ +export function legacyFieldToRule(sub) { + const unitRaw = String(sub.reminderUnit || 'day').toLowerCase(); + const unit = unitRaw === 'hour' || unitRaw === 'hours' ? 'hours' : 'days'; + const fallback = unit === 'hours' ? sub.reminderHours : sub.reminderDays; + const value = Number( + sub.reminderValue !== undefined && sub.reminderValue !== null ? sub.reminderValue : fallback + ); + // value 为非数字时回退 7;value=0 视为"到期当天",保留 + const safeValue = Number.isFinite(value) && value >= 0 ? value : 7; + return { + id: makeRuleId(), + type: safeValue === 0 ? 'on_expiry' : 'before_expiry', + value: safeValue, + unit, + repeatInterval: null, + repeatUntil: 'renewed', + isEnabled: true, + createdAt: new Date().toISOString() + }; +} + +/** + * 校验并归一化一条规则(修正非法字段,回退默认值)。 + * + * @param {any} raw + * @returns {ReminderRule} + */ +export function normalizeRule(raw) { + const r = raw || {}; + const type = ['before_expiry', 'on_expiry', 'after_expiry'].includes(r.type) + ? r.type + : 'before_expiry'; + const unit = r.unit === 'hours' ? 'hours' : 'days'; + const value = Number.isFinite(r.value) && r.value >= 0 ? Math.floor(r.value) : 0; + const repeatInterval = + type === 'after_expiry' && Number.isFinite(r.repeatInterval) && r.repeatInterval > 0 + ? Math.floor(r.repeatInterval) + : null; + const repeatUntil = ['renewed', 'acknowledged', 'never'].includes(r.repeatUntil) + ? r.repeatUntil + : 'renewed'; + return { + id: typeof r.id === 'string' && r.id !== '' ? r.id : makeRuleId(), + type, + value, + unit, + repeatInterval, + repeatUntil, + isEnabled: r.isEnabled !== false, + createdAt: typeof r.createdAt === 'string' ? r.createdAt : new Date().toISOString() + }; +} + +/** + * 读取一个订阅的所有提醒规则。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} subId + * @returns {Promise} + */ +export async function listForSubscription(env, subId) { + const raw = await env.SUBSCRIPTIONS_KV.get(KEY_PREFIX + subId); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.map(normalizeRule); + } catch { + return []; + } +} + +/** + * 整体替换某订阅的提醒规则(CRUD 都基于此)。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} subId + * @param {ReminderRule[]} rules + */ +export async function replaceForSubscription(env, subId, rules) { + const safe = Array.isArray(rules) ? rules.map(normalizeRule) : []; + await env.SUBSCRIPTIONS_KV.put(KEY_PREFIX + subId, JSON.stringify(safe)); +} + +/** + * 添加单条规则。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} subId + * @param {Partial} rule + * @returns {Promise} + */ +export async function addRule(env, subId, rule) { + const list = await listForSubscription(env, subId); + const normalized = normalizeRule({ ...rule, id: rule.id || makeRuleId() }); + list.push(normalized); + await replaceForSubscription(env, subId, list); + return normalized; +} + +/** + * 更新单条规则。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} subId + * @param {string} ruleId + * @param {Partial} patch + * @returns {Promise} + */ +export async function updateRule(env, subId, ruleId, patch) { + const list = await listForSubscription(env, subId); + const idx = list.findIndex((r) => r.id === ruleId); + if (idx === -1) return null; + list[idx] = normalizeRule({ ...list[idx], ...patch, id: ruleId }); + await replaceForSubscription(env, subId, list); + return list[idx]; +} + +/** + * 删除单条规则。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} subId + * @param {string} ruleId + * @returns {Promise} + */ +export async function deleteRule(env, subId, ruleId) { + const list = await listForSubscription(env, subId); + const next = list.filter((r) => r.id !== ruleId); + if (next.length === list.length) return false; + await replaceForSubscription(env, subId, next); + return true; +} + +/** + * 删除某订阅的所有规则(订阅被删除时联动清理)。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} subId + */ +export async function clearForSubscription(env, subId) { + await env.SUBSCRIPTIONS_KV.delete(KEY_PREFIX + subId); +} diff --git a/src/data/scheduler-logs.repo.js b/src/data/scheduler-logs.repo.js new file mode 100644 index 00000000..33a84c46 --- /dev/null +++ b/src/data/scheduler-logs.repo.js @@ -0,0 +1,114 @@ +// @ts-check +/** + * 调度器执行日志仓库 + * + * 用途:每次 Cron `scheduled` 触发都写一条聚合日志,包含本次执行的: + * - 起止时间、用户时区、是否在通知时段 + * - 检查的订阅总数 / 命中提醒规则数 / 去重跳过数 + * - 自动续订条数 / 通知发送统计 + * - 错误(如有) + * + * 配合 notification-logs(细粒度)形成"链路日志": + * sched_log:{isoUtc} ← 一次调度的总览 + * notify_log:{ymdh}:{sub}:{rule}:{ch} ← 该调度发出的每条通知 + * + * Key 规则:sched_log:{ISO_UTC}(如 sched_log:2026-05-24T17:00:00.000Z) + * ISO 字典序 == 时间序,便于 list 倒序读取。 + * + * 默认 TTL:30 天。 + * + */ + +const PREFIX = 'sched_log:'; +const DEFAULT_TTL_SEC = 30 * 24 * 3600; + +/** + * @typedef {Object} SchedulerLogEntry + * @property {string} key 完整 KV key + * @property {string} startedAt ISO + * @property {string} finishedAt ISO + * @property {string} timezone 调度生效时区 + * @property {string} currentHour 用户 TZ 下的当前小时("HH") + * @property {string[]} configuredHours 通知时段配置(用户 TZ 小时) + * @property {boolean} inWindow 是否在通知时段内 + * @property {number} checkedCount 检查订阅数 + * @property {number} matchedCount 命中规则的(订阅×规则)对数 + * @property {number} dedupedCount 因去重跳过数 + * @property {number} sentCount 实际发送通知数 + * @property {number} autoRenewedCount 自动续订订阅数 + * @property {string} status 'ok' | 'skipped' | 'error' + * @property {string} [reason] 跳过原因或错误摘要 + * @property {any} [extra] 附加信息(可选) + */ + +/** + * 写入一条调度日志。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {Omit} entry + * @param {{ ttlSec?: number }} [opts] + * @returns {Promise} + */ +export async function writeLog(env, entry, opts = {}) { + const key = PREFIX + (entry.startedAt || new Date().toISOString()); + const stored = { + startedAt: entry.startedAt || new Date().toISOString(), + finishedAt: entry.finishedAt || new Date().toISOString(), + timezone: entry.timezone || 'UTC', + currentHour: entry.currentHour || '00', + configuredHours: entry.configuredHours || [], + inWindow: !!entry.inWindow, + checkedCount: entry.checkedCount || 0, + matchedCount: entry.matchedCount || 0, + dedupedCount: entry.dedupedCount || 0, + sentCount: entry.sentCount || 0, + autoRenewedCount: entry.autoRenewedCount || 0, + status: entry.status || 'ok', + reason: entry.reason, + extra: entry.extra + }; + await env.SUBSCRIPTIONS_KV.put(key, JSON.stringify(stored), { + expirationTtl: Math.max(60, opts.ttlSec || DEFAULT_TTL_SEC) + }); + return { key, ...stored }; +} + +/** + * 取最近 N 条调度日志(按时间倒序)。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {number} [limit=20] + * @returns {Promise} + */ +export async function getRecent(env, limit = 20) { + const safeLimit = Math.min(200, Math.max(1, Number(limit) || 20)); + + // KV.list 默认升序;想要倒序需要拉全后排序 + const allKeys = []; + let cursor; + do { + const res = await env.SUBSCRIPTIONS_KV.list({ + prefix: PREFIX, + cursor, + limit: 1000 + }); + for (const k of res.keys) allKeys.push(k.name); + cursor = res.list_complete ? undefined : res.cursor; + } while (cursor && allKeys.length < 5000); + + allKeys.sort((a, b) => b.localeCompare(a)); + const top = allKeys.slice(0, safeLimit); + const items = await Promise.all( + top.map(async (key) => { + const raw = await env.SUBSCRIPTIONS_KV.get(key); + if (!raw) return null; + try { + return { key, ...JSON.parse(raw) }; + } catch { + return null; + } + }) + ); + // @ts-ignore - 过滤 null + return items.filter((x) => x != null); +} diff --git a/src/data/subscriptions.js b/src/data/subscriptions.js index dabaa473..b4d6503f 100644 --- a/src/data/subscriptions.js +++ b/src/data/subscriptions.js @@ -1,87 +1,166 @@ +// 注:本文件暂不启用 // @ts-check,因 lunar 库返回类型分支较多,类型清理推迟到后续 Task。 +/** + * 订阅业务层 + * + * 本文件负责"订阅生命周期"相关的业务规则(创建时自动推算到期日、续订生成支付记录、 + * 删除支付记录回退周期、农历周期推算、初始支付记录等)。 + * + * 重构: + * - 数据存储从单 Key 数组改为 sub:{id} 多 Key(见 subscriptions.repo.js) + * - 单条读写通过 repo.getById / repo.save,不再加载整个数组,降低并发风险 + * - 业务逻辑保持兼容,外部 API 签名不变 + * + * 注意:reminderUnit/reminderValue 字段保留兼容,Task 8 会在新 API 引入 + * 多提醒规则(reminder_rules:{id}),届时 Service 层会同步两边。 + */ + import { getConfig } from './config.js'; -import { getCurrentTimeInTimezone, getTimezoneMidnightTimestamp } from '../core/time.js'; +import { + addCalendarPeriodInTimezone, + getNowInTimezone, + getTimezoneDateParts, + getTimezoneMidnightTimestamp, + parseDateInputInTimezone +} from '../core/time.js'; import { lunarCalendar, lunarBiz } from '../core/lunar.js'; import { resolveReminderSetting } from '../services/notify/reminder.js'; - +import * as subRepo from './subscriptions.repo.js'; +import { addCategory } from './categories.js'; + +/** + * 裁剪支付历史,保留 1 条 initial + 最近 N 条其他记录。 + * + * @param {Array} records + * @param {number} limit + * @returns {Array} + */ function trimPaymentHistory(records = [], limit = 100) { const safeLimit = Math.min(1000, Math.max(10, Number(limit) || 100)); if (!Array.isArray(records)) return []; if (records.length <= safeLimit) return records; - const initialRecords = records.filter(item => item && item.type === 'initial'); - const otherRecords = records.filter(item => item && item.type !== 'initial'); + const initialRecords = records.filter((item) => item && item.type === 'initial'); + const otherRecords = records.filter((item) => item && item.type !== 'initial'); const keptOther = otherRecords.slice(-(safeLimit - Math.min(initialRecords.length, 1))); const keptInitial = initialRecords.length > 0 ? [initialRecords[0]] : []; return [...keptInitial, ...keptOther]; } +/** + * @param {string | Date | number | null | undefined} value + * @param {string} timezone + * @returns {Date | null} + */ +function parseOptionalDateInTimezone(value, timezone) { + if (value == null || value === '') return null; + const parsed = parseDateInputInTimezone(value, timezone); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +/** + * @param {number} year + * @param {number} month + * @param {number} day + * @param {string} timezone + * @returns {Date} + */ +function buildTimezoneDate(year, month, day, timezone) { + return parseDateInputInTimezone( + `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`, + timezone + ); +} + +/** + * 获取所有订阅(从新 repo 读取)。 + * + * @param {any} env + * @returns {Promise>} + */ async function getAllSubscriptions(env) { try { - const data = await env.SUBSCRIPTIONS_KV.get('subscriptions'); - return data ? JSON.parse(data) : []; + return await subRepo.listAll(env); } catch (error) { + console.error('[subscriptions] 读取列表失败:', error); return []; } } +/** + * 按 ID 获取单条订阅。 + * + * @param {string} id + * @param {any} env + */ async function getSubscription(id, env) { - const subscriptions = await getAllSubscriptions(env); - return subscriptions.find(s => s.id === id); + return subRepo.getById(env, id); } +/** + * 创建订阅。 + * + * @param {any} subscription 来自前端的字段集 + * @param {any} env + * @returns {Promise<{success: boolean, message?: string, subscription?: any}>} + */ async function createSubscription(subscription, env) { try { - const subscriptions = await getAllSubscriptions(env); - if (!subscription.name || !subscription.expiryDate) { return { success: false, message: '缺少必填字段' }; } - let expiryDate = new Date(subscription.expiryDate); - const currentTime = getCurrentTimeInTimezone('UTC'); + const config = await getConfig(env); + const timezone = config.TIMEZONE || 'Asia/Shanghai'; + const now = getNowInTimezone(timezone); + const todayMidnight = getTimezoneMidnightTimestamp(now.utc, timezone); + const startDate = parseOptionalDateInTimezone(subscription.startDate, timezone); + let expiryDate = parseOptionalDateInTimezone(subscription.expiryDate, timezone); + if (!expiryDate) { + return { success: false, message: '到期日期格式无效' }; + } let useLunar = !!subscription.useLunar; if (useLunar) { + const expiryParts = getTimezoneDateParts(expiryDate, timezone); let lunar = lunarCalendar.solar2lunar( - expiryDate.getFullYear(), - expiryDate.getMonth() + 1, - expiryDate.getDate() + expiryParts.year, + expiryParts.month, + expiryParts.day ); if (lunar && subscription.periodValue && subscription.periodUnit) { - while (expiryDate <= currentTime) { + while (getTimezoneMidnightTimestamp(expiryDate, timezone) < todayMidnight) { lunar = lunarBiz.addLunarPeriod(lunar, subscription.periodValue, subscription.periodUnit); const solar = lunarBiz.lunar2solar(lunar); - expiryDate = new Date(solar.year, solar.month - 1, solar.day); + expiryDate = buildTimezoneDate(solar.year, solar.month, solar.day, timezone); } - subscription.expiryDate = expiryDate.toISOString(); } } else { - if (expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) { - while (expiryDate < currentTime) { - if (subscription.periodUnit === 'day') { - expiryDate.setDate(expiryDate.getDate() + subscription.periodValue); - } else if (subscription.periodUnit === 'month') { - expiryDate.setMonth(expiryDate.getMonth() + subscription.periodValue); - } else if (subscription.periodUnit === 'year') { - expiryDate.setFullYear(expiryDate.getFullYear() + subscription.periodValue); - } + if (getTimezoneMidnightTimestamp(expiryDate, timezone) < todayMidnight && subscription.periodValue && subscription.periodUnit) { + while (getTimezoneMidnightTimestamp(expiryDate, timezone) < todayMidnight) { + expiryDate = addCalendarPeriodInTimezone( + expiryDate, + subscription.periodValue, + subscription.periodUnit, + timezone + ); } - subscription.expiryDate = expiryDate.toISOString(); } } const reminderSetting = resolveReminderSetting(subscription); + const normalizedStartDate = startDate ? startDate.toISOString() : null; + const normalizedExpiryDate = expiryDate.toISOString(); - const initialPaymentDate = subscription.startDate || currentTime.toISOString(); + const initialPaymentDate = normalizedStartDate || now.utc.toISOString(); const newSubscription = { id: Date.now().toString(), name: subscription.name, subscriptionMode: subscription.subscriptionMode || 'cycle', customType: subscription.customType || '', category: subscription.category ? subscription.category.trim() : '', - startDate: subscription.startDate || null, - expiryDate: subscription.expiryDate, + startDate: normalizedStartDate, + expiryDate: normalizedExpiryDate, periodValue: subscription.periodValue || 1, periodUnit: subscription.periodUnit || 'month', reminderUnit: reminderSetting.unit, @@ -89,42 +168,54 @@ async function createSubscription(subscription, env) { reminderDays: reminderSetting.unit === 'day' ? reminderSetting.value : undefined, reminderHours: reminderSetting.unit === 'hour' ? reminderSetting.value : undefined, notes: subscription.notes || '', - amount: subscription.amount !== undefined && subscription.amount !== null ? subscription.amount : null, + amount: + subscription.amount !== undefined && subscription.amount !== null + ? subscription.amount + : null, currency: subscription.currency || 'CNY', lastPaymentDate: initialPaymentDate, - paymentHistory: subscription.amount !== undefined && subscription.amount !== null ? [{ - id: Date.now().toString(), - date: initialPaymentDate, - amount: subscription.amount, - currency: subscription.currency || 'CNY', - type: 'initial', - note: '初始订阅', - periodStart: subscription.startDate || initialPaymentDate, - periodEnd: subscription.expiryDate - }] : [], + paymentHistory: + subscription.amount !== undefined && subscription.amount !== null + ? [ + { + id: Date.now().toString(), + date: initialPaymentDate, + amount: subscription.amount, + currency: subscription.currency || 'CNY', + type: 'initial', + note: '初始订阅', + periodStart: normalizedStartDate || initialPaymentDate, + periodEnd: normalizedExpiryDate + } + ] + : [], isActive: subscription.isActive !== false, autoRenew: subscription.autoRenew !== false, useLunar: useLunar, createdAt: new Date().toISOString() }; - subscriptions.push(newSubscription); - - await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); + await subRepo.save(env, newSubscription); + if (newSubscription.category) await addCategory(env, newSubscription.category); return { success: true, subscription: newSubscription }; } catch (error) { - console.error("创建订阅异常:", error && error.stack ? error.stack : error); + console.error('创建订阅异常:', error && error.stack ? error.stack : error); return { success: false, message: error && error.message ? error.message : '创建订阅失败' }; } } +/** + * 更新订阅。 + * + * @param {string} id + * @param {any} subscription + * @param {any} env + */ async function updateSubscription(id, subscription, env) { try { - const subscriptions = await getAllSubscriptions(env); - const index = subscriptions.findIndex(s => s.id === id); - - if (index === -1) { + const existing = await subRepo.getById(env, id); + if (!existing) { return { success: false, message: '订阅不存在' }; } @@ -132,200 +223,237 @@ async function updateSubscription(id, subscription, env) { return { success: false, message: '缺少必填字段' }; } - let expiryDate = new Date(subscription.expiryDate); - const currentTime = getCurrentTimeInTimezone('UTC'); + const config = await getConfig(env); + const timezone = config.TIMEZONE || 'Asia/Shanghai'; + const now = getNowInTimezone(timezone); + const todayMidnight = getTimezoneMidnightTimestamp(now.utc, timezone); + const incomingStartDate = parseOptionalDateInTimezone(subscription.startDate, timezone); + let expiryDate = parseOptionalDateInTimezone(subscription.expiryDate, timezone); + if (!expiryDate) { + return { success: false, message: '到期日期格式无效' }; + } let useLunar = !!subscription.useLunar; if (useLunar) { + const expiryParts = getTimezoneDateParts(expiryDate, timezone); let lunar = lunarCalendar.solar2lunar( - expiryDate.getFullYear(), - expiryDate.getMonth() + 1, - expiryDate.getDate() + expiryParts.year, + expiryParts.month, + expiryParts.day ); if (!lunar) { return { success: false, message: '农历日期超出支持范围(1900-2100年)' }; } - if (lunar && expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) { + if (lunar && getTimezoneMidnightTimestamp(expiryDate, timezone) < todayMidnight && subscription.periodValue && subscription.periodUnit) { do { lunar = lunarBiz.addLunarPeriod(lunar, subscription.periodValue, subscription.periodUnit); const solar = lunarBiz.lunar2solar(lunar); - expiryDate = new Date(solar.year, solar.month - 1, solar.day); - } while (expiryDate < currentTime); - subscription.expiryDate = expiryDate.toISOString(); + expiryDate = buildTimezoneDate(solar.year, solar.month, solar.day, timezone); + } while (getTimezoneMidnightTimestamp(expiryDate, timezone) < todayMidnight); } } else { - if (expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) { - while (expiryDate < currentTime) { - if (subscription.periodUnit === 'day') { - expiryDate.setDate(expiryDate.getDate() + subscription.periodValue); - } else if (subscription.periodUnit === 'month') { - expiryDate.setMonth(expiryDate.getMonth() + subscription.periodValue); - } else if (subscription.periodUnit === 'year') { - expiryDate.setFullYear(expiryDate.getFullYear() + subscription.periodValue); - } + if (getTimezoneMidnightTimestamp(expiryDate, timezone) < todayMidnight && subscription.periodValue && subscription.periodUnit) { + while (getTimezoneMidnightTimestamp(expiryDate, timezone) < todayMidnight) { + expiryDate = addCalendarPeriodInTimezone( + expiryDate, + subscription.periodValue, + subscription.periodUnit, + timezone + ); } - subscription.expiryDate = expiryDate.toISOString(); } } const reminderSource = { - reminderUnit: subscription.reminderUnit !== undefined ? subscription.reminderUnit : subscriptions[index].reminderUnit, - reminderValue: subscription.reminderValue !== undefined ? subscription.reminderValue : subscriptions[index].reminderValue, - reminderHours: subscription.reminderHours !== undefined ? subscription.reminderHours : subscriptions[index].reminderHours, - reminderDays: subscription.reminderDays !== undefined ? subscription.reminderDays : subscriptions[index].reminderDays + reminderUnit: + subscription.reminderUnit !== undefined ? subscription.reminderUnit : existing.reminderUnit, + reminderValue: + subscription.reminderValue !== undefined ? subscription.reminderValue : existing.reminderValue, + reminderHours: + subscription.reminderHours !== undefined ? subscription.reminderHours : existing.reminderHours, + reminderDays: + subscription.reminderDays !== undefined ? subscription.reminderDays : existing.reminderDays }; const reminderSetting = resolveReminderSetting(reminderSource); - const oldSubscription = subscriptions[index]; - const newAmount = subscription.amount !== undefined ? subscription.amount : oldSubscription.amount; + const newAmount = subscription.amount !== undefined ? subscription.amount : existing.amount; + let paymentHistory = existing.paymentHistory || []; - let paymentHistory = oldSubscription.paymentHistory || []; + const hasInitialPayment = paymentHistory.some((p) => p.type === 'initial'); + const amountChanged = newAmount !== existing.amount || + (subscription.currency !== undefined && subscription.currency !== existing.currency); - if (newAmount !== oldSubscription.amount || (subscription.currency !== undefined && subscription.currency !== oldSubscription.currency)) { - const initialPaymentIndex = paymentHistory.findIndex(p => p.type === 'initial'); - if (initialPaymentIndex !== -1) { - paymentHistory[initialPaymentIndex] = { - ...paymentHistory[initialPaymentIndex], - amount: newAmount, - currency: subscription.currency || oldSubscription.currency || 'CNY' - }; - } + if (amountChanged && hasInitialPayment) { + const idx = paymentHistory.findIndex((p) => p.type === 'initial'); + paymentHistory[idx] = { + ...paymentHistory[idx], + amount: newAmount, + currency: subscription.currency || existing.currency || 'CNY' + }; + } else if (!hasInitialPayment && newAmount !== null && newAmount !== undefined && newAmount > 0) { + const initialDate = existing.startDate || existing.createdAt || new Date().toISOString(); + paymentHistory.unshift({ + id: Date.now().toString(), + date: initialDate, + amount: newAmount, + currency: subscription.currency || existing.currency || 'CNY', + type: 'initial', + note: '初始订阅', + periodStart: existing.startDate || initialDate, + periodEnd: existing.expiryDate || initialDate + }); } - subscriptions[index] = { - ...subscriptions[index], + const merged = { + ...existing, name: subscription.name, - subscriptionMode: subscription.subscriptionMode || subscriptions[index].subscriptionMode || 'cycle', - customType: subscription.customType || subscriptions[index].customType || '', - category: subscription.category !== undefined ? subscription.category.trim() : (subscriptions[index].category || ''), - startDate: subscription.startDate || subscriptions[index].startDate, - expiryDate: subscription.expiryDate, - periodValue: subscription.periodValue || subscriptions[index].periodValue || 1, - periodUnit: subscription.periodUnit || subscriptions[index].periodUnit || 'month', + subscriptionMode: subscription.subscriptionMode || existing.subscriptionMode || 'cycle', + customType: subscription.customType || existing.customType || '', + category: + subscription.category !== undefined + ? subscription.category.trim() + : existing.category || '', + startDate: + subscription.startDate !== undefined + ? incomingStartDate + ? incomingStartDate.toISOString() + : existing.startDate + : existing.startDate, + expiryDate: expiryDate.toISOString(), + periodValue: subscription.periodValue || existing.periodValue || 1, + periodUnit: subscription.periodUnit || existing.periodUnit || 'month', reminderUnit: reminderSetting.unit, reminderValue: reminderSetting.value, reminderDays: reminderSetting.unit === 'day' ? reminderSetting.value : undefined, reminderHours: reminderSetting.unit === 'hour' ? reminderSetting.value : undefined, notes: subscription.notes || '', amount: newAmount, - currency: subscription.currency || subscriptions[index].currency || 'CNY', - lastPaymentDate: subscriptions[index].lastPaymentDate || subscriptions[index].startDate || subscriptions[index].createdAt || currentTime.toISOString(), - paymentHistory: paymentHistory, - isActive: subscription.isActive !== undefined ? subscription.isActive : subscriptions[index].isActive, - autoRenew: subscription.autoRenew !== undefined ? subscription.autoRenew : (subscriptions[index].autoRenew !== undefined ? subscriptions[index].autoRenew : true), + currency: subscription.currency || existing.currency || 'CNY', + lastPaymentDate: + existing.lastPaymentDate || + existing.startDate || + existing.createdAt || + now.utc.toISOString(), + paymentHistory, + isActive: subscription.isActive !== undefined ? subscription.isActive : existing.isActive, + autoRenew: + subscription.autoRenew !== undefined + ? subscription.autoRenew + : existing.autoRenew !== undefined + ? existing.autoRenew + : true, useLunar: useLunar, updatedAt: new Date().toISOString() }; - await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); + await subRepo.save(env, merged); + if (merged.category) await addCategory(env, merged.category); - return { success: true, subscription: subscriptions[index] }; + return { success: true, subscription: merged }; } catch (error) { + console.error('[subscriptions] 更新订阅失败:', error); return { success: false, message: '更新订阅失败' }; } } +/** + * 删除订阅。 + * + * @param {string} id + * @param {any} env + */ async function deleteSubscription(id, env) { try { - const subscriptions = await getAllSubscriptions(env); - const filteredSubscriptions = subscriptions.filter(s => s.id !== id); - - if (filteredSubscriptions.length === subscriptions.length) { - return { success: false, message: '订阅不存在' }; - } - - await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(filteredSubscriptions)); - + const ok = await subRepo.deleteById(env, id); + if (!ok) return { success: false, message: '订阅不存在' }; return { success: true }; } catch (error) { + console.error('[subscriptions] 删除订阅失败:', error); return { success: false, message: '删除订阅失败' }; } } +/** + * 手动续订订阅。 + * + * 业务规则: + * - reset 模式:以支付日期为新开始 + * - cycle 模式:现到期日 > 支付日 时接续,否则以支付日为新开始 + * - 农历模式按农历周期推算 + * + * @param {string} id + * @param {any} env + * @param {{ paymentDate?: string|Date, amount?: number, periodMultiplier?: number, note?: string }} options + */ async function manualRenewSubscription(id, env, options = {}) { try { - const subscriptions = await getAllSubscriptions(env); - const index = subscriptions.findIndex(s => s.id === id); - - if (index === -1) { - return { success: false, message: '订阅不存在' }; - } - - const subscription = subscriptions[index]; + const subscription = await subRepo.getById(env, id); + if (!subscription) return { success: false, message: '订阅不存在' }; if (!subscription.periodValue || !subscription.periodUnit) { return { success: false, message: '订阅未设置续订周期' }; } const config = await getConfig(env); - const currentTime = getCurrentTimeInTimezone('UTC'); - const todayMidnight = getTimezoneMidnightTimestamp(currentTime, 'UTC'); - void todayMidnight; + const timezone = config.TIMEZONE || 'Asia/Shanghai'; + const now = getNowInTimezone(timezone); - const paymentDate = options.paymentDate ? new Date(options.paymentDate) : currentTime; + const paymentDate = options.paymentDate + ? parseDateInputInTimezone(options.paymentDate, timezone) + : now.utc; const amount = options.amount !== undefined ? options.amount : subscription.amount || 0; const periodMultiplier = options.periodMultiplier || 1; const note = options.note || '手动续订'; const mode = subscription.subscriptionMode || 'cycle'; let newStartDate; - let currentExpiryDate = new Date(subscription.expiryDate); + const currentExpiryDate = new Date(subscription.expiryDate); if (mode === 'reset') { newStartDate = new Date(paymentDate); } else { - if (currentExpiryDate.getTime() > paymentDate.getTime()) { - newStartDate = new Date(currentExpiryDate); - } else { - newStartDate = new Date(paymentDate); - } + newStartDate = + currentExpiryDate.getTime() > paymentDate.getTime() + ? new Date(currentExpiryDate) + : new Date(paymentDate); } let newExpiryDate; if (subscription.useLunar) { - const solarStart = { - year: newStartDate.getFullYear(), - month: newStartDate.getMonth() + 1, - day: newStartDate.getDate() - }; + const solarStart = getTimezoneDateParts(newStartDate, timezone); let lunar = lunarCalendar.solar2lunar(solarStart.year, solarStart.month, solarStart.day); - let nextLunar = lunar; for (let i = 0; i < periodMultiplier; i++) { nextLunar = lunarBiz.addLunarPeriod(nextLunar, subscription.periodValue, subscription.periodUnit); } const solar = lunarBiz.lunar2solar(nextLunar); - newExpiryDate = new Date(solar.year, solar.month - 1, solar.day); + newExpiryDate = buildTimezoneDate(solar.year, solar.month, solar.day, timezone); } else { - newExpiryDate = new Date(newStartDate); const totalPeriodValue = subscription.periodValue * periodMultiplier; - - if (subscription.periodUnit === 'day') { - newExpiryDate.setDate(newExpiryDate.getDate() + totalPeriodValue); - } else if (subscription.periodUnit === 'month') { - newExpiryDate.setMonth(newExpiryDate.getMonth() + totalPeriodValue); - } else if (subscription.periodUnit === 'year') { - newExpiryDate.setFullYear(newExpiryDate.getFullYear() + totalPeriodValue); - } + newExpiryDate = addCalendarPeriodInTimezone( + newStartDate, + totalPeriodValue, + subscription.periodUnit, + timezone + ); } const paymentRecord = { id: Date.now().toString(), date: paymentDate.toISOString(), - amount: amount, + amount, currency: subscription.currency || 'CNY', type: 'manual', - note: note, + note, periodStart: newStartDate.toISOString(), periodEnd: newExpiryDate.toISOString() }; - const paymentHistoryLimit = (await getConfig(env)).PAYMENT_HISTORY_LIMIT || 100; - const paymentHistory = subscription.paymentHistory || []; - paymentHistory.push(paymentRecord); + const paymentHistoryLimit = config.PAYMENT_HISTORY_LIMIT || 100; + const paymentHistory = [...(subscription.paymentHistory || []), paymentRecord]; const trimmedPaymentHistory = trimPaymentHistory(paymentHistory, paymentHistoryLimit); - subscriptions[index] = { + const updated = { ...subscription, startDate: newStartDate.toISOString(), expiryDate: newExpiryDate.toISOString(), @@ -333,31 +461,30 @@ async function manualRenewSubscription(id, env, options = {}) { paymentHistory: trimmedPaymentHistory }; - await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); + await subRepo.save(env, updated); - return { success: true, subscription: subscriptions[index], message: '续订成功' }; + return { success: true, subscription: updated, message: '续订成功' }; } catch (error) { console.error('手动续订失败:', error); - return { success: false, message: '续订失败: ' + error.message }; + return { success: false, message: '续订失败: ' + (error && error.message ? error.message : error) }; } } +/** + * 删除一条支付记录(删除时回退到期日)。 + * + * @param {string} subscriptionId + * @param {string} paymentId + * @param {any} env + */ async function deletePaymentRecord(subscriptionId, paymentId, env) { try { - const subscriptions = await getAllSubscriptions(env); - const index = subscriptions.findIndex(s => s.id === subscriptionId); + const subscription = await subRepo.getById(env, subscriptionId); + if (!subscription) return { success: false, message: '订阅不存在' }; - if (index === -1) { - return { success: false, message: '订阅不存在' }; - } - - const subscription = subscriptions[index]; const paymentHistory = subscription.paymentHistory || []; - const paymentIndex = paymentHistory.findIndex(p => p.id === paymentId); - - if (paymentIndex === -1) { - return { success: false, message: '支付记录不存在' }; - } + const paymentIndex = paymentHistory.findIndex((p) => p.id === paymentId); + if (paymentIndex === -1) return { success: false, message: '支付记录不存在' }; const deletedPayment = paymentHistory[paymentIndex]; paymentHistory.splice(paymentIndex, 1); @@ -368,101 +495,122 @@ async function deletePaymentRecord(subscriptionId, paymentId, env) { if (paymentHistory.length > 0) { const sortedByPeriodEnd = [...paymentHistory].sort((a, b) => { const dateA = a.periodEnd ? new Date(a.periodEnd) : new Date(0); - const dateB = a.periodEnd ? new Date(a.periodEnd) : new Date(0); - return dateB - dateA; + const dateB = b.periodEnd ? new Date(b.periodEnd) : new Date(0); + return Number(dateB) - Number(dateA); }); if (sortedByPeriodEnd[0].periodEnd) { newExpiryDate = sortedByPeriodEnd[0].periodEnd; } - const sortedByDate = [...paymentHistory].sort((a, b) => new Date(b.date) - new Date(a.date)); + const sortedByDate = [...paymentHistory].sort( + (a, b) => Number(new Date(b.date)) - Number(new Date(a.date)) + ); newLastPaymentDate = sortedByDate[0].date; } else { - if (deletedPayment.periodStart) { - newExpiryDate = deletedPayment.periodStart; - } - newLastPaymentDate = subscription.startDate || subscription.createdAt || subscription.expiryDate; + if (deletedPayment.periodStart) newExpiryDate = deletedPayment.periodStart; + newLastPaymentDate = + subscription.startDate || subscription.createdAt || subscription.expiryDate; } - subscriptions[index] = { + const updated = { ...subscription, expiryDate: newExpiryDate, paymentHistory, lastPaymentDate: newLastPaymentDate }; - await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); + await subRepo.save(env, updated); - return { success: true, subscription: subscriptions[index], message: '支付记录已删除' }; + return { success: true, subscription: updated, message: '支付记录已删除' }; } catch (error) { console.error('删除支付记录失败:', error); - return { success: false, message: '删除失败: ' + error.message }; + return { + success: false, + message: '删除失败: ' + (error && error.message ? error.message : error) + }; } } +/** + * 更新支付记录。 + * + * @param {string} subscriptionId + * @param {string} paymentId + * @param {{ date?: string, amount?: number, currency?: string, note?: string }} paymentData + * @param {any} env + */ async function updatePaymentRecord(subscriptionId, paymentId, paymentData, env) { try { - const subscriptions = await getAllSubscriptions(env); - const index = subscriptions.findIndex(s => s.id === subscriptionId); - - if (index === -1) { - return { success: false, message: '订阅不存在' }; - } + const subscription = await subRepo.getById(env, subscriptionId); + if (!subscription) return { success: false, message: '订阅不存在' }; + const config = await getConfig(env); + const timezone = config.TIMEZONE || 'Asia/Shanghai'; - const subscription = subscriptions[index]; const paymentHistory = subscription.paymentHistory || []; - const paymentIndex = paymentHistory.findIndex(p => p.id === paymentId); + const paymentIndex = paymentHistory.findIndex((p) => p.id === paymentId); + if (paymentIndex === -1) return { success: false, message: '支付记录不存在' }; - if (paymentIndex === -1) { - return { success: false, message: '支付记录不存在' }; - } + const normalizedPaymentDate = parseOptionalDateInTimezone(paymentData.date, timezone); paymentHistory[paymentIndex] = { ...paymentHistory[paymentIndex], - date: paymentData.date || paymentHistory[paymentIndex].date, - amount: paymentData.amount !== undefined ? paymentData.amount : paymentHistory[paymentIndex].amount, - currency: paymentData.currency || paymentHistory[paymentIndex].currency || subscription.currency || 'CNY', + date: normalizedPaymentDate ? normalizedPaymentDate.toISOString() : paymentHistory[paymentIndex].date, + amount: + paymentData.amount !== undefined ? paymentData.amount : paymentHistory[paymentIndex].amount, + currency: + paymentData.currency || + paymentHistory[paymentIndex].currency || + subscription.currency || + 'CNY', note: paymentData.note !== undefined ? paymentData.note : paymentHistory[paymentIndex].note }; - const sortedPayments = [...paymentHistory].sort((a, b) => new Date(b.date) - new Date(a.date)); + const sortedPayments = [...paymentHistory].sort( + (a, b) => Number(new Date(b.date)) - Number(new Date(a.date)) + ); const newLastPaymentDate = sortedPayments[0].date; - subscriptions[index] = { + const updated = { ...subscription, paymentHistory, lastPaymentDate: newLastPaymentDate }; - await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); + await subRepo.save(env, updated); - return { success: true, subscription: subscriptions[index], message: '支付记录已更新' }; + return { success: true, subscription: updated, message: '支付记录已更新' }; } catch (error) { console.error('更新支付记录失败:', error); - return { success: false, message: '更新失败: ' + error.message }; + return { + success: false, + message: '更新失败: ' + (error && error.message ? error.message : error) + }; } } +/** + * 启用/停用订阅。 + * + * @param {string} id + * @param {boolean} isActive + * @param {any} env + */ async function toggleSubscriptionStatus(id, isActive, env) { try { - const subscriptions = await getAllSubscriptions(env); - const index = subscriptions.findIndex(s => s.id === id); + const existing = await subRepo.getById(env, id); + if (!existing) return { success: false, message: '订阅不存在' }; - if (index === -1) { - return { success: false, message: '订阅不存在' }; - } - - subscriptions[index] = { - ...subscriptions[index], - isActive: isActive, + const updated = { + ...existing, + isActive: !!isActive, updatedAt: new Date().toISOString() }; + await subRepo.save(env, updated); - await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); - - return { success: true, subscription: subscriptions[index] }; + return { success: true, subscription: updated }; } catch (error) { + console.error('[subscriptions] 切换状态失败:', error); return { success: false, message: '更新订阅状态失败' }; } } diff --git a/src/data/subscriptions.repo.js b/src/data/subscriptions.repo.js new file mode 100644 index 00000000..982e149d --- /dev/null +++ b/src/data/subscriptions.repo.js @@ -0,0 +1,196 @@ +// @ts-check +/** + * 订阅仓库 + * + * ── 设计动机 ──────────────────────────────────────────────── + * 早期实现把所有订阅塞在一个 KV key('subscriptions')里,每次读写整数组, + * 并发编辑会丢数据,且数量大时性能下降。 + * + * 改为: + * sub_index = JSON 数组 [id1, id2, ...] ← 用于快速枚举 + * sub:{id} = JSON 对象 ← 单订阅完整数据 + * + * 单订阅 CRUD 不再触碰整数组,仅写 sub:{id}(修改)或 sub_index+sub:{id}(新增/删除)。 + * + * ── 并发安全说明 ──────────────────────────────────────────── + * KV 不支持事务,sub_index 的"读-改-写"在并发下仍可能丢更新。 + * 真实使用场景(单用户、操作稀疏)下风险可接受。 + * 数据本体在 sub:{id},即使索引漏更新也可以通过 KV.list 修复。 + * + */ + +const KEY_INDEX = 'sub_index'; +const KEY_PREFIX = 'sub:'; + +/** + * 读取订阅 ID 列表(索引)。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @returns {Promise} + */ +export async function listIds(env) { + const raw = await env.SUBSCRIPTIONS_KV.get(KEY_INDEX); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : []; + } catch { + return []; + } +} + +/** + * 写回订阅 ID 列表(索引)。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string[]} ids + */ +async function writeIds(env, ids) { + // 去重保序 + const seen = new Set(); + const deduped = []; + for (const id of ids) { + if (typeof id === 'string' && !seen.has(id)) { + seen.add(id); + deduped.push(id); + } + } + await env.SUBSCRIPTIONS_KV.put(KEY_INDEX, JSON.stringify(deduped)); +} + +/** + * 根据 ID 读取单条订阅。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} id + * @returns {Promise} + */ +export async function getById(env, id) { + if (!id) return null; + const raw = await env.SUBSCRIPTIONS_KV.get(KEY_PREFIX + id); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch (err) { + console.error('[sub-repo] 反序列化失败:', id, err); + return null; + } +} + +/** + * 一次性读取所有订阅(按索引顺序)。 + * + * 实现:先读索引,再 Promise.all 并发拿单个订阅;缺失的项被过滤。 + * 对 N 数十量级仍亚秒级。N 上千时建议分批或改用分页接口。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @returns {Promise} + */ +export async function listAll(env) { + const ids = await listIds(env); + if (ids.length === 0) return []; + const items = await Promise.all(ids.map((id) => getById(env, id))); + // @ts-ignore — 过滤 null + return items.filter((it) => it != null); +} + +/** + * 保存(创建或更新)一条订阅。 + * + * - 若 ID 不在索引中:写 sub:{id} 并 append 到索引 + * - 若 ID 已存在:仅写 sub:{id}(不动索引,避免不必要的 RPC) + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {Object} subscription 必须包含 string 类型的 id + * @returns {Promise} 写入后的对象 + */ +export async function save(env, subscription) { + if (!subscription || typeof subscription.id !== 'string' || subscription.id === '') { + throw new Error('订阅缺少有效 id'); + } + await env.SUBSCRIPTIONS_KV.put( + KEY_PREFIX + subscription.id, + JSON.stringify(subscription) + ); + + const ids = await listIds(env); + if (!ids.includes(subscription.id)) { + ids.push(subscription.id); + await writeIds(env, ids); + } + return subscription; +} + +/** + * 批量保存订阅(用于自动续订一次更新多条等场景)。 + * + * 顺序保留;并发写入单条,最后统一写一次索引。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {Object[]} subs + */ +export async function saveMany(env, subs) { + if (!Array.isArray(subs) || subs.length === 0) return; + await Promise.all( + subs.map((s) => env.SUBSCRIPTIONS_KV.put(KEY_PREFIX + s.id, JSON.stringify(s))) + ); + const idsExisting = await listIds(env); + const set = new Set(idsExisting); + for (const s of subs) { + if (typeof s.id === 'string') set.add(s.id); + } + if (set.size !== idsExisting.length) { + await writeIds(env, Array.from(set)); + } +} + +/** + * 删除一条订阅(同时从索引中移除)。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {string} id + * @returns {Promise} true 表示确实存在并已删除 + */ +export async function deleteById(env, id) { + if (!id) return false; + const before = await env.SUBSCRIPTIONS_KV.get(KEY_PREFIX + id); + if (!before) { + // 索引里可能仍有残留,顺手清掉 + const ids = await listIds(env); + if (ids.includes(id)) { + await writeIds(env, ids.filter((x) => x !== id)); + } + return false; + } + await env.SUBSCRIPTIONS_KV.delete(KEY_PREFIX + id); + const ids = await listIds(env); + if (ids.includes(id)) { + await writeIds(env, ids.filter((x) => x !== id)); + } + return true; +} + +/** + * 整个仓库覆盖(用于迁移、导入等场景)。 + * + * 流程:先清旧的 sub:{id}(按当前索引),再写新数据。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {Object[]} subs + */ +export async function replaceAll(env, subs) { + const oldIds = await listIds(env); + await Promise.all( + oldIds.map((id) => env.SUBSCRIPTIONS_KV.delete(KEY_PREFIX + id)) + ); + if (Array.isArray(subs) && subs.length > 0) { + await Promise.all( + subs.map((s) => + env.SUBSCRIPTIONS_KV.put(KEY_PREFIX + s.id, JSON.stringify(s)) + ) + ); + await writeIds(env, subs.map((s) => s.id)); + } else { + await writeIds(env, []); + } +} diff --git a/src/index.js b/src/index.js index 5a50e265..6fea93ce 100644 --- a/src/index.js +++ b/src/index.js @@ -1,45 +1,40 @@ -import { handleApiRequest } from './api/router.js'; -import { handleAdminRequest, handleLoginPage } from './api/admin.js'; -import { handleDebug } from './api/debug.js'; -import { getCurrentTimeInTimezone } from './core/time.js'; +// @ts-check +/** + * Worker 入口 + * + * fetch handler 委托给 Hono 应用(src/app.js)。 + * scheduled handler 触发定时任务执行。 + * + */ + +import app from './app.js'; +import { ensureMigrations } from './data/migrate.js'; import { checkExpiringSubscriptions } from './services/scheduler.js'; -import { getUserFromRequest } from './api/handlers/auth.js'; export default { - async fetch(request, env, ctx) { - const url = new URL(request.url); - - if (url.pathname === '/') { - const { user } = await getUserFromRequest(request, env); - if (user) { - return new Response('', { - status: 302, - headers: { Location: '/admin' } - }); - } - return handleLoginPage(); - } else if (url.pathname === '/debug') { - // 调试页必须登录后才能访问,避免泄露系统信息 - const { user } = await getUserFromRequest(request, env); - if (!user) { - return new Response('未授权访问', { - status: 401, - headers: { 'Content-Type': 'text/plain; charset=utf-8' } - }); - } - return handleDebug(request, env); - } else if (url.pathname.startsWith('/api')) { - return handleApiRequest(request, env); - } else if (url.pathname.startsWith('/admin')) { - return handleAdminRequest(request, env, ctx); - } else { - return handleLoginPage(); - } - }, + fetch: app.fetch, + /** + * 每小时由 Cron 触发一次。 + * + * @param {ScheduledEvent} event + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {ExecutionContext} ctx + */ async scheduled(event, env, ctx) { - const currentTime = getCurrentTimeInTimezone('UTC'); - console.log('[Workers] 定时任务触发', 'cron:', event?.cron || '(unknown)', 'UTC:', new Date().toISOString(), 'runtime:', currentTime.toISOString()); + void ctx; + try { + await ensureMigrations(env); + } catch (err) { + console.error('[index] scheduled 迁移失败:', err); + } + console.log( + '[Workers] 定时任务触发', + 'cron:', + event?.cron || '(unknown)', + 'UTC:', + new Date().toISOString() + ); await checkExpiringSubscriptions(env); } }; diff --git a/src/services/notify/bark.js b/src/services/notify/bark.js index f0578587..6760fadd 100644 --- a/src/services/notify/bark.js +++ b/src/services/notify/bark.js @@ -1,40 +1,87 @@ -async function sendBarkNotification(title, content, config) { - try { - if (!config.BARK_DEVICE_KEY) { - console.error('[Bark] 通知未配置,缺少设备Key'); - return false; +// @ts-check +/** + * Bark 通知渠道(iOS) + * + * 支持两种 URL 模式: + * 1. 标准:BARK_SERVER + BARK_DEVICE_KEY → POST {server}/push + * 2. 自定义:BARK_SERVER 路径不为 / → POST {server}(已含 key 的完整 URL) + */ +import { ok, fail, errorMessage, stripMarkdown } from './channel.js'; + +/** @type {import('./channel.js').Channel} */ +export const barkChannel = { + name: 'bark', + + validateConfig(config) { + if (!config.BARK_SERVER && !config.BARK_DEVICE_KEY) { + return { ok: false, error: '缺少 BARK_DEVICE_KEY 或 BARK_SERVER' }; } + return { ok: true }; + }, + + async send(payload, config) { + const v = barkChannel.validateConfig(config); + if (!v.ok) return fail('bark', v.error || '配置无效'); - console.log('[Bark] 开始发送通知到设备: ' + config.BARK_DEVICE_KEY); + const serverUrl = (config.BARK_SERVER || 'https://api.day.app').replace(/\/+$/, ''); - const serverUrl = config.BARK_SERVER || 'https://api.day.app'; - const url = serverUrl + '/push'; - const payload = { - title: title, - body: content, - device_key: config.BARK_DEVICE_KEY - }; + let url, /** @type {Record} */ headers = { 'Content-Type': 'application/json; charset=utf-8' }, /** @type {Record} */ body; + try { + const parsed = new URL(serverUrl); + const isCustomUrl = parsed.pathname && parsed.pathname !== '/'; - if (config.BARK_IS_ARCHIVE === 'true') { - payload.isArchive = 1; + // Extract Basic Auth credentials if present + if (parsed.username) { + const credentials = `${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password || '')}`; + headers['Authorization'] = `Basic ${btoa(credentials)}`; + parsed.username = ''; + parsed.password = ''; + } + + if (isCustomUrl) { + url = parsed.href.replace(/\/+$/, ''); + body = { title: payload.title, body: stripMarkdown(payload.content) }; + } else { + if (!config.BARK_DEVICE_KEY) return fail('bark', '标准 Bark API 缺少 BARK_DEVICE_KEY'); + url = `${parsed.href.replace(/\/+$/, '')}/push`; + body = { + title: payload.title, + body: stripMarkdown(payload.content), + device_key: config.BARK_DEVICE_KEY + }; + } + } catch { + return fail('bark', `BARK_SERVER 不是合法 URL: ${serverUrl}`); } - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json; charset=utf-8' - }, - body: JSON.stringify(payload) - }); - - const result = await response.json(); - console.log('[Bark] 发送结果:', result); - - return result.code === 200; - } catch (error) { - console.error('[Bark] 发送通知失败:', error); - return false; + if (config.BARK_IS_ARCHIVE === 'true') body.isArchive = 1; + + try { + const r = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body) + }); + const result = await r.json().catch(() => ({})); + return result && result.code === 200 + ? ok('bark', result) + : fail('bark', `Bark 返回 code=${result?.code}`, result); + } catch (err) { + return fail('bark', errorMessage(err)); + } + }, + + async test(config) { + return barkChannel.send( + { title: '订阅管理 - 测试通知', content: '这是一条 Bark 测试通知。' }, + config + ); } -} +}; -export { sendBarkNotification }; +/** @deprecated 旧版兼容函数 */ +export async function sendBarkNotification(title, content, config) { + const r = await barkChannel.send({ title, content }, config); + if (!r.success) console.error('[Bark]', r.error); + return r.success; +} diff --git a/src/services/notify/channel.js b/src/services/notify/channel.js new file mode 100644 index 00000000..0ba48f63 --- /dev/null +++ b/src/services/notify/channel.js @@ -0,0 +1,108 @@ +// @ts-check +/** + * 通知渠道统一适配器协议 + * + * 所有渠道实现均遵循此 shape: + * + * export const xxxChannel = { + * name: 'xxx', // 渠道名(必须与 ENABLED_NOTIFIERS / 配置 key 保持一致) + * validateConfig(config) { ... }, // 返回 { ok: true } 或 { ok: false, error: '...' } + * async send(payload, config) { ... },// 实际发送 + * async test(config) { ... } // 用配置发送一条测试通知 + * }; + * + * 这样 dispatch / 通知日志 / 配置页测试按钮可以用同一份代码处理 9 种渠道。 + * + */ + +/** + * @typedef {Object} ChannelPayload 通知内容载荷 + * @property {string} title 标题 + * @property {string} content 正文(Markdown 风格,由各渠道按需转换) + * @property {Object} [metadata] 元数据(tags、subscriptionId 等) + */ + +/** + * @typedef {Object} ChannelResult 渠道发送结果 + * @property {boolean} success 是否成功 + * @property {string} channel 渠道名 + * @property {string} [error] 失败原因(HTTP 错误、API 拒绝等) + * @property {any} [raw] 三方接口原始返回,便于排查 + */ + +/** + * @typedef {Object} ValidateResult + * @property {boolean} ok + * @property {string} [error] + */ + +/** + * @typedef {Object} Channel + * @property {string} name + * @property {(config: any) => ValidateResult} validateConfig + * @property {(payload: ChannelPayload, config: any) => Promise} send + * @property {(config: any) => Promise} test + */ + +/** + * Telegram MarkdownV2 转义。 + * + * 修复 #81:订阅名包含 `_*[]()~`>#+-=|{}.!\\` 时 Telegram 会拒绝消息。 + * 注意:这个函数只用于发往 Telegram 的内容,不要污染其他渠道。 + * + * @param {string} text + * @returns {string} + */ +export function escapeMarkdownV2(text = '') { + return String(text).replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1'); +} + +/** + * 把 commonContent 中的 Markdown 标记移除(用于纯文本渠道)。 + * + * @param {string} text + * @returns {string} + */ +export function stripMarkdown(text = '') { + return String(text).replace(/(\*\*|\*|##|#|`)/g, ''); +} + +/** + * 构造统一的成功结果。 + * + * @param {string} name + * @param {any} [raw] + * @returns {ChannelResult} + */ +export function ok(name, raw) { + return { success: true, channel: name, raw }; +} + +/** + * 构造统一的失败结果。 + * + * @param {string} name + * @param {string} error + * @param {any} [raw] + * @returns {ChannelResult} + */ +export function fail(name, error, raw) { + return { success: false, channel: name, error, raw }; +} + +/** + * 把任意 error 转字符串(兼容 fetch 抛出的 TypeError、Response 等)。 + * + * @param {any} err + * @returns {string} + */ +export function errorMessage(err) { + if (!err) return 'unknown error'; + if (typeof err === 'string') return err; + if (err.message) return String(err.message); + try { + return JSON.stringify(err); + } catch { + return String(err); + } +} diff --git a/src/services/notify/dispatch.js b/src/services/notify/dispatch.js new file mode 100644 index 00000000..a20ba6cb --- /dev/null +++ b/src/services/notify/dispatch.js @@ -0,0 +1,161 @@ +// @ts-check +/** + * 通知调度器:把一条通知并发分发到所有启用的渠道,并把结果写入通知日志。 + * + * 调用方: + * - services/scheduler.js(定时到期检查) + * - api/handlers/test-notification.js(手动测试单个渠道) + * - api/handlers/notify.js(第三方 /api/notify/{token}) + * + */ + +import { telegramChannel } from './telegram.js'; +import { notifyxChannel } from './notifyx.js'; +import { webhookChannel } from './webhook.js'; +import { wecomChannel } from './wechat.js'; +import { emailChannel } from './email.js'; +import { barkChannel } from './bark.js'; +import { gotifyChannel } from './gotify.js'; +import { serverChanChannel } from './serverchan.js'; +import { pushplusChannel } from './pushplus.js'; +import { writeLog } from '../../data/notification-logs.repo.js'; + +/** 名字到渠道实例的映射;新增渠道在此注册即可 */ +export const ALL_CHANNELS = { + telegram: telegramChannel, + notifyx: notifyxChannel, + webhook: webhookChannel, + wechatbot: wecomChannel, + email: emailChannel, + bark: barkChannel, + gotify: gotifyChannel, + serverchan: serverChanChannel, + pushplus: pushplusChannel +}; + +/** + * @typedef {Object} DispatchOptions + * @property {any} [env] 若提供,会同时把每条结果写入 notify_log + * @property {string} [subId] 关联的订阅 ID(写日志用) + * @property {string} [ruleId] 触发的提醒规则 ID(写日志用) + * @property {Object} [metadata] 附加给 channel.send 的 metadata + * @property {string} [logPrefix] console 日志前缀 + */ + +/** + * 把一条通知发到所有启用渠道。 + * + * @param {{ title: string, content: string }} payload + * @param {any} config 系统配置(含 ENABLED_NOTIFIERS 与各渠道字段) + * @param {DispatchOptions} [options] + * @returns {Promise<{ + * attempted: number, + * successCount: number, + * failedCount: number, + * results: import('./channel.js').ChannelResult[], + * channelResults: Record + * }>} + */ +export async function dispatch(payload, config, options = {}) { + const enabled = Array.isArray(config.ENABLED_NOTIFIERS) ? config.ENABLED_NOTIFIERS : []; + const prefix = options.logPrefix || '[notify]'; + + const channels = enabled + .map((name) => ALL_CHANNELS[name]) + .filter((ch) => ch != null); + + if (channels.length === 0) { + console.log(`${prefix} 未启用任何通知渠道`); + return { attempted: 0, successCount: 0, failedCount: 0, results: [], channelResults: {} }; + } + + const settled = await Promise.allSettled( + channels.map((ch) => + ch.send({ ...payload, metadata: options.metadata }, config).catch((err) => ({ + success: false, + channel: ch.name, + error: err && err.message ? err.message : String(err) + })) + ) + ); + + /** @type {import('./channel.js').ChannelResult[]} */ + const results = settled.map((r, idx) => { + if (r.status === 'fulfilled') { + return /** @type {any} */ (r.value); + } + return { + success: false, + channel: channels[idx].name, + error: r.reason && r.reason.message ? r.reason.message : String(r.reason) + }; + }); + + /** @type {Record} */ + const channelResults = {}; + let successCount = 0; + let failedCount = 0; + for (const r of results) { + channelResults[r.channel] = r.success; + if (r.success) { + successCount++; + console.log(`${prefix} ${r.channel} 发送成功`); + } else { + failedCount++; + console.log(`${prefix} ${r.channel} 发送失败: ${r.error}`); + } + + // 写通知日志(带 env 时) + if (options.env && options.subId) { + try { + await writeLog(options.env, { + subId: options.subId, + ruleId: options.ruleId || null, + channel: r.channel, + status: r.success ? 'success' : 'failed', + title: payload.title, + content: payload.content, + error: r.error, + raw: r.raw + }); + } catch (err) { + console.warn(`${prefix} 写通知日志失败:`, err); + } + } + } + + return { + attempted: results.length, + successCount, + failedCount, + results, + channelResults + }; +} + +/** + * 测试某个渠道(用于配置页"测试发送"按钮)。 + * + * @param {string} channelName + * @param {any} config + * @returns {Promise} + */ +export async function testChannel(channelName, config) { + const ch = ALL_CHANNELS[channelName]; + if (!ch) { + return { + success: false, + channel: channelName, + error: `未知渠道: ${channelName}` + }; + } + try { + return await ch.test(config); + } catch (err) { + return { + success: false, + channel: channelName, + error: err && err.message ? err.message : String(err) + }; + } +} diff --git a/src/services/notify/email.js b/src/services/notify/email.js index 22e11fda..275b70b6 100644 --- a/src/services/notify/email.js +++ b/src/services/notify/email.js @@ -1,85 +1,96 @@ -import { formatTimeInTimezone } from '../../core/time.js'; +// @ts-check +/** + * 邮件通知渠道(Resend API) + */ +import { ok, fail, errorMessage, stripMarkdown } from './channel.js'; +import { formatLocalDate } from '../../core/time.js'; -async function sendEmailNotification(title, content, config) { - try { - if (!config.RESEND_API_KEY || !config.EMAIL_FROM || !config.EMAIL_TO) { - console.error('[邮件通知] 通知未配置,缺少必要参数'); - return false; - } - - console.log('[邮件通知] 开始发送邮件到: ' + config.EMAIL_TO); - - const htmlContent = ` - - - - - - ${title} - - +/** + * 构造 HTML 模板。 + * @param {string} title + * @param {string} content + * @param {string} timezone + */ +function buildHtml(title, content, timezone) { + const safe = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c] || c)); + const ts = formatLocalDate(new Date(), timezone || 'UTC', 'datetime'); + return ` +${safe(title)} + -
-
-

📅 ${title}

-
-
-
- ${content.replace(/\n/g, '
')} -
-

此邮件由订阅管理系统自动发送,请及时处理相关订阅事务。

-
- -
- -`; +
+

📅 ${safe(title)}

+
+
${safe(stripMarkdown(content)).replace(/\n/g, '
')}
+

此邮件由订阅管理系统自动发送,请及时处理相关订阅事务。

+
+ +
+`; +} - const fromEmail = config.EMAIL_FROM_NAME ? - `${config.EMAIL_FROM_NAME} <${config.EMAIL_FROM}>` : - config.EMAIL_FROM; +/** @type {import('./channel.js').Channel} */ +export const emailChannel = { + name: 'email', - const response = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${config.RESEND_API_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - from: fromEmail, - to: config.EMAIL_TO, - subject: title, - html: htmlContent, - text: content - }) - }); + validateConfig(config) { + if (!config.RESEND_API_KEY) return { ok: false, error: '缺少 RESEND_API_KEY' }; + if (!config.EMAIL_FROM) return { ok: false, error: '缺少 EMAIL_FROM' }; + if (!config.EMAIL_TO) return { ok: false, error: '缺少 EMAIL_TO' }; + return { ok: true }; + }, - const result = await response.json(); - console.log('[邮件通知] 发送结果:', response.status, result); + async send(payload, config) { + const v = emailChannel.validateConfig(config); + if (!v.ok) return fail('email', v.error || '配置无效'); - if (response.ok && result.id) { - console.log('[邮件通知] 邮件发送成功,ID:', result.id); - return true; - } else { - console.error('[邮件通知] 邮件发送失败:', result); - return false; + const fromEmail = config.EMAIL_FROM_NAME + ? `${config.EMAIL_FROM_NAME} <${config.EMAIL_FROM}>` + : config.EMAIL_FROM; + + try { + const r = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${config.RESEND_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + from: fromEmail, + to: config.EMAIL_TO, + subject: payload.title, + html: buildHtml(payload.title, payload.content, config.TIMEZONE), + text: stripMarkdown(payload.content) + }) + }); + const result = await r.json().catch(() => ({})); + return r.ok && result && result.id + ? ok('email', result) + : fail('email', result?.message || `HTTP ${r.status}`, result); + } catch (err) { + return fail('email', errorMessage(err)); } - } catch (error) { - console.error('[邮件通知] 发送邮件失败:', error); - return false; + }, + + async test(config) { + return emailChannel.send( + { title: '订阅管理 - 测试通知', content: '这是一条邮件测试通知。' }, + config + ); } -} +}; -export { sendEmailNotification }; +/** @deprecated 旧版兼容函数 */ +export async function sendEmailNotification(title, content, config) { + const r = await emailChannel.send({ title, content }, config); + if (!r.success) console.error('[Email]', r.error); + return r.success; +} diff --git a/src/services/notify/gotify.js b/src/services/notify/gotify.js index 26bee753..9e9e96b3 100644 --- a/src/services/notify/gotify.js +++ b/src/services/notify/gotify.js @@ -1,42 +1,59 @@ -async function sendGotifyNotification(title, content, config) { - try { - const serverUrl = (config.GOTIFY_SERVER_URL || '').trim(); - const token = (config.GOTIFY_APP_TOKEN || '').trim(); +// @ts-check +/** + * Gotify 通知渠道(自托管) + */ +import { ok, fail, errorMessage, stripMarkdown } from './channel.js'; - if (!serverUrl || !token) { - console.log('[Gotify] 未配置 GOTIFY_SERVER_URL 或 GOTIFY_APP_TOKEN'); - return false; - } +/** @type {import('./channel.js').Channel} */ +export const gotifyChannel = { + name: 'gotify', - const url = serverUrl.replace(/\/+$/, '') + '/message?token=' + encodeURIComponent(token); + validateConfig(config) { + if (!config.GOTIFY_SERVER_URL) return { ok: false, error: '缺少 GOTIFY_SERVER_URL' }; + if (!config.GOTIFY_APP_TOKEN) return { ok: false, error: '缺少 GOTIFY_APP_TOKEN' }; + return { ok: true }; + }, - const payload = { - title: title || '通知', - message: content || '', - priority: 5 - }; + async send(payload, config) { + const v = gotifyChannel.validateConfig(config); + if (!v.ok) return fail('gotify', v.error || '配置无效'); - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload) - }); + const url = + String(config.GOTIFY_SERVER_URL).replace(/\/+$/, '') + + '/message?token=' + + encodeURIComponent(String(config.GOTIFY_APP_TOKEN)); - if (!response.ok) { - const text = await response.text().catch(() => ''); - console.error('[Gotify] 请求失败:', response.status, text); - return false; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: payload.title || '通知', + message: stripMarkdown(payload.content) || '', + priority: 5 + }) + }); + if (!r.ok) { + const text = await r.text().catch(() => ''); + return fail('gotify', `HTTP ${r.status}`, text); + } + return ok('gotify'); + } catch (err) { + return fail('gotify', errorMessage(err)); } + }, - return true; - } catch (error) { - console.error('[Gotify] 发送失败:', error); - return false; + async test(config) { + return gotifyChannel.send( + { title: '订阅管理 - 测试通知', content: '这是一条 Gotify 测试通知。' }, + config + ); } -} - -export { - sendGotifyNotification }; + +/** @deprecated 旧版兼容函数 */ +export async function sendGotifyNotification(title, content, config) { + const r = await gotifyChannel.send({ title, content }, config); + if (!r.success) console.error('[Gotify]', r.error); + return r.success; +} diff --git a/src/services/notify/index.js b/src/services/notify/index.js index 468ba995..ca984450 100644 --- a/src/services/notify/index.js +++ b/src/services/notify/index.js @@ -1,103 +1,46 @@ -import { sendNotifyXNotification } from './notifyx.js'; -import { sendTelegramNotification } from './telegram.js'; -import { sendWebhookNotification } from './webhook.js'; -import { sendWechatBotNotification } from './wechat.js'; -import { sendEmailNotification } from './email.js'; -import { sendBarkNotification } from './bark.js'; -import { sendGotifyNotification } from './gotify.js'; -import { sendServerChanNotification } from './serverchan.js'; -import { sendPushPlusNotification } from './pushplus.js'; +// @ts-check +/** + * 通知调度入口 + * + * 旧 sendNotificationToAllChannels 现在是 dispatch.dispatch 的薄壳, + * 保留签名向后兼容。新代码请直接使用 dispatch / testChannel。 + * + */ +import { dispatch } from './dispatch.js'; -async function sendNotificationToAllChannels(title, commonContent, config, logPrefix = '[定时任务]', options = {}) { - const metadata = options.metadata || {}; - const enabledNotifiers = Array.isArray(config.ENABLED_NOTIFIERS) ? config.ENABLED_NOTIFIERS : []; - const result = { - attempted: 0, - successCount: 0, - failedCount: 0, - channelResults: {} - }; - - if (enabledNotifiers.length === 0) { - console.log(`${logPrefix} 未启用任何通知渠道。`); - return result; - } - - if (enabledNotifiers.includes('notifyx')) { - result.attempted += 1; - const notifyxContent = `## ${title}\n\n${commonContent}`; - const success = await sendNotifyXNotification(title, notifyxContent, `订阅提醒`, config); - result.channelResults.notifyx = success; - success ? result.successCount++ : result.failedCount++; - console.log(`${logPrefix} 发送NotifyX通知 ${success ? '成功' : '失败'}`); - } - if (enabledNotifiers.includes('telegram')) { - result.attempted += 1; - const telegramContent = `*${title}*\n\n${commonContent}`; - const success = await sendTelegramNotification(telegramContent, config); - result.channelResults.telegram = success; - success ? result.successCount++ : result.failedCount++; - console.log(`${logPrefix} 发送Telegram通知 ${success ? '成功' : '失败'}`); - } - if (enabledNotifiers.includes('webhook')) { - result.attempted += 1; - const webhookContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); - const success = await sendWebhookNotification(title, webhookContent, config, metadata); - result.channelResults.webhook = success; - success ? result.successCount++ : result.failedCount++; - console.log(`${logPrefix} 发送Webhook通知 ${success ? '成功' : '失败'}`); - } - if (enabledNotifiers.includes('wechatbot')) { - result.attempted += 1; - const wechatbotContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); - const success = await sendWechatBotNotification(title, wechatbotContent, config); - result.channelResults.wechatbot = success; - success ? result.successCount++ : result.failedCount++; - console.log(`${logPrefix} 发送企业微信机器人通知 ${success ? '成功' : '失败'}`); - } - if (enabledNotifiers.includes('email')) { - result.attempted += 1; - const emailContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); - const success = await sendEmailNotification(title, emailContent, config); - result.channelResults.email = success; - success ? result.successCount++ : result.failedCount++; - console.log(`${logPrefix} 发送邮件通知 ${success ? '成功' : '失败'}`); - } - if (enabledNotifiers.includes('bark')) { - result.attempted += 1; - const barkContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); - const success = await sendBarkNotification(title, barkContent, config); - result.channelResults.bark = success; - success ? result.successCount++ : result.failedCount++; - console.log(`${logPrefix} 发送Bark通知 ${success ? '成功' : '失败'}`); - } +/** + * @param {string} title + * @param {string} commonContent + * @param {any} config + * @param {string} [logPrefix='[定时任务]'] + * @param {{ env?: any, subId?: string, ruleId?: string, metadata?: Object }} [options] + */ +export async function sendNotificationToAllChannels( + title, + commonContent, + config, + logPrefix = '[定时任务]', + options = {} +) { + const result = await dispatch( + { title, content: commonContent }, + config, + { + logPrefix, + env: options.env, + subId: options.subId, + ruleId: options.ruleId, + metadata: options.metadata + } + ); - if (enabledNotifiers.includes('gotify')) { - result.attempted += 1; - const gotifyContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); - const success = await sendGotifyNotification(title, gotifyContent, config); - result.channelResults.gotify = success; - success ? result.successCount++ : result.failedCount++; - console.log(`${logPrefix} 发送Gotify通知 ${success ? '成功' : '失败'}`); - } - if (enabledNotifiers.includes('serverchan')) { - result.attempted += 1; - const success = await sendServerChanNotification(title, commonContent, config); - result.channelResults.serverchan = success; - success ? result.successCount++ : result.failedCount++; - console.log(`${logPrefix} 发送Server酱通知 ${success ? '成功' : '失败'}`); - } - if (enabledNotifiers.includes('pushplus')) { - result.attempted += 1; - const success = await sendPushPlusNotification(title, commonContent, config); - result.channelResults.pushplus = success; - success ? result.successCount++ : result.failedCount++; - console.log(`${logPrefix} 发送PushPlus通知 ${success ? '成功' : '失败'}`); - } - - return result; + // 旧调用方期望的字段名 + return { + attempted: result.attempted, + successCount: result.successCount, + failedCount: result.failedCount, + channelResults: result.channelResults + }; } -export { - sendNotificationToAllChannels -}; +export { dispatch, testChannel } from './dispatch.js'; diff --git a/src/services/notify/notifyx.js b/src/services/notify/notifyx.js index a0296c42..bee54662 100644 --- a/src/services/notify/notifyx.js +++ b/src/services/notify/notifyx.js @@ -1,30 +1,57 @@ -async function sendNotifyXNotification(title, content, description, config) { - try { - if (!config.NOTIFYX_API_KEY) { - console.error('[NotifyX] 通知未配置,缺少API Key'); - return false; - } +// @ts-check +/** + * NotifyX 通知渠道(https://www.notifyx.cn) + */ +import { ok, fail, errorMessage } from './channel.js'; + +/** @type {import('./channel.js').Channel} */ +export const notifyxChannel = { + name: 'notifyx', - console.log('[NotifyX] 开始发送通知: ' + title); + validateConfig(config) { + if (!config.NOTIFYX_API_KEY) return { ok: false, error: '缺少 NOTIFYX_API_KEY' }; + return { ok: true }; + }, - const url = 'https://www.notifyx.cn/api/v1/send/' + config.NOTIFYX_API_KEY; - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: title, - content: content, - description: description || '' - }) + async send(payload, config) { + const v = notifyxChannel.validateConfig(config); + if (!v.ok) return fail('notifyx', v.error || '配置无效'); + + const url = `https://www.notifyx.cn/api/v1/send/${config.NOTIFYX_API_KEY}`; + const body = JSON.stringify({ + title: payload.title || '订阅提醒', + content: `## ${payload.title || '订阅提醒'}\n\n${payload.content || ''}`, + description: '订阅提醒' }); - const result = await response.json(); - console.log('[NotifyX] 发送结果:', result); - return result.status === 'queued'; - } catch (error) { - console.error('[NotifyX] 发送通知失败:', error); - return false; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + }); + const result = await r.json(); + return result.status === 'queued' + ? ok('notifyx', result) + : fail('notifyx', `NotifyX 返回 ${result.status || 'unknown'}`, result); + } catch (err) { + return fail('notifyx', errorMessage(err)); + } + }, + + async test(config) { + return notifyxChannel.send( + { title: '订阅管理 - 测试通知', content: '这是一条 NotifyX 测试通知。' }, + config + ); } -} +}; -export { sendNotifyXNotification }; +/** @deprecated 旧版兼容函数 */ +export async function sendNotifyXNotification(title, content, _description, config) { + // 早期接口签名带一个 description 参数;新接口已弃用此字段 + void _description; + const r = await notifyxChannel.send({ title, content }, config); + if (!r.success) console.error('[NotifyX]', r.error); + return r.success; +} diff --git a/src/services/notify/pushplus.js b/src/services/notify/pushplus.js index ea8055a0..99a0b019 100644 --- a/src/services/notify/pushplus.js +++ b/src/services/notify/pushplus.js @@ -1,40 +1,58 @@ -async function sendPushPlusNotification(title, content, config) { - try { - if (!config.PUSHPLUS_TOKEN) { - console.error('[PushPlus] 通知未配置,缺少Token'); - return false; - } +// @ts-check +/** + * PushPlus 通知渠道 + */ +import { ok, fail, errorMessage } from './channel.js'; + +/** @type {import('./channel.js').Channel} */ +export const pushplusChannel = { + name: 'pushplus', - console.log('[PushPlus] 开始发送通知: ' + title); + validateConfig(config) { + if (!config.PUSHPLUS_TOKEN) return { ok: false, error: '缺少 PUSHPLUS_TOKEN' }; + return { ok: true }; + }, - const payload = { + async send(payload, config) { + const v = pushplusChannel.validateConfig(config); + if (!v.ok) return fail('pushplus', v.error || '配置无效'); + + /** @type {Record} */ + const body = { token: config.PUSHPLUS_TOKEN, - title, - content: `## ${title}\n\n${content}`, + title: payload.title || '订阅提醒', + content: `## ${payload.title || '订阅提醒'}\n\n${payload.content || ''}`, template: 'markdown' }; + if (config.PUSHPLUS_TOPIC) body.topic = config.PUSHPLUS_TOPIC; + if (config.PUSHPLUS_CHANNEL) body.channel = config.PUSHPLUS_CHANNEL; - if (config.PUSHPLUS_TOPIC) { - payload.topic = config.PUSHPLUS_TOPIC; - } - - if (config.PUSHPLUS_CHANNEL) { - payload.channel = config.PUSHPLUS_CHANNEL; + try { + const r = await fetch('https://www.pushplus.plus/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const result = await r.json().catch(() => ({})); + return result && result.code === 200 + ? ok('pushplus', result) + : fail('pushplus', `PushPlus 返回 code=${result?.code} ${result?.msg || ''}`, result); + } catch (err) { + return fail('pushplus', errorMessage(err)); } + }, - const response = await fetch('https://www.pushplus.plus/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - const result = await response.json(); - console.log('[PushPlus] 发送结果:', result); - return result.code === 200; - } catch (error) { - console.error('[PushPlus] 发送通知失败:', error); - return false; + async test(config) { + return pushplusChannel.send( + { title: '订阅管理 - 测试通知', content: '这是一条 PushPlus 测试通知。' }, + config + ); } -} +}; -export { sendPushPlusNotification }; +/** @deprecated 旧版兼容函数 */ +export async function sendPushPlusNotification(title, content, config) { + const r = await pushplusChannel.send({ title, content }, config); + if (!r.success) console.error('[PushPlus]', r.error); + return r.success; +} diff --git a/src/services/notify/reminder-engine.js b/src/services/notify/reminder-engine.js new file mode 100644 index 00000000..3b35b9bd --- /dev/null +++ b/src/services/notify/reminder-engine.js @@ -0,0 +1,188 @@ +// @ts-check +/** + * 提醒规则触发引擎 + * + * 给定一条规则 + 当前到期距离(天/小时)+ 上次触发时间, + * 判断"现在这一小时是否应该发出提醒"。 + * + * 设计成纯函数,与 KV / 网络解耦,便于单元测试覆盖所有边界。 + * + * 三种规则类型语义: + * - before_expiry: value 表示"提前 N 天/小时"。当 daysDiff/hoursDiff 落在 [0, value] 区间触发。 + * 特别地,value=0 等同于 on_expiry。 + * - on_expiry: 仅当到期日(daysDiff===0)触发。 + * - after_expiry: 已过期场景。每隔 repeatInterval 小时触发一次,直到达到终止条件 + * (renewed/acknowledged/never)。本引擎不关心终止判断(由 scheduler 在加载规则前过滤), + * 但会校验"距上次触发是否超过 repeatInterval"。 + * + */ + +/** + * @typedef {import('../../data/reminders.repo.js').ReminderRule} ReminderRule + */ + +/** + * @typedef {Object} FireContext + * @property {number} daysDiff 距到期天数(基于用户 TZ 零点;可为负数 = 已过期天数) + * @property {number} hoursDiff 距到期小时数(可为负数 = 已过期小时数) + * @property {string} [lastFireAtIso] 同一规则上次触发的 ISO 时间(用于 after_expiry 重复间隔) + * @property {string} [nowIso] 当前 ISO 时间,默认 new Date().toISOString() + */ + +/** + * @typedef {Object} FireDecision + * @property {boolean} fire 是否应该触发 + * @property {string} [reason] 触发或拒绝的原因(便于日志诊断) + */ + +/** + * 判断规则是否应该在"本次调度"触发。 + * + * @param {ReminderRule} rule + * @param {FireContext} ctx + * @returns {FireDecision} + */ +export function shouldFire(rule, ctx) { + if (!rule || rule.isEnabled === false) { + return { fire: false, reason: 'rule_disabled' }; + } + + const { daysDiff, hoursDiff } = ctx; + if (!Number.isFinite(daysDiff) || !Number.isFinite(hoursDiff)) { + return { fire: false, reason: 'invalid_diff' }; + } + + switch (rule.type) { + case 'before_expiry': + return decideBeforeExpiry(rule, ctx); + case 'on_expiry': + return decideOnExpiry(rule, ctx); + case 'after_expiry': + return decideAfterExpiry(rule, ctx); + default: + return { fire: false, reason: 'unknown_rule_type' }; + } +} + +/** + * @param {ReminderRule} rule + * @param {FireContext} ctx + * @returns {FireDecision} + */ +function decideBeforeExpiry(rule, ctx) { + const { daysDiff, hoursDiff } = ctx; + + if (rule.unit === 'hours') { + // hours 模式:value=0 意味着"到期当小时内" + if (rule.value === 0) { + return hoursDiff >= 0 && hoursDiff < 1 + ? { fire: true, reason: 'within_hour' } + : { fire: false, reason: 'not_within_hour' }; + } + // 其余:剩余小时刚好等于规则 value(精确触发) + if (hoursDiff < 0) return { fire: false, reason: 'already_expired' }; + if (Math.round(hoursDiff) === rule.value) { + return { fire: true, reason: `hours_diff_eq_${rule.value}` }; + } + return { fire: false, reason: `hours_diff=${hoursDiff}_not_match_${rule.value}` }; + } + + // days 模式:value=0 等同 on_expiry + if (rule.value === 0) { + return daysDiff === 0 + ? { fire: true, reason: 'days_diff_zero' } + : { fire: false, reason: `days_diff=${daysDiff}_not_zero` }; + } + // 其余:精确匹配剩余天数 + if (daysDiff === rule.value) { + return { fire: true, reason: `days_diff_eq_${rule.value}` }; + } + return { fire: false, reason: `days_diff=${daysDiff}_not_match_${rule.value}` }; +} + +/** + * @param {ReminderRule} rule + * @param {FireContext} ctx + * @returns {FireDecision} + */ +function decideOnExpiry(rule, ctx) { + void rule; + return ctx.daysDiff === 0 + ? { fire: true, reason: 'on_expiry_day' } + : { fire: false, reason: `days_diff=${ctx.daysDiff}_not_today` }; +} + +/** + * @param {ReminderRule} rule + * @param {FireContext} ctx + * @returns {FireDecision} + */ +function decideAfterExpiry(rule, ctx) { + if (ctx.daysDiff >= 0) return { fire: false, reason: 'not_expired_yet' }; + + // 没设 repeatInterval → 仅在过期当天 / 当天后某一时点触发一次(这里取每天 1 次) + // 正常用法:repeatInterval > 0 + const interval = Number.isFinite(rule.repeatInterval) && rule.repeatInterval > 0 + ? rule.repeatInterval + : 24; + + if (!ctx.lastFireAtIso) { + return { fire: true, reason: 'after_expiry_first_fire' }; + } + + const last = new Date(ctx.lastFireAtIso).getTime(); + const now = ctx.nowIso ? new Date(ctx.nowIso).getTime() : Date.now(); + if (Number.isNaN(last) || Number.isNaN(now)) { + return { fire: true, reason: 'invalid_last_fire_assume_due' }; + } + + const elapsedHours = (now - last) / (3600 * 1000); + if (elapsedHours >= interval) { + return { fire: true, reason: `after_expiry_interval_${interval}h_elapsed` }; + } + return { fire: false, reason: `after_expiry_within_${interval}h_window` }; +} + +/** + * 计算规则的下次触发时间(ISO 字符串)。 + * + * @param {ReminderRule} rule + * @param {string} expiryDateIso 订阅到期日 ISO + * @param {string} [nowIso] 当前时间 ISO(默认 now) + * @returns {string|null} 下次触发的 ISO 时间,或 null(规则已禁用/不再触发) + */ +export function getNextFireTime(rule, expiryDateIso, nowIso) { + if (!rule || rule.isEnabled === false) return null; + + const expiry = new Date(expiryDateIso).getTime(); + const now = nowIso ? new Date(nowIso).getTime() : Date.now(); + if (Number.isNaN(expiry)) return null; + + const MS_HOUR = 3600_000; + const MS_DAY = 86400_000; + + if (rule.type === 'before_expiry') { + let fireAt; + if (rule.unit === 'hours') { + fireAt = expiry - rule.value * MS_HOUR; + } else { + fireAt = expiry - rule.value * MS_DAY; + } + return fireAt >= now ? new Date(fireAt).toISOString() : null; + } + + if (rule.type === 'on_expiry') { + return expiry >= now ? new Date(expiry).toISOString() : null; + } + + if (rule.type === 'after_expiry') { + if (now < expiry) return new Date(expiry).toISOString(); + const interval = (rule.repeatInterval && rule.repeatInterval > 0) ? rule.repeatInterval : 24; + const elapsed = now - expiry; + const periods = Math.ceil(elapsed / (interval * MS_HOUR)); + const nextFire = expiry + periods * interval * MS_HOUR; + return new Date(nextFire).toISOString(); + } + + return null; +} diff --git a/src/services/notify/reminder.js b/src/services/notify/reminder.js index c9c9d7fc..7f5be8b2 100644 --- a/src/services/notify/reminder.js +++ b/src/services/notify/reminder.js @@ -1,5 +1,6 @@ import { formatTimeInTimezone, formatTimezoneDisplay } from '../../core/time.js'; import { lunarCalendar } from '../../core/lunar.js'; +import { formatAmount } from '../../core/currency-format.js'; function resolveReminderSetting(subscription) { const defaultDays = subscription && subscription.reminderDays !== undefined ? Number(subscription.reminderDays) : 7; @@ -89,13 +90,8 @@ function formatNotificationContent(subscriptions, config) { const calendarType = sub.useLunar ? '农历' : '公历'; const autoRenewText = sub.autoRenew ? '是' : '否'; - const currencySymbols = { - CNY: '¥', USD: '$', HKD: 'HK$', TWD: 'NT$', - JPY: '¥', EUR: '€', GBP: '£', KRW: '₩', TRY: '₺' - }; - const amountConfigured = sub.amount !== null && sub.amount !== undefined && !Number.isNaN(Number(sub.amount)); - const amountCurrency = currencySymbols[sub.currency || 'CNY'] || '¥'; - const amountText = amountConfigured ? `\n金额: ${amountCurrency}${Number(sub.amount).toFixed(2)}/周期` : ''; + const formattedAmount = formatAmount(sub.amount, sub.currency || 'CNY'); + const amountText = formattedAmount ? `\n金额: ${formattedAmount}/周期` : ''; const subscriptionContent = `${statusEmoji} **${sub.name}** 类型: ${typeText} ${periodText} diff --git a/src/services/notify/serverchan.js b/src/services/notify/serverchan.js index e6c1c5ac..0e04b7c5 100644 --- a/src/services/notify/serverchan.js +++ b/src/services/notify/serverchan.js @@ -1,31 +1,54 @@ -async function sendServerChanNotification(title, content, config) { - try { - if (!config.SERVERCHAN_SENDKEY) { - console.error('[Server酱] 通知未配置,缺少SendKey'); - return false; - } +// @ts-check +/** + * Server酱 3 通知渠道 + */ +import { ok, fail, errorMessage } from './channel.js'; + +/** @type {import('./channel.js').Channel} */ +export const serverChanChannel = { + name: 'serverchan', - console.log('[Server酱] 开始发送通知: ' + title); + validateConfig(config) { + if (!config.SERVERCHAN_SENDKEY) return { ok: false, error: '缺少 SERVERCHAN_SENDKEY' }; + return { ok: true }; + }, - const endpoint = 'https://sctapi.ftqq.com/' + config.SERVERCHAN_SENDKEY + '.send'; + async send(payload, config) { + const v = serverChanChannel.validateConfig(config); + if (!v.ok) return fail('serverchan', v.error || '配置无效'); + + const endpoint = `https://sctapi.ftqq.com/${config.SERVERCHAN_SENDKEY}.send`; const body = new URLSearchParams({ - title, - desp: `## ${title}\n\n${content}` + title: payload.title || '订阅提醒', + desp: `## ${payload.title || '订阅提醒'}\n\n${payload.content || ''}` }); - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body.toString() - }); + try { + const r = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString() + }); + const result = await r.json().catch(() => ({})); + return result && result.code === 0 + ? ok('serverchan', result) + : fail('serverchan', `Server酱返回 code=${result?.code} ${result?.message || ''}`, result); + } catch (err) { + return fail('serverchan', errorMessage(err)); + } + }, - const result = await response.json(); - console.log('[Server酱] 发送结果:', result); - return result.code === 0; - } catch (error) { - console.error('[Server酱] 发送通知失败:', error); - return false; + async test(config) { + return serverChanChannel.send( + { title: '订阅管理 - 测试通知', content: '这是一条 Server酱 测试通知。' }, + config + ); } -} +}; -export { sendServerChanNotification }; +/** @deprecated 旧版兼容函数 */ +export async function sendServerChanNotification(title, content, config) { + const r = await serverChanChannel.send({ title, content }, config); + if (!r.success) console.error('[Server酱]', r.error); + return r.success; +} diff --git a/src/services/notify/telegram.js b/src/services/notify/telegram.js index a02b2230..70df0adb 100644 --- a/src/services/notify/telegram.js +++ b/src/services/notify/telegram.js @@ -1,52 +1,90 @@ -function escapeMarkdownV2(text = '') { - return String(text).replace(/([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g, '\\$1'); -} - -async function sendTelegramNotification(message, config) { - try { - if (!config.TG_BOT_TOKEN || !config.TG_CHAT_ID) { - console.error('[Telegram] 通知未配置,缺少Bot Token或Chat ID'); - return false; - } +// @ts-check +/** + * Telegram 通知渠道 + * + * 接口:MarkdownV2 + 失败时降级纯文本兜底。 + * 关键修复(#81):订阅名含 `_*` 等特殊字符时不再炸。 + */ +import { escapeMarkdownV2, ok, fail, errorMessage } from './channel.js'; - console.log('[Telegram] 开始发送通知到 Chat ID: ' + config.TG_CHAT_ID); +/** @type {import('./channel.js').Channel} */ +export const telegramChannel = { + name: 'telegram', - const url = 'https://api.telegram.org/bot' + config.TG_BOT_TOKEN + '/sendMessage'; - const escapedMessage = escapeMarkdownV2(message); + validateConfig(config) { + if (!config.TG_BOT_TOKEN) return { ok: false, error: '缺少 TG_BOT_TOKEN' }; + if (!config.TG_CHAT_ID) return { ok: false, error: '缺少 TG_CHAT_ID' }; + return { ok: true }; + }, - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chat_id: config.TG_CHAT_ID, - text: escapedMessage, - parse_mode: 'MarkdownV2' - }) - }); + async send(payload, config) { + const v = telegramChannel.validateConfig(config); + if (!v.ok) return fail('telegram', v.error || '配置无效'); - const result = await response.json(); + const url = `https://api.telegram.org/bot${config.TG_BOT_TOKEN}/sendMessage`; + const fullText = payload.title + ? `*${payload.title}*\n\n${payload.content}` + : String(payload.content || ''); + const escaped = escapeMarkdownV2(fullText); - // 兜底:如果 MarkdownV2 仍失败,降级纯文本再发一次 - if (!result.ok && result.description && result.description.includes('parse entities')) { - const fallbackResponse = await fetch(url, { + try { + const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: config.TG_CHAT_ID, - text: String(message) + text: escaped, + parse_mode: 'MarkdownV2' }) }); - const fallbackResult = await fallbackResponse.json(); - console.log('[Telegram] 发送结果(纯文本兜底):', fallbackResult); - return fallbackResult.ok; + const result = await r.json(); + + if (result.ok) return ok('telegram', result); + + // 兜底:MarkdownV2 仍解析失败时降级纯文本 + if (result.description && /parse entities/i.test(result.description)) { + const r2 = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: config.TG_CHAT_ID, text: fullText }) + }); + const result2 = await r2.json(); + return result2.ok + ? ok('telegram', result2) + : fail('telegram', `Telegram 拒绝: ${result2.description || '未知'}`, result2); + } + + return fail('telegram', `Telegram 拒绝: ${result.description || '未知'}`, result); + } catch (err) { + return fail('telegram', errorMessage(err)); } + }, - console.log('[Telegram] 发送结果:', result); - return result.ok; - } catch (error) { - console.error('[Telegram] 发送通知失败:', error); - return false; + async test(config) { + return telegramChannel.send( + { + title: '订阅管理 - 测试通知', + content: '这是一条来自订阅管理系统的测试消息。如果你收到此消息,说明 Telegram 配置正常。' + }, + config + ); } +}; + +/** + * 旧的导出函数:调用方传 `*title*\n\n...` 拼好的 message。 + * + * @deprecated 新代码请用 telegramChannel.send + * @param {string} message + * @param {any} config + * @returns {Promise} + */ +export async function sendTelegramNotification(message, config) { + // 旧调用方传入的 message 已经是组合好的 `*title*\n\ncontent` + // 这里把它整体作为 content,title 留空避免重复加包装 + const r = await telegramChannel.send({ title: '', content: message }, config); + if (!r.success) console.error('[Telegram]', r.error); + return r.success; } -export { sendTelegramNotification, escapeMarkdownV2 }; +export { escapeMarkdownV2 }; diff --git a/src/services/notify/webhook.js b/src/services/notify/webhook.js index 5650091a..024b02b5 100644 --- a/src/services/notify/webhook.js +++ b/src/services/notify/webhook.js @@ -1,104 +1,138 @@ -import { formatTimeInTimezone } from '../../core/time.js'; +// @ts-check +/** + * Webhook 通知渠道 + * + * 支持自定义请求方法、Header、消息模板({{title}} / {{content}} / {{tags}} 等)。 + */ +import { ok, fail, errorMessage } from './channel.js'; +import { formatLocalDate } from '../../core/time.js'; -async function sendWebhookNotification(title, content, config, metadata = {}) { - try { - if (!config.WEBHOOK_URL) { - console.error('[Webhook通知] 通知未配置,缺少URL'); - return false; +/** + * 把 value 转成可嵌入 JSON 字符串的安全片段。 + * + * @param {any} value + */ +function escapeForJsonString(value) { + if (value === null || value === undefined) return ''; + return JSON.stringify(String(value)).slice(1, -1); +} + +/** + * @param {any} template + * @param {Record} data + */ +function applyTemplate(template, data) { + const templateString = JSON.stringify(template); + const replaced = templateString.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { + if (Object.prototype.hasOwnProperty.call(data, key)) { + return escapeForJsonString(data[key]); } + return ''; + }); + return JSON.parse(replaced); +} - console.log('[Webhook通知] 开始发送通知到: ' + config.WEBHOOK_URL); +/** + * 构造可供模板替换的变量集合。 + * + * @param {import('./channel.js').ChannelPayload} payload + * @param {any} config + */ +function buildTemplateData(payload, config) { + const tagsArray = Array.isArray(payload.metadata?.tags) + ? payload.metadata.tags + .filter((t) => typeof t === 'string' && t.trim().length > 0) + .map((t) => t.trim()) + : []; + const tagsBlock = tagsArray.length ? tagsArray.map((t) => `- ${t}`).join('\n') : ''; + const tagsLine = tagsArray.length ? '标签:' + tagsArray.join('、') : ''; + const timestamp = formatLocalDate(new Date(), config?.TIMEZONE || 'UTC', 'datetime'); + const formattedMessage = [ + payload.title, + payload.content, + tagsLine, + `发送时间:${timestamp}` + ] + .filter((s) => s && s.trim().length > 0) + .join('\n\n'); - let requestBody; - let headers = { 'Content-Type': 'application/json' }; + return { + title: payload.title, + content: payload.content, + tags: tagsBlock, + tagsLine, + rawTags: tagsArray, + timestamp, + formattedMessage, + message: formattedMessage, + // 扩展字段,便于规则化模板 + daysRemaining: payload.metadata?.daysRemaining ?? '', + ruleType: payload.metadata?.ruleType ?? '', + ruleValue: payload.metadata?.ruleValue ?? '' + }; +} +/** @type {import('./channel.js').Channel} */ +export const webhookChannel = { + name: 'webhook', + + validateConfig(config) { + if (!config.WEBHOOK_URL) return { ok: false, error: '缺少 WEBHOOK_URL' }; + return { ok: true }; + }, + + async send(payload, config) { + const v = webhookChannel.validateConfig(config); + if (!v.ok) return fail('webhook', v.error || '配置无效'); + + let headers = { 'Content-Type': 'application/json' }; if (config.WEBHOOK_HEADERS) { try { const customHeaders = JSON.parse(config.WEBHOOK_HEADERS); headers = { ...headers, ...customHeaders }; - } catch (error) { - console.warn('[Webhook通知] 自定义请求头格式错误,使用默认请求头'); + } catch { + console.warn('[Webhook] 自定义请求头格式错误,使用默认请求头'); } } - const tagsArray = Array.isArray(metadata.tags) - ? metadata.tags.filter(tag => typeof tag === 'string' && tag.trim().length > 0).map(tag => tag.trim()) - : []; - const tagsBlock = tagsArray.length ? tagsArray.map(tag => `- ${tag}`).join('\n') : ''; - const tagsLine = tagsArray.length ? '标签:' + tagsArray.join('、') : ''; - const timestamp = formatTimeInTimezone(new Date(), config?.TIMEZONE || 'UTC', 'datetime'); - const formattedMessage = [title, content, tagsLine, `发送时间:${timestamp}`] - .filter(section => section && section.trim().length > 0) - .join('\n\n'); - - const templateData = { - title, - content, - tags: tagsBlock, - tagsLine, - rawTags: tagsArray, - timestamp, - formattedMessage, - message: formattedMessage - }; - - const escapeForJson = (value) => { - if (value === null || value === undefined) { - return ''; - } - return JSON.stringify(String(value)).slice(1, -1); - }; - - const applyTemplate = (template, data) => { - const templateString = JSON.stringify(template); - const replaced = templateString.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { - if (Object.prototype.hasOwnProperty.call(data, key)) { - return escapeForJson(data[key]); - } - return ''; - }); - return JSON.parse(replaced); - }; - + const data = buildTemplateData(payload, config); + let requestBody; if (config.WEBHOOK_TEMPLATE) { try { const template = JSON.parse(config.WEBHOOK_TEMPLATE); - requestBody = applyTemplate(template, templateData); - } catch (error) { - console.warn('[Webhook通知] 消息模板格式错误,使用默认格式'); - requestBody = { - title, - content, - tags: tagsArray, - tagsLine, - timestamp, - message: formattedMessage - }; + requestBody = applyTemplate(template, data); + } catch { + console.warn('[Webhook] 消息模板格式错误,使用默认格式'); + requestBody = { ...data }; } } else { - requestBody = { - title, - content, - tags: tagsArray, - tagsLine, - timestamp, - message: formattedMessage - }; + requestBody = { ...data }; } - const response = await fetch(config.WEBHOOK_URL, { - method: config.WEBHOOK_METHOD || 'POST', - headers: headers, - body: JSON.stringify(requestBody) - }); + try { + const r = await fetch(config.WEBHOOK_URL, { + method: config.WEBHOOK_METHOD || 'POST', + headers, + body: JSON.stringify(requestBody) + }); + const text = await r.text().catch(() => ''); + return r.ok ? ok('webhook', text) : fail('webhook', `HTTP ${r.status}`, text); + } catch (err) { + return fail('webhook', errorMessage(err)); + } + }, - const result = await response.text(); - console.log('[Webhook通知] 发送结果:', response.status, result); - return response.ok; - } catch (error) { - console.error('[Webhook通知] 发送通知失败:', error); - return false; + async test(config) { + return webhookChannel.send( + { title: '订阅管理 - 测试通知', content: '这是一条 Webhook 测试通知。' }, + config + ); } -} +}; -export { sendWebhookNotification }; +/** @deprecated 旧版兼容函数 */ +export async function sendWebhookNotification(title, content, config, metadata = {}) { + const r = await webhookChannel.send({ title, content, metadata }, config); + if (!r.success) console.error('[Webhook]', r.error); + return r.success; +} diff --git a/src/services/notify/wechat.js b/src/services/notify/wechat.js index 26294b45..925b81d7 100644 --- a/src/services/notify/wechat.js +++ b/src/services/notify/wechat.js @@ -1,76 +1,81 @@ -async function sendWechatBotNotification(title, content, config) { - try { - if (!config.WECHATBOT_WEBHOOK) { - console.error('[企业微信机器人] 通知未配置,缺少Webhook URL'); - return false; - } +// @ts-check +/** + * 企业微信机器人通知渠道 + * + * 支持 text / markdown 两种消息格式,可配置 @所有人 / @手机号。 + */ +import { ok, fail, errorMessage, stripMarkdown } from './channel.js'; - console.log('[企业微信机器人] 开始发送通知到: ' + config.WECHATBOT_WEBHOOK); +/** @type {import('./channel.js').Channel} */ +export const wecomChannel = { + name: 'wechatbot', + + validateConfig(config) { + if (!config.WECHATBOT_WEBHOOK) return { ok: false, error: '缺少 WECHATBOT_WEBHOOK' }; + return { ok: true }; + }, + + async send(payload, config) { + const v = wecomChannel.validateConfig(config); + if (!v.ok) return fail('wechatbot', v.error || '配置无效'); - let messageData; const msgType = config.WECHATBOT_MSG_TYPE || 'text'; + let messageData; if (msgType === 'markdown') { - const markdownContent = `# ${title}\n\n${content}`; - messageData = { - msgtype: 'markdown', - markdown: { content: markdownContent } - }; + const markdownContent = `# ${payload.title}\n\n${payload.content}`; + messageData = { msgtype: 'markdown', markdown: { content: markdownContent } }; } else { - const textContent = `${title}\n\n${content}`; - messageData = { - msgtype: 'text', - text: { content: textContent } - }; + const textContent = `${payload.title}\n\n${stripMarkdown(payload.content)}`; + messageData = { msgtype: 'text', text: { content: textContent } }; } - if (config.WECHATBOT_AT_ALL === 'true') { - if (msgType === 'text') { - messageData.text.mentioned_list = ['@all']; - } + if (config.WECHATBOT_AT_ALL === 'true' && msgType === 'text') { + messageData.text.mentioned_list = ['@all']; } else if (config.WECHATBOT_AT_MOBILES) { - const mobiles = config.WECHATBOT_AT_MOBILES.split(',').map(m => m.trim()).filter(m => m); - if (mobiles.length > 0) { - if (msgType === 'text') { - messageData.text.mentioned_mobile_list = mobiles; - } + const mobiles = String(config.WECHATBOT_AT_MOBILES) + .split(',') + .map((m) => m.trim()) + .filter(Boolean); + if (mobiles.length > 0 && msgType === 'text') { + messageData.text.mentioned_mobile_list = mobiles; } } - console.log('[企业微信机器人] 发送消息数据:', JSON.stringify(messageData, null, 2)); + try { + const r = await fetch(config.WECHATBOT_WEBHOOK, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(messageData) + }); + const text = await r.text(); + if (!r.ok) return fail('wechatbot', `HTTP ${r.status}`, text); - const response = await fetch(config.WECHATBOT_WEBHOOK, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(messageData) - }); - - const responseText = await response.text(); - console.log('[企业微信机器人] 响应状态:', response.status); - console.log('[企业微信机器人] 响应内容:', responseText); - - if (response.ok) { + let result; try { - const result = JSON.parse(responseText); - if (result.errcode === 0) { - console.log('[企业微信机器人] 通知发送成功'); - return true; - } else { - console.error('[企业微信机器人] 发送失败,错误码:', result.errcode, '错误信息:', result.errmsg); - return false; - } - } catch (parseError) { - console.error('[企业微信机器人] 解析响应失败:', parseError); - return false; + result = JSON.parse(text); + } catch { + return fail('wechatbot', '响应非 JSON', text); } - } else { - console.error('[企业微信机器人] HTTP请求失败,状态码:', response.status); - return false; + return result.errcode === 0 + ? ok('wechatbot', result) + : fail('wechatbot', `企业微信返回 errcode=${result.errcode} ${result.errmsg || ''}`, result); + } catch (err) { + return fail('wechatbot', errorMessage(err)); } - } catch (error) { - console.error('[企业微信机器人] 发送通知失败:', error); - return false; + }, + + async test(config) { + return wecomChannel.send( + { title: '订阅管理 - 测试通知', content: '这是一条企业微信测试通知。' }, + config + ); } -} +}; -export { sendWechatBotNotification }; +/** @deprecated 旧版兼容函数 */ +export async function sendWechatBotNotification(title, content, config) { + const r = await wecomChannel.send({ title, content }, config); + if (!r.success) console.error('[企业微信]', r.error); + return r.success; +} diff --git a/src/services/scheduler.js b/src/services/scheduler.js index c5d2edd2..80b01996 100644 --- a/src/services/scheduler.js +++ b/src/services/scheduler.js @@ -1,232 +1,353 @@ +// 注:本文件暂不启用 // @ts-check,因 lunar 库返回类型分支较多,类型清理推迟到后续 Task。 +/** + * 定时任务调度器 + * + * ── 修复的核心问题(#91 / #52 / #166 根因)───────────────── + * 旧调度器把"当前 UTC 时刻的小时"当作"用户本地小时"来对比 NOTIFICATION_HOURS, + * 配合"通知时段语义不一致"的文档表述,造成大量"不响 / 错时响"。 + * + * 修复: + * 1. 统一时区基准:通过 getNowInTimezone(config.TIMEZONE) 取用户 TZ 下的 hourString + * 与 NOTIFICATION_HOURS(按用户 TZ 解释)比对,语义清晰。 + * 2. 多提醒规则:从 reminders.repo 加载每个订阅的规则数组,逐条调 + * reminder-engine.shouldFire 判断(不再单点 reminderUnit/reminderValue)。 + * 3. 去重粒度细化:dedup key 改为 (subId × ruleId × ymdh-local),避免一条订阅 + * 多规则相互打架。 + * 4. 结构化日志:每次执行写一条 sched_log;每条通知发送(成功/失败)写 notify_log。 + * + * 数据流: + * Cron tick → + * ensureMigrations → + * load config + subs + rules → + * check window → + * for each (sub, rule): + * - daysDiff/hoursDiff 用 getDaysBetween(按用户 TZ)算 + * - 自动续订(针对 sub 整体,仅算一次) + * - shouldFire? → dedupe → dispatch.send → notify_log + * → sched_log + * + */ + import { getConfig } from '../data/config.js'; import { getAllSubscriptions } from '../data/subscriptions.js'; -import { getCurrentTimeInTimezone, MS_PER_HOUR, MS_PER_DAY, getTimezoneMidnightTimestamp } from '../core/time.js'; -import { formatNotificationContent, shouldTriggerReminder } from './notify/reminder.js'; -import { sendNotificationToAllChannels } from './notify/index.js'; +import * as subRepo from '../data/subscriptions.repo.js'; +import * as remindersRepo from '../data/reminders.repo.js'; +import * as schedulerLogsRepo from '../data/scheduler-logs.repo.js'; +import { + MS_PER_HOUR, + getNowInTimezone, + getDaysBetween +} from '../core/time.js'; +import { formatNotificationContent } from './notify/reminder.js'; +import { dispatch } from './notify/dispatch.js'; +import { shouldFire } from './notify/reminder-engine.js'; import { lunarCalendar, lunarBiz } from '../core/lunar.js'; -async function saveSchedulerStatus(env, status) { - try { - await env.SUBSCRIPTIONS_KV.put('scheduler_status', JSON.stringify(status)); - - const historyLimit = 20; - const historyRaw = await env.SUBSCRIPTIONS_KV.get('scheduler_status_history'); - const history = historyRaw ? JSON.parse(historyRaw) : []; - const nextHistory = [status, ...(Array.isArray(history) ? history : [])].slice(0, historyLimit); - await env.SUBSCRIPTIONS_KV.put('scheduler_status_history', JSON.stringify(nextHistory)); - } catch (error) { - console.error('[定时任务] 写入执行状态失败:', error); - } -} - -async function dedupeNotifications(env, subscriptions, bucketKey) { - const deduped = []; - let skipped = 0; - - for (const subscription of subscriptions) { - const key = `notify_dedupe:${subscription.id}:${bucketKey}`; - const exists = await env.SUBSCRIPTIONS_KV.get(key); - if (exists) { - skipped += 1; - continue; - } - - await env.SUBSCRIPTIONS_KV.put(key, '1', { expirationTtl: 60 * 60 * 48 }); - deduped.push(subscription); - } - - return { deduped, skipped }; -} +const DEDUPE_TTL_SEC = 60 * 60 * 48; // 48h -async function checkExpiringSubscriptions(env) { +/** + * 入口:被 Cron 触发的 scheduled() 调用。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @returns {Promise} + */ +export async function checkExpiringSubscriptions(env) { + const startedAtIso = new Date().toISOString(); try { const config = await getConfig(env); - const timezone = 'UTC'; - const currentTime = getCurrentTimeInTimezone('UTC'); - const todayMidnight = getTimezoneMidnightTimestamp(currentTime, 'UTC'); + const timezone = config.TIMEZONE || 'UTC'; + const now = getNowInTimezone(timezone); + + const normalizedHours = Array.isArray(config.NOTIFICATION_HOURS) + ? config.NOTIFICATION_HOURS + .map((h) => String(h).trim()) + .filter((h) => h.length > 0) + .map((h) => { + const up = h.toUpperCase(); + if (up === '*' || up === 'ALL') return '*'; + // 仅对纯数字做两位补齐;'*' 之类通配符保持原样 + return /^\d+$/.test(h) ? h.padStart(2, '0') : up; + }) + : []; + const inWindow = + normalizedHours.length === 0 || + normalizedHours.includes('*') || + normalizedHours.includes('ALL') || + normalizedHours.includes(now.hourString); const subscriptions = await getAllSubscriptions(env); - const expiringSubscriptions = []; - const updatedSubscriptions = []; - let hasUpdates = false; + let activeCount = 0; + let matchedCount = 0; + let dedupedCount = 0; + let sentCount = 0; + let autoRenewedCount = 0; - const normalizedNotificationHours = Array.isArray(config.NOTIFICATION_HOURS) - ? config.NOTIFICATION_HOURS.map(h => String(h).padStart(2, '0')) - : []; - const currentHour = String(currentTime.getHours()).padStart(2, '0'); - const shouldNotifyThisHour = - normalizedNotificationHours.includes('*') || - normalizedNotificationHours.includes('ALL') || - normalizedNotificationHours.includes(currentHour) || - normalizedNotificationHours.length === 0; - - const status = { - lastRunAt: new Date().toISOString(), - timezone, - currentHour, - configuredHours: normalizedNotificationHours, - shouldNotifyThisHour, - checkedSubscriptions: Array.isArray(subscriptions) ? subscriptions.length : 0, - activeSubscriptions: 0, - expiringMatched: 0, - dedupeSkipped: 0, - updatedSubscriptions: 0, - sent: false, - sendResult: null, - reason: '' - }; + // 不在通知时段:不发送但仍跑自动续订(业务上希望续订总能发生) + /** @type {Array<{ sub: any, rule: any, daysDiff: number, hoursDiff: number }>} */ + const candidates = []; + + /** @type {Array} */ + const updatedSubsToSave = []; for (const subscription of subscriptions) { if (!subscription.isActive) continue; - status.activeSubscriptions += 1; + activeCount++; - const reminderSetting = { unit: subscription.reminderUnit || 'day', value: subscription.reminderValue ?? 7 }; + // 计算到期天数(按用户 TZ) let expiryDate = new Date(subscription.expiryDate); - let daysDiff = Math.ceil((expiryDate.getTime() - todayMidnight) / MS_PER_DAY); - let diffMs = expiryDate.getTime() - currentTime.getTime(); - let diffHours = diffMs / MS_PER_HOUR; + let daysDiff = getDaysBetween(now.utc, expiryDate, timezone); + let hoursDiff = (expiryDate.getTime() - now.utc.getTime()) / MS_PER_HOUR; + // 自动续订:已过期 + autoRenew=true → 推进到期日并写支付记录 if (subscription.autoRenew && daysDiff < 0) { - const mode = subscription.subscriptionMode || 'cycle'; - let periodsAdded = 0; - - if (subscription.useLunar) { - let lunar = lunarCalendar.solar2lunar(expiryDate.getFullYear(), expiryDate.getMonth() + 1, expiryDate.getDate()); - while (expiryDate <= currentTime) { - lunar = lunarBiz.addLunarPeriod(lunar, subscription.periodValue, subscription.periodUnit); - const solar = lunarBiz.lunar2solar(lunar); - expiryDate = new Date(solar.year, solar.month - 1, solar.day); - periodsAdded++; - } - } else { - while (expiryDate <= currentTime) { - if (mode === 'reset') { - expiryDate = new Date(currentTime); - } - if (subscription.periodUnit === 'day') { - expiryDate.setDate(expiryDate.getDate() + subscription.periodValue); - } else if (subscription.periodUnit === 'month') { - expiryDate.setMonth(expiryDate.getMonth() + subscription.periodValue); - } else if (subscription.periodUnit === 'year') { - expiryDate.setFullYear(expiryDate.getFullYear() + subscription.periodValue); - } - periodsAdded++; - } + const renewed = autoRenew(subscription, now.utc, timezone, config); + if (renewed) { + updatedSubsToSave.push(renewed.next); + autoRenewedCount++; + // 续订后重算 diff + expiryDate = new Date(renewed.next.expiryDate); + daysDiff = getDaysBetween(now.utc, expiryDate, timezone); + hoursDiff = (expiryDate.getTime() - now.utc.getTime()) / MS_PER_HOUR; + // 用续订后的对象作后续判断 + subscription.expiryDate = renewed.next.expiryDate; + subscription.startDate = renewed.next.startDate; + subscription.lastPaymentDate = renewed.next.lastPaymentDate; + subscription.paymentHistory = renewed.next.paymentHistory; } + } - const newStartDate = mode === 'reset' ? new Date(currentTime) : new Date(subscription.expiryDate); - const newExpiryDate = expiryDate; - const paymentRecord = { - id: Date.now().toString(), - date: currentTime.toISOString(), - amount: subscription.amount || 0, - type: 'auto', - note: `自动续订 (${mode === 'reset' ? '重置模式' : '接续模式'}${periodsAdded > 1 ? ', 补齐' + periodsAdded + '周期' : ''})`, - periodStart: newStartDate.toISOString(), - periodEnd: newExpiryDate.toISOString() - }; - - const paymentHistory = subscription.paymentHistory || []; - paymentHistory.push(paymentRecord); - const paymentHistoryLimit = Number(config.PAYMENT_HISTORY_LIMIT) || 100; - const trimmedPaymentHistory = paymentHistory.length > paymentHistoryLimit - ? paymentHistory.slice(-paymentHistoryLimit) - : paymentHistory; - - const updatedSubscription = { - ...subscription, - startDate: newStartDate.toISOString(), - expiryDate: newExpiryDate.toISOString(), - lastPaymentDate: currentTime.toISOString(), - paymentHistory: trimmedPaymentHistory - }; - - updatedSubscriptions.push(updatedSubscription); - hasUpdates = true; - - diffMs = newExpiryDate.getTime() - currentTime.getTime(); - diffHours = diffMs / MS_PER_HOUR; - daysDiff = Math.ceil((newExpiryDate.getTime() - todayMidnight) / MS_PER_DAY); - const shouldRemindAfterRenewal = shouldTriggerReminder(reminderSetting, daysDiff, diffHours); - if (shouldRemindAfterRenewal) { - expiringSubscriptions.push({ - ...updatedSubscription, - daysRemaining: daysDiff, - hoursRemaining: Math.round(diffHours) - }); - } - continue; + // 加载规则;老订阅没有规则时,用 legacyFieldToRule 现场转一条 + let rules = await remindersRepo.listForSubscription(env, subscription.id); + if (rules.length === 0) { + rules = [remindersRepo.legacyFieldToRule(subscription)]; } - const shouldRemind = shouldTriggerReminder(reminderSetting, daysDiff, diffHours); - if (daysDiff < 0 && subscription.autoRenew === false) { - expiringSubscriptions.push({ - ...subscription, - daysRemaining: daysDiff, - hoursRemaining: Math.round(diffHours) - }); - } else if (shouldRemind) { - expiringSubscriptions.push({ - ...subscription, - daysRemaining: daysDiff, - hoursRemaining: Math.round(diffHours) - }); + for (const rule of rules) { + const decision = shouldFire(rule, { daysDiff, hoursDiff, nowIso: now.utc.toISOString() }); + if (!decision.fire) continue; + matchedCount++; + candidates.push({ sub: subscription, rule, daysDiff, hoursDiff }); } } - if (hasUpdates) { - const mergedSubscriptions = subscriptions.map(sub => { - const updated = updatedSubscriptions.find(u => u.id === sub.id); - return updated || sub; + // 持久化自动续订结果 + if (updatedSubsToSave.length > 0) { + await subRepo.saveMany(env, updatedSubsToSave); + console.log(`[定时任务] 已自动续订 ${updatedSubsToSave.length} 个订阅`); + } + + // 不在通知时段 → 写日志后返回 + if (!inWindow) { + const entry = await schedulerLogsRepo.writeLog(env, { + startedAt: startedAtIso, + finishedAt: new Date().toISOString(), + timezone, + currentHour: now.hourString, + configuredHours: normalizedHours, + inWindow: false, + checkedCount: activeCount, + matchedCount, + dedupedCount: 0, + sentCount: 0, + autoRenewedCount, + status: 'skipped', + reason: `当前用户 TZ 小时 ${now.hourString} 不在配置时段 [${normalizedHours.join(',') || '空'}] 内` }); - await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(mergedSubscriptions)); - console.log(`[定时任务] 已更新 ${updatedSubscriptions.length} 个自动续费订阅`); + return entry; } - status.updatedSubscriptions = updatedSubscriptions.length; - status.expiringMatched = expiringSubscriptions.length; - - if (expiringSubscriptions.length > 0) { - if (!shouldNotifyThisHour) { - status.sent = false; - status.reason = `当前小时 ${currentHour} 未在通知时段内 (${normalizedNotificationHours.join(',') || '空'})`; - console.log(`[定时任务] ${status.reason},跳过发送`); - } else { - expiringSubscriptions.sort((a, b) => a.daysRemaining - b.daysRemaining); - const bucketKey = `${new Date().toISOString().slice(0, 13)}`; - const dedupeResult = await dedupeNotifications(env, expiringSubscriptions, bucketKey); - status.dedupeSkipped = dedupeResult.skipped; - - if (dedupeResult.deduped.length === 0) { - status.sent = false; - status.reason = `命中 ${expiringSubscriptions.length} 条,但全部在去重窗口内(跳过 ${dedupeResult.skipped} 条)`; - console.log(`[定时任务] ${status.reason}`); - } else { - console.log(`[定时任务] 发送 ${dedupeResult.deduped.length} 条提醒通知(去重跳过 ${dedupeResult.skipped} 条)`); - const commonContent = formatNotificationContent(dedupeResult.deduped, config); - const sendResult = await sendNotificationToAllChannels('订阅到期/续费提醒', commonContent, config, '[定时任务]'); - status.sent = true; - status.sendResult = sendResult; - status.reason = sendResult && sendResult.attempted > 0 - ? `已尝试发送到 ${sendResult.attempted} 个渠道,成功 ${sendResult.successCount} 个(去重跳过 ${dedupeResult.skipped} 条)` - : '未启用任何通知渠道'; - } + // 在时段:去重 + 发送 + /** @type {Array<{ sub: any, rule: any, daysDiff: number, hoursDiff: number }>} */ + const ready = []; + const ymdhLocal = `${now.parts.year}${String(now.parts.month).padStart(2, '0')}${String( + now.parts.day + ).padStart(2, '0')}${now.hourString}`; + for (const c of candidates) { + const dedupeKey = `notify_dedupe:${c.sub.id}:${c.rule.id}:${ymdhLocal}`; + const exists = await env.SUBSCRIPTIONS_KV.get(dedupeKey); + if (exists) { + dedupedCount++; + continue; } - } else { - status.sent = false; - status.reason = '本次未命中需要提醒的订阅'; + await env.SUBSCRIPTIONS_KV.put(dedupeKey, '1', { expirationTtl: DEDUPE_TTL_SEC }); + ready.push(c); + } + + if (ready.length === 0) { + const entry = await schedulerLogsRepo.writeLog(env, { + startedAt: startedAtIso, + finishedAt: new Date().toISOString(), + timezone, + currentHour: now.hourString, + configuredHours: normalizedHours, + inWindow: true, + checkedCount: activeCount, + matchedCount, + dedupedCount, + sentCount: 0, + autoRenewedCount, + status: matchedCount > 0 ? 'skipped' : 'ok', + reason: + matchedCount > 0 + ? `命中 ${matchedCount} 条规则但全部在去重窗口内(跳过 ${dedupedCount})` + : '本次未命中任何提醒规则' + }); + return entry; } - await saveSchedulerStatus(env, status); + // 排序:按剩余天数升序,更紧迫的在前 + ready.sort((a, b) => a.daysDiff - b.daysDiff); + + // 一次性聚合所有订阅成一条通知(与既有渠道契约一致) + // notify_log 按 (subId, ruleId, channel) 维度落,仍可细粒度查询 + const enrichedSubs = ready.map((c) => ({ + ...c.sub, + daysRemaining: c.daysDiff, + hoursRemaining: Math.round(c.hoursDiff) + })); + const content = formatNotificationContent(enrichedSubs, config); + const title = '订阅到期/续费提醒'; + + // 给 dispatch 提供主 subId+ruleId(聚合通知用第一条做归属) + const primary = ready[0]; + const dispatchResult = await dispatch( + { title, content }, + config, + { + env, + subId: primary.sub.id, + ruleId: primary.rule.id, + logPrefix: '[定时任务]', + metadata: { + tags: enrichedSubs.map((s) => s.name), + daysRemaining: primary.daysDiff, + ruleType: primary.rule.type, + ruleValue: primary.rule.value + } + } + ); + sentCount = dispatchResult.successCount; + + const entry = await schedulerLogsRepo.writeLog(env, { + startedAt: startedAtIso, + finishedAt: new Date().toISOString(), + timezone, + currentHour: now.hourString, + configuredHours: normalizedHours, + inWindow: true, + checkedCount: activeCount, + matchedCount, + dedupedCount, + sentCount, + autoRenewedCount, + status: dispatchResult.failedCount > 0 && sentCount === 0 ? 'error' : 'ok', + reason: + dispatchResult.attempted > 0 + ? `发送到 ${dispatchResult.attempted} 个渠道,成功 ${dispatchResult.successCount} / 失败 ${dispatchResult.failedCount}` + : '未启用任何通知渠道', + extra: { + candidates: ready.map((c) => ({ + subId: c.sub.id, + subName: c.sub.name, + ruleId: c.rule.id, + ruleType: c.rule.type, + ruleValue: c.rule.value, + daysDiff: c.daysDiff + })), + channelResults: dispatchResult.channelResults + } + }); + return entry; } catch (error) { console.error('[定时任务] 执行失败:', error); - await saveSchedulerStatus(env, { - lastRunAt: new Date().toISOString(), - sent: false, + return schedulerLogsRepo.writeLog(env, { + startedAt: startedAtIso, + finishedAt: new Date().toISOString(), + timezone: 'UTC', + currentHour: '00', + configuredHours: [], + inWindow: false, + checkedCount: 0, + matchedCount: 0, + dedupedCount: 0, + sentCount: 0, + autoRenewedCount: 0, + status: 'error', reason: '执行异常: ' + (error && error.message ? error.message : String(error)), - errorStack: error && error.stack ? error.stack : undefined + extra: { stack: error && error.stack } }); } } -export { checkExpiringSubscriptions }; +/** + * 自动续订:把已过期的订阅按周期推进,生成 auto 类型支付记录。 + * + * 按"cycle / reset 模式 + 公历 / 农历分支。 + * + * @param {any} sub + * @param {Date} now UTC 时刻 + * @param {string} timezone + * @param {any} config + * @returns {{ next: any } | null} + */ +function autoRenew(sub, now, timezone, config) { + const mode = sub.subscriptionMode || 'cycle'; + let expiryDate = new Date(sub.expiryDate); + let periodsAdded = 0; + + if (sub.useLunar) { + let lunar = lunarCalendar.solar2lunar( + expiryDate.getFullYear(), + expiryDate.getMonth() + 1, + expiryDate.getDate() + ); + while (expiryDate <= now) { + lunar = lunarBiz.addLunarPeriod(lunar, sub.periodValue, sub.periodUnit); + const solar = lunarBiz.lunar2solar(lunar); + expiryDate = new Date(solar.year, solar.month - 1, solar.day); + periodsAdded++; + if (periodsAdded > 60) break; // 防御 + } + } else { + while (expiryDate <= now) { + if (mode === 'reset') expiryDate = new Date(now); + if (sub.periodUnit === 'day') expiryDate.setDate(expiryDate.getDate() + sub.periodValue); + else if (sub.periodUnit === 'month') expiryDate.setMonth(expiryDate.getMonth() + sub.periodValue); + else if (sub.periodUnit === 'year') expiryDate.setFullYear(expiryDate.getFullYear() + sub.periodValue); + periodsAdded++; + if (periodsAdded > 120) break; + } + } + + if (periodsAdded === 0) return null; + + const newStartDate = mode === 'reset' ? new Date(now) : new Date(sub.expiryDate); + const newExpiryDate = expiryDate; + void timezone; + + const paymentRecord = { + id: Date.now().toString(), + date: now.toISOString(), + amount: sub.amount || 0, + type: 'auto', + note: `自动续订 (${mode === 'reset' ? '重置模式' : '接续模式'}${ + periodsAdded > 1 ? ', 补齐' + periodsAdded + '周期' : '' + })`, + periodStart: newStartDate.toISOString(), + periodEnd: newExpiryDate.toISOString() + }; + + const paymentHistoryLimit = Number(config.PAYMENT_HISTORY_LIMIT) || 100; + const ph = [...(sub.paymentHistory || []), paymentRecord]; + const trimmed = ph.length > paymentHistoryLimit ? ph.slice(-paymentHistoryLimit) : ph; + + return { + next: { + ...sub, + startDate: newStartDate.toISOString(), + expiryDate: newExpiryDate.toISOString(), + lastPaymentDate: now.toISOString(), + paymentHistory: trimmed + } + }; +} diff --git a/src/views/adminPage.html b/src/views/adminPage.html index 01fa589c..2f7d42bc 100644 --- a/src/views/adminPage.html +++ b/src/views/adminPage.html @@ -326,11 +326,20 @@ } /* Toast 样式 */ + #toast-container { + position: fixed; top: 20px; right: 20px; z-index: 10000; + display: flex; flex-direction: column; gap: 8px; max-width: 380px; + } .toast { - position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 8px; - color: white; font-weight: 500; z-index: 1000; transform: translateX(400px); + padding: 12px 36px 12px 16px; border-radius: 8px; position: relative; + color: white; font-weight: 500; transform: translateX(400px); transition: all 0.3s ease-in-out; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } + .toast .toast-close { + position: absolute; top: 8px; right: 10px; cursor: pointer; + opacity: 0.7; font-size: 14px; line-height: 1; + } + .toast .toast-close:hover { opacity: 1; } .toast.show { transform: translateX(0); } .toast.success { background-color: #10b981; } .toast.error { background-color: #ef4444; } @@ -344,6 +353,12 @@ html.dark .toast.error { background-color: #dc2626; } html.dark .toast.info { background-color: #2563eb; } html.dark .toast.warning { background-color: #d97706; } + + /* 模态框动画 */ + #subscriptionModal { transition: opacity 0.2s ease; opacity: 0; } + #subscriptionModal:not(.hidden) { opacity: 1; } + #subscriptionModal > div { transition: transform 0.2s ease; transform: scale(0.95); } + #subscriptionModal:not(.hidden) > div { transform: scale(1); } @@ -367,6 +382,9 @@ 订阅列表 + + 通知历史 + 系统配置 @@ -392,6 +410,9 @@ 订阅列表 + + 通知历史 + 系统配置 @@ -411,10 +432,13 @@

订阅列表

- + +
-
-
- -
-
-
-

- 0 = 仅在到期时提醒; 选择"小时"需要将 Worker 定时任务调整为小时级执行 -

+ +
+
+ +
+ +
+
+
+ +
+

+ 类型说明:到期前 N 天/小时 触发;到期当天;到期后每 X 小时重复(直到续费/手动确认)。 +

+ + + +
+ +
+
- +