Initial release of SIMKEEP

This commit is contained in:
SIMKEEP committed 2026-10-06 15:49:27 +08:00
commit bb3fa56a48
46 files changed
+4617

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
*
!Dockerfile
!requirements.txt
!index.html
!style.css
!app.js
!api.js
!core.js
!money.js
!countries.js
!favicon.svg
!server/
!server/*.py
!scripts/
!scripts/build.py
!scripts/backup.py
!scripts/restore.py
+21
View File
@@ -0,0 +1,21 @@
# 访问地址。远程部署时替换为自己的地址,端口需与 SIMKEEP_PORT 一致。
SIMKEEP_PUBLIC_URL=http://localhost:5180
SIMKEEP_SECURE_COOKIES=0
# 本地运行时的数据库路径;Docker Compose 会覆盖为容器内路径。
SIMKEEP_DB=data/simkeep.db
# Docker Compose 公网端口;改端口时同步修改 SIMKEEP_PUBLIC_URL。
SIMKEEP_PORT=5180
# 可选的公共默认通知服务;用户也可在网页单独配置自己的服务。
# 从 BotFather 获取 Token。可选用户名不包含 @;留空会自动查询。
# 每个 Bot 只运行一个 getUpdates 消费者,不要与其他 Bot 程序共用 Token。
SIMKEEP_TELEGRAM_TOKEN=
SIMKEEP_TELEGRAM_USERNAME=
# SMTP_MODE: starttls (通常 587)、ssl (通常 465)、plain (仅用于受控邮件中继)。
SIMKEEP_SMTP_HOST=
SIMKEEP_SMTP_PORT=587
SIMKEEP_SMTP_MODE=starttls
SIMKEEP_SMTP_USER=
SIMKEEP_SMTP_PASSWORD=
SIMKEEP_SMTP_FROM=
+39
View File
@@ -0,0 +1,39 @@
# Private runtime configuration (only the empty template is public).
.env*
*.env
*.env.*
!.env.example
# Databases, backups and exports contain account and notification credentials.
data/
backups/
docker-backups/
*.db
*.db-*
*.sqlite
*.sqlite-*
*.sqlite3
*.sqlite3-*
*.sql
*.backup
*.bak
simkeep-*.json
# Generated builds, browser traces and local tooling.
preview/
dist/
output/
releases/
node_modules/
.venv/
venv/
__pycache__/
*.pyc
.playwright-cli/
*.log
*.zip
*.tar
*.tar.gz
.DS_Store
.idea/
.vscode/
# Local files.
.local/
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim AS frontend
WORKDIR /build
COPY index.html style.css app.js api.js core.js money.js countries.js favicon.svg ./
COPY scripts/build.py ./scripts/build.py
RUN python scripts/build.py
FROM python:3.12-slim AS runtime
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 SIMKEEP_DB=/app/data/simkeep.db SIMKEEP_STATIC_DIR=/app/dist
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server ./server
COPY scripts/backup.py scripts/restore.py ./scripts/
COPY --from=frontend /build/dist ./dist
RUN groupadd --gid 10001 simkeep && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin simkeep && install -d -m 0700 -o simkeep -g simkeep /app/data
USER 10001:10001
EXPOSE 5180
CMD ["python", "-m", "uvicorn", "server.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "5180", "--workers", "1", "--no-access-log"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 rest-rain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+169
View File
@@ -0,0 +1,169 @@
<p align="center">
<img src="docs/images/cover.svg" width="100%" alt="SIMKEEP 续卡:记住每张卡的下一次续期">
</p>
# SIMKEEP · 续卡
**记住每张卡的下一次续期。**
SIMKEEP 是一个可自行部署的 SIM / eSIM 管理平台。把开通日期、平台注册关系、多币种余额、续期操作和通知放在同一个地方,让每个人管理自己的卡片。
名字来自 **SIM + Keep**:让需要保留的号码持续保持可用。
[界面预览](#界面预览) · [Docker 部署](#docker-部署) · [通知配置](#通知配置) · [备份与更新](#备份与更新)
## 功能
- **卡片档案**:记录 SIM / eSIM 类型、运营商、地区、号码、开通时间、套餐及备注,支持归档与恢复。
- **续期计划**:一张卡设置多条规则,支持重置、购买套餐、打电话、发短信、充值及自定义操作;周期按天或日历月计算。
- **下次日期自动计算**:记录完成时间后,按完成日顺延或固定周期更新下一次续期,保留操作历史。
- **多币种余额与费用**:支持 23 种币种,分别记录余额、预计费用与实际费用;完成操作时默认扣除同币种余额,也可只记录费用。充值可计入余额。
- **平台注册记录**:关联 Telegram、Google、WhatsApp、PayPal 等平台及账号标识,支持搜索和筛选。
- **Telegram / 邮件提醒**:每个账号在网页填写自己的 Bot Token 或 SMTP,设置时区、提醒时间、提前天数及逾期提醒,查看发送记录。
- **多人使用**:注册、登录和退出;卡片、费用、平台关系、通知设置及凭据按账号隔离。
- **桌面与手机**:卡片视图、列表、续期日历、移动端布局和 JSON 导出;界面不依赖外部 CDN。
金额使用十进制与整数最小单位计算,不进行汇率换算。续期操作由你实际执行,SIMKEEP 负责提醒、记录和计算。
## 界面预览
下图使用独立演示账号和虚构资料;号码、邮箱、余额及费用均为示例,运营商周期请以实际条款为准。
### 卡片总览
查看各币种余额、近期续期金额、卡片和日历。
![SIMKEEP 卡片总览](docs/images/overview.png)
### 卡片详情与续期历史
把卡片资料、关联平台、续期规则和实际费用放在一起。
![卡片详情与费用历史](docs/images/card-detail.png)
### 通知设置
在网页配置自己的通知服务,再绑定 Telegram 或验证接收邮箱。
![Telegram 与邮件通知设置](docs/images/notifications.png)
<details>
<summary>查看手机界面</summary>
<p align="center">
<img src="docs/images/mobile.png" width="390" alt="SIMKEEP 手机卡片管理界面">
</p>
</details>
## Docker 部署
需要 Docker Engine 和 Docker Compose 插件。镜像会自动构建前端,服务器无需安装 Node.js 或 Python。
下载仓库并进入项目目录后执行:
```bash
cp .env.example .env
chmod 600 .env
```
编辑 `.env`,把 `SIMKEEP_PUBLIC_URL` 改成自己的访问地址,例如 `http://your-server:5180`。本机试用可保留 `http://localhost:5180`。
```bash
docker compose up -d --build --wait
docker compose ps
```
打开 `http://localhost:5180` 或设置的服务器地址,点击“创建账号”。没有预置管理员、初始密码或演示资料。
| 配置项 | 默认值 | 用途 |
| --- | --- | --- |
| `SIMKEEP_PUBLIC_URL` | `http://localhost:5180` | 网页访问地址和提醒中的链接 |
| `SIMKEEP_PORT` | `5180` | 宿主机端口;修改时同步调整访问地址 |
| `SIMKEEP_BIND_ADDRESS` | `0.0.0.0` | 宿主机监听地址 |
| `SIMKEEP_SECURE_COOKIES` | `0` | HTTP 使用 `0`;通过 HTTPS 访问时使用 `1` |
数据保存于命名卷 `simkeep_simkeep-data` 中的 `/app/data/simkeep.db`。容器重启和镜像更新会保留数据。使用单个 Web worker 和一个提醒调度进程。
详见 [Docker 部署与恢复说明](docs/DOCKER.md)。
## 通知配置
登录后进入 **通知设置**。每个渠道都有配置按钮,保存后立即生效,无需重启 Docker。
### Telegram
1. 在 [BotFather](https://t.me/BotFather) 创建 Bot,获取 Token。
2. 点击 **配置 Telegram**,填写 Token,然后 **保存并检查**。
3. 点击 **绑定 Telegram**,打开 Bot 并点击“开始”,回到网页检查绑定。
4. 开启 Telegram 提醒,选择提醒时间并 **保存设置**。
同一个 Bot Token 只用于一个账号,且需要由 SIMKEEP 独占 `getUpdates`。不要同时运行其他消费者或设置其他 webhook。更换 Token 后需要重新绑定。
### 邮件
1. 点击 **配置邮件**,填写 SMTP 服务器、端口、安全方式、发件邮箱、用户名和密码 / 授权码。
2. 点击 **保存并检查**。端口 `587` 通常使用 STARTTLS,`465` 通常使用 SSL / TLS;以邮件服务说明为准。
3. 保存接收邮箱,点击 **发送验证码**,填写邮件中的验证码。
4. 开启邮箱提醒,选择提醒时间并 **保存设置**。
支持无需登录认证的 SMTP,此时用户名留空。发件邮箱需是邮件服务允许使用的地址。
**保存并检查**只检查连接与认证,不发送消息。绑定或验证后可以点击 **发送测试** 检查真实投递。已保存的 Token / 密码不会回显,编辑时留空保留原值。
默认提前 7、3、1 天及到期当天提醒,也可开启每天一次的逾期提醒。提醒按各账号时区和时间运行,关闭网页后仍有效。
`.env` 中的通知凭据也可作为公共默认服务;个人配置优先。清除个人配置后,如果服务器配置了公共服务,会恢复使用公共服务。
## 备份与更新
使用 SQLite backup API 创建一致的备份:
```bash
docker compose exec -T web python scripts/backup.py
docker compose cp web:/app/data/backups ./docker-backups
```
备份包含账号、卡片、历史和个人通知密钥,应放在受保护的位置。不要只复制正在使用的主数据库文件而遗漏 WAL。
更新代码和镜像:
```bash
docker compose up -d --build --wait
```
修改 `.env` 后,需要重建容器加载新环境变量:
```bash
docker compose up -d --force-recreate --wait web
```
`docker compose restart` 不会加载修改后的 `.env`。不要执行 `docker compose down -v`,它会删除数据卷。
## 本地开发
后端使用 Python / FastAPI / SQLite,前端使用原生 JavaScript、HTML 和 CSS。
```bash
python3 -m pip install -r requirements.txt
npm run build
npm start
```
本地启动读取进程环境变量;`.env` 由 Docker Compose 加载,本地开发需要自行设置所需变量。
验证命令:
```bash
npm test
python3 -m unittest discover -s tests -p 'test_*.py' -q
node --check app.js
```
测试使用临时数据库和模拟投递边界,不需要真实 Telegram / SMTP 凭据。
## 使用范围
适合小规模单实例部署。当前包含卡片维护与提醒,不自动向运营商拨号、发短信或购买套餐,也不包含共享卡片、密码找回和管理员面板。
提醒失败会退避重试,最多尝试 5 次;修复凭据后当天未发送的失败任务可重新排队。外部服务已经接收消息、进程尚未写入成功记录时崩溃,恢复后可能重复一次;停机期间错过的历史提醒不会逐条补发。
+44
View File
@@ -0,0 +1,44 @@
export class ApiError extends Error {
constructor(message,status=0,generation) {super(message);this.status=status;this.sessionGeneration=generation;}
}
export class StaleResponse extends Error {
constructor() {super('账号已切换,忽略旧请求');this.name='StaleResponse';}
}
export class API {
constructor(transport=globalThis.fetch.bind(globalThis)) {
this.transport=transport;
this.csrf='';
this.generation=0;
}
clear() {this.csrf='';this.generation++;}
assertCurrent(result) {if(result._sessionGeneration!==this.generation) throw new StaleResponse();}
async request(method,path,payload) {
const generation=this.generation;
const current=()=>{if(generation!==this.generation) throw new StaleResponse();};
const headers={Accept:'application/json'};
if (payload !== undefined) headers['Content-Type']='application/json';
if (!['GET','HEAD'].includes(method) && this.csrf) headers['X-CSRF-Token']=this.csrf;
let response;
try {
response=await this.transport(path,{method,headers,credentials:'same-origin',...(payload!==undefined ? {body:JSON.stringify(payload)} : {})});
} catch {
current();
throw new ApiError('无法连接服务器,请检查网络后重试',0,generation);
}
let result;
try {result=await response.json();} catch {current();throw new ApiError('服务器响应异常,请稍后重试',response.status,generation);}
current();
if (!response.ok) {
const detail=result.detail;
const message=typeof detail==='string' ? detail : Array.isArray(detail) ? detail[0]?.msg?.replace(/^Value error, /,'') : '';
throw new ApiError(message || '操作未完成,请稍后重试',response.status,generation);
}
if (path==='/api/auth/login' || path==='/api/auth/register') this.generation++;
if (result.csrfToken) this.csrf=result.csrfToken;
Object.defineProperty(result,'_sessionGeneration',{value:this.generation});
return result;
}
}
export const api=new API();
+625
View File
@@ -0,0 +1,625 @@
import {ACTIONS, addCycle, completeRenewal, createId, cycleText, daysUntil, dueStatus, formatDate, getTasks, parseDate, todayString, validateActivationChange} from './core.js';
import {COUNTRIES} from './countries.js';
import {api} from './api.js';
import {CURRENCIES,COUNTRY_CURRENCY,normalizeAmount,sumMoney,balanceAfter,formatMoney} from './money.js';
const paths = {
grid:'<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
sim:'<path d="M7 3h7l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a2 2 0 0 1 2-2Z"/><rect x="8" y="11" width="8" height="6" rx="1.5"/><path d="M12 11v6m-4-3h8"/>',
esim:'<path d="M7 3h7l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a2 2 0 0 1 2-2Z"/><path d="m13 10-4 5h4l-2 4"/>',
calendar:'<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4m10-4v4M3 11h18m-13 4h2m4 0h2m-8 3h2"/>',
layers:'<path d="m12 3 9 5-9 5-9-5 9-5Zm-9 9 9 5 9-5M3 16l9 5 9-5"/>',
bell:'<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',
search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4.5 4.5"/>',
chevron:'<path d="m9 5 7 7-7 7"/>',
left:'<path d="m15 5-7 7 7 7"/>',
down:'<path d="m7 10 5 5 5-5"/>',
plus:'<path d="M12 5v14M5 12h14"/>',
export:'<path d="M12 3v12m-4-4 4 4 4-4M5 15v5h14v-5"/>',
more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
list:'<path d="M9 6h12M9 12h12M9 18h12M3 6h1m-1 6h1m-1 6h1"/>',
message:'<path d="M5 4h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8l-5 3V6a2 2 0 0 1 2-2Z"/><path d="M7 9h10m-10 4h6"/>',
phone:'<path d="m8 3 3 5-3 3c1.5 2.4 2.6 3.5 5 5l3-3 5 3c1 5-3 5-5 4C9 18 6 15 3 8 2 6 3 2 8 3Z"/>',
bag:'<path d="M4 7h16l1 14H3L4 7Z"/><path d="M8 8V6a4 4 0 0 1 8 0v2"/>',
refresh:'<path d="M20 10a8 8 0 0 0-14-5L3 8m0-5v5h5M4 14a8 8 0 0 0 14 5l3-3m0 5v-5h-5"/>',
wallet:'<path d="M20 7H5a2 2 0 0 1 0-4h13v4M3 5v14a2 2 0 0 0 2 2h15V7m0 5h-5v5h5"/><path d="M16 14.5h1"/>',
check:'<path d="m5 12 4 4L19 6"/>',
checkCircle:'<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
link:'<path d="m10 13 4-4m-7 6-1 1a3.5 3.5 0 0 1-5-5l4-4a3.5 3.5 0 0 1 5 0m4 2 1-1a3.5 3.5 0 0 1 5 5l-4 4a3.5 3.5 0 0 1-5 0" transform="translate(1 1)"/>',
telegram:'<path d="M21 3 3 10l7 3 3 7 8-17Z"/><path d="m10 13 6-6"/>',
mail:'<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 6 9 7 9-7"/>',
shield:'<path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6l8-3Z"/><path d="m8 12 3 3 5-6"/>',
globe:'<circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/>',
close:'<path d="m6 6 12 12M6 18 18 6"/>',
edit:'<path d="m14 5 5 5M4 20l5-1L21 7a2 2 0 0 0-4-4L5 15l-1 5Z"/>',
settings:'<path d="m10 3-.5 3-2 1.2L4.7 6 2.8 9.3l2.3 1.8v2L2.8 15l1.9 3.3 2.8-1.2 2 1.2.5 2.7h4l.5-2.7 2-1.2 2.8 1.2 1.9-3.3-2.3-1.9v-2l2.3-1.8L19.3 6l-2.8 1.2-2-1.2L14 3Z"/><circle cx="12" cy="12" r="3"/>',
archive:'<path d="M4 8h16v13H4V8Z"/><path d="M3 3h18v5H3zm6 10h6"/>',
trash:'<path d="M4 6h16M9 6V3h6v3M6 6l1 15h10l1-15M10 10v7m4-7v7"/>',
menu:'<path d="M4 6h16M4 12h16M4 18h16"/>',
help:'<circle cx="12" cy="12" r="9"/><path d="M9 8a3 3 0 0 1 6 0c0 2-3 2-3 4m0 4h.01"/>',
spark:'<path d="m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3Z"/>',
};
const icon = name => `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">${paths[name] || paths.sim}</svg>`;
const e = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const uid = createId;
const el = selector => document.querySelector(selector);
const NAV = [
{id:'overview',label:'总览',icon:'grid'}, {id:'cards',label:'我的卡片',icon:'sim'},
{id:'renewals',label:'续期计划',icon:'calendar'}, {id:'platforms',label:'关联平台',icon:'layers'},
{id:'settings',label:'通知设置',icon:'bell'},
];
const colors = {Telegram:'#63a9d9',WhatsApp:'#4cac88',Google:'#5e8be3',PayPal:'#5b80b7',Apple:'#7e8593',Amazon:'#da9b43',Discord:'#7987d9',WeChat:'#65b684',AlipayHK:'#609ee2',GitHub:'#697487',Microsoft:'#6d9dce',MobiMatter:'#e6935e',My3:'#5c87df',Tello:'#56a294',Airalo:'#ce7689'};
let serverUser = null, authMode = 'login', isMutating = false;
let view = 'overview', filter = 'all', layout = 'grid', query = '', showArchived = false, taskFilter = 'all';
const emptyWorkspace = () => ({version:2,cards:[],events:[],settings:{timezone:'Asia/Shanghai',time:'09:00',offsets:[7,3,1],overdue:true,telegram:false,email:false,emailAddress:'',emailVerified:false,telegramVerified:false,chatId:''},services:{telegram:{configured:false},email:{configured:false}},notifications:[]});
let data = emptyWorkspace();
let monthDate = parseDate(`${today().slice(0,7)}-01`), selectedDate = null;
let previousFocus = null, overlayCleanup = null, toastTimer, detailReturn = null, telegramBindingCode = '';
function today() {return todayString(data?.settings?.timezone || 'Asia/Shanghai');}
const user = () => serverUser;
const findCard = id => data.cards.find(card => card.id === id);
const tasks = () => getTasks(data.cards);
const nearest = card => [...card.rules].sort((a,b) => a.dueDate.localeCompare(b.dueDate))[0];
function savedMessage(message) {toast(message);}
const configured = channel => Boolean(data.services?.[channel]?.configured);
const verified = channel => Boolean(data.settings[channel === 'telegram' ? 'telegramVerified' : 'emailVerified']);
const channelReady = channel => configured(channel) && verified(channel) && data.settings[channel];
const channelStatus = channel => !configured(channel) ? '服务待配置' : !verified(channel) ? channel === 'telegram' ? '待绑定' : '待验证' : data.settings[channel] ? '已启用' : '未启用';
async function mutation(method,path,payload) {
if (isMutating) throw new Error('上一项操作正在保存,请稍候');
isMutating = true;
try {const result = await api.request(method,path,payload);api.assertCurrent(result);if (result.workspace) data = result.workspace;return result;}
finally {isMutating = false;}
}
function reportError(error,form=null) {
if (error.name === 'StaleResponse') return;
if (error.sessionGeneration !== undefined && error.sessionGeneration !== api.generation) return;
if (error.status === 401 && serverUser) {
closeOverlay(false);serverUser = null;data = emptyWorkspace();api.clear();renderAuth('登录已过期,请重新登录');return;
}
if (form?.isConnected && form.querySelector('.form-error')) formError(form,error.message);else toast(error.message,true);
}
async function refreshWorkspace() {
const result = await api.request('GET','/api/workspace');
api.assertCurrent(result);data = result.workspace;
}
function flag(country) {
const graphics = {
GB:'<rect width="60" height="40" fill="#344579"/><path d="m0 0 60 40m0-40L0 40" stroke="#fff" stroke-width="9"/><path d="m0 0 60 40m0-40L0 40" stroke="#d95364" stroke-width="3"/><path d="M30 0v40M0 20h60" stroke="#fff" stroke-width="13"/><path d="M30 0v40M0 20h60" stroke="#d95364" stroke-width="7"/>',
US:'<rect width="60" height="40" fill="#fff"/><path d="M0 3h60M0 10h60M0 17h60M0 24h60M0 31h60M0 38h60" stroke="#d46b77" stroke-width="3.5"/><rect width="26" height="22" fill="#5477b2"/><path d="M5 5h1m5 0h1m5 0h1m-13 6h1m5 0h1m5 0h1m-13 6h1m5 0h1m5 0h1" stroke="#fff" stroke-width="2"/>',
HK:'<rect width="60" height="40" fill="#dc6268"/><g fill="#fff" transform="translate(30 20)"><ellipse rx="4" ry="8" transform="translate(0 -6)"/><ellipse rx="4" ry="8" transform="rotate(72) translate(0 -6)"/><ellipse rx="4" ry="8" transform="rotate(144) translate(0 -6)"/><ellipse rx="4" ry="8" transform="rotate(216) translate(0 -6)"/><ellipse rx="4" ry="8" transform="rotate(288) translate(0 -6)"/></g>',
JP:'<rect width="60" height="40" fill="#fff"/><circle cx="30" cy="20" r="11" fill="#d95b6a"/>',
CN:'<rect width="60" height="40" fill="#dc6268"/><path d="m12 7 2 5 5 .2-4 3 1.4 5L12 17l-4.4 3.2 1.4-5-4-3 5-.2Z" fill="#f2d077"/>',
DE:'<rect width="60" height="14" fill="#3f4450"/><rect y="14" width="60" height="13" fill="#d66b72"/><rect y="27" width="60" height="13" fill="#edc660"/>',
SG:'<rect width="60" height="20" fill="#d96b74"/><rect y="20" width="60" height="20" fill="#fff"/><circle cx="15" cy="10" r="7" fill="#fff"/><circle cx="18" cy="9" r="6" fill="#d96b74"/>',
};
return `<span class="flag" title="${e(COUNTRIES[country] || COUNTRIES.OTHER)}"><svg viewBox="0 0 60 40" aria-hidden="true">${graphics[country] || '<rect width="60" height="40" fill="#aab7cf"/><circle cx="30" cy="20" r="12" fill="none" stroke="white" stroke-width="3"/>'}</svg></span>`;
}
const provider = (card, mini = false) => `<span class="${mini ? 'provider-mini' : 'provider-logo'}" style="--brand-color:${e(card.color)}" data-provider="${e(card.provider)}">${e(card.mark)}</span>`;
const typeTag = card => `<span class="type-tag ${card.type === 'SIM' ? 'physical' : ''}">${icon(card.type === 'SIM' ? 'sim' : 'esim')}${e(card.type)}</span>`;
const statusBadge = date => {const status = dueStatus(date,today());return `<span class="status ${status.tone}">${status.label}</span>`;};
const platformBadge = registration => `<span class="platform-tag"><span class="platform-dot" style="--platform-color:${colors[registration.name] || '#8e9ab3'}">${e(registration.name[0])}</span>${e(registration.name)}</span>`;
const moneyTags = values => values.length ? values.map(value=>`<span class="money-tag">${e(formatMoney(value))}</span>`).join('') : '<span class="money-unknown">未记录余额</span>';
const cardCurrency = card => card?.balances?.[0]?.currency || COUNTRY_CURRENCY[card?.country] || 'USD';
function renderMoneySummary() {
const balances = sumMoney(data.cards.filter(card=>!card.archived).flatMap(card=>card.balances || []));
const upcoming = tasks().filter(task=>daysUntil(task.rule.dueDate,today())<=30);
const costs = sumMoney(upcoming.map(task=>task.rule.cost));
const unpriced = upcoming.filter(task=>!task.rule.cost).length;
return `<section class="money-summary" aria-label="余额与续期金额"><div><h2>${icon('wallet')}在用卡片余额</h2><div class="money-tags">${moneyTags(balances)}</div><p>按币种分别汇总</p></div><div><h2>${icon('calendar')}近 30 天待办金额</h2><div class="money-tags">${costs.length ? moneyTags(costs) : '<span class="money-unknown">暂无已填写金额的待办</span>'}</div><p>含逾期${unpriced ? ` · ${unpriced} 项未填写金额` : ''}</p></div></section>`;
}
function renderAuth(message = '') {
const registering = authMode === 'register';
el('#app').innerHTML = `<main class="auth-shell"><section class="auth-story"><a class="brand" href="#" aria-label="续卡 SIMKEEP"><span class="brand-symbol">${icon('sim')}</span><span class="brand-name">续卡<small>SIMKEEP</small></span></a><div class="auth-story-content"><h1>每个号码,<br>都有它的下一次。</h1><p>从第一次开通,到每一次续期。<br>把卡片、账号与提醒,放在自己的空间里。</p><div class="connection-illustration" aria-hidden="true"><div class="illustration-sim">${icon('sim')}<span>SIM & eSIM</span><div class="sim-chip"><i></i><i></i><i></i><i></i><i></i><i></i></div></div><div class="illustration-timeline"><div><span class="timeline-dot done"></span><strong>开通连接</strong><small>留下最初的日期</small></div><div><span class="timeline-dot"></span><strong>安排下次续期</strong><small>短信、通话、重置或购买套餐</small></div><div><span class="timeline-dot hollow"></span><strong>提醒,准时到达</strong><small>Telegram 与邮箱陪你记得</small></div></div></div></div><p class="auth-story-foot">每一条连接,都有迹可循</p></section><section class="auth-panel"><div class="auth-form-wrap"><div class="auth-tabs" role="group" aria-label="账号入口"><button data-action="auth-login" class="${!registering ? 'active' : ''}" aria-pressed="${!registering}">登录</button><button data-action="auth-register" class="${registering ? 'active' : ''}" aria-pressed="${registering}">创建账号</button></div><h2>${registering ? '开启你的卡片空间' : '回来照顾你的连接'}</h2><p class="auth-intro">${registering ? '每个人都有独立的卡片、平台与续期记录。' : '登录后,接着管理你的 SIM 与 eSIM。'}</p><form id="auth-form"><div class="form-error" ${message ? '' : 'hidden'} role="alert">${e(message)}</div>${registering ? field('auth-name','你的名字',`<input id="auth-name" name="name" autocomplete="nickname" required maxlength="45" placeholder="怎么称呼你">`) : ''}${field('auth-email','邮箱',`<input id="auth-email" name="email" type="email" autocomplete="username" required maxlength="120" placeholder="you@example.com">`)}${field('auth-password','密码',`<input id="auth-password" name="password" type="password" autocomplete="${registering ? 'new-password' : 'current-password'}" minlength="${registering ? '10' : '1'}" maxlength="128" required placeholder="${registering ? '至少 10 个字符' : '输入密码'}">`)}<button class="btn btn-primary auth-submit" type="submit">${registering ? '创建账号,开始管理' : '登录我的空间'}${icon('chevron')}</button></form><p class="auth-account-note">${icon('shield')}你的卡片记录随账号保存</p></div><footer class="auth-footer">续卡 SIMKEEP · SIM 与 eSIM 管理</footer></section></main>`;
}
async function bootstrap() {
const generation = api.generation;
el('#app').innerHTML = `<main class="bootstrap"><span class="brand-symbol">${icon('sim')}</span><p>正在打开你的卡片空间…</p></main>`;
try {
const session = await api.request('GET','/api/auth/me');
const result = await api.request('GET','/api/workspace');
api.assertCurrent(session);api.assertCurrent(result);
serverUser = session.user;data = result.workspace;
monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp();
} catch (error) {
if (generation !== api.generation) return;
if (error.name === 'StaleResponse') return;
if (error.status === 401) renderAuth();
else el('#app').innerHTML = `<main class="bootstrap"><span class="brand-symbol">${icon('sim')}</span><p>${e(error.message)}</p><button class="btn btn-primary" data-action="retry-bootstrap">重新连接</button></main>`;
}
}
function renderApp(keepSearch = false) {
if (!serverUser) {renderAuth();return;}
const pos = keepSearch ? el('#global-search')?.selectionStart : null;
const count = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7).length;
el('#app').innerHTML = `<div class="app-shell">
<aside class="sidebar" aria-label="主导航">
<a class="brand" href="#overview" data-nav="overview"><span class="brand-symbol">${icon('sim')}</span><span class="brand-name">续卡<small>SIMKEEP</small></span></a>
<div class="nav-caption">工作空间</div><nav class="nav-list">${NAV.map(item => `<button class="nav-button ${view === item.id ? 'active' : ''}" data-nav="${item.id}" title="${item.label}" ${view === item.id ? 'aria-current="page"' : ''}>${icon(item.icon)}<span>${item.label}</span>${item.id === 'renewals' && count ? `<span class="nav-count">${count}</span>` : ''}</button>`).join('')}</nav>
<div class="sidebar-spacer"></div>
<button class="nav-button" data-action="help" title="帮助与说明">${icon('help')}<span>帮助与说明</span></button>
<div class="sidebar-note"><div class="note-icon">${icon('shield')}</div><h4>你的号码,你的空间</h4><p>卡片与平台记录随账号管理<br>每一条连接都有自己的归属</p><button data-action="account">查看当前账号 ${icon('chevron')}</button></div>
<button class="sidebar-user" data-action="account" aria-label="我的账号"><span class="avatar">${e(user().initial)}</span><span class="user-text"><strong>${e(user().name)}</strong><small>我的个人空间</small></span>${icon('down')}</button>
</aside>
<div class="main-shell"><header class="topbar"><button class="icon-button mobile-menu" data-action="menu" aria-label="打开导航">${icon('menu')}</button><div class="breadcrumbs"><span>工作空间</span>${icon('chevron')}<strong>${NAV.find(item => item.id === view)?.label}</strong></div>
<div class="topbar-right"><label class="global-search" for="global-search">${icon('search')}<input id="global-search" name="global-search" value="${e(query)}" placeholder="搜索卡片、号码或平台" aria-label="搜索卡片、号码或平台"><kbd>⌘ K</kbd></label><button class="icon-button" data-action="refresh" aria-label="刷新卡片资料" title="刷新卡片资料">${icon('refresh')}</button><button class="icon-button notification-button" data-action="reminders" aria-label="查看近期提醒">${icon('bell')}</button><span class="topbar-divider"></span><button class="topbar-avatar" data-action="account" aria-label="我的账号"><span class="avatar">${e(user().initial)}</span>${icon('down')}</button></div>
</header><main class="content" id="content">${renderContent()}</main></div>
</div>`;
if (keepSearch) {el('#global-search').focus();if (pos !== null) el('#global-search').setSelectionRange(pos,pos);}
}
function heading(title, subtitle, actions = true) {
return `<div class="page-heading"><div><div class="heading-title"><h1>${title}</h1></div><p>${subtitle}</p></div>${actions ? `<div class="heading-actions"><button class="btn" data-action="export">${icon('export')}导出</button><button class="btn btn-primary" data-action="add-card">${icon('plus')}添加卡片</button></div>` : ''}</div>`;
}
function footer() {return `<footer class="page-footer"><span>续卡 SIMKEEP <span>·</span> 每一条连接,都有迹可循</span><span>${icon('shield')}资料已保存到你的账号</span></footer>`;}
function renderContent() {
if (view === 'settings') return renderSettings() + footer();
if (view === 'renewals') return renderRenewals() + footer();
if (view === 'platforms') return renderPlatforms() + footer();
return heading(view === 'overview' ? '卡片总览' : '我的卡片', view === 'overview' ? '管理每一条连接,让重要的号码一直在线。' : '号码、开通时间与平台记录,放在一起更清楚。') +
(view === 'overview' ? renderStats() + renderMoneySummary() + renderUpcoming() : '') +
`<div class="workspace-grid"><section aria-label="卡片集合">${renderCollection()}</section><aside class="right-rail">${renderCalendar()}${renderReminderPanel()}<p class="rail-tip">${icon('clock')}所有日期按 ${e(data.settings.timezone)} 计算</p></aside></div>` + footer();
}
function renderStats() {
const active = data.cards.filter(card => !card.archived);
const soon = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7).length;
const registrations = active.reduce((sum,card) => sum + card.platforms.length,0);
return `<section class="stat-strip" aria-label="管理概况">
<div class="stat"><div class="stat-label">${icon('sim')}卡片总数</div><div class="stat-line"><span class="stat-value">${active.length.toString().padStart(2,'0')}</span><span class="stat-unit">张</span></div><p class="stat-foot"><span class="stat-colored">${active.filter(card => card.type === 'eSIM').length} eSIM</span> <span> / </span> ${active.filter(card => card.type === 'SIM').length} 实体 SIM</p></div>
<div class="stat"><div class="stat-label">${icon('calendar')}近期续期</div><div class="stat-line"><span class="stat-value">${soon.toString().padStart(2,'0')}</span><span class="stat-unit">项</span></div><p class="stat-foot">未来 7 天内需要照顾的连接</p></div>
<div class="stat"><div class="stat-label">${icon('layers')}平台绑定</div><div class="stat-line"><span class="stat-value">${registrations.toString().padStart(2,'0')}</span><span class="stat-unit">条</span></div><p class="stat-foot">重要账号与号码一一对应</p></div>
<div class="stat"><div class="stat-label">${icon('bell')}提醒渠道</div><div class="stat-line"><span class="stat-value">${['telegram','email'].filter(channelReady).length.toString().padStart(2,'0')}</span><span class="stat-unit">个</span></div><p class="stat-foot">Telegram 和邮箱自动提醒</p></div>
</section>`;
}
function renderUpcoming() {
const soon = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7);
return `<section class="renewal-strip" aria-label="近期续期任务"><span class="renewal-strip-icon">${icon(soon.length ? 'calendar' : 'checkCircle')}</span><div class="renewal-intro"><h3>${soon.length ? `${soon.length} 项续期,记得安排` : '本周的连接都已照顾好'}</h3><p>${soon.length ? '做一个小动作,让号码继续在线' : '没有临近到期的任务,安心使用'}</p></div><div class="renewal-pills">${soon.slice(0,3).map(({card,rule}) => `<button class="renewal-pill" data-action="complete" data-card="${e(card.id)}" data-rule="${e(rule.id)}" title="记录 ${e(card.name)} 的${ACTIONS[rule.action].label}">${provider(card,true)}<span><strong>${e(card.provider)}</strong><small>${formatDate(rule.dueDate,true)} · ${ACTIONS[rule.action].label}</small></span></button>`).join('')}</div><button class="text-button" data-nav="renewals">查看计划 ${icon('chevron')}</button></section>`;
}
function matches(card) {
const haystack = [card.name,card.provider,card.phone,COUNTRIES[card.country],...card.platforms.flatMap(platform => [platform.name,platform.account])].join(' ').toLowerCase();
return haystack.includes(query.trim().toLowerCase());
}
function visibleCards() {return data.cards.filter(card => (showArchived ? card.archived : !card.archived) && (filter === 'all' || card.type === filter) && matches(card));}
function renderCollection() {
const cards = visibleCards();
const all = data.cards.filter(card => showArchived ? card.archived : !card.archived);
const count = type => all.filter(card => card.type === type).length;
return `<div class="section-header"><h2 class="section-title">${showArchived ? '已归档卡片' : '我的卡片'}<span class="count">${cards.length}</span></h2><div class="section-controls"><div class="segmented" aria-label="按卡片类型筛选">${[['all','全部',all.length],['eSIM','eSIM',count('eSIM')],['SIM','SIM',count('SIM')]].map(([value,label,n]) => `<button data-filter="${value}" class="${filter === value ? 'active' : ''}" aria-pressed="${filter === value}">${label}<span>${n}</span></button>`).join('')}</div><div class="view-toggle" aria-label="卡片视图"><button data-layout="grid" class="${layout === 'grid' ? 'active' : ''}" aria-label="网格视图" aria-pressed="${layout === 'grid'}">${icon('grid')}</button><button data-layout="list" class="${layout === 'list' ? 'active' : ''}" aria-label="列表视图" aria-pressed="${layout === 'list'}">${icon('list')}</button></div></div></div>
${cards.length ? layout === 'grid' ? `<div class="card-grid">${cards.map(renderCard).join('')}</div>` : renderCardTable(cards) : empty(query ? '没有找到匹配的卡片' : showArchived ? '还没有归档的卡片' : '在这里,留下你的第一条连接', query ? '试试搜索运营商、号码尾号或已关联的平台。' : '添加 SIM 或 eSIM,设置续期周期,剩下的日期交给续卡。', !query && !showArchived)}
<div class="collection-foot"><span>${query ? `搜索“${e(query)}” · ` : ''}共 ${cards.length} 张${showArchived ? '归档' : ''}卡片</span><button class="text-button" data-action="toggle-archived">${showArchived ? '返回在用卡片' : '查看已归档'}</button></div>`;
}
function empty(title, description, add = false) {return `<div class="empty-state">${icon('sim')}<h3>${title}</h3><p>${description}</p>${add ? `<button class="btn btn-primary" data-action="add-card">${icon('plus')}添加卡片</button>` : ''}</div>`;}
function renderCard(card) {
const rule = nearest(card);
return `<article class="sim-card"><div class="card-top">${provider(card)}<div class="card-heading"><button data-action="detail" data-card="${e(card.id)}"><h3>${e(card.name)}</h3></button><p>${e(card.provider)}</p></div>${typeTag(card)}<button class="icon-button" data-action="card-actions" data-card="${e(card.id)}" aria-label="${e(card.name)}的更多操作">${icon('more')}</button></div>
<div class="card-number ${!card.phone.startsWith('+') ? 'data-number' : ''}">${flag(card.country)}${e(card.phone || '未记录号码')}</div><div class="card-meta"><span>${icon('globe')}${e(COUNTRIES[card.country] || COUNTRIES.OTHER)}</span><span>${icon('clock')}${formatDate(card.activatedAt)} 开通</span></div>
<div class="card-rule">${icon(rule ? ACTIONS[rule.action].icon : 'calendar')}<div class="card-rule-name">${rule ? ACTIONS[rule.action].label : '未设置续期'}<span>${rule ? cycleText(rule) : '添加一条规则'}${rule?.cost ? ` · ${e(formatMoney(rule.cost))}` : ''}</span></div><span class="card-rule-date">${rule ? formatDate(rule.dueDate) : '—'}</span></div>
<div class="card-balance"><span>余额</span><div class="money-tags">${moneyTags(card.balances || [])}</div></div>
<div class="platform-label">${icon('link')}关联平台 ${card.platforms.length ? `<span>(${card.platforms.length})</span>` : ''}${card.rules.length > 1 ? `<span style="margin-left:auto">${card.rules.length} 条续期规则</span>` : ''}</div><div class="platform-tags">${card.platforms.length ? card.platforms.slice(0,3).map(platformBadge).join('') + (card.platforms.length > 3 ? `<span class="platform-tag">+${card.platforms.length-3}</span>` : '') : '<span class="platform-tag">尚未关联平台</span>'}</div>
<div class="card-footer">${card.archived ? '<span class="status neutral">已归档</span>' : rule ? statusBadge(rule.dueDate) : '<span class="status neutral">待设置续期</span>'}<button class="text-button" data-action="detail" data-card="${e(card.id)}">查看详情 ${icon('chevron')}</button></div>
</article>`;
}
function renderCardTable(cards) {
return `<div class="card-list"><table><thead><tr><th>卡片名称</th><th>类型 / 地区</th><th>开通日期</th><th>余额</th><th>下次操作 / 金额</th><th>下次续期</th><th></th></tr></thead><tbody>${cards.map(card => {const rule = nearest(card);return `<tr><td><button class="list-card-name" data-action="detail" data-card="${e(card.id)}">${provider(card,true)}<span><strong>${e(card.name)}</strong><small>${e(card.phone)}</small></span></button></td><td>${typeTag(card)} <span class="muted">${e(COUNTRIES[card.country])}</span></td><td class="muted">${formatDate(card.activatedAt)}</td><td><div class="money-tags">${moneyTags(card.balances || [])}</div></td><td>${rule ? ACTIONS[rule.action].label : '尚未设置'}${rule?.cost ? `<br><span class="muted">${e(formatMoney(rule.cost))}</span>` : ''}</td><td>${rule ? `${formatDate(rule.dueDate)}<br>${statusBadge(rule.dueDate)}` : '—'}</td><td><button class="icon-button" data-action="detail" data-card="${e(card.id)}" aria-label="查看${e(card.name)}">${icon('chevron')}</button></td></tr>`;}).join('')}</tbody></table></div>`;
}
function renderCalendar() {
const year = monthDate.getUTCFullYear(), month = monthDate.getUTCMonth();
const start = new Date(Date.UTC(year,month,1));
start.setUTCDate(1 - ((start.getUTCDay() + 6) % 7));
const dates = Array.from({length:42},(_,i) => {const date = new Date(start);date.setUTCDate(start.getUTCDate()+i);return date;});
const upcoming = selectedDate ? tasks().filter(task => task.rule.dueDate === selectedDate) : tasks().filter(task => task.rule.dueDate.slice(0,7) === `${year}-${String(month+1).padStart(2,'0')}`).slice(0,3);
return `<section class="panel calendar-panel"><div class="panel-heading"><h2>${icon('calendar')}续期日历</h2><button class="text-button" data-action="calendar-today">今天</button></div><div class="calendar-toolbar"><strong>${year} 年 ${month+1} 月</strong><div class="calendar-controls"><button class="icon-button" data-month="-1" aria-label="上个月">${icon('left')}</button><button class="icon-button" data-month="1" aria-label="下个月">${icon('chevron')}</button></div></div><div class="calendar-grid">${['一','二','三','四','五','六','日'].map(day => `<span class="weekday">${day}</span>`).join('')}${dates.map(date => {const value = date.toISOString().slice(0,10);const n = tasks().filter(task => task.rule.dueDate === value).length;return `<button class="calendar-day ${date.getUTCMonth() !== month ? 'other' : ''} ${value === today() ? 'today' : ''} ${selectedDate === value ? 'selected' : ''} ${n ? 'has-task' : ''}" data-date="${value}" aria-label="${value}${n ? `,${n}项续期` : ',没有续期任务'}" aria-pressed="${selectedDate === value}">${date.getUTCDate()}</button>`;}).join('')}</div><div class="calendar-legend"><span><i class="legend-dot"></i>今天</span><span><i class="legend-dot orange"></i>有续期任务</span></div><div class="calendar-events"><h4>${selectedDate ? `${formatDate(selectedDate,true)}的续期` : '本月接下来的安排'}</h4>${upcoming.length ? upcoming.map(({card,rule}) => `<button class="calendar-event" data-action="complete" data-card="${e(card.id)}" data-rule="${e(rule.id)}"><span class="calendar-event-day"><small>${Number(rule.dueDate.slice(5,7))}月</small>${Number(rule.dueDate.slice(8))}</span><span class="calendar-event-text"><strong>${e(card.name)}</strong><small>${ACTIONS[rule.action].label} · ${cycleText(rule)}</small></span>${icon('chevron')}</button>`).join('') : '<p class="empty-calendar">这段时间没有续期任务。<br>可以安心使用你的号码。</p>'}</div></section>`;
}
function renderReminderPanel() {
return `<section class="panel reminder-panel"><div class="reminder-head"><span class="reminder-icon">${icon('bell')}</span><h3>重要的续期,有人记得</h3><p>提前收到提醒,按照步骤完成操作。<br>让每一张卡保持连接。</p></div><div class="channel-line"><span class="channel-symbol">${icon('telegram')}</span><strong>Telegram Bot</strong><span class="channel-label">${e(channelStatus('telegram'))}</span></div><div class="channel-line"><span class="channel-symbol email">${icon('mail')}</span><strong>邮箱提醒</strong><span class="channel-label">${e(channelStatus('email'))}</span></div><div class="panel-actions"><button class="text-button" data-nav="settings">管理通知 ${icon('chevron')}</button><button class="text-button" data-action="preview">预览提醒</button></div></section>`;
}
function renderRenewals() {
const all = tasks().filter(task => matches(task.card));
const shown = all.filter(task => taskFilter === 'all' || (taskFilter === 'soon' ? daysUntil(task.rule.dueDate,today()) <= 7 : daysUntil(task.rule.dueDate,today()) < 0));
return heading('续期计划','什么时候,做什么。每一次保号与续订都有清楚的安排。') + `<section class="section-full"><div class="section-header"><h2 class="section-title">待完成的续期<span class="count">${shown.length}</span></h2><div class="segmented">${[['all','全部计划'],['soon','近 7 天'],['overdue','已逾期']].map(([key,label]) => `<button data-task-filter="${key}" class="${taskFilter === key ? 'active' : ''}" aria-pressed="${taskFilter === key}">${label}</button>`).join('')}</div></div><div class="task-list">${shown.length ? shown.map(({card,rule}) => `<article class="task-row"><div class="task-date"><small>${Number(rule.dueDate.slice(5,7))}月</small>${Number(rule.dueDate.slice(8))}</div>${provider(card,true)}<div class="task-info"><h3>${e(card.name)} <span class="muted">/ ${e(card.provider)}</span></h3><p><span>${icon(ACTIONS[rule.action].icon)}${ACTIONS[rule.action].label}</span><span>${cycleText(rule)}</span><span>${rule.anchor === 'scheduled' ? '固定周期' : '完成后顺延'}</span>${rule.cost ? `<span class="renewal-cost">${e(formatMoney(rule.cost))}</span>` : '<span class="muted">金额未填写</span>'}</p></div>${statusBadge(rule.dueDate)}<button class="btn btn-soft btn-small" data-action="complete" data-card="${e(card.id)}" data-rule="${e(rule.id)}">${icon('check')}记录完成</button></article>`).join('') : empty('这段时间没有待办','完成操作后,新的续期日期会自动出现在这里。')}</div></section>`;
}
function renderPlatforms() {
const registrations = data.cards.filter(card => !card.archived && matches(card)).flatMap(card => card.platforms.map(platform => ({card,platform}))).filter(({platform,card}) => !query || `${platform.name} ${platform.account} ${card.name} ${card.phone} ${card.provider}`.toLowerCase().includes(query.toLowerCase()));
return heading('关联平台','记住每个号码注册过什么,让账号归属有迹可循。',false) + `<div class="section-header"><h2 class="section-title">平台记录<span class="count">${registrations.length}</span></h2><button class="btn btn-primary btn-small" data-action="choose-platform">${icon('plus')}添加平台关联</button></div>${registrations.length ? `<div class="card-list"><table class="platform-table"><thead><tr><th>平台</th><th>关联卡片</th><th>账号标识</th><th>用途</th><th></th></tr></thead><tbody>${registrations.map(({card,platform}) => `<tr><td><span class="platform-name-cell"><span class="platform-initial" style="--platform-color:${colors[platform.name] || '#8e9ab3'}">${e(platform.name[0])}</span>${e(platform.name)}</span></td><td><button class="text-button" data-action="detail" data-card="${e(card.id)}">${e(card.name)}</button></td><td class="muted">${e(platform.account || '未填写')}</td><td class="muted">${e(platform.purpose || '未填写')}</td><td><button class="icon-button" data-action="remove-platform" data-card="${e(card.id)}" data-platform="${e(platform.id)}" aria-label="移除${e(platform.name)}关联">${icon('trash')}</button></td></tr>`).join('')}</tbody></table></div>` : empty('还没有匹配的平台记录','在卡片详情中关联平台,也可以点击上方按钮添加。')}`;
}
function previewMessage(task = tasks()[0]) {
if (!task) return '<p class="preview-note">添加卡片和续期规则后,就可以预览提醒内容。</p>';
const {card,rule} = task;
const balance = (card.balances || []).find(item=>rule.cost && item.currency===rule.cost.currency);
return `<div class="preview-message"><strong>${icon('bell')}续卡提醒 · ${e(card.name)}</strong><div><span class="preview-label">卡片</span>${e(card.provider)} / ${e(card.type)}</div><div><span class="preview-label">号码</span>${card.phone.startsWith('+') ? `尾号 ${e(card.phone.replace(/\s/g,'').slice(-4))}` : '数据卡 / 未填写号码'}</div><div><span class="preview-label">到期时间</span>${formatDate(rule.dueDate)}</div><div><span class="preview-label">续期操作</span>${ACTIONS[rule.action].label}</div><div><span class="preview-label">续期金额</span>${e(formatMoney(rule.cost))}</div>${balance ? `<div><span class="preview-label">同币种余额</span>${e(formatMoney(balance))}</div>` : ''}<div class="preview-instructions">${e(rule.instructions || '完成后在续卡记录操作,自动计算下一次日期。')}</div><button class="preview-link" data-action="detail" data-card="${e(card.id)}" style="width:100%">查看卡片并记录操作</button></div>`;
}
function renderSettings() {
const s = data.settings;
const zones = [['Asia/Shanghai','中国标准时间 · UTC+8'],['Asia/Hong_Kong','香港时间 · UTC+8'],['Europe/London','伦敦时间'],['America/New_York','纽约时间'],['Asia/Tokyo','东京时间 · UTC+9']];
if (!zones.some(([zone]) => zone === s.timezone)) zones.push([s.timezone,s.timezone]);
return heading('通知设置','配置自己的通知服务,再绑定接收账号,按时收到续期提醒。',false) + `<div class="settings-layout"><form id="settings-form" class="settings-main"><div class="form-error" hidden role="alert"></div><section class="panel settings-panel"><h2>${icon('telegram')}通知渠道</h2><p>Bot Token 和 SMTP 按账号单独保存。可以同时启用两种提醒。</p>
<div class="settings-channel"><div class="settings-channel-head"><span class="channel-symbol">${icon('telegram')}</span><div><strong>Telegram Bot</strong><p>在 Bot 中点击开始,绑定你的个人账号</p></div><input class="switch" type="checkbox" name="telegram" aria-label="启用 Telegram 提醒" ${s.telegram ? 'checked' : ''}></div>${serviceSetup('telegram')}<div class="channel-actions"><span class="status ${channelReady('telegram') ? 'success' : 'neutral'}">${e(channelStatus('telegram'))}</span><button class="btn btn-small" type="button" data-action="bind-telegram" ${!configured('telegram') ? 'disabled' : ''}>${s.telegramVerified ? '重新绑定' : '绑定 Telegram'}</button>${s.telegramVerified ? `<button class="text-button" type="button" data-action="test-telegram" ${!configured('telegram') ? 'disabled' : ''}>发送测试</button><button class="text-button" type="button" data-action="unbind-telegram">解除绑定</button>` : ''}</div></div>
<div class="settings-channel"><div class="settings-channel-head"><span class="channel-symbol email">${icon('mail')}</span><div><strong>邮箱提醒</strong><p>验证接收邮箱,确保提醒送到你手中</p></div><input class="switch" type="checkbox" name="email" aria-label="启用邮箱提醒" ${s.email ? 'checked' : ''}></div>${serviceSetup('email')}<div class="field"><label for="notify-email">接收邮箱</label><input id="notify-email" name="emailAddress" type="email" value="${e(s.emailAddress)}" placeholder="you@example.com" maxlength="120"><small>修改邮箱后先保存设置,再发送验证码。</small></div><div class="channel-actions"><span class="status ${s.emailVerified ? 'success' : 'neutral'}">${s.emailVerified ? '邮箱已验证' : '邮箱待验证'}</span><button class="btn btn-small" type="button" data-action="verify-email" ${!configured('email') ? 'disabled' : ''}>${s.emailVerified ? '重新验证' : '发送验证码'}</button>${s.emailVerified ? `<button class="text-button" type="button" data-action="test-email" ${!configured('email') ? 'disabled' : ''}>发送测试</button>` : ''}</div></div></section>
<section class="panel settings-panel"><h2>${icon('clock')}提醒时间</h2><p>按你所在的时区安排提醒。到期当天总会提醒一次。</p><div class="fields-grid"><div class="field"><label for="timezone">时区</label><select name="timezone" id="timezone">${zones.map(([key,label]) => option(key,label,s.timezone)).join('')}</select></div><div class="field"><label for="notify-time">每天提醒时间</label><input type="time" id="notify-time" name="time" value="${e(s.time)}" required></div></div><hr class="form-divider"><span class="field-label">提前几天提醒</span><div class="offsets">${[...new Set([14,7,3,1,...s.offsets])].sort((a,b) => b-a).map(offset => `<label class="offset-check"><input type="checkbox" name="offset" value="${offset}" ${s.offsets.includes(offset) ? 'checked' : ''}>${offset} 天前</label>`).join('')}<span class="offset-check">${icon('check')}到期当天</span></div><label class="checkbox-line"><input type="checkbox" name="overdue" ${s.overdue ? 'checked' : ''}>已逾期的任务,每天提醒一次,直到记录完成</label></section><div class="settings-actions"><button class="btn" type="button" data-action="preview">${icon('mail')}预览提醒</button><button class="btn btn-primary" type="submit">${icon('check')}保存设置</button></div></form>
<aside class="panel notification-preview"><h3>${icon('message')}通知看起来会是这样</h3>${previewMessage()}<p class="preview-note">开启渠道、完成绑定或验证并保存设置后,提醒会自动发送。关闭页面也能收到。</p><div class="service-status"><span>Telegram 服务 <strong>${configured('telegram') ? '已配置' : '待配置'}</strong></span><span>邮件服务 <strong>${configured('email') ? '已配置' : '待配置'}</strong></span></div></aside></div>${renderNotificationLog()}`;
}
function serviceSetup(channel) {
const service = data.services[channel];
const name = channel === 'telegram' ? 'Telegram' : '邮件';
const explanation = service.source === 'account' ? '已保存本账号的配置' : service.source === 'server' ? '当前使用公共服务,可配置自己的服务' : channel === 'telegram' ? '先填写 BotFather 提供的 Bot Token' : '先填写发信服务器与 SMTP 授权码';
return `<div class="channel-setup"><p>${icon(service.configured ? 'checkCircle' : 'settings')}${e(explanation)}</p><button type="button" class="btn btn-small service-config-button" data-action="configure-service" data-channel="${channel}">${icon('settings')}配置${name === 'Telegram' ? ' ' : ''}${name}</button></div>`;
}
function notificationConfigForm(channel) {
const service = data.services[channel], telegram = channel === 'telegram';
const secretNote = saved => saved ? '已保存,留空保留原值' : '填写后只保存在服务器,不会回显';
const body = telegram ? `<div class="config-intro">${icon('telegram')}使用你在 BotFather 创建的 Bot。保存后,回到通知设置绑定 Telegram。</div>${field('telegram-token','Bot Token',`<input id="telegram-token" name="token" type="password" autocomplete="new-password" autocapitalize="off" spellcheck="false" maxlength="160" ${service.tokenSaved ? '' : 'required'} placeholder="${service.tokenSaved ? '已保存,留空保留原 Token' : '123456789:AA…'}"><small>${secretNote(service.tokenSaved)}</small>`)}<p class="channel-help">同一个 Bot 只能用于一个账号。更换 Token 后需要重新绑定。请让续卡独占这个 Bot 的更新读取。</p>` : `<div class="config-intro">${icon('mail')}配置发送邮件的服务。接收提醒的邮箱仍在通知设置中填写。</div><div class="fields-grid">${field('smtp-host','SMTP 服务器',`<input id="smtp-host" name="host" required maxlength="253" value="${e(service.host || '')}" placeholder="smtp.example.com" autocapitalize="off" spellcheck="false">`,true)}${field('smtp-port','端口',`<input id="smtp-port" name="port" type="number" min="1" max="65535" required value="${service.port || 587}">`)}${field('smtp-mode','安全方式',`<select id="smtp-mode" name="mode">${[['starttls','STARTTLS · 通常 587'],['ssl','SSL / TLS · 通常 465'],['plain','无加密']].map(([value,label])=>option(value,label,service.mode || 'starttls')).join('')}</select>`)}${field('smtp-from','发件邮箱',`<input id="smtp-from" name="sender" type="email" required maxlength="120" value="${e(service.from || '')}" placeholder="sender@example.com"><small>填写邮件服务允许使用的发件地址。</small>`,true)}${field('smtp-user','SMTP 用户名',`<input id="smtp-user" name="user" maxlength="254" value="${e(service.user || '')}" autocomplete="off" placeholder="通常为完整邮箱"><small>服务无需登录认证时留空。</small>`,true)}${field('smtp-password','SMTP 密码 / 授权码',`<input id="smtp-password" name="password" type="password" maxlength="1024" autocomplete="new-password" placeholder="${service.passwordSaved ? '已保存,留空保留原密码' : '填写密码或邮箱授权码'}"><small>${secretNote(service.passwordSaved)}</small>`,true)}</div>`;
openOverlay(telegram ? '配置 Telegram' : '配置邮件','仅用于当前账号。Token 和密码保存后不会回显。',`<form id="notification-config-form" data-channel="${channel}"><div class="form-error" hidden role="alert"></div><div class="config-feedback" hidden role="status"></div>${body}<p class="config-check-note">“保存并检查”会测试连接与认证,不发送消息。</p>${service.source === 'account' ? `<button type="button" class="text-button config-clear" data-action="clear-service-config" data-channel="${channel}">清除本账号配置</button>` : ''}</form>`,`<button class="btn" data-action="close">取消</button><button class="btn" type="submit" form="notification-config-form" value="save">保存配置</button><button class="btn btn-primary" type="submit" form="notification-config-form" value="check">保存并检查</button>`,'config-modal');
}
function renderNotificationLog() {
const labels = {pending:'等待发送',processing:'发送中',sent:'已发送',failed:'发送失败',cancelled:'已取消'};
return `<section class="notification-log"><div class="section-header"><h2 class="section-title">发送记录<span class="count">${data.notifications.length}</span></h2><button class="text-button" data-action="refresh">${icon('refresh')}刷新记录</button></div>${data.notifications.length ? `<div class="card-list"><table><thead><tr><th>提醒渠道</th><th>安排时间</th><th>状态</th><th>尝试次数</th><th>说明</th></tr></thead><tbody>${data.notifications.map(log => `<tr><td>${log.channel === 'telegram' ? 'Telegram' : '邮箱'}</td><td>${e(new Intl.DateTimeFormat('zh-CN',{timeZone:data.settings.timezone,dateStyle:'short',timeStyle:'short'}).format(new Date(log.created_at*1000)))}</td><td><span class="status ${log.status === 'sent' ? 'success' : log.status === 'failed' ? 'danger' : 'neutral'}">${labels[log.status] || e(log.status)}</span></td><td>${log.attempts}</td><td>${e(log.error || (log.status === 'sent' ? '已提交到通知服务' : '—'))}</td></tr>`).join('')}</tbody></table></div>` : `<div class="notification-log-empty">${icon('bell')}还没有发送记录。续期提醒安排发送后,会出现在这里。</div>`}</section>`;
}
function emailCodeForm() {
openOverlay('验证接收邮箱',`验证码已发送至 ${e(data.settings.emailAddress)}`,`<form id="email-code-form"><div class="form-error" hidden role="alert"></div>${field('email-code','6 位验证码',`<input id="email-code" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" minlength="6" maxlength="6" required placeholder="输入邮件里的验证码">`)}<p class="account-note">验证码 10 分钟内有效。没有收到时,请检查垃圾邮件。</p></form>`,`<button class="btn" data-action="close">稍后验证</button><button class="btn btn-primary" form="email-code-form" type="submit">验证邮箱</button>`,'small');
}
async function bindTelegram() {
const binding = await mutation('POST','/api/notifications/telegram/binding');
telegramBindingCode = binding.code;
openOverlay('绑定 Telegram','打开 Bot,点击“开始”,然后回到这里检查绑定。',`<div class="binding-steps"><span class="channel-symbol">${icon('telegram')}</span><a class="btn btn-primary" href="${e(binding.url)}" target="_blank" rel="noopener noreferrer">打开 Telegram Bot ${icon('link')}</a><p>如果没有自动发送,手动向 Bot 发送:</p><div class="field"><input aria-label="Telegram 绑定指令" readonly value="/start ${e(binding.code)}"></div><p>绑定指令 10 分钟内有效,仅绑定你的私人聊天。</p></div>`,`<button class="btn" data-action="close">稍后绑定</button><button class="btn btn-primary" data-action="check-telegram">检查绑定</button>`,'small');
}
function toast(message, error = false) {
clearTimeout(toastTimer);
el('#toast-root').innerHTML = `<div class="toast ${error ? 'error' : ''}" role="status">${icon(error ? 'help' : 'checkCircle')}${e(message)}</div>`;
toastTimer = setTimeout(() => {el('#toast-root').innerHTML = '';},4200);
}
function closeOverlay(restoreFocus = true) {
if (overlayCleanup) {overlayCleanup();overlayCleanup = null;}
el('#overlay-root').innerHTML = '';
document.body.classList.remove('body-overlay');
el('#app').inert = false;
if (restoreFocus && previousFocus?.isConnected) previousFocus.focus();
}
function openOverlay(title, subtitle, body, footerHtml = '', kind = '') {
closeOverlay(false);
previousFocus = document.activeElement;
el('#overlay-root').innerHTML = `<div class="overlay-backdrop ${kind === 'drawer' ? 'drawer-backdrop' : ''}"><section class="${kind === 'drawer' ? 'drawer' : `modal ${kind}`}" role="dialog" aria-modal="true" aria-labelledby="dialog-title" tabindex="-1"><header class="modal-header"><div><h2 id="dialog-title">${title}</h2>${subtitle ? `<p>${subtitle}</p>` : ''}</div><button class="icon-button" data-action="close" aria-label="关闭对话框">${icon('close')}</button></header><div class="modal-body">${body}</div>${footerHtml ? `<div class="modal-footer">${footerHtml}</div>` : ''}</section></div>`;
document.body.classList.add('body-overlay');
el('#app').inert = true;
const dialog = el('[role="dialog"]');
const focusables = () => Array.from(dialog.querySelectorAll('button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex="0"]'));
requestAnimationFrame(() => {(dialog.querySelector('input:not([type="hidden"]),select') || dialog.querySelector('button') || dialog).focus();});
const onKey = event => {
if (event.key === 'Escape') {event.preventDefault();closeOverlay();}
if (event.key === 'Tab') {
const elements = focusables(), first = elements[0], last = elements.at(-1);
if (!first) {event.preventDefault();dialog.focus();}
else if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) {event.preventDefault();last.focus();}
else if (!event.shiftKey && document.activeElement === last) {event.preventDefault();first.focus();}
}
};
document.addEventListener('keydown',onKey);
overlayCleanup = () => document.removeEventListener('keydown',onKey);
}
const formError = (form, message) => {const target = form.querySelector('.form-error');target.textContent = message;target.hidden = false;target.scrollIntoView({block:'nearest'});};
const option = (value, label, selected) => `<option value="${e(value)}" ${value === selected ? 'selected' : ''}>${e(label)}</option>`;
const field = (id, label, input, wide = false) => `<div class="field ${wide ? 'wide' : ''}"><label for="${id}">${label}</label>${input}</div>`;
const currencyOptions = selected => Object.entries(CURRENCIES).map(([code,item])=>option(code,`${code} · ${item.label}`,selected)).join('');
function amountField(id,name,label,value='') {
return field(id,label,`<input id="${e(id)}" name="${e(name)}" inputmode="decimal" maxlength="30" pattern="[0-9]+([.][0-9]+)?" value="${e(value)}" placeholder="留空表示未填写">`);
}
function costFields(prefix,cost=null,currency='USD',label='预计续期金额') {
return `${amountField(prefix+'-amount','costAmount',label,cost?.amount || '')}${field(prefix+'-currency','金额币种',`<select id="${prefix}-currency" name="costCurrency">${currencyOptions(cost?.currency || currency)}</select>`)}`;
}
function readCost(form) {
const value = form.elements.costAmount.value.trim();
if (!value) return null;
const currency = form.elements.costCurrency.value;
return {currency,amount:normalizeAmount(value,currency)};
}
function balanceRow(balance={currency:'USD',amount:''}) {
const id = 'balance-'+uid();
return `<div class="balance-row">${field(id+'-currency','余额币种',`<select id="${id}-currency" name="balanceCurrency">${currencyOptions(balance.currency)}</select>`)}${amountField(id+'-amount','balanceAmount','余额金额',balance.amount)}<button class="icon-button" type="button" data-action="remove-balance-row" aria-label="移除这条余额">${icon('trash')}</button></div>`;
}
function readBalances(form) {
const balances = [...form.querySelectorAll('.balance-row')].flatMap(row => {
const amount = row.querySelector('[name="balanceAmount"]').value.trim();
if (!amount) return [];
const currency = row.querySelector('[name="balanceCurrency"]').value;
return [{currency,amount:normalizeAmount(amount,currency)}];
});
if (new Set(balances.map(item=>item.currency)).size !== balances.length) throw new Error('同一张卡的每个币种只能记录一条余额');
return balances;
}
function ruleFields(rule = {}, minDate = today(), currency = 'USD') {
return `<div class="fields-grid">
${field('rule-action','续期操作<span>*</span>',`<select id="rule-action" name="action">${Object.entries(ACTIONS).map(([key,item]) => option(key,item.label,rule.action || 'sms')).join('')}</select>`)}
${field('rule-anchor','日期计算方式',`<select id="rule-anchor" name="anchor">${option('completion','按完成日期顺延',rule.anchor || 'completion')}${option('scheduled','保持固定周期',rule.anchor)}</select>`)}
${field('rule-interval','续期周期<span>*</span>',`<input id="rule-interval" name="interval" type="number" min="1" max="3650" step="1" required value="${rule.interval || 90}">`)}
${field('rule-unit','周期单位',`<select id="rule-unit" name="unit">${option('days','天',rule.unit || 'days')}${option('months','个月',rule.unit)}</select>`)}
${field('rule-due','下次到期日期<span>*</span>',`<input id="rule-due" name="dueDate" type="date" min="${minDate}" required value="${rule.dueDate || addCycle(minDate,rule.interval || 90,rule.unit || 'days')}">`,true)}
${costFields('rule-cost',rule.cost,currency)}
${field('rule-instructions','操作步骤',`<textarea id="rule-instructions" name="instructions" maxlength="1000" placeholder="例如:发送一条收费短信,确认成功后记录完成。">${e(rule.instructions || '')}</textarea>`,true)}
</div>`;
}
function cardForm(card = null) {
const editing = Boolean(card);
const c = card || {name:'',provider:'',phone:'',type:'eSIM',country:'HK',activatedAt:today(),notes:'',plan:''};
openOverlay(editing ? '编辑卡片' : '添加一张新卡片',editing ? '修改卡片档案;续期规则可在卡片详情中单独调整。' : '记下这条连接,再为它安排第一次续期。',`<form id="card-form" data-card="${e(card?.id || '')}" data-version="${card?.version || ''}"><div class="form-error" hidden role="alert"></div><div class="fields-grid">
${field('card-name','卡片名称<span>*</span>',`<input id="card-name" name="name" required maxlength="45" value="${e(c.name)}" placeholder="例如:英国常用号码">`,true)}
${field('card-provider','运营商<span>*</span>',`<input id="card-provider" name="provider" required maxlength="45" value="${e(c.provider)}" placeholder="例如:giffgaff">`)}
${field('card-type','卡片类型',`<select id="card-type" name="type">${option('eSIM','eSIM',c.type)}${option('SIM','实体 SIM',c.type)}</select>`)}
${field('card-phone','手机号码',`<input id="card-phone" name="phone" type="tel" maxlength="35" value="${e(c.phone)}" placeholder="数据 eSIM 可留空">`)}
${field('card-country','国家或地区',`<select id="card-country" name="country">${Object.entries(COUNTRIES).map(([key,label]) => option(key,label,c.country)).join('')}</select>`)}
${field('card-activation','开通日期<span>*</span>',`<input id="card-activation" name="activatedAt" type="date" required max="${today()}" value="${c.activatedAt}">`)}
${field('card-plan','套餐说明',`<input id="card-plan" name="plan" maxlength="60" value="${e(c.plan)}" placeholder="例如:10 GB / 30 天">`)}
</div><hr class="form-divider"><div class="form-section-title"><h3>卡片余额</h3><button class="text-button" type="button" data-action="add-balance-row">${icon('plus')}添加币种</button></div><div id="balance-fields">${(c.balances?.length ? c.balances : [{currency:cardCurrency(c),amount:''}]).map(balanceRow).join('')}</div><p class="form-help">同一张卡可记录多个币种。留空表示未记录,填 0 表示余额为零。</p>${editing ? '' : `<hr class="form-divider"><h3 class="form-section-title">第一次续期<span class="muted" style="font-size:10px;font-weight:400">之后可添加更多规则</span></h3>${ruleFields({},today(),cardCurrency(c))}`}
<hr class="form-divider">${field('card-notes','备注',`<textarea id="card-notes" name="notes" maxlength="1000" placeholder="卡片用途、保号条件或其他需要记住的事">${e(c.notes)}</textarea>`)}
</form>`,`<button class="btn" data-action="close">取消</button><button class="btn btn-primary" type="submit" form="card-form">${icon('check')}${editing ? '保存修改' : '添加卡片'}</button>`);
const form = el('#card-form');
if (!editing) {
form.addEventListener('input',event => {
if (!['activatedAt','interval','unit'].includes(event.target.name)) return;
try {form.elements.dueDate.min = form.elements.activatedAt.value;form.elements.dueDate.value = addCycle(form.elements.activatedAt.value,Number(form.elements.interval.value),form.elements.unit.value);} catch { /* Let form validation explain an unfinished value on submit. */ }
});
}
}
function readRule(form, existing = null) {
const f = form.elements;
const interval = Number(f.interval.value), unit = f.unit.value;
if (!Number.isInteger(interval) || interval < 1 || interval > (unit === 'months' ? 120 : 3650)) throw new Error(`周期必须为 1–${unit === 'months' ? 120 : 3650} 的整数`);
const dueDate = f.dueDate.value;
parseDate(dueDate);
return {action:f.action.value,interval,unit,dueDate,anchor:f.anchor.value,instructions:f.instructions.value.trim(),cost:readCost(form),...(existing ? {version:existing.version} : {})};
}
function ruleForm(cardId, ruleId = '') {
const card = findCard(cardId), rule = card.rules.find(item => item.id === ruleId);
detailReturn = cardId;
openOverlay(rule ? '编辑续期规则' : '添加续期规则',`${e(card.name)} · 每条规则独立计算下一次日期`, `<form id="rule-form" data-card="${e(cardId)}" data-rule="${e(ruleId)}" data-version="${rule?.version || ''}"><div class="form-error" hidden role="alert"></div>${ruleFields(rule,card.activatedAt,cardCurrency(card))}<div class="instructions-box" style="margin-top:18px;margin-bottom:0">按完成日期顺延适合保号;固定周期适合套餐账单。修改当前到期日期会建立新的周期基准。</div></form>`,`<button class="btn" data-action="detail" data-card="${e(cardId)}">返回详情</button><button class="btn btn-primary" type="submit" form="rule-form">保存规则</button>`);
}
function completionForm(cardId, ruleId) {
const card = findCard(cardId), rule = card?.rules.find(item => item.id === ruleId);
if (!rule || card.archived) return;
const minDate = rule.lastCompletedAt && rule.lastCompletedAt > card.activatedAt ? rule.lastCompletedAt : card.activatedAt;
const balanceAction = rule.action === 'topup' ? 'credit' : 'deduct';
openOverlay('记录续期完成','确认已经完成操作,记录花费并自动安排下一次。',`<div class="completion-summary">${provider(card)}<div><h3>${e(card.name)}</h3><p>${ACTIONS[rule.action].label} · ${cycleText(rule)} · ${rule.anchor === 'scheduled' ? '固定周期' : '完成后顺延'}</p></div></div><div class="instructions-box">${e(rule.instructions || '请确认续期操作成功后,再记录完成。')}</div><form id="completion-form" data-card="${e(cardId)}" data-rule="${e(ruleId)}" data-version="${rule.version}" data-card-version="${card.version}" data-balance-action="${balanceAction}"><div class="form-error" hidden role="alert"></div><div class="fields-grid">${field('completed-date','实际完成日期<span>*</span>',`<input id="completed-date" name="completedAt" type="date" required min="${minDate}" max="${today()}" value="${today()}">`,true)}${costFields('completed-cost',rule.cost,cardCurrency(card),'本次实际金额')}${field('completed-note','操作备注',`<textarea id="completed-note" name="note" maxlength="1000" placeholder="例如:短信已发送并扣费成功"></textarea>`,true)}</div><label class="checkbox-line balance-action"><input type="checkbox" name="updateBalance" ${balanceAction === 'deduct' ? 'checked' : ''}>${balanceAction === 'credit' ? '将本次金额计入同币种余额' : '从卡片同币种余额扣除本次金额'}</label><p class="form-help">${balanceAction === 'credit' ? '未勾选时只记录费用,余额保持当前记录。' : '默认扣除本次金额。取消勾选可只记录费用。'}</p><div class="calculation-preview balance-preview"><span>${balanceAction === 'credit' ? '入账后余额' : '扣除后余额'}</span><strong id="balance-after-preview">只记录费用</strong></div><div class="calculation-preview"><span>完成后,下次续期日期</span><strong id="next-date-preview">—</strong></div></form>`,`<button class="btn" data-action="close">取消</button><button class="btn btn-primary" type="submit" form="completion-form">${icon('check')}确认已完成</button>`,'small');
const form = el('#completion-form');
const updateDate = () => {try {el('#next-date-preview').textContent = formatDate(completeRenewal(card,ruleId,form.elements.completedAt.value,today()).card.rules.find(item => item.id === ruleId).dueDate);} catch (error) {el('#next-date-preview').textContent = error.message;}};
const updateMoney = () => {
const target = el('#balance-after-preview');
try {
target.textContent = form.elements.updateBalance.checked ? formatMoney(balanceAfter(card.balances || [],readCost(form),balanceAction)) : '只记录费用';
target.classList.remove('money-error');
} catch (error) {target.textContent = error.message;target.classList.add('money-error');}
};
form.dataset.requestId = uid();form.elements.completedAt.addEventListener('input',updateDate);form.addEventListener('input',updateMoney);form.addEventListener('change',updateMoney);updateDate();updateMoney();
}
function showDetail(cardId) {
const card = findCard(cardId);
if (!card) return;
const history = data.events.filter(item => item.cardId === cardId).sort((a,b) => b.completedAt.localeCompare(a.completedAt));
openOverlay('卡片详情','开通、平台与续期,一张卡的完整记录。',`<div class="detail-brand">${provider(card)}<div><h2>${e(card.name)}</h2><p>${e(card.provider)} ${typeTag(card)} ${card.archived ? '<span class="status neutral">已归档</span>' : ''}</p></div></div><dl class="detail-grid"><div><dt>号码</dt><dd>${e(card.phone)}</dd></div><div><dt>国家或地区</dt><dd>${flag(card.country)} ${e(COUNTRIES[card.country])}</dd></div><div><dt>开通日期</dt><dd>${formatDate(card.activatedAt)}</dd></div><div><dt>套餐说明</dt><dd>${e(card.plan || '未填写')}</dd></div><div class="detail-money"><dt>卡片余额</dt><dd class="money-tags">${moneyTags(card.balances || [])}</dd></div></dl><section class="detail-section"><div class="section-header"><h3>续期规则 <span class="muted">${card.rules.length}</span></h3><button class="text-button" data-action="add-rule" data-card="${e(cardId)}">${icon('plus')}添加规则</button></div>${card.rules.length ? [...card.rules].sort((a,b) => a.dueDate.localeCompare(b.dueDate)).map(rule => `<div class="detail-rule"><div class="detail-rule-head">${icon(ACTIONS[rule.action].icon)}<strong>${ACTIONS[rule.action].label}</strong><span>${cycleText(rule)}</span></div><div class="detail-rule-cost">预计续期金额 <strong>${e(formatMoney(rule.cost))}</strong></div><p>${e(rule.instructions || '尚未填写操作说明')}</p><div class="detail-rule-bottom"><span>下次 ${formatDate(rule.dueDate)}<br><span style="line-height:2">${rule.anchor === 'scheduled' ? '固定周期' : '按完成日顺延'}</span></span><div><button class="text-button" data-action="edit-rule" data-card="${e(cardId)}" data-rule="${e(rule.id)}">编辑</button>${!card.archived ? `<button class="btn btn-soft btn-small" data-action="complete" data-card="${e(cardId)}" data-rule="${e(rule.id)}">记录完成</button>` : ''}</div></div></div>`).join('') : '<p class="detail-note">添加一条规则,开始记录这张卡的续期计划。</p>'}</section><section class="detail-section"><div class="section-header"><h3>关联平台 <span class="muted">${card.platforms.length}</span></h3><button class="text-button" data-action="add-platform" data-card="${e(cardId)}">${icon('plus')}添加平台</button></div>${card.platforms.length ? card.platforms.map(platform => `<div class="detail-platform"><span class="platform-initial" style="--platform-color:${colors[platform.name] || '#8e9ab3'}">${e(platform.name[0])}</span><div><strong>${e(platform.name)}</strong><p>${e([platform.account,platform.purpose].filter(Boolean).join(' · ') || '未填写账号标识')}</p></div><button class="icon-button" data-action="remove-platform" data-card="${e(cardId)}" data-platform="${e(platform.id)}" aria-label="移除${e(platform.name)}关联">${icon('trash')}</button></div>`).join('') : '<p class="detail-note">记录这张卡注册的平台,下次找账号更方便。</p>'}</section><section class="detail-section"><div class="section-header"><h3>续期历史 <span class="muted">${history.length}</span></h3></div>${history.length ? history.map(item => `<div class="history-item"><strong>${formatDate(item.completedAt)} · ${ACTIONS[item.action]?.label || '续期操作'}</strong><p>下次续期 ${formatDate(item.newDueDate)}<br>本次金额 ${e(formatMoney(item.cost))}${item.balanceAfter ? `<br>${item.balanceAction === 'credit' ? '计入余额后' : '扣除后余额'} ${e(formatMoney(item.balanceAfter))}` : ''}${item.note ? `<br>${e(item.note)}` : ''}</p></div>`).join('') : '<p class="detail-note">还没有操作记录。完成一次续期后,历史会留在这里。</p>'}</section>${card.notes ? `<section class="detail-section"><h3 style="margin-bottom:10px">备注</h3><p class="detail-note">${e(card.notes)}</p></section>` : ''}`,`<button class="btn" data-action="archive-card" data-card="${e(cardId)}">${icon('archive')}${card.archived ? '恢复卡片' : '归档卡片'}</button><button class="btn btn-primary" data-action="edit-card" data-card="${e(cardId)}">${icon('edit')}编辑卡片</button>`,'drawer');
}
function platformForm(cardId = '') {
const card = findCard(cardId);
detailReturn = cardId || null;
const available = data.cards.filter(item => !item.archived);
if (!available.length && !card) {toast('请先添加一张卡片,再关联平台');return;}
openOverlay('添加平台关联',card ? `${e(card.name)} · 记住这个号码注册过什么` : '选择卡片,记录平台和账号归属。',`<form id="platform-form" data-card="${e(cardId)}"><div class="form-error" hidden role="alert"></div><div class="fields-grid">${!card ? field('platform-card','关联卡片',`<select name="cardId" id="platform-card">${available.map(item => option(item.id,item.name,available[0].id)).join('')}</select>`,true) : ''}${field('platform-name','平台名称<span>*</span>',`<input name="name" id="platform-name" required maxlength="45" list="platform-options" placeholder="例如:Telegram、Google、PayPal"><datalist id="platform-options">${Object.keys(colors).map(name => `<option value="${name}">`).join('')}</datalist>`,true)}${field('platform-account','账号标识',`<input name="account" id="platform-account" maxlength="120" placeholder="邮箱、用户名或号码尾号">`,true)}${field('platform-purpose','用途',`<input name="purpose" id="platform-purpose" maxlength="80" placeholder="例如:账号验证、日常通讯、支付">`,true)}</div></form>`,`<button class="btn" ${card ? `data-action="detail" data-card="${e(cardId)}"` : 'data-action="close"'}>取消</button><button class="btn btn-primary" type="submit" form="platform-form">保存关联</button>`,'small');
}
function removePlatform(cardId, platformId) {
const card = findCard(cardId), platform = card?.platforms.find(item => item.id === platformId);
if (!platform) return;
const wasDetail = Boolean(el('.drawer'));
openOverlay('移除平台关联',`${e(card.name)} · ${e(platform.name)}`,`<p class="help-copy">移除这条平台记录?这只会更新续卡中的记录,不会注销该平台的实际账号。</p>`,`<button class="btn" ${wasDetail ? `data-action="detail" data-card="${e(cardId)}"` : 'data-action="close"'}>取消</button><button class="btn btn-danger" data-action="confirm-remove-platform" data-card="${e(cardId)}" data-platform="${e(platformId)}" data-return="${wasDetail ? cardId : ''}">移除关联</button>`,'small');
}
function showAccount() {
openOverlay('我的账号','卡片、规则与通知设置保存在你的个人空间。',`<div class="account-option"><span class="avatar">${e(user().initial)}</span><div><strong>${e(user().name)}</strong><p>${e(user().email)}</p></div>${icon('checkCircle')}</div><p class="account-note">登录这个账号,可以在不同设备上继续管理你的卡片。导出的文件包含卡片和平台资料,请妥善保管。</p>`,`<button class="btn" data-action="logout">退出登录</button><button class="btn btn-primary" data-action="close">完成</button>`,'small');
}
function showHelp() {
openOverlay('认识续卡','把号码与维护它的那些小事,放在同一个地方。',`<div class="help-copy"><h3>记录卡片</h3><p>添加运营商、号码、卡片类型和开通日期。数据 eSIM 可以不填写号码。</p><h3>安排续期</h3><p>一张卡可以有多条规则,分别管理短信保号、通话、充值、重置和购买套餐。实际完成后点击“记录完成”,自动更新所选规则的下次日期。</p><h3>找到平台注册记录</h3><p>打开卡片详情添加平台与账号标识;使用顶部搜索可以按平台找到关联卡片。</p><h3>接收自动提醒</h3><p>在通知设置绑定 Telegram 或验证邮箱,开启渠道并保存提醒时间。归档卡片会停止安排提醒,历史资料仍然保留。</p></div>`,`<button class="btn btn-primary" data-action="close">知道了</button>`);
}
function showReminders() {
const upcoming = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7);
openOverlay('近期提醒',`${upcoming.length} 项操作需要在未来 7 天内完成。`,upcoming.length ? `<div class="task-list">${upcoming.map(({card,rule}) => `<div class="completion-summary" style="margin-bottom:0">${provider(card,true)}<div style="flex:1"><h3>${e(card.name)}</h3><p>${formatDate(rule.dueDate)} · ${ACTIONS[rule.action].label}</p></div><button class="text-button" data-action="complete" data-card="${e(card.id)}" data-rule="${e(rule.id)}">记录完成</button></div>`).join('')}</div><p class="account-note">开启通知渠道并完成绑定或验证后,续期提醒会按设置的时间发送。</p>` : empty('本周没有待完成的续期','有新的临近到期任务时,会出现在这里。'),`<button class="btn btn-primary" data-nav="renewals">查看全部计划</button>`);
}
function exportData() {
const blob = new Blob([JSON.stringify({exportedAt:new Date().toISOString(),user:user(),...data},null,2)],{type:'application/json'});
const url = URL.createObjectURL(blob), link = document.createElement('a');
link.href = url;link.download = `simkeep-${serverUser.id}-${today()}.json`;link.click();
setTimeout(() => URL.revokeObjectURL(url),1000);toast('已导出卡片、平台与续期记录');
}
function navigate(next) {
closeOverlay(false);view = next;query = '';showArchived = false;filter = 'all';
location.hash = next;
renderApp();window.scrollTo({top:0,behavior:'instant'});
}
function showCardActions(cardId) {
const card = findCard(cardId);
openOverlay(e(card.name),'选择要进行的操作。',`<div class="task-list"><button class="btn" style="justify-content:flex-start" data-action="detail" data-card="${e(cardId)}">${icon('sim')}查看完整资料</button><button class="btn" style="justify-content:flex-start" data-action="edit-card" data-card="${e(cardId)}">${icon('edit')}编辑卡片</button><button class="btn" style="justify-content:flex-start" data-action="archive-card" data-card="${e(cardId)}">${icon('archive')}${card.archived ? '恢复到在用卡片' : '归档,不再安排提醒'}</button></div>`,'','small');
}
document.addEventListener('click',async event => {
if (event.target.classList.contains('overlay-backdrop')) {closeOverlay();return;}
if (event.target.classList.contains('menu-scrim')) {event.target.remove();el('.sidebar').classList.remove('open');return;}
const button = event.target.closest('[data-action],[data-nav],[data-filter],[data-layout],[data-month],[data-date],[data-task-filter]');
if (!button || button.disabled) return;
if (button.dataset.nav) {event.preventDefault();navigate(button.dataset.nav);return;}
if (button.dataset.filter) {filter = button.dataset.filter;renderApp();return;}
if (button.dataset.layout) {layout = button.dataset.layout;renderApp();return;}
if (button.dataset.taskFilter) {taskFilter = button.dataset.taskFilter;renderApp();return;}
if (button.dataset.month) {monthDate.setUTCMonth(monthDate.getUTCMonth()+Number(button.dataset.month));selectedDate = null;renderApp();return;}
if (button.dataset.date) {selectedDate = selectedDate === button.dataset.date ? null : button.dataset.date;renderApp();return;}
const {action,card:cardId,rule:ruleId,platform:platformId} = button.dataset;
if (button.type !== 'submit') event.preventDefault();
const busy = ['refresh','retry-bootstrap','confirm-remove-platform','confirm-archive','archive-card','logout','bind-telegram','check-telegram','verify-email','test-email','test-telegram','confirm-unbind-telegram','confirm-clear-service-config'].includes(action);
if (busy) button.disabled = true;
try {
switch (action) {
case 'auth-login':authMode = 'login';renderAuth();break;
case 'auth-register':authMode = 'register';renderAuth();break;
case 'retry-bootstrap':await bootstrap();break;
case 'refresh':await refreshWorkspace();closeOverlay(false);renderApp();toast('资料已刷新');break;
case 'close':closeOverlay();break;
case 'add-card':cardForm();break;
case 'add-balance-row': {
const container = el('#balance-fields');
const used = [...container.querySelectorAll('[name="balanceCurrency"]')].map(input=>input.value);
const currency = Object.keys(CURRENCIES).find(code=>!used.includes(code));
if (!currency) throw new Error('所有支持的币种均已添加');
container.insertAdjacentHTML('beforeend',balanceRow({currency,amount:''}));container.lastElementChild.querySelector('select').focus();break;
}
case 'remove-balance-row':button.closest('.balance-row').remove();break;
case 'edit-card':cardForm(findCard(cardId));break;
case 'detail':showDetail(cardId);break;
case 'card-actions':showCardActions(cardId);break;
case 'complete':completionForm(cardId,ruleId);break;
case 'add-rule':ruleForm(cardId);break;
case 'edit-rule':ruleForm(cardId,ruleId);break;
case 'add-platform':platformForm(cardId);break;
case 'choose-platform':platformForm();break;
case 'remove-platform':removePlatform(cardId,platformId);break;
case 'confirm-remove-platform': {
const card = findCard(cardId);
await mutation('DELETE',`/api/cards/${cardId}/platforms/${platformId}?version=${card.version}`);
closeOverlay(false);renderApp();savedMessage('已移除平台关联');if (button.dataset.return) showDetail(button.dataset.return);break;
}
case 'archive-card': {
const card = findCard(cardId);
if (card.archived) {
await mutation('POST',`/api/cards/${cardId}/archive`,{archived:false,version:card.version});closeOverlay(false);renderApp();savedMessage('卡片已恢复,续期计划重新显示');
} else openOverlay('归档这张卡片?',e(card.name),'<p class="help-copy">归档后保留卡片资料、平台关联和操作历史,停止安排续期提醒。你可以随时在“已归档”中恢复。</p>',`<button class="btn" data-action="detail" data-card="${e(cardId)}">取消</button><button class="btn btn-primary" data-action="confirm-archive" data-card="${e(cardId)}">确认归档</button>`,'small');
break;
}
case 'confirm-archive':await mutation('POST',`/api/cards/${cardId}/archive`,{archived:true,version:findCard(cardId).version});closeOverlay(false);renderApp();savedMessage('卡片已归档');break;
case 'toggle-archived':showArchived = !showArchived;renderApp();break;
case 'calendar-today':monthDate = parseDate(`${today().slice(0,7)}-01`);selectedDate = null;renderApp();break;
case 'account':showAccount();break;
case 'logout': {
await mutation('POST','/api/auth/logout');closeOverlay(false);serverUser = null;data = emptyWorkspace();api.clear();view = 'overview';query = '';filter = 'all';showArchived = false;selectedDate = null;location.hash = '';renderAuth();toast('已退出登录');break;
}
case 'bind-telegram':await bindTelegram();break;
case 'configure-service':notificationConfigForm(button.dataset.channel);break;
case 'clear-service-config': {
const channel = button.dataset.channel;
openOverlay(`清除${channel === 'telegram' ? ' Telegram' : '邮件'}配置?`,'只影响当前账号。',`<p class="help-copy">清除后将停止使用你保存的凭据。${channel === 'telegram' ? 'Telegram 绑定也会解除。' : '已验证的接收邮箱会保留。'}如果服务器提供公共通知服务,将恢复使用公共服务。</p>`,`<button class="btn" data-action="configure-service" data-channel="${channel}">返回配置</button><button class="btn btn-danger" data-action="confirm-clear-service-config" data-channel="${channel}">清除配置</button>`,'small');break;
}
case 'confirm-clear-service-config':await mutation('DELETE',`/api/notifications/config/${button.dataset.channel}`);closeOverlay(false);renderApp();toast('本账号的通知服务配置已清除');break;
case 'check-telegram': {
const checked = await mutation('POST','/api/notifications/telegram/binding/check',{code:telegramBindingCode});
if (checked.bindingStatus === 'bound') {closeOverlay(false);renderApp();toast('Telegram 已绑定,请开启提醒并保存设置');}
else if (checked.bindingStatus === 'expired') toast('绑定指令已失效,请关闭后重新绑定',true);
else toast('还未收到绑定指令,请在 Bot 中点击“开始”后再检查',true);
break;
}
case 'unbind-telegram':openOverlay('解除 Telegram 绑定?','解除后将停止向这个 Telegram 账号发送提醒。','<p class="help-copy">你的卡片和操作记录仍保留,可以随时重新绑定。</p>','<button class="btn" data-action="close">取消</button><button class="btn btn-danger" data-action="confirm-unbind-telegram">解除绑定</button>','small');break;
case 'confirm-unbind-telegram':await mutation('DELETE','/api/notifications/telegram/binding');closeOverlay(false);renderApp();toast('Telegram 绑定已解除');break;
case 'verify-email': {
if (el('#notify-email').value.trim().toLowerCase() !== data.settings.emailAddress) throw new Error('请先保存新的接收邮箱,再发送验证码');
await mutation('POST','/api/notifications/email/verify');emailCodeForm();break;
}
case 'test-email':case 'test-telegram':await mutation('POST',`/api/notifications/test/${action === 'test-email' ? 'email' : 'telegram'}`);toast('测试提醒已发送,请检查接收消息');break;
case 'preview':openOverlay('续期提醒预览','这是通知内容预览。可在通知设置中发送测试提醒。',previewMessage(),'<button class="btn btn-primary" data-action="close">关闭预览</button>','small');break;
case 'reminders':showReminders();break;
case 'help':showHelp();break;
case 'export':exportData();break;
case 'menu':el('.sidebar').classList.add('open');el('#app').insertAdjacentHTML('beforeend','<div class="menu-scrim"></div>');break;
}
} catch (error) {reportError(error);}
finally {if (busy && button.isConnected) button.disabled = false;}
});
document.addEventListener('input',event => {
if (event.target.id !== 'global-search') return;
query = event.target.value;
if (view === 'settings') view = 'cards';
renderApp(true);
});
document.addEventListener('keydown',event => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k' && !el('[role="dialog"]')) {event.preventDefault();el('#global-search')?.focus();}
if (event.key === 'Escape' && el('.sidebar.open')) {el('.sidebar').classList.remove('open');el('.menu-scrim')?.remove();}
});
document.addEventListener('submit',async event => {
const form = event.target;
if (!['auth-form','card-form','rule-form','completion-form','platform-form','settings-form','email-code-form','notification-config-form'].includes(form.id)) return;
event.preventDefault();
if (form.dataset.pending === 'true') return;
form.dataset.pending = 'true';
const submits = [...form.querySelectorAll('button[type="submit"]'),...document.querySelectorAll(`button[type="submit"][form="${form.id}"]`)];
for (const button of submits) button.disabled = true;
const errorBox = form.querySelector('.form-error');if (errorBox) errorBox.hidden = true;
try {
const f = form.elements;
if (form.id === 'auth-form') {
const payload = {email:f.email.value.trim(),password:f.password.value};
if (authMode === 'register') payload.name = f.name.value.trim();
const result = await mutation('POST',`/api/auth/${authMode}`,payload);
serverUser = result.user;data = result.workspace;query = '';filter = 'all';showArchived = false;monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp();toast(authMode === 'register' ? '账号已创建,添加你的第一张卡片吧' : '已登录你的个人空间');
} else if (form.id === 'card-form') {
const existing = findCard(form.dataset.card);
const activatedAt = f.activatedAt.value;
validateActivationChange(existing,data.events,activatedAt,today());
if (!f.name.value.trim() || !f.provider.value.trim()) throw new Error('请填写卡片名称和运营商');
const card = {name:f.name.value.trim(),provider:f.provider.value.trim(),type:f.type.value,country:f.country.value,phone:f.phone.value.trim(),activatedAt,plan:f.plan.value.trim(),notes:f.notes.value.trim(),balances:readBalances(form)};
if (!existing) {const rule = readRule(form);if (rule.dueDate < activatedAt) throw new Error('首次到期日期不能早于开通日期');card.rules = [rule];}
else card.version = Number(form.dataset.version);
await mutation(existing ? 'PUT' : 'POST',existing ? `/api/cards/${existing.id}` : '/api/cards',card);
closeOverlay(false);renderApp();savedMessage(existing ? '卡片资料已更新' : '卡片已添加,第一次续期已安排');
} else if (form.id === 'rule-form') {
const card = findCard(form.dataset.card), existing = card.rules.find(item => item.id === form.dataset.rule), rule = readRule(form,existing);
if (rule.dueDate < card.activatedAt) throw new Error('到期日期不能早于卡片开通日期');
if (existing?.lastCompletedAt && rule.dueDate <= existing.lastCompletedAt) throw new Error('新的到期日期必须晚于上次完成日期');
if (existing) rule.version = Number(form.dataset.version);
await mutation(existing ? 'PUT' : 'POST',existing ? `/api/rules/${existing.id}` : `/api/cards/${card.id}/rules`,rule);
renderApp();savedMessage('续期规则已保存');showDetail(card.id);
} else if (form.id === 'completion-form') {
const card = findCard(form.dataset.card);
// The local calculation is a preview; the API owns the date and the atomic history write.
completeRenewal(card,form.dataset.rule,f.completedAt.value,today());
const cost = readCost(form), balanceAction = f.updateBalance.checked ? form.dataset.balanceAction : 'none';
if (balanceAction !== 'none') balanceAfter(card.balances || [],cost,balanceAction);
await mutation('POST',`/api/rules/${form.dataset.rule}/completions`,{completedAt:f.completedAt.value,note:f.note.value.trim(),requestId:form.dataset.requestId,version:Number(form.dataset.version),cost,balanceAction,cardVersion:Number(form.dataset.cardVersion)});
const next = findCard(card.id).rules.find(rule => rule.id === form.dataset.rule).dueDate;
closeOverlay(false);renderApp();savedMessage(`已记录完成,下次续期 ${formatDate(next)}`);
} else if (form.id === 'platform-form') {
const card = findCard(form.dataset.card || f.cardId.value);
const name = f.name.value.trim();if (!name) throw new Error('请填写平台名称');
await mutation('POST',`/api/cards/${card.id}/platforms`,{name,account:f.account.value.trim(),purpose:f.purpose.value.trim(),version:card.version});
closeOverlay(false);renderApp();savedMessage('平台关联已保存');if (detailReturn) showDetail(card.id);
} else if (form.id === 'settings-form') {
const emailAddress = f.emailAddress.value.trim();
if (f.email.checked && !emailAddress) throw new Error('启用邮箱提醒时,请填写接收邮箱');
await mutation('PUT','/api/settings',{telegram:f.telegram.checked,email:f.email.checked,emailAddress,timezone:f.timezone.value,time:f.time.value,offsets:Array.from(form.querySelectorAll('input[name="offset"]:checked')).map(input => Number(input.value)),overdue:f.overdue.checked});
selectedDate = null;monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp();savedMessage('通知设置已保存');
} else if (form.id === 'notification-config-form') {
const channel = form.dataset.channel;
const payload = channel === 'telegram' ? {token:f.token.value.trim()} : {host:f.host.value.trim(),port:Number(f.port.value),mode:f.mode.value,user:f.user.value.trim(),password:f.password.value,from:f.sender.value.trim()};
const feedback = form.querySelector('.config-feedback');feedback.hidden = true;
await mutation('PUT',`/api/notifications/config/${channel}`,payload);
renderApp();
const secret = channel === 'telegram' ? f.token : f.password;
secret.value = '';secret.required = false;secret.placeholder = '已保存,留空保留原值';
if (event.submitter?.value === 'check') {
feedback.textContent = '配置已保存,正在检查连接…';feedback.hidden = false;
try {
const checked = await mutation('POST',`/api/notifications/config/${channel}/check`);
feedback.textContent = channel === 'telegram' ? `配置已保存,已连接 @${checked.botUsername}。下一步绑定 Telegram。` : '配置已保存,SMTP 连接与认证通过。下一步验证接收邮箱。';
feedback.scrollIntoView({block:'nearest'});
} catch (error) {feedback.hidden = true;error.message = `配置已保存。${error.message}`;throw error;}
} else {closeOverlay(false);toast(channel === 'telegram' ? 'Telegram 配置已保存,可以绑定账号了' : '邮件配置已保存,可以验证接收邮箱了');}
} else if (form.id === 'email-code-form') {
await mutation('POST','/api/notifications/email/confirm',{code:f.code.value});closeOverlay(false);renderApp();toast('邮箱已验证,请开启提醒并保存设置');
}
} catch (error) {reportError(error,form);}
finally {form.dataset.pending = 'false';for (const button of submits) if (button.isConnected) button.disabled = false;}
});
if (NAV.some(item => `#${item.id}` === location.hash)) view = location.hash.slice(1);
window.addEventListener('hashchange',() => {const next = location.hash.slice(1);if (NAV.some(item => item.id === next) && next !== view) {view = next;closeOverlay(false);query = '';renderApp();}});
bootstrap();
+36
View File
@@ -0,0 +1,36 @@
name: simkeep
services:
web:
image: ${SIMKEEP_IMAGE:-simkeep:local}
build: .
init: true
restart: unless-stopped
stop_grace_period: 30s
env_file: ${SIMKEEP_ENV_FILE:-.env}
environment:
SIMKEEP_DB: /app/data/simkeep.db
SIMKEEP_STATIC_DIR: /app/dist
ports:
- "${SIMKEEP_BIND_ADDRESS:-0.0.0.0}:${SIMKEEP_PORT:-5180}:5180"
volumes:
- simkeep-data:/app/data
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=32m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5180/api/health',timeout=5)"]
interval: 10s
timeout: 6s
retries: 3
start_period: 15s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
simkeep-data:
+132
View File
@@ -0,0 +1,132 @@
const DAY = 86_400_000;
export function createId(cryptoSource = globalThis.crypto) {
if (typeof cryptoSource?.randomUUID === 'function') return cryptoSource.randomUUID();
const bytes = cryptoSource.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2,'0')).join('');
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}
export function parseDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) throw new Error('请输入有效日期');
const [year, month, day] = value.split('-').map(Number);
const date = new Date(0);
date.setUTCFullYear(year, month - 1, day);
date.setUTCHours(0, 0, 0, 0);
if (year < 1900 || year > 9999 || date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) throw new Error('请输入有效日期');
return date;
}
const isoDate = date => date.toISOString().slice(0, 10);
export function todayString(timeZone = 'Asia/Shanghai', now = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {timeZone, year:'numeric', month:'2-digit', day:'2-digit'}).formatToParts(now);
const get = type => parts.find(part => part.type === type).value;
return `${get('year')}-${get('month')}-${get('day')}`;
}
export function addDays(value, count) {
if (!Number.isInteger(count)) throw new Error('周期必须为整数');
const date = parseDate(value);
date.setUTCDate(date.getUTCDate() + count);
const result = isoDate(date);
parseDate(result);
return result;
}
export function addCycle(value, interval, unit) {
if (!Number.isInteger(interval) || interval < 1) throw new Error('周期必须为正整数');
if (unit === 'days') return addDays(value, interval);
if (unit !== 'months') throw new Error('请选择有效周期单位');
const date = parseDate(value);
const day = date.getUTCDate();
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() + interval);
const end = new Date(date);
end.setUTCMonth(end.getUTCMonth() + 1, 0);
date.setUTCDate(Math.min(day, end.getUTCDate()));
const result = isoDate(date);
parseDate(result);
return result;
}
export function daysUntil(value, today = todayString()) {
return Math.round((parseDate(value) - parseDate(today)) / DAY);
}
export function nextAfterCompletion(rule, completedAt) {
parseDate(completedAt);
parseDate(rule.dueDate);
if (rule.anchor !== 'scheduled') return addCycle(completedAt, rule.interval, rule.unit);
const origin = rule.anchorDate || rule.dueDate;
const after = completedAt > rule.dueDate ? completedAt : rule.dueDate;
// Calculate every occurrence from the original anchor, preserving month-end intent.
for (let cycle = 1; cycle < 50_000; cycle++) {
const candidate = addCycle(origin, rule.interval * cycle, rule.unit);
if (candidate > after) return candidate;
}
throw new Error('周期跨度过大,请调整规则');
}
export function completeRenewal(card, ruleId, completedAt, today, note = '') {
parseDate(completedAt);
parseDate(today);
const rule = card.rules.find(item => item.id === ruleId);
if (!rule) throw new Error('找不到这条续期规则');
if (card.archived) throw new Error('请先恢复这张卡片,再记录续期');
if (completedAt > today) throw new Error('不能记录未来的完成日期');
if (completedAt < card.activatedAt) throw new Error('完成日期不能早于开通日期');
if (rule.lastCompletedAt && completedAt < rule.lastCompletedAt) throw new Error('完成日期不能早于上次完成日期');
if (rule.lastCompletedAt === completedAt) throw new Error('这一天已经记录过该操作');
const nextDueDate = nextAfterCompletion(rule, completedAt);
const updated = {...card, rules:card.rules.map(item => item.id === ruleId ? {...item, dueDate:nextDueDate, lastCompletedAt:completedAt} : {...item})};
const event = {
id:createId(),
cardId:card.id, ruleId, action:rule.action, completedAt,
oldDueDate:rule.dueDate, newDueDate:nextDueDate, note,
};
return {card:updated, event};
}
export function getTasks(cards) {
return cards.filter(card => !card.archived)
.flatMap(card => card.rules.map(rule => ({card, rule})))
.sort((a, b) => a.rule.dueDate.localeCompare(b.rule.dueDate) || a.card.name.localeCompare(b.card.name));
}
export function validateActivationChange(card, events, activatedAt, today) {
parseDate(activatedAt);
parseDate(today);
if (activatedAt > today) throw new Error('开通日期不能是未来的日期');
if (!card) return;
const conflictsWithRule = card.rules.some(rule => rule.dueDate < activatedAt || (rule.lastCompletedAt && rule.lastCompletedAt < activatedAt));
const conflictsWithHistory = events.some(event => event.cardId === card.id && event.completedAt < activatedAt);
if (conflictsWithRule || conflictsWithHistory) throw new Error('开通日期不能晚于已有的续期记录或计划日期');
}
export const ACTIONS = {
sms:{label:'发送短信', icon:'message'},
call:{label:'拨打电话', icon:'phone'},
purchase:{label:'购买套餐', icon:'bag'},
reset:{label:'重置有效期', icon:'refresh'},
topup:{label:'账户充值', icon:'wallet'},
custom:{label:'自定义操作', icon:'check'},
};
export function dueStatus(date, today = todayString()) {
const days = daysUntil(date, today);
if (days < 0) return {label:`逾期 ${-days} 天`, tone:'danger', days};
if (days === 0) return {label:'今天到期', tone:'danger', days};
if (days <= 7) return {label:`${days} 天后到期`, tone:'warning', days};
return {label:`${days} 天后到期`, tone:'success', days};
}
export const cycleText = rule => `每 ${rule.interval} ${rule.unit === 'months' ? '个月' : '天'}`;
export function formatDate(value, short = false) {
if (!value) return '尚未设置';
const date = parseDate(value);
return short ? `${date.getUTCMonth() + 1} 月 ${date.getUTCDate()} 日` : value.replaceAll('-', '.');
}
+1
View File
@@ -0,0 +1 @@
export const COUNTRIES={GB:'英国',US:'美国',HK:'中国香港',JP:'日本',CN:'中国大陆',DE:'德国',SG:'新加坡',OTHER:'其他地区'};
+76
View File
@@ -0,0 +1,76 @@
# Docker Compose 部署
需要 Docker Engine 与 Docker Compose 插件。镜像构建会自动打包前端,不需要宿主机安装 Node.js、npm 或 Python。应用使用单个 Uvicorn worker,后台续期提醒与 Web 同时启动。
## 在新服务器部署
进入包含 Dockerfile 和 compose.yml 的 simkeep 目录,准备配置:
```bash
test -f .env || cp .env.example .env
chmod 600 .env
```
编辑 `.env`,将 `SIMKEEP_PUBLIC_URL` 改为实际访问地址,例如 `http://服务器IP:5180`。`SIMKEEP_PORT` 控制宿主机端口,默认 5180;改端口时同步修改公网地址。保持 HTTP 时使用 `SIMKEEP_SECURE_COOKIES=0`。每个用户登录网页的“通知设置”,点击“配置 Telegram / 配置邮件”保存自己的凭据;不需要修改 `.env` 或重启。`.env` 中的通知凭据仅作为可选公共默认服务。
```bash
docker compose up -d --build --wait
docker compose ps
```
访问 `http://服务器IP:5180/`,首次使用创建自己的账号。服务器防火墙需要允许选定的 TCP 端口。
默认 Compose 项目名为 `simkeep`,持久化卷为 `simkeep_simkeep-data`,数据库位于容器 `/app/data/simkeep.db`。保持项目名即可复用同一个卷。容器重启、镜像更新及普通 `docker compose down` 都会保留卷;`docker compose down -v` 会删除数据卷。
## 更新、配置与日志
更新代码并重新构建镜像:
```bash
docker compose up -d --build --wait
```
修改 `.env` 后重建容器加载新配置:
```bash
docker compose up -d --force-recreate --wait web
```
仅执行 `docker compose restart` 不会加载修改后的环境配置。日常查看或重启:
```bash
docker compose logs --tail=100 web
docker compose restart web
docker compose ps
```
容器设置 `unless-stopped` 自动重启,Docker 服务需要随服务器启动。镜像以 UID / GID 10001 运行,根文件系统只读,数据卷和临时目录可写。健康检查访问 `/api/health`,日志最多保留三份,每份 10 MB。`.env`、数据库、备份与测试文件均不进入镜像或公开静态目录。
## 备份
使用 SQLite backup API 获取一致的数据库备份:
```bash
docker compose exec web python scripts/backup.py
docker compose cp web:/app/data/backups ./docker-backups
```
备份保存在卷中的 `/app/data/backups/`,第二条命令复制到宿主机。复制出的备份包含账号、卡片、余额、历史、渠道设置以及用户在网页保存的 Bot Token / SMTP 密码,应存放在受保护的位置。迁移到另一台服务器时同时保留 `.env`,其中的可选公共服务密钥不在数据库备份中。个人配置会随数据卷或数据库备份一起恢复;旧版备份没有配置表时,应用启动自动补建。
## 恢复或导入已有 SQLite
先创建当前库备份,再停止应用。将选定的一致备份命名为当前项目目录下的 `restore.db`。不要直接复制运行中的主数据库而遗漏 WAL。
```bash
docker compose stop web
docker compose run --rm --no-deps -T --entrypoint python web scripts/restore.py --replace < restore.db
docker compose up -d --wait
```
恢复容器以应用用户读取标准输入,不需要 root 或修改宿主机备份权限。它检查数据库完整性与 SIMKEEP 表结构,临时生成恢复库后替换;未传 --replace 时会拒绝覆盖现有库。恢复容器只执行数据库操作,不启动提醒进程。使用 `docker compose -p 其他项目名` 时,以上所有命令也须带相同项目名。
## 从其他运行方式迁移
先用 SQLite backup API 创建一致性备份,保留原运行配置,然后停止旧进程。使用上面的“恢复或导入已有 SQLite”步骤导入 Docker 命名卷,再启动 web 容器。确认数据与通知设置后停用旧服务的自动启动;Docker 启动后的新数据以容器数据卷为准。
同一份 Telegram Bot 配置只运行一个提醒进程。不要同时运行旧服务和 Docker 容器,以免重复消费 Telegram 更新或重复安排提醒。
Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="280" viewBox="0 0 1200 280" role="img" aria-labelledby="title desc">
<title id="title">SIMKEEP · 续卡</title>
<desc id="desc">记住每张卡的下一次续期。SIM 与 eSIM 管理,余额、续期与提醒。</desc>
<rect width="1200" height="280" rx="18" fill="#f5f7fc"/>
<g transform="translate(60 68)" fill="none" stroke="#5360ea" stroke-width="4" stroke-linejoin="round">
<path d="M12 0h78l30 30v104a10 10 0 0 1-10 10H12a10 10 0 0 1-10-10V10A10 10 0 0 1 12 0Z" fill="#fff"/>
<rect x="30" y="64" width="64" height="44" rx="7" fill="#eff1ff"/>
<path d="M62 64v44m-32-22h64" stroke-width="2"/>
<path d="m83 19-11 18h14L77 53" stroke-width="3"/>
</g>
<g font-family="'Noto Sans CJK SC','PingFang SC','Microsoft YaHei',sans-serif">
<text x="222" y="114" font-size="48" font-weight="700" fill="#20283e">SIMKEEP <tspan font-size="30" font-weight="500">续卡</tspan></text>
<text x="225" y="159" font-size="23" fill="#20283e">记住每张卡的下一次续期。</text>
<text x="225" y="204" font-size="16" fill="#77839a">SIM / eSIM 管理 · 多币种余额 · Telegram 与邮件提醒</text>
</g>
<g transform="translate(845 72)" font-family="'Noto Sans CJK SC','PingFang SC','Microsoft YaHei',sans-serif">
<path d="M10 15v117" fill="none" stroke="#d6dcf0" stroke-width="2"/>
<circle cx="10" cy="15" r="7" fill="#168576"/>
<circle cx="10" cy="74" r="7" fill="#5360ea"/>
<circle cx="10" cy="133" r="7" fill="#f5f7fc" stroke="#9ca7c3" stroke-width="2"/>
<text x="35" y="21" font-size="17" fill="#20283e">记录开通</text>
<text x="35" y="80" font-size="17" fill="#20283e">安排续期</text>
<text x="35" y="139" font-size="17" fill="#20283e">准时提醒</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="13" fill="#5360EA"/><path d="M15 10h13l7 7v20H15a3 3 0 0 1-3-3V13a3 3 0 0 1 3-3Z" fill="none" stroke="#fff" stroke-width="2.5"/><rect x="18" y="22" width="11" height="9" rx="2" fill="none" stroke="#fff" stroke-width="2"/><path d="M23.5 22v9M18 26.5h11" stroke="#fff" stroke-width="1.5"/></svg>

After

Width:  |  Height:  |  Size: 387 B

+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#5360EA">
<meta name="description" content="续卡 SIMKEEP:管理 SIM 与 eSIM 卡片、关联平台、续期计划和自动提醒。">
<title>续卡 SIMKEEP · 卡片管理</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app"></div>
<div id="overlay-root"></div>
<div id="toast-root" aria-live="polite" aria-atomic="true"></div>
<script type="module" src="app.js"></script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
export const CURRENCIES={
CNY:{label:'人民币',places:2},USD:{label:'美元',places:2},HKD:{label:'港币',places:2},
GBP:{label:'英镑',places:2},EUR:{label:'欧元',places:2},JPY:{label:'日元',places:0},
TWD:{label:'新台币',places:2},SGD:{label:'新加坡元',places:2},AUD:{label:'澳元',places:2},
CAD:{label:'加元',places:2},CHF:{label:'瑞士法郎',places:2},NZD:{label:'新西兰元',places:2},
KRW:{label:'韩元',places:0},THB:{label:'泰铢',places:2},MYR:{label:'马来西亚林吉特',places:2},
AED:{label:'阿联酋迪拉姆',places:2},SAR:{label:'沙特里亚尔',places:2},IDR:{label:'印尼盾',places:2},
INR:{label:'印度卢比',places:2},PHP:{label:'菲律宾比索',places:2},VND:{label:'越南盾',places:0},
KWD:{label:'科威特第纳尔',places:3},BHD:{label:'巴林第纳尔',places:3},
};
export const COUNTRY_CURRENCY={GB:'GBP',US:'USD',HK:'HKD',JP:'JPY',CN:'CNY',DE:'EUR',SG:'SGD',OTHER:'USD'};
export function normalizeAmount(value,currency) {
const places=CURRENCIES[currency]?.places;
if(places===undefined) throw new Error('请选择支持的币种');
if(typeof value!=='string'||value.length>30||!/^\d+(?:\.\d+)?$/.test(value)) throw new Error('金额须为不小于 0 的数字');
const [rawInteger,fraction='']=value.split('.');
const integer=rawInteger.replace(/^0+(?=\d)/,'');
if(BigInt(integer)>999999999n||(BigInt(integer)===999999999n&&/[1-9]/.test(fraction))) throw new Error('金额不能超过 999999999');
if(/[1-9]/.test(fraction.slice(places))) throw new Error(`${currency} 金额最多保留 ${places} 位小数`);
return integer+(places ? '.'+fraction.slice(0,places).padEnd(places,'0') : '');
}
const units = money => BigInt(normalizeAmount(money.amount,money.currency).replace('.',''));
function fromUnits(value,currency) {
const places=CURRENCIES[currency].places;
const digits=value.toString().padStart(places+1,'0');
return places ? digits.slice(0,-places)+'.'+digits.slice(-places) : digits;
}
export function sumMoney(values) {
const sums=new Map();
for(const money of values.filter(Boolean)) sums.set(money.currency,(sums.get(money.currency)||0n)+units(money));
return [...sums].sort(([a],[b])=>a.localeCompare(b)).map(([currency,value])=>({currency,amount:fromUnits(value,currency)}));
}
export function balanceAfter(balances,cost,operation) {
if(!cost||!['deduct','credit'].includes(operation)) throw new Error('更新余额时须填写本次金额和币种');
const current=balances.find(item=>item.currency===cost.currency);
if(!current&&operation==='deduct') throw new Error(`尚未记录 ${cost.currency} 余额,请先在卡片资料中添加`);
const before=current ? units(current) : 0n;
const after=operation==='deduct' ? before-units(cost) : before+units(cost);
if(after<0n) throw new Error(`${cost.currency} 余额不足,可取消更新余额后单独记录费用`);
return {currency:cost.currency,amount:normalizeAmount(fromUnits(after,cost.currency),cost.currency)};
}
export function formatMoney(money) {
if(!money) return '未填写';
const [integer,fraction]=money.amount.split('.');
return `${money.currency} ${integer.replace(/\B(?=(\d{3})+(?!\d))/g,',')}${fraction===undefined ? '' : '.'+fraction}`;
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "simkeep",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "python3 -m uvicorn server.main:create_app --factory --host 0.0.0.0 --port 5180 --workers 1",
"build": "python3 scripts/build.py",
"test": "node tests/core.test.js && node tests/api.test.js && node tests/money.test.js"
}
}
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.141.1
uvicorn==0.53.0
pydantic==2.12.4
httpx==0.28.1
+17
View File
@@ -0,0 +1,17 @@
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
root=Path(__file__).resolve().parent.parent
source=Path(os.getenv('SIMKEEP_DB',str(root/'data'/'simkeep.db'))).resolve()
if not source.is_file():
raise SystemExit('数据库尚未创建')
directory=source.parent/'backups'
directory.mkdir(mode=0o700,exist_ok=True)
target=directory/f"simkeep-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')}.db"
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
os.close(fd)
with sqlite3.connect(f'{source.as_uri()}?mode=ro',uri=True) as original,sqlite3.connect(target) as backup:
original.backup(backup)
print(f'备份完成:{target}')
+25
View File
@@ -0,0 +1,25 @@
"""Publish only browser assets. Server files, databases and .env are never copied."""
import argparse
import shutil
from pathlib import Path
ROOT=Path(__file__).resolve().parent.parent
ASSETS=('index.html','style.css','app.js','api.js','core.js','money.js','countries.js','favicon.svg')
parser=argparse.ArgumentParser()
parser.add_argument('--output',default='dist')
args=parser.parse_args()
target=(ROOT/args.output).resolve()
if target.parent!=ROOT or target.name not in ('dist','preview'):
parser.error('output must be dist or preview in this project')
for asset in ASSETS:
if not (ROOT/asset).is_file():
raise SystemExit(f'Missing asset: {asset}')
target.mkdir(exist_ok=True)
for asset in target.iterdir():
if asset.is_file() and asset.name not in ASSETS:
asset.unlink()
for asset in ASSETS:
temp=target/(asset+'.next')
shutil.copyfile(ROOT/asset,temp)
temp.replace(target/asset)
print(f'Published {len(ASSETS)} browser assets to {target}')
+60
View File
@@ -0,0 +1,60 @@
"""Restore a consistent SIMKEEP backup from stdin while the application is stopped."""
import argparse
import os
import shutil
import sqlite3
import sys
import tempfile
from contextlib import closing
from pathlib import Path
TABLES={'users','sessions','cards','events','bindings','notification_jobs','system_state'}
def restore_database(destination,stream,replace=False):
destination=Path(destination).resolve()
if destination.exists() and not replace:
raise ValueError('数据库已存在;先备份并停止应用,确认替换后使用 --replace')
staged=None
try:
with tempfile.TemporaryDirectory(prefix='simkeep-import-') as directory:
incoming=Path(directory)/'backup.db'
with incoming.open('wb') as target:
shutil.copyfileobj(stream,target)
if incoming.stat().st_size==0:
raise ValueError('备份为空,数据库未修改')
with closing(sqlite3.connect(f'{incoming.as_uri()}?mode=ro',uri=True)) as source:
if source.execute('PRAGMA integrity_check').fetchone()[0]!='ok':
raise ValueError('备份完整性检查失败,数据库未修改')
tables={row[0] for row in source.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if not TABLES.issubset(tables):
raise ValueError('请选择完整的 SIMKEEP 数据库备份,数据库未修改')
destination.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
with tempfile.NamedTemporaryFile(prefix='.simkeep-restore-',suffix='.db',dir=destination.parent,delete=False) as target:
staged=Path(target.name)
with closing(sqlite3.connect(staged)) as target:
source.backup(target)
if target.execute('PRAGMA integrity_check').fetchone()[0]!='ok':
raise ValueError('恢复文件完整性检查失败,数据库未修改')
staged.chmod(0o600)
for suffix in ('-wal','-shm'):
Path(str(destination)+suffix).unlink(missing_ok=True)
staged.replace(destination)
except sqlite3.DatabaseError:
raise ValueError('备份格式无效或已损坏,数据库未修改') from None
finally:
if staged is not None:
staged.unlink(missing_ok=True)
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--replace',action='store_true',help='明确替换现有数据库;须先备份并停止应用')
args=parser.parse_args()
root=Path(__file__).resolve().parent.parent
destination=os.getenv('SIMKEEP_DB',str(root/'data'/'simkeep.db'))
try:
restore_database(destination,sys.stdin.buffer,args.replace)
except (ValueError,OSError) as error:
parser.exit(1,f'恢复失败:{error}\n')
print('数据库恢复完成')
+1
View File
@@ -0,0 +1 @@
"""SIMKEEP application services."""
+45
View File
@@ -0,0 +1,45 @@
import hashlib
import hmac
import secrets
import threading
import time
from fastapi import HTTPException
def digest(value):
return hashlib.sha256(value.encode()).hexdigest()
def hash_password(password):
salt=secrets.token_bytes(16)
key=hashlib.scrypt(password.encode(),salt=salt,n=16384,r=8,p=1,dklen=32)
return f'scrypt${salt.hex()}${key.hex()}'
def verify_password(password,encoded):
try:
algorithm,salt,key=encoded.split('$')
if algorithm != 'scrypt':
return False
actual=hashlib.scrypt(password.encode(),salt=bytes.fromhex(salt),n=16384,r=8,p=1,dklen=32)
return hmac.compare_digest(actual,bytes.fromhex(key))
except (ValueError,TypeError):
return False
DUMMY_HASH=hash_password('never-a-real-account-password')
class RateLimit:
def __init__(self):
self.entries={}
self.lock=threading.Lock()
def check(self,key,limit=20,window=60):
now=time.monotonic()
with self.lock:
self.entries={k:v for k,v in self.entries.items() if now < v[0]}
expiry,count=self.entries.get(key,(now+window,0))
if count >= limit:
raise HTTPException(429,'操作过于频繁,请稍后重试')
self.entries[key]=(expiry,count+1)
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import json
import sqlite3
from contextlib import contextmanager
from pathlib import Path
SCHEMA = '''
CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY,email TEXT NOT NULL UNIQUE,name TEXT NOT NULL,password_hash TEXT NOT NULL,settings TEXT NOT NULL,created_at REAL NOT NULL);
CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,csrf TEXT NOT NULL,expires_at REAL NOT NULL);
CREATE TABLE IF NOT EXISTS cards(id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,document TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS cards_user ON cards(user_id);
CREATE TABLE IF NOT EXISTS events(id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,card_id TEXT NOT NULL REFERENCES cards(id),rule_id TEXT NOT NULL,request_id TEXT NOT NULL,document TEXT NOT NULL,UNIQUE(user_id,request_id));
CREATE INDEX IF NOT EXISTS events_user ON events(user_id);
CREATE TABLE IF NOT EXISTS bindings(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id),kind TEXT NOT NULL,destination TEXT,expires_at REAL NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,created_at REAL NOT NULL);
CREATE TABLE IF NOT EXISTS notification_jobs(id TEXT PRIMARY KEY,dedupe_key TEXT NOT NULL UNIQUE,user_id TEXT NOT NULL REFERENCES users(id),card_id TEXT NOT NULL REFERENCES cards(id),rule_id TEXT NOT NULL,rule_version INTEGER NOT NULL,channel TEXT NOT NULL,destination TEXT NOT NULL,payload TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'pending',attempts INTEGER NOT NULL DEFAULT 0,ready_at REAL NOT NULL,created_at REAL NOT NULL,sent_at REAL,error TEXT);
CREATE INDEX IF NOT EXISTS notification_ready ON notification_jobs(status,ready_at);
CREATE TABLE IF NOT EXISTS system_state(key TEXT PRIMARY KEY,value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS notification_configs(user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,channel TEXT NOT NULL CHECK(channel IN ('telegram','email')),document TEXT NOT NULL,telegram_identity TEXT UNIQUE,PRIMARY KEY(user_id,channel));
'''
class Database:
def __init__(self,path):
self.path=Path(path)
self.path.parent.mkdir(parents=True,exist_ok=True)
with self.connect() as conn:
conn.execute('PRAGMA journal_mode=WAL')
conn.executescript(SCHEMA)
@contextmanager
def connect(self,write=False):
conn=sqlite3.connect(str(self.path),timeout=15)
conn.row_factory=sqlite3.Row
conn.execute('PRAGMA foreign_keys=ON')
conn.execute('PRAGMA busy_timeout=15000')
try:
if write:
conn.execute('BEGIN IMMEDIATE')
yield conn
conn.commit()
except BaseException:
conn.rollback()
raise
finally:
conn.close()
def encode(value):
return json.dumps(value,ensure_ascii=False,separators=(',',':'))
def decode(value):
return json.loads(value)
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import calendar
import re
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
ACTIONS = {'sms':'发送短信','call':'拨打电话','purchase':'购买套餐','reset':'重置有效期','topup':'账户充值','custom':'自定义操作'}
def parse_date(value: str) -> date:
if not isinstance(value,str) or not re.fullmatch(r'\d{4}-\d{2}-\d{2}',value):
raise ValueError('请输入有效日期')
try:
result = date.fromisoformat(value)
if result.year < 1900:
raise ValueError()
return result
except ValueError:
raise ValueError('请输入有效日期') from None
def add_cycle(value: str, interval: int, unit: str) -> str:
start = parse_date(value)
if type(interval) is not int or interval < 1:
raise ValueError('周期必须为正整数')
try:
if unit == 'days':
return (start + timedelta(days=interval)).isoformat()
if unit != 'months':
raise ValueError('请选择有效周期单位')
year, month = divmod(start.year * 12 + start.month - 1 + interval,12)
month += 1
return date(year,month,min(start.day,calendar.monthrange(year,month)[1])).isoformat()
except (OverflowError,ValueError):
raise ValueError('周期或日期超出允许范围') from None
def next_due(rule: dict, completed: str) -> str:
parse_date(completed)
if rule['anchor'] != 'scheduled':
return add_cycle(completed,rule['interval'],rule['unit'])
origin = rule.get('anchorDate') or rule['dueDate']
after = max(completed,rule['dueDate'])
for cycle in range(1,50000):
candidate = add_cycle(origin,rule['interval']*cycle,rule['unit'])
if candidate > after:
return candidate
raise ValueError('周期跨度过大,请调整规则')
def business_today(zone: str, now: datetime | None = None) -> str:
return (now or datetime.now(timezone.utc)).astimezone(ZoneInfo(zone)).date().isoformat()
def check_activation(card: dict | None, events: list[dict], activated: str, today: str):
parse_date(activated)
if activated > today:
raise ValueError('开通日期不能是未来日期')
if not card:
return
if any(r['dueDate'] < activated or (r.get('lastCompletedAt') and r['lastCompletedAt'] < activated) for r in card['rules']):
raise ValueError('开通日期不能晚于已有的续期记录或计划日期')
if any(ev['cardId'] == card['id'] and ev['completedAt'] < activated for ev in events):
raise ValueError('开通日期不能晚于已有的续期记录或计划日期')
def check_completion(card: dict, rule: dict, completed: str, today: str):
parse_date(completed)
if card['archived']:
raise ValueError('请先恢复卡片,再记录续期')
if completed > today:
raise ValueError('不能记录未来的完成日期')
if completed < card['activatedAt']:
raise ValueError('完成日期不能早于开通日期')
if rule.get('lastCompletedAt') and completed <= rule['lastCompletedAt']:
raise ValueError('完成日期须晚于上次完成日期,这一天可能已经记录过')
+362
View File
@@ -0,0 +1,362 @@
from __future__ import annotations
import asyncio
import hmac
import os
import secrets
import sqlite3
import time
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4
from fastapi import Depends,FastAPI,HTTPException,Request,Response
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from fastapi.staticfiles import StaticFiles
from .auth import DUMMY_HASH,RateLimit,digest,hash_password,verify_password
from .db import Database,decode,encode
from .domain import business_today,check_activation,check_completion,next_due
from .models import ArchiveInput,CardInput,Completion,PlatformInput,Register,Login,RuleInput,SettingsInput
from .money import apply_balance
ROOT=Path(__file__).resolve().parent.parent
COLORS={'giffgaff':'#242C40','MobiMatter':'#F38B4A','Ultra Mobile':'#8562CA','3HK':'#457DE7','Tello':'#30A593','Airalo':'#D65870'}
def create_app(db_path=None,start_worker=True,config=None):
db=Database(db_path or os.getenv('SIMKEEP_DB',str(ROOT/'data'/'simkeep.db')))
rate=RateLimit()
from .notifications import Config,Notifier
configuration=config or Config.from_env()
notifier=Notifier(db,configuration)
@asynccontextmanager
async def lifespan(app):
worker=asyncio.create_task(notifier.run()) if start_worker else None
try:
yield
finally:
if worker:
worker.cancel()
try:
await worker
except asyncio.CancelledError:
pass
app=FastAPI(title='续卡 SIMKEEP',lifespan=lifespan,docs_url=None,redoc_url=None)
app.state.db=db
app.state.notifier=notifier
@app.exception_handler(RequestValidationError)
async def invalid_request(request,exception):
errors=[{key:error[key] for key in ('loc','msg','type')} for error in exception.errors()]
return JSONResponse({'detail':errors},status_code=422)
@app.exception_handler(ValueError)
async def invalid_value(request,exception):
return JSONResponse({'detail':str(exception)},status_code=422)
@app.middleware('http')
async def protect_response(request,call_next):
if request.url.path.startswith('/api/'):
try:
length=int(request.headers.get('content-length','0') or 0)
except ValueError:
return JSONResponse({'detail':'请求长度无效'},status_code=400)
if length < 0:
return JSONResponse({'detail':'请求长度无效'},status_code=400)
if length > 1024*1024:
return JSONResponse({'detail':'请求内容过大'},status_code=413)
if request.method not in ('GET','HEAD','OPTIONS'):
origin=request.headers.get('origin')
allowed={str(request.base_url).rstrip('/')}
if configuration.public_url:
allowed.add(configuration.public_url.rstrip('/'))
if origin and origin.rstrip('/') not in allowed:
return JSONResponse({'detail':'请求来源不匹配'},status_code=403)
response=await call_next(request)
response.headers['X-Content-Type-Options']='nosniff'
response.headers['Referrer-Policy']='same-origin'
response.headers['X-Frame-Options']='DENY'
if request.url.path.startswith('/api/'):
response.headers['Cache-Control']='no-store'
return response
def session(request:Request):
token=request.cookies.get('simkeep_session','')
with db.connect() as conn:
row=conn.execute('SELECT users.*,sessions.csrf,sessions.token_hash FROM sessions JOIN users ON users.id=sessions.user_id WHERE token_hash=? AND expires_at>?',(digest(token),time.time())).fetchone()
if not row:
raise HTTPException(401,'请先登录')
if request.method not in ('GET','HEAD','OPTIONS'):
supplied=request.headers.get('x-csrf-token','')
if not hmac.compare_digest(supplied,row['csrf']):
raise HTTPException(403,'请求校验失败,请刷新页面后重试')
return dict(row)
def public_user(row):
return {'id':row['id'],'email':row['email'],'name':row['name'],'initial':row['name'][0]}
def workspace(user_id):
with db.connect() as conn:
person=conn.execute('SELECT * FROM users WHERE id=?',(user_id,)).fetchone()
cards=[decode(row['document']) for row in conn.execute('SELECT document FROM cards WHERE user_id=? ORDER BY rowid',(user_id,))]
events=[decode(row['document']) for row in conn.execute('SELECT document FROM events WHERE user_id=? ORDER BY rowid',(user_id,))]
logs=[dict(row) for row in conn.execute('SELECT id,channel,status,attempts,created_at,sent_at,error FROM notification_jobs WHERE user_id=? ORDER BY created_at DESC LIMIT 30',(user_id,))]
return {'version':2,'cards':cards,'events':events,'settings':decode(person['settings']),'services':notifier.configs.status(user_id,notifier.config),'notifications':logs}
def result(user_id,**extra):
return {'workspace':workspace(user_id),**extra}
def card_for(conn,user_id,card_id):
row=conn.execute('SELECT document FROM cards WHERE id=? AND user_id=?',(card_id,user_id)).fetchone()
if not row:
raise HTTPException(404,'找不到这张卡片')
return decode(row['document'])
def rule_for(conn,user_id,rule_id):
for row in conn.execute('SELECT document FROM cards WHERE user_id=?',(user_id,)):
card=decode(row['document'])
for rule in card['rules']:
if rule['id']==rule_id:
return card,rule
raise HTTPException(404,'找不到这条续期规则')
def expected(actual,supplied):
if actual != supplied:
raise HTTPException(409,'资料已更新,请刷新后再操作')
def save_card(conn,owner,card):
card['version']+=1
conn.execute('UPDATE cards SET document=? WHERE id=? AND user_id=?',(encode(card),card['id'],owner))
def cancel_jobs(conn,owner,card_id,rule_id=None):
query="UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND card_id=? AND status IN ('pending','processing')"
args=[owner,card_id]
if rule_id:
query+=' AND rule_id=?'
args.append(rule_id)
conn.execute(query,args)
def new_rule(payload,card,old=None):
rule=payload.model_dump(exclude={'version'})
if old and 'cost' not in payload.model_fields_set:
rule['cost']=old.get('cost')
if rule['unit']=='months' and rule['interval']>120:
raise ValueError('月份周期须为 1–120')
if rule['dueDate']<card['activatedAt']:
raise ValueError('到期日期不能早于卡片开通日期')
if old and old.get('lastCompletedAt') and rule['dueDate']<=old['lastCompletedAt']:
raise ValueError('到期日期须晚于上次完成日期')
changed=old and any(old[key]!=rule[key] for key in ('dueDate','interval','unit','anchor'))
return {**(old or {}),**rule,'id':old['id'] if old else str(uuid4()),'anchorDate':old['anchorDate'] if old and not changed else rule['dueDate'],'version':old['version']+1 if old else 1}
def new_session(conn,user_id,response):
token=secrets.token_urlsafe(32)
csrf=secrets.token_urlsafe(24)
conn.execute('DELETE FROM sessions WHERE expires_at<?',(time.time(),))
conn.execute('INSERT INTO sessions VALUES(?,?,?,?)',(digest(token),user_id,csrf,time.time()+30*86400))
response.set_cookie('simkeep_session',token,max_age=30*86400,httponly=True,samesite='lax',secure=configuration.secure_cookies)
return csrf
@app.get('/api/health')
def health():
return {'status':'ok','app':'simkeep'}
@app.post('/api/auth/register',status_code=201)
def register(payload:Register,request:Request,response:Response):
rate.check(('register',request.client.host),10,3600)
user_id=str(uuid4())
settings={'timezone':'Asia/Shanghai','time':'09:00','offsets':[7,3,1],'overdue':True,'telegram':False,'email':False,'emailAddress':payload.email,'emailVerified':False,'chatId':'','telegramVerified':False}
hashed=hash_password(payload.password)
try:
with db.connect(write=True) as conn:
conn.execute('INSERT INTO users VALUES(?,?,?,?,?,?)',(user_id,payload.email,payload.name,hashed,encode(settings),time.time()))
csrf=new_session(conn,user_id,response)
except sqlite3.IntegrityError:
raise HTTPException(409,'这个邮箱已经注册,请直接登录') from None
return {'user':{'id':user_id,'email':payload.email,'name':payload.name,'initial':payload.name[0]},'csrfToken':csrf,'workspace':workspace(user_id)}
@app.post('/api/auth/login')
def login(payload:Login,request:Request,response:Response):
rate.check(('login',request.client.host),20,60)
with db.connect(write=True) as conn:
row=conn.execute('SELECT * FROM users WHERE email=?',(payload.email,)).fetchone()
if not verify_password(payload.password,row['password_hash'] if row else DUMMY_HASH) or not row:
raise HTTPException(401,'邮箱或密码不正确')
csrf=new_session(conn,row['id'],response)
return {'user':public_user(row),'csrfToken':csrf,'workspace':workspace(row['id'])}
@app.get('/api/auth/me')
def me(person=Depends(session)):
return {'user':public_user(person),'csrfToken':person['csrf']}
@app.post('/api/auth/logout')
def logout(response:Response,person=Depends(session)):
with db.connect(write=True) as conn:
conn.execute('DELETE FROM sessions WHERE token_hash=?',(person['token_hash'],))
response.delete_cookie('simkeep_session')
return {'ok':True}
@app.get('/api/workspace')
def get_workspace(person=Depends(session)):
return result(person['id'])
@app.get('/api/cards/{card_id}')
def get_card(card_id:str,person=Depends(session)):
with db.connect() as conn:
return {'card':card_for(conn,person['id'],card_id)}
@app.post('/api/cards',status_code=201)
def add_card(payload:CardInput,person=Depends(session)):
owner=person['id']
check_activation(None,[],payload.activatedAt,business_today(decode(person['settings'])['timezone']))
card=payload.model_dump(exclude={'rules','version','balances'})
card['balances']=[item.model_dump() for item in payload.balances or []]
card.update(id=str(uuid4()),version=1,archived=False,platforms=[],rules=[],color=COLORS.get(payload.provider,'#5360EA'),mark='3' if payload.provider=='3HK' else payload.provider[0])
card['phone']=card['phone'] or '数据卡 · 无号码'
card['rules']=[new_rule(r,card) for r in payload.rules]
with db.connect(write=True) as conn:
if conn.execute('SELECT count(*) FROM cards WHERE user_id=?',(owner,)).fetchone()[0]>=1000:
raise HTTPException(422,'每个账号最多保存 1000 张卡片')
conn.execute('INSERT INTO cards VALUES(?,?,?)',(card['id'],owner,encode(card)))
return result(owner)
@app.put('/api/cards/{card_id}')
def update_card(card_id:str,payload:CardInput,person=Depends(session)):
owner=person['id']
with db.connect(write=True) as conn:
card=card_for(conn,owner,card_id)
expected(card['version'],payload.version)
if payload.rules:
raise HTTPException(422,'请通过续期规则入口修改规则')
events=[decode(r['document']) for r in conn.execute('SELECT document FROM events WHERE user_id=? AND card_id=?',(owner,card_id))]
check_activation(card,events,payload.activatedAt,business_today(decode(person['settings'])['timezone']))
card.update(payload.model_dump(exclude={'rules','version','balances'}))
if payload.balances is not None:
card['balances']=[item.model_dump() for item in payload.balances]
card.update(color=COLORS.get(payload.provider,'#5360EA'),mark='3' if payload.provider=='3HK' else payload.provider[0])
save_card(conn,owner,card)
return result(owner)
@app.post('/api/cards/{card_id}/archive')
def archive(card_id:str,payload:ArchiveInput,person=Depends(session)):
owner=person['id']
with db.connect(write=True) as conn:
card=card_for(conn,owner,card_id)
expected(card['version'],payload.version)
card['archived']=payload.archived
save_card(conn,owner,card)
cancel_jobs(conn,owner,card_id)
return result(owner)
@app.post('/api/cards/{card_id}/rules',status_code=201)
def add_rule(card_id:str,payload:RuleInput,person=Depends(session)):
owner=person['id']
with db.connect(write=True) as conn:
card=card_for(conn,owner,card_id)
if len(card['rules'])>=20:
raise HTTPException(422,'每张卡片最多设置 20 条续期规则')
card['rules'].append(new_rule(payload,card))
save_card(conn,owner,card)
return result(owner)
@app.put('/api/rules/{rule_id}')
def update_rule(rule_id:str,payload:RuleInput,person=Depends(session)):
owner=person['id']
with db.connect(write=True) as conn:
card,rule=rule_for(conn,owner,rule_id)
expected(rule['version'],payload.version)
replacement=new_rule(payload,card,rule)
card['rules']=[replacement if r['id']==rule_id else r for r in card['rules']]
save_card(conn,owner,card)
cancel_jobs(conn,owner,card['id'],rule_id)
return result(owner)
@app.post('/api/rules/{rule_id}/completions')
def complete(rule_id:str,payload:Completion,person=Depends(session)):
owner=person['id']
with db.connect(write=True) as conn:
card,rule=rule_for(conn,owner,rule_id)
previous=conn.execute('SELECT rule_id,document FROM events WHERE user_id=? AND request_id=?',(owner,payload.requestId)).fetchone()
if previous:
if previous['rule_id']!=rule_id:
raise HTTPException(409,'这次提交已用于另一条规则')
recorded=decode(previous['document'])
requested_cost=payload.cost.model_dump() if payload.cost else None
changed=(payload.completedAt!=recorded['completedAt'] or payload.note!=recorded.get('note','') or payload.balanceAction!=recorded.get('balanceAction','none'))
if 'cost' in payload.model_fields_set:
changed=changed or requested_cost!=recorded.get('cost')
if changed:
raise HTTPException(409,'本次续期已保存,但重试内容有变化。请刷新卡片查看已保存的记录')
else:
expected(rule['version'],payload.version)
check_completion(card,rule,payload.completedAt,business_today(decode(person['settings'])['timezone']))
due=next_due(rule,payload.completedAt)
cost=payload.cost.model_dump() if payload.cost else None
if 'cost' not in payload.model_fields_set:
cost=rule.get('cost')
before=after=None
if payload.balanceAction!='none':
if payload.cardVersion is None:
raise ValueError('更新余额时须提交当前卡片版本,请刷新后重试')
expected(card['version'],payload.cardVersion)
if payload.balanceAction=='credit' and rule['action']!='topup':
raise ValueError('只有账户充值操作可以将金额计入余额')
if payload.balanceAction=='deduct' and rule['action']=='topup':
raise ValueError('账户充值请使用计入余额,或只记录费用')
card['balances'],before,after=apply_balance(card.get('balances',[]),cost,payload.balanceAction)
event={'id':str(uuid4()),'cardId':card['id'],'ruleId':rule_id,'action':rule['action'],'completedAt':payload.completedAt,'oldDueDate':rule['dueDate'],'newDueDate':due,'note':payload.note,'cost':cost,'balanceAction':payload.balanceAction,'balanceBefore':before,'balanceAfter':after}
rule.update(dueDate=due,lastCompletedAt=payload.completedAt,version=rule['version']+1)
save_card(conn,owner,card)
conn.execute('INSERT INTO events VALUES(?,?,?,?,?,?)',(event['id'],owner,card['id'],rule_id,payload.requestId,encode(event)))
cancel_jobs(conn,owner,card['id'],rule_id)
return result(owner)
@app.post('/api/cards/{card_id}/platforms',status_code=201)
def add_platform(card_id:str,payload:PlatformInput,person=Depends(session)):
owner=person['id']
with db.connect(write=True) as conn:
card=card_for(conn,owner,card_id)
expected(card['version'],payload.version)
if len(card['platforms'])>=200:
raise HTTPException(422,'每张卡片最多保存 200 条平台关联')
if any(p['name'].casefold()==payload.name.casefold() and p['account'].casefold()==payload.account.casefold() for p in card['platforms']):
raise HTTPException(409,'这条平台与账号关联已经存在')
card['platforms'].append({'id':str(uuid4()),**payload.model_dump(exclude={'version'})})
save_card(conn,owner,card)
return result(owner)
@app.delete('/api/cards/{card_id}/platforms/{platform_id}')
def delete_platform(card_id:str,platform_id:str,version:int,person=Depends(session)):
owner=person['id']
with db.connect(write=True) as conn:
card=card_for(conn,owner,card_id)
expected(card['version'],version)
if not any(p['id']==platform_id for p in card['platforms']):
raise HTTPException(404,'找不到这条平台关联')
card['platforms']=[p for p in card['platforms'] if p['id']!=platform_id]
save_card(conn,owner,card)
return result(owner)
@app.put('/api/settings')
def settings(payload:SettingsInput,person=Depends(session)):
if payload.email and not payload.emailAddress:
raise HTTPException(422,'启用邮箱提醒时须填写接收邮箱')
owner=person['id']
with db.connect(write=True) as conn:
old=decode(conn.execute('SELECT settings FROM users WHERE id=?',(owner,)).fetchone()['settings'])
updated={**old,**payload.model_dump()}
if old['emailAddress']!=payload.emailAddress:
updated['emailVerified']=False
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='email'",(owner,))
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(updated),owner))
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND status IN ('pending','processing')",(owner,))
return result(owner)
notifier.install_routes(app,session,result,rate)
static_path=Path(os.getenv('SIMKEEP_STATIC_DIR',str(ROOT/'dist')))
if static_path.exists():
app.mount('/',StaticFiles(directory=static_path,html=True),name='frontend')
return app
+161
View File
@@ -0,0 +1,161 @@
import re
from typing import Literal
from zoneinfo import ZoneInfo,ZoneInfoNotFoundError
from pydantic import BaseModel,ConfigDict,Field,StrictInt,field_validator,model_validator
from .domain import parse_date
from .money import CURRENCIES,normalize_amount
class Model(BaseModel):
model_config=ConfigDict(extra='forbid',str_strip_whitespace=True)
class Money(Model):
currency:str=Field(min_length=3,max_length=3)
amount:str=Field(min_length=1,max_length=30)
@model_validator(mode='after')
def valid_money(self):
self.currency=self.currency.upper()
self.amount=normalize_amount(self.amount,self.currency)
return self
class Login(Model):
model_config=ConfigDict(extra='forbid',str_strip_whitespace=False)
email:str=Field(min_length=3,max_length=120)
password:str=Field(min_length=1,max_length=128)
@field_validator('email')
@classmethod
def email_format(cls,value):
value=value.strip()
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[^\s@]+',value):
raise ValueError('请输入有效邮箱')
return value.lower()
class Register(Login):
name:str=Field(min_length=1,max_length=45)
password:str=Field(min_length=10,max_length=128)
@field_validator('name')
@classmethod
def valid_name(cls,value):
value=value.strip()
if not value:
raise ValueError('请填写名字')
return value
class RuleInput(Model):
action:Literal['sms','call','purchase','reset','topup','custom']
interval:StrictInt=Field(ge=1,le=3650)
unit:Literal['days','months']
anchor:Literal['completion','scheduled']
dueDate:str
instructions:str=Field(default='',max_length=1000)
version:StrictInt|None=Field(default=None,ge=1)
cost:Money|None=None
@field_validator('dueDate')
@classmethod
def valid_date(cls,value):
parse_date(value)
return value
class CardInput(Model):
name:str=Field(min_length=1,max_length=45)
provider:str=Field(min_length=1,max_length=45)
type:Literal['SIM','eSIM']
country:Literal['GB','US','HK','JP','CN','DE','SG','OTHER']
phone:str=Field(default='',max_length=35)
activatedAt:str
notes:str=Field(default='',max_length=1000)
plan:str=Field(default='',max_length=60)
rules:list[RuleInput]=Field(default_factory=list,max_length=20)
version:StrictInt|None=Field(default=None,ge=1)
balances:list[Money]|None=Field(default=None,max_length=len(CURRENCIES))
@field_validator('balances')
@classmethod
def unique_currencies(cls,value):
if value is not None and len({item.currency for item in value})!=len(value):
raise ValueError('同一张卡的每个币种只能记录一条余额')
return value
@field_validator('activatedAt')
@classmethod
def valid_date(cls,value):
parse_date(value)
return value
class ArchiveInput(Model):
archived:bool
version:StrictInt=Field(ge=1)
class Completion(Model):
completedAt:str
note:str=Field(default='',max_length=1000)
requestId:str=Field(min_length=8,max_length=100)
version:StrictInt=Field(ge=1)
cost:Money|None=None
balanceAction:Literal['none','deduct','credit']='none'
cardVersion:StrictInt|None=Field(default=None,ge=1)
class PlatformInput(Model):
name:str=Field(min_length=1,max_length=45)
account:str=Field(default='',max_length=120)
purpose:str=Field(default='',max_length=80)
version:StrictInt=Field(ge=1)
class SettingsInput(Model):
timezone:str
time:str
offsets:list[StrictInt]=Field(max_length=12)
overdue:bool
telegram:bool
email:bool
emailAddress:str=Field(default='',max_length=120)
@field_validator('timezone')
@classmethod
def valid_zone(cls,value):
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError,ValueError):
raise ValueError('请选择有效时区') from None
return value
@field_validator('time')
@classmethod
def valid_time(cls,value):
if not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d',value):
raise ValueError('请输入有效提醒时间')
return value
@field_validator('offsets')
@classmethod
def valid_offsets(cls,value):
if any(v < 1 or v > 365 for v in value):
raise ValueError('提前提醒天数须为 1–365')
return sorted(set(value),reverse=True)
@field_validator('emailAddress')
@classmethod
def valid_email(cls,value):
if value:
return Login.email_format(value)
return value
class EmailCode(Model):
code:str=Field(min_length=6,max_length=6)
class TelegramCode(Model):
code:str=Field(min_length=20,max_length=100)
+56
View File
@@ -0,0 +1,56 @@
"""Exact, nonnegative amounts in their original currency. No FX conversion."""
import re
from decimal import Decimal, InvalidOperation
CURRENCIES={
'CNY':2,'USD':2,'HKD':2,'GBP':2,'EUR':2,'JPY':0,'TWD':2,'SGD':2,
'AUD':2,'CAD':2,'CHF':2,'NZD':2,'KRW':0,'THB':2,'MYR':2,'AED':2,
'SAR':2,'IDR':2,'INR':2,'PHP':2,'VND':0,'KWD':3,'BHD':3,
}
MAX_AMOUNT=Decimal('999999999')
def normalize_amount(value,currency):
if currency not in CURRENCIES:
raise ValueError('请选择支持的币种')
if not isinstance(value,str) or len(value)>30 or not re.fullmatch(r'[0-9]+(?:\.[0-9]+)?',value):
raise ValueError('金额须为不小于 0 的数字')
try:
amount=Decimal(value)
if amount>MAX_AMOUNT:
raise ValueError('金额不能超过 999999999')
places=CURRENCIES[currency]
normalized=amount.quantize(Decimal(1).scaleb(-places))
if normalized!=amount:
raise ValueError(f'{currency} 金额最多保留 {places} 位小数')
return format(normalized,f'.{places}f')
except InvalidOperation:
raise ValueError('请输入有效金额') from None
def format_money(money):
if money is None:
return '未填写'
amount=normalize_amount(money['amount'],money['currency'])
return f"{money['currency']} {Decimal(amount):,f}"
def apply_balance(balances,cost,operation):
if operation not in ('deduct','credit') or not cost:
raise ValueError('更新余额时须填写本次金额和币种')
currency=cost['currency']
amount=Decimal(normalize_amount(cost['amount'],currency))
current=next((item for item in balances if item['currency']==currency),None)
if operation=='deduct' and current is None:
raise ValueError(f'尚未记录 {currency} 余额,请先在卡片资料中添加')
before={'currency':currency,'amount':normalize_amount(current['amount'] if current else '0',currency)}
remaining=Decimal(before['amount'])-amount if operation=='deduct' else Decimal(before['amount'])+amount
if remaining<0:
raise ValueError(f'{currency} 余额不足,可取消更新余额后单独记录费用')
after={'currency':currency,'amount':normalize_amount(format(remaining,'f'),currency)}
updated=[dict(item) for item in balances if item['currency']!=currency]
if current:
updated=[dict(after) if item['currency']==currency else dict(item) for item in balances]
else:
updated.append(dict(after))
return updated,before,after
+135
View File
@@ -0,0 +1,135 @@
"""Owned notification credentials. Only status and non-secret fields leave this module."""
import re
import sqlite3
from dataclasses import replace
from fastapi import HTTPException
from pydantic import ConfigDict, Field, StrictInt, field_validator
from typing import Literal
from .auth import digest
from .db import decode, encode
from .models import Login, Model
class TelegramConfigInput(Model):
token: str = Field(default='', max_length=160)
@field_validator('token')
@classmethod
def token_format(cls, value):
if value and not re.fullmatch(r'[0-9]{1,20}:[A-Za-z0-9_-]{20,120}', value):
raise ValueError('Bot Token 格式无效,请复制 BotFather 提供的完整 Token')
if not value:
return value
bot_id, secret = value.split(':', 1)
if int(bot_id) == 0:
raise ValueError('Bot Token 格式无效,请复制 BotFather 提供的完整 Token')
return f'{int(bot_id)}:{secret}'
class EmailConfigInput(Model):
# SMTP passwords may intentionally start or end with spaces.
model_config = ConfigDict(extra='forbid', str_strip_whitespace=False)
host: str = Field(min_length=1, max_length=253)
port: StrictInt = Field(ge=1, le=65535)
mode: Literal['starttls', 'ssl', 'plain']
user: str = Field(default='', max_length=254)
password: str = Field(default='', max_length=1024)
sender: str = Field(alias='from', min_length=3, max_length=120)
@field_validator('host')
@classmethod
def host_format(cls, value):
value = value.strip()
if not re.fullmatch(r'[A-Za-z0-9_.:\[\]-]+', value):
raise ValueError('SMTP 服务器填写主机名或 IP,不包含网址、路径或端口')
return value
@field_validator('user')
@classmethod
def user_format(cls, value):
value = value.strip()
if '\r' in value or '\n' in value:
raise ValueError('SMTP 用户名不能包含换行')
return value
@field_validator('sender')
@classmethod
def sender_format(cls, value):
return Login.email_format(value)
class NotificationConfigs:
def __init__(self, db):
self.db = db
def read(self, conn, owner):
return {row['channel']: decode(row['document']) for row in conn.execute('SELECT channel,document FROM notification_configs WHERE user_id=?', (owner,))}
def load(self, owner, defaults, conn=None):
if conn is None:
with self.db.connect() as connection:
return self.load(owner, defaults, connection)
personal = self.read(conn, owner)
fields = {}
if 'telegram' in personal:
fields.update(telegram_token=personal['telegram']['token'], telegram_username='')
if 'email' in personal:
fields.update({f'smtp_{key}': value for key, value in personal['email'].items()})
return replace(defaults, **fields)
def status(self, owner, defaults):
with self.db.connect() as conn:
personal = self.read(conn, owner)
config = self.load(owner, defaults, conn)
result = config.status()
for channel in ('telegram', 'email'):
result[channel]['source'] = 'account' if channel in personal else 'server' if config.configured(channel) else 'none'
result['telegram']['tokenSaved'] = bool(personal.get('telegram', {}).get('token'))
email = personal.get('email', {})
result['email'].update({key: email.get(key, default) for key, default in [('host',''),('port',587),('mode','starttls'),('user',''),('from','')]})
result['email']['passwordSaved'] = bool(email.get('password'))
return result
def invalidate(self, conn, owner, channel, bot_changed=False):
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND channel=? AND status IN ('pending','processing','failed') AND sent_at IS NULL", (owner, channel))
if bot_changed:
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?', (owner,)).fetchone()[0])
settings.update(chatId='', telegramVerified=False, telegram=False)
conn.execute('UPDATE users SET settings=? WHERE id=?', (encode(settings), owner))
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='telegram'", (owner,))
def save(self, owner, channel, payload, defaults):
try:
with self.db.connect(write=True) as conn:
personal = self.read(conn, owner)
old = personal.get(channel, {})
before = self.load(owner, defaults, conn)
document = payload.model_dump(by_alias=True)
identity = None
if channel == 'telegram':
document['token'] = document['token'] or old.get('token', '')
if not document['token']:
raise HTTPException(422, '请先填写 Bot Token')
# A guessed Bot ID with a fake secret must not reserve a real Bot.
identity = digest(document['token'])
if defaults.telegram_token and document['token'] == defaults.telegram_token:
raise HTTPException(409, '这个 Bot 已用于公共服务,请为本账号使用独立的 Bot')
else:
document['password'] = (document['password'] or old.get('password', '')) if document['user'] else ''
if document['user'] and not document['password']:
raise HTTPException(422, '填写 SMTP 用户名后,请填写密码或授权码')
if document == old:
return
conn.execute('INSERT INTO notification_configs(user_id,channel,document,telegram_identity) VALUES(?,?,?,?) ON CONFLICT(user_id,channel) DO UPDATE SET document=excluded.document,telegram_identity=excluded.telegram_identity', (owner, channel, encode(document), identity))
self.invalidate(conn, owner, channel, channel == 'telegram' and before.telegram_token != document['token'])
except sqlite3.IntegrityError:
raise HTTPException(409, '这个 Bot 已由其他账号使用,请使用自己的 Bot') from None
def clear(self, owner, channel, defaults):
with self.db.connect(write=True) as conn:
before = self.load(owner, defaults, conn)
conn.execute('DELETE FROM notification_configs WHERE user_id=? AND channel=?', (owner, channel))
after = self.load(owner, defaults, conn)
self.invalidate(conn, owner, channel, channel == 'telegram' and before.telegram_token != after.telegram_token)
+456
View File
@@ -0,0 +1,456 @@
from __future__ import annotations
import asyncio
import hmac
import logging
import os
import re
import secrets
import smtplib
import ssl
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from email.message import EmailMessage
from typing import Literal
from uuid import uuid4
from zoneinfo import ZoneInfo
import httpx
from fastapi import Depends, HTTPException
from .auth import digest
from .db import decode, encode
from .domain import ACTIONS, parse_date
from .models import EmailCode, TelegramCode
from .money import format_money
from .notification_config import EmailConfigInput, NotificationConfigs, TelegramConfigInput
logger = logging.getLogger('simkeep.notifications')
SCHEDULE_FIELDS = ('timezone','time','offsets','overdue')
@dataclass(frozen=True)
class Config:
public_url: str = ''
secure_cookies: bool = False
telegram_token: str = ''
telegram_username: str = ''
smtp_host: str = ''
smtp_port: int = 587
smtp_user: str = ''
smtp_password: str = ''
smtp_from: str = ''
smtp_mode: str = 'starttls'
@classmethod
def from_env(cls):
mode = os.getenv('SIMKEEP_SMTP_MODE','starttls').lower()
if mode not in ('starttls','ssl','plain'):
raise ValueError('SIMKEEP_SMTP_MODE 须为 starttls、ssl 或 plain')
return cls(
public_url=os.getenv('SIMKEEP_PUBLIC_URL','').rstrip('/'),
secure_cookies=os.getenv('SIMKEEP_SECURE_COOKIES','0')=='1',
telegram_token=os.getenv('SIMKEEP_TELEGRAM_TOKEN',''),
telegram_username=os.getenv('SIMKEEP_TELEGRAM_USERNAME','').lstrip('@'),
smtp_host=os.getenv('SIMKEEP_SMTP_HOST',''),
smtp_port=int(os.getenv('SIMKEEP_SMTP_PORT','465' if mode=='ssl' else '587')),
smtp_user=os.getenv('SIMKEEP_SMTP_USER',''),
smtp_password=os.getenv('SIMKEEP_SMTP_PASSWORD',''),
smtp_from=os.getenv('SIMKEEP_SMTP_FROM',os.getenv('SIMKEEP_SMTP_USER','')),
smtp_mode=mode,
)
def configured(self, channel):
if channel == 'telegram':
return bool(self.telegram_token)
return bool(self.smtp_host and self.smtp_from and (not self.smtp_user or self.smtp_password))
def status(self):
# Only capability flags are public; destinations and secrets stay on the server.
return {'telegram':{'configured':self.configured('telegram')},'email':{'configured':self.configured('email')}}
class Transport:
def __init__(self, config):
self.config = config
self.username = config.telegram_username
async def telegram(self, method, payload):
async with httpx.AsyncClient(timeout=20) as client:
response = await client.post(f'https://api.telegram.org/bot{self.config.telegram_token}/{method}',json=payload)
if response.status_code != 200:
raise RuntimeError('Telegram 暂时无法发送')
result = response.json()
if not result.get('ok'):
raise RuntimeError('Telegram 暂时无法发送')
return result['result']
async def bot_username(self):
if not self.username:
self.username = (await self.telegram('getMe',{}))['username']
if not re.fullmatch(r'[A-Za-z0-9_]{5,32}', self.username):
raise RuntimeError('Bot 用户名无效')
return self.username
async def updates(self, offset):
return await self.telegram('getUpdates',{'offset':offset,'timeout':0,'allowed_updates':['message']})
def smtp(self, destination=None, text=''):
context = ssl.create_default_context()
if self.config.smtp_mode == 'ssl':
connection = smtplib.SMTP_SSL(self.config.smtp_host,self.config.smtp_port,timeout=20,context=context)
else:
connection = smtplib.SMTP(self.config.smtp_host,self.config.smtp_port,timeout=20)
with connection:
connection.ehlo_or_helo_if_needed()
if self.config.smtp_mode == 'starttls':
connection.starttls(context=context)
connection.ehlo()
if self.config.smtp_user:
connection.login(self.config.smtp_user,self.config.smtp_password)
if destination is None:
return
message = EmailMessage()
message['Subject'] = '续卡 SIMKEEP · 续期提醒' if '验证码' not in text else '续卡 SIMKEEP · 验证邮箱'
message['From'] = self.config.smtp_from
message['To'] = destination
message.set_content(text)
refused = connection.send_message(message)
if refused:
raise RuntimeError('邮箱服务暂时无法发送')
async def send(self, channel, destination, text):
if channel == 'telegram':
await self.telegram('sendMessage',{'chat_id':destination,'text':text[:4000],'disable_web_page_preview':True})
else:
await asyncio.to_thread(self.smtp,destination,text)
def destination(settings, channel):
if channel == 'telegram':
return settings.get('chatId','') if settings.get('telegramVerified') else ''
return settings.get('emailAddress','') if settings.get('emailVerified') else ''
class Notifier:
def __init__(self, db, config, transport=None):
self.db = db
self.config = config
self.transport = transport or Transport(config)
self.configs = NotificationConfigs(db)
def transport_for(self, owner, config=None):
config = config or self.configs.load(owner, self.config)
return self.transport if config == self.config else Transport(config)
def recover(self):
with self.db.connect(write=True) as conn:
conn.execute("UPDATE notification_jobs SET status='pending',error='发送中断,等待重试' WHERE status='processing'")
def enqueue(self, now=None):
now = now or datetime.now(timezone.utc)
timestamp = now.timestamp()
with self.db.connect(write=True) as conn:
for person in conn.execute('SELECT id,settings FROM users').fetchall():
config = self.configs.load(person['id'], self.config, conn)
settings = decode(person['settings'])
local = now.astimezone(ZoneInfo(settings['timezone']))
if local.strftime('%H:%M') < settings['time']:
continue
day = local.date()
for row in conn.execute('SELECT document FROM cards WHERE user_id=?',(person['id'],)).fetchall():
card = decode(row['document'])
if card['archived']:
continue
for rule in card['rules']:
remaining = (parse_date(rule['dueDate'])-day).days
if remaining not in [0,*settings['offsets']] and not (remaining < 0 and settings['overdue']):
continue
payload = {'dueDate':rule['dueDate'],'day':day.isoformat(),'remaining':remaining,'schedule':{k:settings[k] for k in SCHEDULE_FIELDS}}
for channel in ('telegram','email'):
target = destination(settings,channel)
if not settings[channel] or not target or not config.configured(channel):
continue
key = ':'.join((person['id'],card['id'],rule['id'],str(rule['version']),channel,day.isoformat()))
values = (str(uuid4()),key,person['id'],card['id'],rule['id'],rule['version'],channel,target,encode(payload),timestamp,timestamp)
conn.execute('INSERT OR IGNORE INTO notification_jobs(id,dedupe_key,user_id,card_id,rule_id,rule_version,channel,destination,payload,ready_at,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',values)
# Restore only unsent, cancelled jobs. Sent keys remain final even after settings change.
conn.execute("UPDATE notification_jobs SET status='pending',attempts=0,error=NULL,destination=?,payload=?,ready_at=? WHERE dedupe_key=? AND status='cancelled' AND sent_at IS NULL",(target,encode(payload),timestamp,key))
def valid(self, conn, job, now):
person = conn.execute('SELECT settings FROM users WHERE id=?',(job['user_id'],)).fetchone()
row = conn.execute('SELECT document FROM cards WHERE id=? AND user_id=?',(job['card_id'],job['user_id'])).fetchone()
if not person or not row:
return None
settings, card, payload = decode(person[0]), decode(row[0]), decode(job['payload'])
rule = next((r for r in card['rules'] if r['id']==job['rule_id']),None)
if card['archived'] or not rule or rule['version']!=job['rule_version'] or rule['dueDate']!=payload['dueDate']:
return None
if not settings[job['channel']] or destination(settings,job['channel'])!=job['destination']:
return None
if {k:settings[k] for k in SCHEDULE_FIELDS}!=payload['schedule']:
return None
if now.astimezone(ZoneInfo(settings['timezone'])).date().isoformat()!=payload['day']:
return None
return card, rule, payload
def message(self, card, rule, payload):
remaining = payload['remaining']
timing = f'还有 {remaining} 天' if remaining > 0 else '今天到期' if remaining == 0 else f'已逾期 {-remaining} 天'
action = ACTIONS[rule['action']]
phone = f"号码尾号 {re.sub(r'\s','',card['phone'])[-4:]}" if card['phone'].startswith('+') else '数据卡 / 未填写号码'
cost = rule.get('cost')
balance = next((item for item in card.get('balances',[]) if cost and item['currency']==cost['currency']),None)
money = f"续期金额:{format_money(cost)}\n"
if balance is not None:
money += f"同币种余额:{format_money(balance)}\n"
return f"续卡 SIMKEEP · 续期提醒\n\n{card['name']} · {card['provider']}\n{phone}\n{action} · {timing}\n到期日期:{rule['dueDate']}\n{money}操作说明:{rule['instructions'] or '请按运营商的要求完成续期'}\n\n完成操作后,请在续卡记录完成,更新下次续期日期。\n{self.config.public_url}/#renewals"
async def deliver(self, now=None):
now = now or datetime.now(timezone.utc)
timestamp = now.timestamp()
with self.db.connect(write=True) as conn:
# Expire stale dates even when their channel is currently unavailable.
for person in conn.execute('SELECT id,settings FROM users').fetchall():
local_day = now.astimezone(ZoneInfo(decode(person['settings'])['timezone'])).date().isoformat()
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND status='pending' AND json_extract(payload,'$.day')<>?",(person['id'],local_day))
configurations = {}
pending = []
# Apply availability before the batch limit, including per-account services.
for row in conn.execute("SELECT * FROM notification_jobs WHERE status='pending' AND ready_at<=? ORDER BY ready_at", (timestamp,)):
owner = row['user_id']
if owner not in configurations:
configurations[owner] = self.configs.load(owner, self.config, conn)
if configurations[owner].configured(row['channel']):
pending.append(dict(row))
if len(pending) == 50:
break
for job in pending:
with self.db.connect(write=True) as conn:
config = self.configs.load(job['user_id'], self.config, conn)
if not config.configured(job['channel']):
continue
current = conn.execute("SELECT * FROM notification_jobs WHERE id=? AND status='pending'",(job['id'],)).fetchone()
if not current:
continue
valid = self.valid(conn,current,now)
if not valid:
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE id=?",(job['id'],))
continue
attempts = current['attempts']+1
conn.execute("UPDATE notification_jobs SET status='processing',attempts=? WHERE id=?",(attempts,job['id']))
try:
await self.transport_for(job['user_id'],config).send(job['channel'],job['destination'],self.message(*valid))
except Exception:
# Raw transport exceptions can contain credentials. Persist only a safe, actionable message.
error = '发送失败,请检查服务配置或接收地址' if attempts>=5 else '发送失败,稍后自动重试'
with self.db.connect(write=True) as conn:
conn.execute("UPDATE notification_jobs SET status=?,error=?,ready_at=? WHERE id=? AND status='processing'",('failed' if attempts>=5 else 'pending',error,timestamp+60*2**(attempts-1),job['id']))
else:
with self.db.connect(write=True) as conn:
# An edit during the network call cannot recall an accepted message; record the actual outcome.
conn.execute("UPDATE notification_jobs SET status='sent',sent_at=?,error=NULL WHERE id=?",(timestamp,job['id']))
def process_update(self, update, user_id=None, token=None):
message = update.get('message',{})
chat = message.get('chat',{})
match = re.fullmatch(r'/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]+)',message.get('text','').strip())
if chat.get('type')!='private' or not isinstance(chat.get('id'),int) or not match:
return False
chat_id = str(chat['id'])
with self.db.connect(write=True) as conn:
binding = conn.execute("SELECT * FROM bindings WHERE token_hash=? AND kind='telegram' AND destination IS NULL AND expires_at>?",(digest(match[1]),time.time())).fetchone()
if not binding:
return False
expected = token if token is not None else self.configs.load(user_id, self.config, conn).telegram_token if user_id else self.config.telegram_token
current = self.configs.load(binding['user_id'], self.config, conn)
if (user_id is not None and binding['user_id'] != user_id) or current.telegram_token != expected:
return False
for row in conn.execute('SELECT id,settings FROM users'):
existing = decode(row['settings'])
same_bot = self.configs.load(row['id'],self.config,conn).telegram_token == current.telegram_token
if same_bot and row['id']!=binding['user_id'] and existing.get('telegramVerified') and existing.get('chatId')==chat_id:
return False
row = conn.execute('SELECT settings FROM users WHERE id=?',(binding['user_id'],)).fetchone()
settings = decode(row[0])
settings.update(chatId=chat_id,telegramVerified=True)
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),binding['user_id']))
conn.execute('UPDATE bindings SET destination=? WHERE token_hash=?',(chat_id,binding['token_hash']))
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND channel='telegram' AND status IN ('pending','processing')",(binding['user_id'],))
return True
async def poll(self):
with self.db.connect() as conn:
people = [row['id'] for row in conn.execute('SELECT id FROM users')]
personal = [(owner,self.configs.load(owner,self.config,conn)) for owner in people]
targets = [(owner,config) for owner,config in personal if config.telegram_token and config.telegram_token != self.config.telegram_token]
if self.config.configured('telegram'):
targets.append((None,self.config))
semaphore = asyncio.Semaphore(4)
async def guarded(owner, config):
async with semaphore:
try:
await self.poll_bot(owner, config)
except Exception:
logger.warning('Telegram 绑定暂时不可用,请检查本账号的 Bot 配置与网络')
await asyncio.gather(*(guarded(owner,config) for owner,config in targets))
async def poll_bot(self, owner, config):
key = f'telegram_offset:{owner}:{digest(config.telegram_token)}' if owner else 'telegram_offset'
with self.db.connect() as conn:
row = conn.execute('SELECT value FROM system_state WHERE key=?', (key,)).fetchone()
offset = int(row[0]) if row else 0
updates = await self.transport_for(owner,config).updates(offset)
for update in updates:
self.process_update(update,user_id=owner,token=config.telegram_token)
with self.db.connect(write=True) as conn:
conn.execute('INSERT OR REPLACE INTO system_state VALUES(?,?)',(key,str(update['update_id']+1)))
async def run(self):
self.recover()
while True:
try:
await self.poll()
except Exception:
logger.warning('Telegram 绑定暂时不可用,请检查 Bot 配置与网络')
try:
self.enqueue()
await self.deliver()
with self.db.connect(write=True) as conn:
conn.execute('DELETE FROM bindings WHERE expires_at<?',(time.time(),))
except Exception:
logger.warning('续期调度暂时失败,将在下一轮重试')
await asyncio.sleep(5)
def install_routes(self, app, session, result, rate):
def require_config(channel, owner):
config = self.configs.load(owner,self.config)
if not config.configured(channel):
raise HTTPException(503,'请先配置本账号的 Telegram Bot' if channel=='telegram' else '请先配置本账号的邮件服务')
return self.transport_for(owner,config)
@app.get('/api/notifications/config')
def read_config(person=Depends(session)):
return {'services':self.configs.status(person['id'],self.config)}
@app.put('/api/notifications/config/telegram')
def save_telegram_config(payload:TelegramConfigInput,person=Depends(session)):
self.configs.save(person['id'],'telegram',payload,self.config)
return result(person['id'])
@app.put('/api/notifications/config/email')
def save_email_config(payload:EmailConfigInput,person=Depends(session)):
self.configs.save(person['id'],'email',payload,self.config)
return result(person['id'])
@app.delete('/api/notifications/config/{channel}')
def clear_config(channel:Literal['email','telegram'],person=Depends(session)):
self.configs.clear(person['id'],channel,self.config)
return result(person['id'])
@app.post('/api/notifications/config/{channel}/check')
async def check_config(channel:Literal['email','telegram'],person=Depends(session)):
transport = require_config(channel,person['id'])
rate.check(('config_check',channel,person['id']),5,600)
try:
if channel == 'telegram':
username = await transport.bot_username()
return {'checked':True,'botUsername':username}
await asyncio.to_thread(transport.smtp)
except Exception:
message = '无法连接 Telegram Bot,请检查 Token 和服务器网络' if channel=='telegram' else 'SMTP 连接或认证失败,请检查服务器、端口、安全方式及密码 / 授权码'
raise HTTPException(503,message) from None
return {'checked':True}
@app.post('/api/notifications/telegram/binding')
async def telegram_binding(person=Depends(session)):
transport = require_config('telegram',person['id'])
token = getattr(transport,'config',self.config).telegram_token
rate.check(('telegram_binding',person['id']),3,60)
try:
username = await transport.bot_username()
except Exception:
raise HTTPException(503,'无法连接 Telegram Bot,请检查本账号的配置') from None
code = secrets.token_urlsafe(24)
with self.db.connect(write=True) as conn:
if self.configs.load(person['id'],self.config,conn).telegram_token != token:
raise HTTPException(409,'Bot 配置已更改,请重新开始绑定')
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='telegram'",(person['id'],))
conn.execute('INSERT INTO bindings(token_hash,user_id,kind,expires_at,created_at) VALUES(?,?,?,?,?)',(digest(code),person['id'],'telegram',time.time()+600,time.time()))
return {'code':code,'url':f'https://t.me/{username}?start={code}','expiresIn':600}
@app.post('/api/notifications/telegram/binding/check')
def check_telegram_binding(payload:TelegramCode,person=Depends(session)):
with self.db.connect() as conn:
binding = conn.execute("SELECT destination FROM bindings WHERE token_hash=? AND user_id=? AND kind='telegram' AND expires_at>?",(digest(payload.code),person['id'],time.time())).fetchone()
status = 'expired' if not binding else 'bound' if binding['destination'] else 'waiting'
return result(person['id'],bindingStatus=status)
@app.delete('/api/notifications/telegram/binding')
def unbind_telegram(person=Depends(session)):
with self.db.connect(write=True) as conn:
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
settings.update(chatId='',telegramVerified=False,telegram=False)
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),person['id']))
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='telegram'",(person['id'],))
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND channel='telegram' AND status IN ('pending','processing')",(person['id'],))
return result(person['id'])
@app.post('/api/notifications/email/verify')
async def verify_email(person=Depends(session)):
transport = require_config('email',person['id'])
rate.check(('email_verify',person['id']),3,600)
code = f'{secrets.randbelow(1000000):06d}'
nonce = secrets.token_urlsafe(24)
token_hash = digest(nonce+code)
with self.db.connect(write=True) as conn:
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
address = settings['emailAddress']
if not address:
raise HTTPException(422,'请先保存接收邮箱')
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='email'",(person['id'],))
conn.execute('INSERT INTO bindings(token_hash,user_id,kind,destination,expires_at,created_at) VALUES(?,?,?,?,?,?)',(token_hash,person['id'],'email',encode({'address':address,'nonce':nonce}),time.time()+600,time.time()))
try:
await transport.send('email',address,f'续卡 SIMKEEP · 验证邮箱\n\n验证码:{code}\n10 分钟内有效。请在通知设置输入验证码,确认这是你的接收邮箱。')
except Exception:
with self.db.connect(write=True) as conn:
conn.execute('DELETE FROM bindings WHERE token_hash=?',(token_hash,))
raise HTTPException(503,'验证码发送失败,请检查邮件服务配置') from None
return {'sent':True,'expiresIn':600}
@app.post('/api/notifications/email/confirm')
def confirm_email(payload:EmailCode,person=Depends(session)):
rate.check(('email_confirm',person['id']),20,600)
valid = False
with self.db.connect(write=True) as conn:
binding = conn.execute("SELECT * FROM bindings WHERE user_id=? AND kind='email' ORDER BY created_at DESC LIMIT 1",(person['id'],)).fetchone()
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
if binding and binding['expires_at']>time.time() and binding['attempts']<5:
stored = decode(binding['destination'])
valid = stored['address']==settings['emailAddress'] and hmac.compare_digest(binding['token_hash'],digest(stored['nonce']+payload.code))
if valid:
settings['emailVerified'] = True
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),person['id']))
conn.execute('DELETE FROM bindings WHERE token_hash=?',(binding['token_hash'],))
else:
conn.execute('UPDATE bindings SET attempts=attempts+1 WHERE token_hash=?',(binding['token_hash'],))
if not valid:
raise HTTPException(422,'验证码不正确或已失效,请重新发送')
return result(person['id'])
@app.post('/api/notifications/test/{channel}')
async def test_notification(channel:Literal['email','telegram'],person=Depends(session)):
transport = require_config(channel,person['id'])
rate.check(('notification_test',person['id']),5,600)
with self.db.connect() as conn:
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
target = destination(settings,channel)
if not target:
raise HTTPException(422,'请先验证邮箱或绑定 Telegram')
try:
await transport.send(channel,target,'续卡 SIMKEEP · 测试提醒\n\n通知渠道已连接。开启提醒并保存设置后,续期提醒将按你选择的时间发送。')
except Exception:
raise HTTPException(503,'测试提醒发送失败,请检查服务配置或接收地址') from None
return {'sent':True}
+667
View File
@@ -0,0 +1,667 @@
@charset "UTF-8";
:root {
--canvas:#f5f7fc; --surface:#fff; --ink:#20283e; --muted:#77839a;
--accent:#5360ea; --accent-dark:#414dd1; --accent-soft:#eff1ff;
--border:#e9edf5; --success:#168576; --warning:#a96616; --danger:#c4495a;
font-family:"Noto Sans CJK SC","PingFang SC","Microsoft YaHei",Arial,sans-serif;
font-size:14px; font-synthesis:none; color:var(--ink); background:var(--canvas);
-webkit-font-smoothing:antialiased;
}
* {box-sizing:border-box}
body {margin:0}
.bootstrap {min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:22px;color:var(--muted)}
.auth-shell {min-height:100vh;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(420px,1fr);background:white}
.auth-story {padding:48px 58px 30px;background:#f0f3fc;display:flex;flex-direction:column;border-right:1px solid #e2e7f4}
.auth-story .brand {margin:0;align-self:flex-start}
.auth-story-content {max-width:560px;margin:auto 0;padding:60px 0}
.auth-story h1 {font-size:43px;line-height:1.4;letter-spacing:1px;font-weight:700;margin-bottom:23px}
.auth-story-content>p {font-size:14px;line-height:2.1;color:#6f7e9c}
.connection-illustration {display:flex;align-items:center;gap:40px;margin-top:48px;padding:28px 0}
.illustration-sim {width:146px;height:178px;border:1px solid #c6ceee;flex:none;background:white;clip-path:polygon(0 0,76% 0,100% 20%,100% 100%,0 100%);border-radius:9px;position:relative;display:flex;flex-direction:column;padding:21px;transform:rotate(-6deg)}
.illustration-sim>.icon {color:var(--accent);width:27px;height:27px;margin-bottom:14px}
.illustration-sim>span {font-size:11px;color:#6e7daa}
.sim-chip {display:grid;grid-template-columns:repeat(3,1fr);gap:2px;width:62px;height:47px;margin-top:20px;background:#e2e6ff;border:1px solid #9faaf2;border-radius:6px;overflow:hidden}
.sim-chip i {background:#eff1ff;border:1px solid #c7cefa}
.illustration-timeline {display:grid;gap:27px}
.illustration-timeline>div {position:relative;padding-left:23px}
.illustration-timeline>div:not(:last-child)::after {content:"";position:absolute;top:15px;bottom:-30px;left:4px;width:1px;background:#c7d0ee}
.timeline-dot {position:absolute;left:0;top:5px;width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 5px #e2e6ff;z-index:1}
.timeline-dot.done {background:#39a390;box-shadow:none}
.timeline-dot.hollow {background:#f0f3fc;box-shadow:none;border:1px solid #99a8c8}
.illustration-timeline strong {font-size:12px;font-weight:550;display:block}
.illustration-timeline small {font-size:10px;display:block;color:#8c98b2;margin-top:6px;line-height:1.8}
.auth-story-foot {font-size:11px;color:#8d9ab5}
.auth-panel {position:relative;display:flex;align-items:center;justify-content:center;padding:60px 42px 90px}
.auth-form-wrap {width:100%;max-width:350px}
.auth-tabs {display:flex;gap:26px;border-bottom:1px solid var(--border);margin-bottom:34px}
.auth-tabs button {padding:11px 0;font-size:13px;color:var(--muted);position:relative}
.auth-tabs button.active {color:var(--accent);font-weight:650}
.auth-tabs button.active::after {content:"";position:absolute;bottom:-1px;left:0;right:0;height:2px;background:var(--accent)}
.auth-form-wrap h2 {font-size:23px;font-weight:650;margin-bottom:10px}
.auth-intro {font-size:12px;line-height:1.8;color:var(--muted);margin-bottom:28px}
#auth-form {display:grid;gap:21px}
#auth-form .field input {padding:12px 14px;font-size:13px}
.auth-submit {width:100%;padding:13px;margin-top:6px;justify-content:space-between}
.auth-account-note {display:flex;align-items:center;justify-content:center;gap:6px;margin-top:25px;color:#95a0b5;font-size:11px}
.auth-account-note>.icon {width:14px;height:14px}
.auth-footer {position:absolute;bottom:30px;font-size:10px;color:#a1aabc}
.channel-actions {display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin-top:17px}
.channel-help {font-size:10px;color:var(--muted);line-height:1.8;margin-top:12px}
.service-status {display:grid;gap:10px;border-top:1px solid var(--border);padding-top:17px;margin-top:17px;font-size:10px;color:var(--muted)}
.service-status>span {display:flex;justify-content:space-between}
.service-status strong {font-weight:500;color:var(--ink)}
.binding-steps {display:grid;gap:19px}
.binding-steps a {text-decoration:none}
.binding-steps>p {font-size:12px;line-height:1.9;color:var(--muted)}
.binding-steps .field input {font-size:10px}
.notification-log {margin-top:35px}
.notification-log-empty {display:flex;align-items:center;gap:12px;background:white;border:1px dashed #dce2f0;border-radius:9px;padding:27px;font-size:11px;color:var(--muted);line-height:1.9}
.money-summary {display:grid;grid-template-columns:1fr 1fr;margin:17px 0 22px;padding:19px 24px;background:white;border:1px solid var(--border);border-radius:10px;gap:24px}
.money-summary>div {min-width:0}
.money-summary>div+div {border-left:1px solid var(--border);padding-left:24px}
.money-summary h2 {display:flex;align-items:center;gap:7px;font-size:11px;font-weight:500;color:var(--muted);margin-bottom:12px}
.money-summary h2 .icon {width:15px;height:15px}
.money-summary p {font-size:10px;color:var(--muted);line-height:1.8;margin-top:9px}
.money-tags {display:flex;flex-wrap:wrap;gap:5px 8px;align-items:center;min-width:0}
.money-tag {display:inline-block;background:#f3f5fc;color:#576482;border:1px solid #e8edf7;border-radius:5px;padding:4px 7px;font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
.money-unknown {color:var(--muted);font-size:10px}
.card-balance {display:flex;align-items:flex-start;gap:8px;margin:0 0 14px;padding-top:4px;min-width:0}
.card-balance>span {font-size:10px;color:var(--muted);line-height:25px;flex:none}
.card-balance .money-tag {font-size:10px;padding:3px 6px}
.card-rule-name {min-width:0}
.card-rule-name>span {white-space:normal;line-height:1.8}
.task-info p {flex-wrap:wrap}
.task-info .renewal-cost {font-variant-numeric:tabular-nums;color:var(--accent);font-weight:550}
.detail-money {grid-column:1/-1}
.detail-rule-cost {font-size:11px;display:flex;gap:12px;align-items:center;color:var(--muted);margin-top:12px}
.detail-rule-cost strong {font-weight:500;color:var(--ink);font-variant-numeric:tabular-nums}
#balance-fields {display:grid;gap:12px}
.balance-row {display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 30px;gap:12px;align-items:end}
.balance-row>.icon-button {height:39px;padding:5px}
.form-help {font-size:10px;color:var(--muted);line-height:1.8;margin-top:11px}
.form-section-title>h3 {font-size:12px;font-weight:600}
.balance-action {margin-top:19px;align-items:flex-start}
.balance-action input {margin-top:3px;flex:none}
.calculation-preview.balance-preview strong {font-size:12px;text-align:right;line-height:1.7;max-width:65%;white-space:normal;overflow-wrap:anywhere}
.balance-preview .money-error {color:var(--danger);font-size:10px}
@media(max-width:600px) {
.money-summary {grid-template-columns:1fr;padding:18px 20px;gap:15px}
.money-summary>div+div {border-left:0;border-top:1px solid var(--border);padding:15px 0 0}
.balance-row {gap:8px;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 26px}
.balance-row .field select,.balance-row .field input {padding:10px 7px;font-size:11px}
}
.status.success {background:#eaf8f3;color:#168576}
.status.danger {background:#fff0f1;color:#c4495a}
.user-text {min-width:0}
.user-text strong {overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@media(max-width:1000px) {
.auth-story {padding:38px 35px 25px}
.auth-story h1 {font-size:33px}
.connection-illustration {gap:25px}
.illustration-sim {width:116px;height:157px;padding:17px}
}
@media(max-width:760px) {
.auth-shell {grid-template-columns:1fr}
.auth-story {padding:26px;border-right:0;border-bottom:1px solid #e2e7f4}
.auth-story .brand {gap:9px}
.auth-story .brand-symbol {width:33px;height:36px}
.auth-story .brand-name {font-size:18px}
.auth-story .brand-name {display:block}
.auth-story-content {padding:29px 0 0;max-width:none}
.auth-story h1 {font-size:27px;line-height:1.5;margin-bottom:12px}
.auth-story h1 br {display:initial}
.auth-story-content>p {font-size:12px;line-height:1.9}
.connection-illustration,.auth-story-foot {display:none}
.auth-panel {padding:30px 26px 76px;align-items:flex-start}
.auth-form-wrap {max-width:420px}
.auth-tabs {margin-bottom:24px}
.auth-form-wrap h2 {font-size:21px}
.auth-intro {margin-bottom:24px}
.auth-footer {bottom:22px}
}
button,input,select,textarea {font:inherit}
button {cursor:pointer}
button,a,input,select,textarea { -webkit-tap-highlight-color:transparent }
button {color:inherit}
button:disabled {cursor:not-allowed;opacity:.5}
button:focus-visible,a:focus-visible {outline:3px solid #8b94ff;outline-offset:3px}
input:focus,select:focus,textarea:focus {outline:2px solid #9aa3ff;outline-offset:1px;border-color:var(--accent)}
button {border:0;background:none}
h1,h2,h3,h4,p {margin:0}
svg.icon {width:20px;height:20px;flex:none;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}
.small-icon svg.icon {width:16px;height:16px}
.app-shell {display:grid;grid-template-columns:222px minmax(0,1fr);min-height:100vh}
.sidebar {position:fixed;inset:0 auto 0 0;width:222px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:30;padding:33px 17px 18px}
.brand {display:flex;align-items:center;gap:11px;margin:0 15px 45px;text-decoration:none;color:var(--ink)}
.brand-symbol {width:39px;height:42px;background:var(--accent);border-radius:11px;display:grid;place-items:center;color:white;box-shadow:0 4px 9px #5360ea21}
.brand-symbol svg {width:26px;height:30px}
.brand-name {font-size:21px;font-weight:750;letter-spacing:1.2px;line-height:1.3}
.brand-name small {display:block;font-size:10px;letter-spacing:1.8px;font-weight:550;color:#97a0b4;margin-top:4px}
.nav-caption {font-size:11px;color:#939daf;padding-left:20px;margin-bottom:13px}
.nav-list {display:grid;gap:7px}
.nav-button {position:relative;display:flex;align-items:center;gap:13px;text-align:left;width:100%;padding:12px 18px;border-radius:8px;color:#7b8498;font-size:13px;font-weight:500;transition:background .15s}
.nav-button:hover {background:#f7f8fc;color:var(--ink)}
.nav-button.active {background:var(--accent-soft);color:var(--accent);font-weight:650}
.nav-button.active::before {content:"";position:absolute;left:0;top:16px;bottom:16px;width:3px;border-radius:4px;background:var(--accent)}
.nav-count {margin-left:auto;font-size:10px;line-height:19px;min-width:20px;border-radius:5px;text-align:center;background:#e0e4ff;color:var(--accent);font-weight:700}
.sidebar-spacer {flex:1;min-height:32px}
.sidebar-note {margin:14px 9px 23px;padding:16px 14px 14px;background:#f8f9ff;border:1px solid #eceefb;border-radius:10px;position:relative;overflow:hidden}
.sidebar-note::after {content:"";position:absolute;width:65px;height:65px;border:1px solid #e2e6ff;border-radius:50%;right:-28px;top:-24px}
.note-icon {color:var(--accent);margin-bottom:9px}
.sidebar-note h4 {font-size:12px;font-weight:600;margin-bottom:6px}
.sidebar-note p {font-size:10px;line-height:1.9;color:#8a93a8}
.sidebar-note button {font-size:11px;color:var(--accent);display:flex;align-items:center;gap:8px;margin-top:11px;padding:0}
.sidebar-user {border-top:1px solid var(--border);padding:18px 9px 1px;display:flex;align-items:center;gap:10px;width:100%;text-align:left}
.avatar {width:34px;height:34px;border-radius:11px;background:#eee7e0;color:#8b6c55;display:grid;place-items:center;flex:none;font-size:12px;font-weight:600}
.user-text {flex:1}
.user-text strong {font-size:12px;font-weight:600;display:block}
.user-text small {font-size:10px;color:#99a1b2;display:block;margin-top:3px}
.main-shell {grid-column:2;min-width:0}
.topbar {height:77px;display:flex;align-items:center;justify-content:space-between;padding:0 34px;background:#fff;border-bottom:1px solid var(--border);gap:20px}
.breadcrumbs {display:flex;align-items:center;gap:13px;font-size:12px;color:#99a1b3;white-space:nowrap}
.breadcrumbs strong {font-weight:500;color:#657087}
.breadcrumbs svg.icon {width:13px;height:13px}
.topbar-right {display:flex;align-items:center;gap:19px}
.global-search {display:flex;align-items:center;gap:9px;padding:9px 11px;width:245px;border:1px solid var(--border);border-radius:7px;background:#f9fafc;color:#a3acc0}
.global-search svg {width:16px;height:16px}
.global-search input {border:0;outline:none;background:none;width:100%;font-size:11px;min-width:0;color:var(--ink)}
.global-search input:focus {outline:none}
.global-search:focus-within {outline:2px solid #abb2ff}
.global-search input::placeholder {color:#99a3b5}
kbd {font:10px Arial;color:#a5adbc;border:1px solid #e2e6ef;padding:1px 4px;border-radius:3px;white-space:nowrap}
.icon-button {display:inline-flex;align-items:center;justify-content:center;padding:7px;color:#818b9e;border-radius:7px;gap:4px}
.icon-button:hover {background:var(--accent-soft);color:var(--accent)}
.notification-button {position:relative}
.notification-button::after {content:"";position:absolute;width:5px;height:5px;background:var(--accent);border:2px solid white;border-radius:50%;top:6px;right:8px;box-sizing:content-box}
.topbar-divider {height:23px;width:1px;background:var(--border);margin:0 -4px}
.topbar-avatar {display:flex;align-items:center;gap:7px;padding:0}
.topbar-avatar .avatar {width:32px;height:32px;border-radius:50%}
.mobile-menu {display:none}
.content {padding:29px 34px 30px;max-width:1550px;margin:auto}
.page-heading {display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:27px}
.heading-title {display:flex;align-items:center;gap:12px;margin-bottom:8px}
h1 {font-size:27px;font-weight:650;letter-spacing:-.5px}
.demo-badge {font-size:9px;border:1px solid #dfe4f1;color:#8893a9;padding:3px 6px;border-radius:4px;background:#fff;white-space:nowrap;letter-spacing:.3px}
.page-heading p {font-size:12px;color:var(--muted);line-height:1.8}
.heading-actions {display:flex;gap:10px;flex:none}
.btn {display:inline-flex;align-items:center;justify-content:center;gap:7px;border-radius:7px;padding:10px 15px;font-size:12px;line-height:1.4;font-weight:500;white-space:nowrap;border:1px solid var(--border);background:white}
.btn svg.icon {width:16px;height:16px}
.btn:hover {border-color:#c5ccdd;background:#fafbfe}
.btn-primary {color:#fff;background:var(--accent);border-color:var(--accent);box-shadow:0 3px 7px #5360ea16}
.btn-primary:hover {background:var(--accent-dark);border-color:var(--accent-dark)}
.btn-soft {background:var(--accent-soft);color:var(--accent);border-color:#e3e7ff}
.btn-danger {color:var(--danger);border-color:#f0d9de;background:#fff6f8}
.btn-small {font-size:11px;padding:7px 11px;border-radius:6px}
.text-button {padding:0;color:var(--accent);display:inline-flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}
.text-button svg.icon {width:14px;height:14px}
.stat-strip {display:grid;grid-template-columns:repeat(4,1fr);background:#fff;border:1px solid var(--border);border-radius:12px;padding:23px 0;margin-bottom:23px}
.stat {padding:0 24px;position:relative;border-right:1px solid var(--border)}
.stat:last-child {border-right:0}
.stat-label {font-size:11px;color:#78849a;display:flex;align-items:center;gap:7px}
.stat-label svg.icon {width:15px;height:15px;color:#949eb3}
.stat-line {display:flex;align-items:baseline;gap:8px;margin-top:8px}
.stat-value {font-size:31px;line-height:1.2;font-weight:650;letter-spacing:-1px;font-variant-numeric:tabular-nums}
.stat-unit {font-size:11px;color:#8e98ac}
.stat-foot {font-size:10px;color:#98a1b2;line-height:1.7;margin-top:8px}
.stat-foot .stat-colored {color:var(--accent);font-weight:600}
.stat:nth-child(2) .stat-value {color:#d59a3e}
.stat:nth-child(2) .stat-foot {color:#b68e54}
.renewal-strip {border-radius:10px;background:#edf0ff;border:1px solid #e4e8fc;padding:17px 21px;margin-bottom:27px;display:flex;align-items:center;gap:19px;position:relative}
.renewal-strip-icon {width:39px;height:39px;border-radius:10px;background:#fff;display:grid;place-items:center;color:var(--accent);flex:none}
.renewal-intro {flex:none;min-width:125px}
.renewal-intro h3 {font-size:12px;font-weight:650;margin-bottom:5px}
.renewal-intro p {font-size:10px;color:#8690b0}
.renewal-pills {display:flex;flex:1;gap:18px;justify-content:space-around;min-width:0}
.renewal-pill {display:flex;align-items:center;gap:9px;min-width:0;text-align:left;padding:0}
.renewal-pill .provider-mini {width:29px;height:29px;font-size:15px;border-radius:8px}
.renewal-pill strong {font-size:10px;display:block;font-weight:550;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100px}
.renewal-pill small {font-size:9px;color:#8992af;display:block;margin-top:4px;white-space:nowrap}
.renewal-strip>.text-button {flex:none;margin-left:5px;font-size:10px}
.workspace-grid {display:grid;grid-template-columns:minmax(0,1fr) 274px;gap:24px;align-items:start}
.section-header {display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;gap:14px}
.section-title {font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}
.section-title .count {font-size:10px;font-weight:500;color:#929bb0;background:#e9edf5;padding:2px 6px;border-radius:4px}
.section-controls {display:flex;gap:8px;align-items:center}
.segmented {display:flex;gap:2px;background:#e9edf5;padding:3px;border-radius:7px;align-items:center}
.segmented button {font-size:10px;color:#8c95a9;padding:5px 11px;border-radius:5px;white-space:nowrap}
.segmented button.active {color:var(--ink);background:white;box-shadow:0 1px 3px #1720390b;font-weight:550}
.segmented button span {font-size:9px;margin-left:4px;color:#a3acbc}
.view-toggle {display:flex;gap:0;border:1px solid #e2e6ee;border-radius:6px;overflow:hidden;background:white}
.view-toggle button {padding:5px 7px;color:#a2abbd;display:flex}
.view-toggle button svg {width:15px;height:15px}
.view-toggle button.active {background:#eef0ff;color:var(--accent)}
.card-grid {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px}
.sim-card {border:1px solid #e4e9f2;background:white;border-radius:12px;padding:19px 19px 0;min-width:0;position:relative;transition:border-color .16s,box-shadow .16s}
.sim-card:hover {border-color:#c9cff3;box-shadow:0 5px 19px #30395007}
.card-top {display:flex;align-items:center;gap:11px;margin-bottom:17px}
.provider-logo,.provider-mini {display:grid;place-items:center;background:var(--brand-color);color:white;font-size:22px;font-weight:750;border-radius:10px;width:39px;height:40px;font-family:Arial,sans-serif;flex:none;line-height:1}
.provider-logo[data-provider="giffgaff"] {background:#f8fafc;color:#263044;border:1px solid #e4e9f2;font-size:31px;font-weight:800}
.provider-logo[data-provider="3HK"] {font-size:29px;font-weight:800}
.provider-mini {width:31px;height:31px;font-size:16px;border-radius:8px}
.card-heading {flex:1;min-width:0}
.card-heading button {display:block;padding:0;max-width:100%;text-align:left}
.card-heading h3 {font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.card-heading p {font-size:11px;color:#7c889e;margin-top:5px}
.type-tag {font-size:9px;padding:3px 6px;border-radius:4px;background:#eef0ff;color:#5863c5;letter-spacing:.2px;font-weight:650;display:inline-flex;align-items:center;gap:4px;white-space:nowrap}
.type-tag.physical {background:#eef2f6;color:#8b96a7}
.type-tag svg {width:11px;height:11px}
.card-top>.icon-button {padding:3px;margin-right:-6px;margin-left:-2px}
.card-top>.icon-button svg {width:16px;height:16px}
.card-number {display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:17px;letter-spacing:.3px;font-variant-numeric:tabular-nums;font-weight:500;min-height:24px}
.card-number.data-number {font-size:13px;letter-spacing:0;color:#525e75}
.flag {width:18px;height:13px;flex:none;display:inline-block;border-radius:2px;overflow:hidden;box-shadow:0 0 0 1px #00000007;position:relative}
.flag svg {display:block;width:100%;height:100%}
.card-meta {display:flex;gap:13px;font-size:10px;color:#7c889e;margin-bottom:17px}
.card-meta span {display:flex;align-items:center;gap:4px}
.card-meta svg {width:12px;height:12px}
.card-rule {padding:11px 12px;background:#f7f8fc;border-radius:7px;display:flex;align-items:center;gap:8px;margin-bottom:14px;position:relative}
.card-rule>.icon {width:15px;height:15px;color:#7f8aaa}
.card-rule-name {font-size:11px;font-weight:500;flex:1;display:flex;align-items:center;gap:7px}
.card-rule-name span {font-size:10px;color:#7e8ba3;font-weight:400}
.card-rule-date {font-size:11px;font-weight:600;color:#536482;font-variant-numeric:tabular-nums}
.platform-label {font-size:10px;color:#7e8ba3;margin-bottom:9px;display:flex;align-items:center;gap:5px}
.platform-label svg {width:12px;height:12px}
.platform-tags {display:flex;gap:5px;flex-wrap:wrap;min-height:21px;margin-bottom:16px}
.platform-tag {font-size:9px;color:#68758e;border:1px solid #e4e9f2;background:white;border-radius:4px;padding:4px 6px;display:inline-flex;align-items:center;gap:4px;line-height:1.25}
.platform-tag .platform-dot {width:9px;height:9px;border-radius:3px;background:var(--platform-color,#7885a7);display:grid;place-items:center;color:#fff;font:bold 7px Arial}
.card-footer {border-top:1px solid #edf0f6;display:flex;align-items:center;justify-content:space-between;margin:0 -19px;padding:12px 19px;gap:8px;font-size:9px}
.status {display:inline-flex;align-items:center;gap:5px;font-size:10px;color:var(--success);white-space:nowrap}
.status::before {content:"";width:5px;height:5px;background:currentColor;border-radius:50%;flex:none}
.status.warning {color:var(--warning)}
.status.danger {color:var(--danger)}
.status.neutral {color:#939daf}
.card-footer .text-button {font-size:10px;color:#78869f}
.card-footer .text-button:hover {color:var(--accent)}
.card-footer .text-button svg {width:12px;height:12px}
.collection-foot {display:flex;justify-content:space-between;align-items:center;margin-top:18px;color:#a0a8b9;font-size:10px;gap:12px}
.collection-foot button {font-size:10px}
.right-rail {display:grid;gap:20px;padding-top:1px}
.panel {border:1px solid var(--border);background:white;border-radius:11px;overflow:hidden}
.panel-heading {display:flex;align-items:center;justify-content:space-between;padding:18px 18px 14px}
.panel-heading h2 {font-size:13px;font-weight:600;display:flex;align-items:center;gap:7px}
.panel-heading svg {width:15px;height:15px;color:#8b95b0}
.calendar-toolbar {display:flex;justify-content:space-between;align-items:center;padding:0 18px 14px}
.calendar-toolbar strong {font-size:12px;font-weight:550}
.calendar-controls {display:flex;gap:3px}
.calendar-controls .icon-button {padding:4px;border:1px solid #edf0f6;border-radius:5px}
.calendar-controls svg {width:12px;height:12px}
.calendar-grid {display:grid;grid-template-columns:repeat(7,1fr);padding:0 12px 16px;row-gap:4px}
.weekday {text-align:center;font-size:9px;color:#a2aabc;line-height:25px;margin-bottom:3px}
.calendar-day {height:30px;font-size:10px;color:#66738d;position:relative;margin:0 2px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums}
.calendar-day.other {color:#c4cada}
.calendar-day:hover {background:#f2f4ff}
.calendar-day.today {background:var(--accent);color:white;box-shadow:0 2px 4px #5360ea20}
.calendar-day.selected:not(.today) {background:var(--accent-soft);box-shadow:inset 0 0 0 1px #cfd5ff;color:var(--accent)}
.calendar-day.has-task::after {content:"";width:3px;height:3px;border-radius:50%;background:#e7a047;position:absolute;bottom:3px;left:calc(50% - 1.5px)}
.calendar-day.today.has-task::after {background:white}
.calendar-legend {font-size:9px;color:#a0a9ba;border-top:1px solid #eff1f6;padding:11px 18px;display:flex;gap:16px}
.calendar-legend span {display:flex;align-items:center;gap:5px}
.legend-dot {width:5px;height:5px;background:var(--accent);border-radius:50%}
.legend-dot.orange {background:#e7a047}
.calendar-events {border-top:1px solid #eff1f6;padding:16px 18px}
.calendar-events h4 {font-size:10px;color:#8995ab;font-weight:500;margin-bottom:13px}
.calendar-event {display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:0;margin:12px 0}
.calendar-event:last-child {margin-bottom:0}
.calendar-event-day {background:#f5f6fc;border:1px solid #eceef6;border-radius:6px;width:32px;height:35px;display:flex;flex-direction:column;align-items:center;justify-content:center;flex:none;color:#717d97;line-height:1.3;font-size:12px;font-weight:650}
.calendar-event-day small {font-size:7px;font-weight:400;color:#9ea7ba}
.calendar-event-text {flex:1}
.calendar-event-text strong {display:block;font-size:11px;font-weight:500}
.calendar-event-text small {display:block;font-size:10px;color:#7e8ba3;margin-top:4px}
.calendar-event .icon {width:13px;height:13px;color:#b1b9c9}
.empty-calendar {font-size:10px;color:#a2aabb;line-height:1.8;padding:5px 0}
.reminder-panel {background:#fff}
.reminder-head {padding:18px 18px 12px}
.reminder-head .reminder-icon {width:32px;height:32px;display:grid;place-items:center;border-radius:9px;background:#f0f2ff;color:var(--accent);margin-bottom:12px}
.reminder-head h3 {font-size:12px;font-weight:600;line-height:1.7;margin-bottom:6px}
.reminder-head p {font-size:11px;color:#7e8ba3;line-height:1.9}
.channel-line {display:flex;gap:9px;align-items:center;margin:0 18px;padding:11px 0;border-bottom:1px solid #f0f2f7}
.channel-line:last-of-type {border-bottom:0}
.channel-symbol {width:24px;height:24px;border-radius:7px;display:grid;place-items:center;background:#eaf5fe;color:#4b9acd}
.channel-symbol.email {background:#f0effe;color:#8a7aca}
.channel-symbol svg {width:13px;height:13px}
.channel-line strong {font-size:11px;font-weight:500;flex:1}
.channel-label {font-size:8px;color:#92a0b6;display:flex;align-items:center;gap:3px}
.channel-label svg {width:10px;height:10px;color:var(--success)}
.reminder-panel .panel-actions {display:flex;justify-content:space-between;align-items:center;padding:13px 18px 17px}
.reminder-panel .text-button {font-size:9px}
.rail-tip {font-size:9px;color:#a0a9bb;text-align:center;display:flex;justify-content:center;align-items:center;gap:5px;line-height:1.7}
.rail-tip svg {width:11px;height:11px}
.page-footer {display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:9px;color:#a5aebf;margin-top:34px;padding-top:17px;border-top:1px solid #e9edf5}
.page-footer span {display:flex;align-items:center;gap:5px}
.page-footer svg {width:12px;height:12px}
.card-list {background:white;border:1px solid var(--border);border-radius:11px;overflow-x:auto}
.card-list table {width:100%;border-collapse:collapse;min-width:610px}
th {font-size:10px;font-weight:500;color:#8e99ae;text-align:left;background:#fafbfe;padding:14px 16px;border-bottom:1px solid var(--border)}
td {font-size:11px;padding:17px 16px;border-bottom:1px solid #eef1f7;white-space:nowrap}
tr:last-child td {border-bottom:0}
.list-card-name {display:flex;align-items:center;gap:9px;text-align:left;padding:0}
.list-card-name strong {font-size:11px;font-weight:550;display:block}
.list-card-name small {font-size:9px;color:#a0a8bb;display:block;margin-top:4px}
.empty-state {border:1px dashed #dce2f0;border-radius:12px;padding:55px 20px;background:#ffffff90;text-align:center;grid-column:1/-1}
.empty-state>.icon {width:36px;height:36px;color:#acb4cd;margin-bottom:15px}
.empty-state h3 {font-size:14px;font-weight:550;margin-bottom:8px}
.empty-state p {font-size:11px;color:#98a2b7;line-height:1.9;margin-bottom:20px}
.section-full {margin-top:8px}
.task-list {display:grid;gap:12px}
.task-row {background:white;border:1px solid var(--border);border-radius:10px;padding:19px 21px;display:flex;align-items:center;gap:17px}
.task-date {flex:none;width:62px;border-right:1px solid var(--border);padding-right:16px;text-align:center;font-size:21px;font-weight:650;color:#5360a5;line-height:1.2}
.task-date small {display:block;font-size:9px;font-weight:400;color:#9aa3b7;margin-bottom:5px}
.task-row .provider-mini {width:36px;height:36px;border-radius:9px}
.task-info {flex:1;min-width:0}
.task-info h3 {font-size:12px;font-weight:550;margin-bottom:7px}
.task-info p {font-size:10px;color:#939db2;display:flex;gap:15px;align-items:center;line-height:1.8}
.task-info p span {display:flex;gap:5px;align-items:center}
.task-info p svg {width:12px;height:12px}
.task-row>.status {margin-right:10px}
.platform-table td {white-space:normal}
.platform-name-cell {display:flex;gap:10px;align-items:center}
.platform-initial {width:29px;height:29px;border-radius:8px;background:var(--platform-color);color:white;display:grid;place-items:center;font-weight:650;font-family:Arial;font-size:14px}
.muted {color:var(--muted)}
.settings-layout {display:grid;grid-template-columns:minmax(0,1fr) 305px;gap:23px;align-items:start}
.settings-main {display:grid;gap:19px}
.settings-panel {padding:25px}
.settings-panel h2 {font-size:14px;font-weight:600;margin-bottom:7px;display:flex;align-items:center;gap:9px}
.settings-panel h2 svg {width:17px;height:17px;color:#7e8bb2}
.settings-panel>p {font-size:11px;color:#98a1b5;line-height:1.8;margin-bottom:23px}
.settings-channel {border:1px solid var(--border);border-radius:9px;margin-top:13px;padding:17px}
.settings-channel-head {display:flex;align-items:center;gap:10px}
.settings-channel-head>div:nth-child(2) {flex:1}
.settings-channel-head strong {display:block;font-size:12px;font-weight:550}
.settings-channel-head p {font-size:10px;color:#98a2b6;margin-top:5px}
.switch {appearance:none;width:31px;height:18px;border-radius:12px;background:#cfd5e3;cursor:pointer;position:relative;flex:none;transition:background .15s;border:0}
.switch::after {content:"";position:absolute;top:3px;left:3px;width:12px;height:12px;background:white;border-radius:50%;transition:transform .15s;box-shadow:0 1px 3px #0001}
.switch:checked {background:var(--accent)}
.switch:checked::after {transform:translateX(13px)}
.settings-channel .field {margin-top:16px}
.channel-setup {display:flex;align-items:center;justify-content:space-between;gap:12px;margin:16px 0 12px;padding:12px 0;border-block:1px solid var(--border);flex-wrap:wrap}
.channel-setup p {display:flex;align-items:center;gap:7px;line-height:1.7;font-size:11px;color:var(--muted);margin:0;flex:1;min-width:140px}
.channel-setup p svg {width:14px;height:14px;flex:none}
.service-config-button {color:var(--accent);border-color:#d4dafb;background:var(--accent-soft);flex:none}
.config-intro {display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.8;color:#637087;margin-bottom:20px}
.config-intro svg {width:18px;height:18px;color:var(--accent);flex:none;margin-top:3px}
.config-check-note {font-size:11px;color:var(--muted);line-height:1.8;margin:18px 0 0}
.config-clear {color:var(--danger);margin-top:18px}
.config-feedback {font-size:12px;color:var(--success);padding:12px 14px;border:1px solid #cce5df;background:#f2faf7;border-radius:7px;line-height:1.8;margin-bottom:18px}
.config-feedback[hidden] {display:none}
.config-modal .modal-footer {flex-wrap:wrap}
.fields-grid {display:grid;grid-template-columns:1fr 1fr;gap:18px}
.field {display:flex;flex-direction:column;gap:8px;min-width:0}
.field>label,.field-label {font-size:11px;color:#637087;font-weight:500;display:block}
.field label span {color:#cb6573;margin-left:3px}
.field input,.field select,.field textarea {width:100%;border:1px solid #e0e5ef;border-radius:7px;background:white;padding:10px 11px;font-size:12px;line-height:1.5;color:var(--ink);min-width:0}
.field input::placeholder,.field textarea::placeholder {color:#aab2c1}
.field textarea {resize:vertical;min-height:75px;line-height:1.8}
.field small {font-size:10px;color:#99a3b7;line-height:1.7}
.field.wide {grid-column:1/-1}
.form-divider {border:0;border-top:1px solid var(--border);margin:22px 0}
.form-section-title {font-size:12px;font-weight:600;margin-bottom:15px;display:flex;justify-content:space-between}
.offsets {display:flex;flex-wrap:wrap;gap:9px;margin:10px 0 17px}
.offset-check {display:flex;align-items:center;gap:7px;background:#f6f7fc;border:1px solid #e8ecf5;border-radius:7px;padding:9px 13px;font-size:11px;color:#78869f;cursor:pointer}
.offset-check:has(input:checked) {background:#eff1ff;border-color:#cfd5ff;color:var(--accent)}
input[type=checkbox]:not(.switch) {accent-color:var(--accent);width:13px;height:13px;margin:0}
.checkbox-line {display:flex;gap:9px;align-items:center;font-size:11px;color:#7a879e;line-height:1.7}
.settings-actions {display:flex;justify-content:flex-end;gap:11px;padding-top:5px}
.notification-preview {padding:22px;background:#eef1ff;border-color:#e0e5fc}
.notification-preview h3 {font-size:12px;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:7px;color:#616fb7}
.preview-message {background:white;border-radius:9px;padding:18px;box-shadow:0 3px 10px #5963a108;font-size:11px;line-height:2.1;color:#66748e}
.preview-message strong {display:flex;align-items:center;gap:6px;color:var(--ink);font-size:12px;margin-bottom:8px}
.preview-message strong .icon {width:15px;height:15px;color:var(--accent)}
.preview-message .preview-label {color:#99a4b8;display:inline-block;width:58px}
.preview-message .preview-instructions {border-top:1px solid #eef0f6;margin-top:9px;padding-top:10px;font-size:10px;line-height:1.9}
.preview-message .preview-link {display:block;text-align:center;color:var(--accent);background:#f2f4ff;border:1px solid #e5e9fc;padding:7px 10px;border-radius:6px;margin-top:15px;font-size:10px}
.preview-note {font-size:10px;color:#8995b7;line-height:1.9;margin-top:15px}
.overlay-backdrop {position:fixed;inset:0;background:#222c4657;backdrop-filter:blur(3px);z-index:100;display:flex;justify-content:center;align-items:center;padding:24px;animation:backdrop-in .15s ease}
.modal {background:white;border-radius:15px;width:570px;max-width:100%;max-height:calc(100dvh - 48px);display:flex;flex-direction:column;box-shadow:0 16px 80px #1d29402b;overflow:hidden;animation:modal-in .18s ease}
.modal.small {width:440px}
.modal-header {padding:23px 25px 18px;display:flex;align-items:flex-start;justify-content:space-between;gap:15px;border-bottom:1px solid #edf0f6}
.modal-header h2 {font-size:17px;font-weight:600;margin-bottom:6px}
.modal-header p {font-size:10px;color:#98a2b6;line-height:1.8}
.modal-header .icon-button {padding:3px}
.modal-body {padding:24px 25px;overflow-y:auto}
.modal-footer {border-top:1px solid var(--border);padding:17px 25px;display:flex;align-items:center;justify-content:flex-end;gap:9px;background:#fcfcfe}
.form-error {font-size:11px;color:var(--danger);padding:10px 12px;border:1px solid #f0d2d8;background:#fff5f7;border-radius:6px;margin-bottom:16px;line-height:1.8}
.form-error[hidden] {display:none}
.calculation-preview {background:#f2f4ff;border:1px solid #e3e7ff;border-radius:8px;padding:13px 15px;margin-top:18px;font-size:11px;color:#7c88ac;display:flex;justify-content:space-between;gap:12px}
.calculation-preview strong {color:var(--accent);font-size:12px;font-weight:600;white-space:nowrap}
.completion-summary {display:flex;align-items:center;gap:12px;border-radius:9px;padding:15px;background:#f7f8fc;margin-bottom:20px}
.completion-summary h3 {font-size:12px;font-weight:600;margin-bottom:5px}
.completion-summary p {font-size:10px;color:#92a0b7}
.instructions-box {font-size:11px;color:#7b889f;line-height:1.9;padding:13px 15px;background:#f9fafc;border-left:2px solid #d7def3;margin-bottom:20px;border-radius:0 6px 6px 0}
.drawer-backdrop {justify-content:flex-end;padding:0}
.drawer {width:525px;max-width:100%;height:100dvh;background:white;display:flex;flex-direction:column;box-shadow:-10px 0 50px #1c28421a;animation:drawer-in .2s ease;overflow:hidden}
.drawer .modal-header {padding:22px 27px;align-items:center}
.drawer .modal-body {padding:26px 27px}
.detail-brand {display:flex;align-items:center;gap:13px;margin-bottom:24px}
.detail-brand .provider-logo {width:51px;height:53px;font-size:30px;border-radius:13px}
.detail-brand h2 {font-size:18px;font-weight:600;margin-bottom:8px}
.detail-brand p {font-size:11px;color:#8f9ab1;display:flex;align-items:center;gap:8px}
.detail-grid {display:grid;grid-template-columns:1fr 1fr;gap:19px;background:#f8f9fc;border-radius:10px;padding:18px;margin-bottom:24px}
.detail-grid dt {font-size:10px;color:#98a2b7;margin-bottom:6px}
.detail-grid dd {margin:0;font-size:12px;color:#57667e;font-variant-numeric:tabular-nums;line-height:1.8}
.detail-section {border-top:1px solid var(--border);padding-top:23px;margin-top:23px}
.detail-section .section-header {margin-bottom:14px}
.detail-section h3 {font-size:13px;font-weight:550}
.detail-rule {border:1px solid var(--border);border-radius:9px;padding:15px;margin:10px 0}
.detail-rule-head {display:flex;align-items:center;gap:8px;margin-bottom:10px}
.detail-rule-head>.icon {width:16px;height:16px;color:var(--accent)}
.detail-rule-head strong {font-size:11px;font-weight:550;flex:1}
.detail-rule-head span {font-size:10px;color:#99a4b8}
.detail-rule>p {font-size:10px;color:#94a0b6;line-height:1.9;margin-bottom:12px}
.detail-rule-bottom {display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:10px;color:#8592ad}
.detail-rule-bottom>div:last-child {display:flex;gap:10px}
.detail-platform {display:flex;align-items:center;gap:9px;border-bottom:1px solid #f0f2f7;padding:12px 0}
.detail-platform:last-child {border:0}
.detail-platform>div:nth-child(2) {flex:1;min-width:0}
.detail-platform strong {display:block;font-size:11px;font-weight:500}
.detail-platform p {font-size:9px;color:#99a4b8;line-height:1.7;margin-top:3px;overflow-wrap:anywhere}
.detail-platform .icon-button svg {width:14px;height:14px}
.history-item {border-left:2px solid #e4e8fc;padding:0 0 18px 14px;position:relative}
.history-item::before {content:"";position:absolute;width:6px;height:6px;border:2px solid white;background:#adb7ec;border-radius:50%;left:-5px;top:0;box-sizing:content-box}
.history-item strong {font-size:10px;font-weight:500}
.history-item p {font-size:9px;color:#95a1b6;line-height:1.8;margin-top:5px}
.detail-note {font-size:11px;color:#8793aa;line-height:1.9;white-space:pre-wrap;overflow-wrap:anywhere}
.help-copy {font-size:12px;color:#7d8ba3;line-height:2}
.help-copy h3 {font-size:13px;font-weight:600;color:#475570;margin:17px 0 5px}
.help-copy h3:first-child {margin-top:0}
.help-copy ul {padding-left:19px;margin:6px 0 15px}
.account-option {border:1px solid var(--border);border-radius:9px;display:flex;align-items:center;gap:12px;width:100%;padding:14px;text-align:left;margin:10px 0}
.account-option.active {background:var(--accent-soft);border-color:#d5dbff}
.account-option>div:nth-child(2) {flex:1}
.account-option strong {font-size:12px;font-weight:550}
.account-option p {font-size:10px;color:#97a2b7;margin-top:5px}
.account-option>.icon {width:17px;height:17px;color:var(--accent)}
.account-note {font-size:10px;color:#99a3b7;line-height:1.9;margin-top:20px}
#toast-root {position:fixed;left:calc(50% + 111px);bottom:30px;transform:translateX(-50%);z-index:200;max-width:calc(100% - 32px)}
.toast {display:flex;align-items:center;gap:9px;background:#28334a;color:white;padding:13px 20px;border-radius:9px;box-shadow:0 8px 30px #1d27402b;font-size:12px;line-height:1.7;animation:modal-in .18s ease}
.toast svg {width:16px;height:16px;color:#9ad7c6}
.toast.error svg {color:#ffb6c4}
.body-overlay {overflow:hidden}
@keyframes backdrop-in {from{opacity:0}to{opacity:1}}
@keyframes modal-in {from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:translateY(0)}}
@keyframes drawer-in {from{transform:translateX(30px);opacity:.5}to{transform:translateX(0);opacity:1}}
@media (min-width:1650px) {
.card-grid {grid-template-columns:repeat(3,minmax(0,1fr))}
.workspace-grid {grid-template-columns:minmax(0,1fr) 292px}
}
@media (max-width:1250px) {
.app-shell {grid-template-columns:195px minmax(0,1fr)}
.sidebar {width:195px;padding-inline:12px}
.brand {margin-inline:13px}
.content {padding:26px 25px}
.topbar {padding-inline:25px}
.workspace-grid {grid-template-columns:minmax(0,1fr) 250px;gap:19px}
.card-grid {gap:13px}
.sim-card {padding-inline:15px}
.card-footer {margin-inline:-15px;padding-inline:15px}
.card-top {gap:8px}
.card-heading h3 {font-size:11px}
.card-number {font-size:13px}
.provider-logo {width:33px;height:35px;border-radius:8px;font-size:20px}
.card-rule {padding-inline:9px}
.card-rule-name {gap:4px;font-size:9px}
.card-rule-name span {font-size:8px}
.card-rule-date {font-size:9px}
.renewal-pills {gap:9px}
.renewal-pill:nth-child(3) {display:none}
.stat {padding-inline:20px}
.section-controls {gap:6px}
.segmented button {padding-inline:8px}
#toast-root {left:calc(50% + 97px)}
}
@media (max-width:1060px) {
.app-shell {grid-template-columns:80px minmax(0,1fr)}
.sidebar {width:80px;padding:28px 10px 19px;align-items:center}
.brand {margin:0 0 35px}
.brand-name,.nav-caption,.nav-button>span,.sidebar-note,.user-text,.sidebar-user>.icon {display:none}
.nav-list {width:100%;gap:12px}
.nav-button {padding:13px;justify-content:center}
.nav-button .icon {width:21px;height:21px}
.nav-button.active::before {top:15px;bottom:15px}
.sidebar-user {justify-content:center;padding-top:20px}
.topbar {height:70px}
.content {padding:25px}
.workspace-grid {grid-template-columns:minmax(0,1fr) 245px;gap:19px}
.settings-layout {grid-template-columns:minmax(0,1fr) 265px}
.settings-panel {padding:20px}
#toast-root {left:calc(50% + 40px)}
}
@media (max-width:890px) {
.workspace-grid {grid-template-columns:minmax(0,1fr)}
.right-rail {grid-template-columns:1fr 1fr;align-items:start;margin-top:10px}
.rail-tip {grid-column:1/-1}
.card-grid {gap:17px}
.sim-card {padding:19px 19px 0}
.card-footer {margin-inline:-19px;padding-inline:19px}
.card-heading h3 {font-size:12px}
.card-number {font-size:15px}
.card-rule-name {font-size:10px}
.card-rule-name span {font-size:9px}
.card-rule-date {font-size:10px}
.provider-logo {width:39px;height:40px;font-size:22px}
.renewal-pill:nth-child(3) {display:flex}
.renewal-intro {min-width:105px}
.renewal-strip {gap:13px;padding-inline:17px}
.renewal-strip>.text-button {font-size:0;gap:0}
.renewal-strip>.text-button svg {width:15px;height:15px}
.global-search {width:220px}
.stat {padding-inline:18px}
.stat-foot {font-size:9px}
.settings-layout {grid-template-columns:1fr}
.notification-preview {max-width:460px}
.task-row {gap:13px;padding:17px}
.task-row>.status {margin-right:0}
}
@media (max-width:680px) {
.app-shell {display:block}
.sidebar {transform:translateX(-100%);visibility:hidden;width:222px;padding:28px 17px 18px;align-items:stretch;transition:transform .2s;z-index:70;box-shadow:none}
.sidebar.open {transform:translateX(0);visibility:visible;box-shadow:8px 0 45px #20305010}
.sidebar.open .brand {margin:0 15px 35px}
.sidebar.open .brand-name,.sidebar.open .nav-caption,.sidebar.open .nav-button>span,.sidebar.open .sidebar-note,.sidebar.open .user-text {display:block}
.sidebar.open .nav-button {justify-content:flex-start;padding:12px 18px}
.sidebar.open .nav-list {gap:7px}
.sidebar.open .sidebar-user {justify-content:flex-start}
.menu-scrim {position:fixed;inset:0;background:#25304a4a;z-index:60}
.topbar {height:62px;padding:0 18px;gap:8px}
.mobile-menu {display:flex;padding:5px;margin-left:-5px}
.breadcrumbs {font-size:11px;gap:8px;flex:1}
.breadcrumbs>span:first-child,.breadcrumbs>.icon {display:none}
.topbar-right {gap:11px}
.global-search {width:155px;padding:7px 9px;gap:5px}
.global-search input {font-size:10px}
.global-search kbd,.topbar-divider,.topbar-avatar>.icon {display:none}
.notification-button {padding:4px}
.topbar-avatar .avatar {width:28px;height:28px;font-size:10px}
.content {padding:25px 19px 24px}
.page-heading {align-items:flex-start;gap:10px;margin-bottom:21px;flex-wrap:wrap}
.heading-title {gap:8px;margin-bottom:7px}
h1 {font-size:24px}
.page-heading p {font-size:11px;max-width:320px}
.heading-actions {gap:7px;margin-top:7px}
.heading-actions .btn {font-size:11px;padding:9px 12px}
.stat-strip {grid-template-columns:1fr 1fr;padding:0;margin-bottom:18px;overflow:hidden}
.stat {padding:17px 19px;border-right:0}
.stat:nth-child(odd) {border-right:1px solid var(--border)}
.stat:nth-child(-n+2) {border-bottom:1px solid var(--border)}
.stat-value {font-size:28px}
.stat-foot {margin-top:6px;font-size:9px}
.renewal-strip {padding:15px;gap:11px;flex-wrap:wrap;margin-bottom:24px}
.renewal-strip-icon {width:32px;height:32px;border-radius:8px}
.renewal-strip-icon .icon {width:17px;height:17px}
.renewal-intro {flex:1}
.renewal-strip>.text-button {position:absolute;top:20px;right:15px;font-size:10px;gap:4px}
.renewal-pills {flex-basis:100%;justify-content:flex-start;border-top:1px solid #dfe4fb;padding-top:13px;margin-top:1px;gap:16px}
.renewal-pill {flex:1;gap:7px}
.renewal-pill .provider-mini {width:26px;height:26px;font-size:14px;flex:none}
.renewal-pill strong {font-size:9px;max-width:80px}
.renewal-pill small {font-size:8px;margin-top:3px}
.renewal-pill:nth-child(3) {display:none}
.section-header {gap:8px;margin-bottom:15px;flex-wrap:wrap}
.section-title {font-size:14px;gap:7px}
.section-controls {margin-left:auto;gap:6px}
.segmented button {font-size:9px;padding:5px 8px}
.segmented button span {display:none}
.view-toggle button {padding:5px}
.card-grid {grid-template-columns:1fr;gap:14px}
.card-top {gap:11px;margin-bottom:16px}
.card-heading h3 {font-size:13px}
.card-heading p {font-size:10px}
.card-number {font-size:17px;margin-bottom:9px}
.card-meta {font-size:10px;margin-bottom:17px}
.card-rule {padding:13px 12px;margin-bottom:16px}
.card-rule-name {font-size:11px}
.card-rule-name span {font-size:10px;margin-left:2px}
.card-rule-date {font-size:11px}
.platform-label {font-size:10px}
.platform-tag {font-size:9px;padding:4px 7px}
.card-footer {padding-block:14px}
.card-footer .status,.card-footer .text-button {font-size:10px}
.right-rail {grid-template-columns:1fr;margin-top:15px;gap:17px}
.calendar-grid {row-gap:5px;padding-inline:17px}
.calendar-day {height:35px;font-size:12px;margin-inline:3px}
.weekday {font-size:10px}
.calendar-events {padding-bottom:20px}
.panel-heading h2 {font-size:14px}
.calendar-toolbar strong {font-size:13px}
.page-footer {font-size:8px;align-items:flex-start;line-height:1.8;margin-top:26px}
.page-footer>span:last-child {max-width:145px;text-align:right}
.task-row {flex-wrap:wrap;padding:16px;gap:12px}
.task-date {width:45px;padding-right:11px;font-size:18px}
.task-row .provider-mini {width:30px;height:30px}
.task-info {flex-basis:calc(100% - 104px)}
.task-info h3 {font-size:11px}
.task-info p {font-size:9px;gap:8px;flex-wrap:wrap}
.task-row>.status {margin-left:57px;flex:1}
.task-row>.btn {font-size:10px;padding:7px 10px}
.fields-grid {gap:16px}
.settings-panel {padding:20px 17px}
.settings-panel>.fields-grid {grid-template-columns:1fr}
.offset-check {padding:8px 10px}
.overlay-backdrop {padding:14px}
.modal {max-height:calc(100dvh - 28px);border-radius:12px}
.modal-header {padding:20px 20px 16px}
.modal-header h2 {font-size:16px}
.modal-body {padding:21px 20px}
.modal-footer {padding:15px 20px}
.modal .fields-grid {gap:14px}
.field input,.field select,.field textarea {font-size:13px}
.drawer-backdrop {padding:0}
.drawer .modal-header {padding:20px}
.drawer .modal-body {padding:23px 20px}
#toast-root {left:50%;bottom:22px;width:max-content}
.toast {font-size:11px;padding:12px 16px}
}
@media (max-width:370px) {
.content {padding-inline:15px}
.topbar {padding-inline:15px}
.global-search {width:110px}
.topbar-right {gap:8px}
.breadcrumbs strong {font-size:10px}
.segmented button {padding-inline:6px}
.modal .fields-grid {grid-template-columns:1fr}
.field.wide {grid-column:auto}
.stat {padding-inline:16px}
}
@media (prefers-reduced-motion:reduce) {
*,*::before,*::after {animation:none!important;transition:none!important;scroll-behavior:auto!important}
}
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
let API;
try { ({API}=await import('../api.js')); } catch {}
assert.ok(API,'Same-origin authenticated API client must exist');
const requests=[];
const transport=async (path,options) => {
requests.push({path,options});
if (path==='/api/auth/login') return {ok:true,status:200,json:async()=>({csrfToken:'session-csrf',user:{name:'Alice'}})};
if (path==='/api/failure') return {ok:false,status:409,json:async()=>({detail:'资料已更新'})};
if (path==='/api/validation') return {ok:false,status:422,json:async()=>({detail:[{msg:'Value error, 请输入有效日期'}]})};
return {ok:true,status:200,json:async()=>({workspace:{cards:[{name:'Persisted SIM'}]}})};
};
const client=new API(transport);
await client.request('POST','/api/auth/login',{email:'alice@example.com',password:' password '});
const result=await client.request('POST','/api/cards',{name:'Persisted SIM'});
assert.equal(requests[0].options.credentials,'same-origin');
assert.equal(JSON.parse(requests[0].options.body).password,' password ');
assert.equal(requests[1].options.headers['X-CSRF-Token'],'session-csrf');
assert.equal(result.workspace.cards[0].name,'Persisted SIM');
await assert.rejects(client.request('GET','/api/failure'),error=>error.status===409 && error.message==='资料已更新');
await assert.rejects(client.request('GET','/api/validation'),error=>error.message==='请输入有效日期');
const offline=new API(async()=>{throw new TypeError('Failed to fetch')});
await assert.rejects(offline.request('GET','/api/workspace'),/无法连接服务器/);
client.clear();
await client.request('GET','/api/workspace');
assert.equal(requests.at(-1).options.headers['X-CSRF-Token'],undefined);
let finishOldRead, finishOldError;
const deferred = new API((path) => {
if(path==='/api/old-workspace') return new Promise(resolve=>{finishOldRead=resolve;});
if(path==='/api/old-error') return new Promise(resolve=>{finishOldError=resolve;});
return Promise.resolve({ok:true,status:200,json:async()=>({csrfToken:'bob-csrf',user:{id:'bob'},workspace:{cards:[]}})});
});
const oldRead=deferred.request('GET','/api/old-workspace');
const oldError=deferred.request('GET','/api/old-error');
deferred.clear();
const bob=await deferred.request('POST','/api/auth/login',{email:'bob@example.com',password:'long-password'});
const rejectOld=assert.rejects(oldRead,error=>error.name==='StaleResponse');
const rejectError=assert.rejects(oldError,error=>error.name==='StaleResponse');
finishOldRead({ok:true,status:200,json:async()=>({csrfToken:'alice-csrf',workspace:{cards:[{name:'Alice private SIM'}]}})});
finishOldError({ok:false,status:401,json:async()=>({detail:'请先登录'})});
await rejectOld;await rejectError;
assert.equal(bob.user.id,'bob');
assert.equal(deferred.csrf,'bob-csrf');
deferred.clear();
assert.throws(()=>deferred.assertCurrent(bob),error=>error.name==='StaleResponse');
console.log('API client: 12 checks passed');
+115
View File
@@ -0,0 +1,115 @@
import test from 'node:test';
import assert from 'node:assert/strict';
const core = await import('../core.js').catch(error => {
if (error.code === 'ERR_MODULE_NOT_FOUND') return {};
throw error;
});
function invoke(name, ...args) {
assert.equal(typeof core[name], 'function', `${name} is required for renewal calculations`);
return core[name](...args);
}
test('days advance across a year boundary', () => {
assert.equal(invoke('addDays', '2026-12-31', 2), '2027-01-02');
});
test('calendar months clamp January 31 to the last day of February', () => {
assert.equal(invoke('addCycle', '2026-01-31', 1, 'months'), '2026-02-28');
});
test('calendar months respect leap years', () => {
assert.equal(invoke('addCycle', '2028-01-31', 1, 'months'), '2028-02-29');
});
test('days use whole business dates independent of daylight saving time', () => {
assert.equal(invoke('daysUntil', '2026-03-09', '2026-03-07'), 2);
assert.equal(invoke('daysUntil', '2026-10-03', '2026-10-04'), -1);
});
test('completion-based renewal starts from the actual completion date', () => {
const rule = {dueDate:'2026-10-03', interval:90, unit:'days', anchor:'completion', anchorDate:'2026-07-05'};
assert.equal(invoke('nextAfterCompletion', rule, '2026-10-04'), '2027-01-02');
});
test('early fixed renewal advances one scheduled cycle rather than retaining the completed deadline', () => {
const rule = {dueDate:'2026-10-10', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-10-10'};
assert.equal(invoke('nextAfterCompletion', rule, '2026-10-04'), '2026-11-10');
});
test('overdue fixed renewal skips elapsed cycles', () => {
const rule = {dueDate:'2026-01-31', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-01-31'};
assert.equal(invoke('nextAfterCompletion', rule, '2026-04-01'), '2026-04-30');
});
test('fixed monthly schedules restore the original day after a short month', () => {
const rule = {dueDate:'2026-02-28', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-01-31'};
assert.equal(invoke('nextAfterCompletion', rule, '2026-02-28'), '2026-03-31');
});
test('invalid dates and cycles are rejected instead of silently overflowing', () => {
assert.throws(() => invoke('addCycle', '2026-02-30', 1, 'months'), /日期/);
assert.throws(() => invoke('addCycle', '2026-10-04', 0, 'days'), /周期/);
assert.throws(() => invoke('addCycle', '2026-10-04', -1, 'days'), /周期/);
assert.throws(() => invoke('addCycle', '2026-10-04', 1.5, 'days'), /周期/);
});
test('recording one rule leaves another renewal rule untouched', () => {
const card = {id:'c1', activatedAt:'2026-01-01', rules:[
{id:'sms', dueDate:'2026-10-03', interval:90, unit:'days', anchor:'completion', anchorDate:'2026-10-03'},
{id:'plan', dueDate:'2026-10-20', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-10-20'}
]};
const result = invoke('completeRenewal', card, 'sms', '2026-10-04', '2026-10-04', '发了一条短信');
assert.equal(result.card.rules[0].dueDate, '2027-01-02');
assert.equal(result.card.rules[1].dueDate, '2026-10-20');
assert.equal(card.rules[0].dueDate, '2026-10-03');
assert.equal(result.event.oldDueDate, '2026-10-03');
assert.equal(result.event.newDueDate, '2027-01-02');
});
test('completion cannot be in the future, before activation or before the last completion', () => {
const card = {activatedAt:'2026-02-01', rules:[{id:'r1', dueDate:'2026-10-20', lastCompletedAt:'2026-09-20', interval:30, unit:'days', anchor:'completion'}]};
assert.throws(() => invoke('completeRenewal', card, 'r1', '2026-10-05', '2026-10-04'), /未来/);
assert.throws(() => invoke('completeRenewal', card, 'r1', '2026-01-01', '2026-10-04'), /开通/);
assert.throws(() => invoke('completeRenewal', card, 'r1', '2026-09-01', '2026-10-04'), /上次/);
});
test('archived cards are excluded and remaining tasks sort by their actual deadline', () => {
const cards = [
{id:'a', archived:false, rules:[{id:'late',dueDate:'2026-10-20'},{id:'soon',dueDate:'2026-10-03'}]},
{id:'b', archived:true, rules:[{id:'archived',dueDate:'2026-09-01'}]},
{id:'c', archived:false, rules:[{id:'middle',dueDate:'2026-10-10'}]}
];
assert.deepEqual(invoke('getTasks', cards).map(task => task.rule.id), ['soon','middle','late']);
});
test('user timezone determines today around the UTC midnight boundary', () => {
const instant = new Date('2026-10-03T17:00:00Z');
assert.equal(invoke('todayString', 'Asia/Shanghai', instant), '2026-10-04');
assert.equal(invoke('todayString', 'America/New_York', instant), '2026-10-03');
});
test('editing activation cannot invalidate an older renewal event even when the latest completion is later', () => {
const card = {id:'c1', activatedAt:'2026-01-01', rules:[{dueDate:'2026-12-01',lastCompletedAt:'2026-10-04'}]};
const events = [
{cardId:'c1',completedAt:'2026-06-01'},
{cardId:'c1',completedAt:'2026-10-04'},
];
assert.throws(() => invoke('validateActivationChange', card, events, '2026-08-01', '2026-10-04'), /续期记录/);
});
test('editing activation ignores another card history and permits a correction before its own history', () => {
const card = {id:'c1',activatedAt:'2026-01-01',rules:[{dueDate:'2026-12-01'}]};
const events = [{cardId:'c2',completedAt:'2026-02-01'},{cardId:'c1',completedAt:'2026-06-01'}];
assert.doesNotThrow(() => invoke('validateActivationChange', card, events, '2026-05-01', '2026-10-04'));
});
test('HTTP browsers without randomUUID can still generate distinct valid card identifiers', () => {
const httpCrypto = {getRandomValues:globalThis.crypto.getRandomValues.bind(globalThis.crypto)};
const first = invoke('createId', httpCrypto);
const second = invoke('createId', httpCrypto);
assert.match(first, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
assert.match(second, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
assert.notEqual(first,second);
});
+20
View File
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
let money;
try {money=await import('../money.js');} catch {}
assert.ok(money,'Exact currency helpers must exist');
const {normalizeAmount,sumMoney,balanceAfter,formatMoney}=money;
assert.equal(normalizeAmount('00019.9','USD'),'19.90');
assert.equal(normalizeAmount('1000.00','JPY'),'1000');
assert.equal(normalizeAmount('1.234','KWD'),'1.234');
for(const [amount,currency] of [['1.001','USD'],['1.1','JPY'],['-1','USD'],['1e3','USD'],['NaN','USD'],['1000000000','USD'],['1','ZZZ']]) {
assert.throws(()=>normalizeAmount(amount,currency));
}
assert.deepEqual(sumMoney([{currency:'USD',amount:'0.10'},{currency:'USD',amount:'0.20'},{currency:'JPY',amount:'1000'}]),[{currency:'JPY',amount:'1000'},{currency:'USD',amount:'0.30'}]);
assert.equal(balanceAfter([{currency:'USD',amount:'0.30'}],{currency:'USD',amount:'0.10'},'deduct').amount,'0.20');
assert.throws(()=>balanceAfter([],{currency:'USD',amount:'0.10'},'deduct'));
assert.throws(()=>balanceAfter([{currency:'USD',amount:'0.30'}],{currency:'USD',amount:'0.31'},'deduct'));
assert.equal(balanceAfter([],{currency:'HKD',amount:'5.50'},'credit').amount,'5.50');
assert.equal(formatMoney({currency:'USD',amount:'1250.50'}),'USD 1,250.50');
assert.equal(formatMoney({currency:'JPY',amount:'1000'}),'JPY 1,000');
assert.equal(formatMoney(null),'未填写');
console.log('Money: 18 checks passed');
+141
View File
@@ -0,0 +1,141 @@
import tempfile
import unittest
from concurrent.futures import ThreadPoolExecutor
from datetime import date,timedelta
from pathlib import Path
from fastapi.testclient import TestClient
try:
from server.main import create_app
except ImportError:
create_app = None
class ApiTests(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(create_app,'Authenticated card API must be implemented')
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.db = Path(self.temp.name)/'test.db'
self.app = create_app(self.db, start_worker=False)
self.a = TestClient(self.app)
self.b = TestClient(self.app)
self.addCleanup(self.a.close)
self.addCleanup(self.b.close)
for client,name in [(self.a,'Alice'),(self.b,'Bob')]:
response = client.post('/api/auth/register',json={'name':name,'email':f'{name.lower()}@example.com','password':'safe-password-123'})
self.assertEqual(response.status_code,201,response.text)
def headers(self,client):
return {'X-CSRF-Token':client.get('/api/auth/me').json()['csrfToken']}
def card_payload(self):
today = date.today().isoformat()
return dict(name='My SIM',provider='Example',type='eSIM',country='US',phone='+1 202 555 0100',activatedAt='2026-01-01',notes='',plan='',rules=[dict(action='sms',interval=90,unit='days',anchor='completion',dueDate=today,instructions='发送短信')])
def create_card(self,client=None):
client = client or self.a
response = client.post('/api/cards',headers=self.headers(client),json=self.card_payload())
self.assertEqual(response.status_code,201,response.text)
return response.json()['workspace']['cards'][0]
def test_login_cookie_csrf_logout(self):
cookie = self.a.cookies.get('simkeep_session')
self.assertTrue(cookie)
self.assertEqual(self.a.post('/api/cards',json=self.card_payload()).status_code,403)
self.assertEqual(self.a.post('/api/auth/logout',headers=self.headers(self.a)).status_code,200)
self.assertEqual(self.a.get('/api/workspace').status_code,401)
self.assertEqual(self.a.post('/api/auth/login',json={'email':'alice@example.com','password':'wrong-password'}).status_code,401)
self.assertEqual(self.a.post('/api/auth/login',json={'email':'alice@example.com','password':'safe-password-123'}).status_code,200)
self.assertEqual(self.a.get('/api/workspace').status_code,200)
def test_card_rule_platform_and_history_are_private(self):
card = self.create_card()
self.assertEqual(self.b.get('/api/workspace').json()['workspace']['cards'],[])
self.assertEqual(self.b.get(f"/api/cards/{card['id']}").status_code,404)
self.assertEqual(self.b.post(f"/api/cards/{card['id']}/archive",headers=self.headers(self.b),json={'archived':True,'version':card['version']}).status_code,404)
self.assertEqual(self.b.post(f"/api/cards/{card['id']}/platforms",headers=self.headers(self.b),json={'name':'Google','account':'a','purpose':'auth','version':card['version']}).status_code,404)
rule = card['rules'][0]
self.assertEqual(self.b.post(f"/api/rules/{rule['id']}/completions",headers=self.headers(self.b),json={'completedAt':date.today().isoformat(),'note':'','requestId':'other-user-attempt','version':rule['version']}).status_code,404)
self.assertEqual(len(self.a.get('/api/workspace').json()['workspace']['cards']),1)
def test_completion_is_server_calculated_and_idempotent(self):
card = self.create_card()
rule = card['rules'][0]
payload = {'completedAt':date.today().isoformat(),'note':'done','requestId':'one-logical-operation','version':rule['version']}
url=f"/api/rules/{rule['id']}/completions"
first = self.a.post(url,headers=self.headers(self.a),json=payload)
self.assertEqual(first.status_code,200,first.text)
second = self.a.post(url,headers=self.headers(self.a),json=payload)
self.assertEqual(second.status_code,200,second.text)
workspace=second.json()['workspace']
self.assertEqual(len(workspace['events']),1)
self.assertEqual(workspace['cards'][0]['rules'][0]['dueDate'],(date.today()+timedelta(days=90)).isoformat())
payload['requestId']='another-operation'
self.assertEqual(self.a.post(url,headers=self.headers(self.a),json=payload).status_code,409)
def test_persisted_card_survives_application_recreation(self):
card=self.create_card()
other=TestClient(create_app(self.db,start_worker=False))
self.addCleanup(other.close)
other.post('/api/auth/login',json={'email':'alice@example.com','password':'safe-password-123'})
self.assertEqual(other.get('/api/workspace').json()['workspace']['cards'][0]['id'],card['id'])
def test_settings_validation_and_credentials_not_exposed(self):
payload={'timezone':'Asia/Shanghai','time':'09:00','offsets':[7,3,1],'overdue':True,'telegram':True,'email':True,'emailAddress':'alice@example.com'}
response=self.a.put('/api/settings',headers=self.headers(self.a),json=payload)
self.assertEqual(response.status_code,200,response.text)
self.assertFalse(response.json()['workspace']['settings']['emailVerified'])
self.assertNotIn('smtpPassword',response.text)
payload['timezone']='Invalid/Zone'
self.assertEqual(self.a.put('/api/settings',headers=self.headers(self.a),json=payload).status_code,422)
def test_card_update_rejects_old_version_and_history_conflict(self):
card=self.create_card()
payload=self.card_payload()
payload.pop('rules')
payload['version']=card['version']
payload['name']='Renamed'
first=self.a.put(f"/api/cards/{card['id']}",headers=self.headers(self.a),json=payload)
self.assertEqual(first.status_code,200,first.text)
self.assertEqual(self.a.put(f"/api/cards/{card['id']}",headers=self.headers(self.a),json=payload).status_code,409)
def test_concurrent_completions_only_advance_once(self):
card=self.create_card()
rule=card['rules'][0]
headers=self.headers(self.a)
def submit(request_id):
return self.a.post(f"/api/rules/{rule['id']}/completions",headers=headers,json={'completedAt':date.today().isoformat(),'note':'','requestId':request_id,'version':rule['version']}).status_code
with ThreadPoolExecutor(max_workers=2) as executor:
statuses=list(executor.map(submit,['concurrent-one','concurrent-two']))
self.assertEqual(sorted(statuses),[200,409])
self.assertEqual(len(self.a.get('/api/workspace').json()['workspace']['events']),1)
def test_activation_edit_checks_older_history_after_later_completion(self):
card=self.create_card()
rule=card['rules'][0]
for completed,request_id in [('2026-02-01','older-history'),('2026-09-01','later-history')]:
response=self.a.post(f"/api/rules/{rule['id']}/completions",headers=self.headers(self.a),json={'completedAt':completed,'note':'','requestId':request_id,'version':rule['version']})
self.assertEqual(response.status_code,200,response.text)
card=response.json()['workspace']['cards'][0]
rule=card['rules'][0]
payload=self.card_payload()
payload.pop('rules')
payload.update(activatedAt='2026-05-01',version=card['version'])
self.assertEqual(self.a.put(f"/api/cards/{card['id']}",headers=self.headers(self.a),json=payload).status_code,422)
def test_bad_length_and_validation_never_echo_password(self):
response=self.a.get('/api/health',headers={'Content-Length':'invalid'})
self.assertEqual(response.status_code,400)
response=self.a.post('/api/auth/register',json={'name':'Test','email':'test@example.com','password':'secret12'})
self.assertEqual(response.status_code,422)
self.assertNotIn('secret12',response.text)
def test_foreign_origin_cannot_create_a_card(self):
headers={**self.headers(self.a),'Origin':'http://another-site.example'}
self.assertEqual(self.a.post('/api/cards',headers=headers,json=self.card_payload()).status_code,403)
self.assertEqual(self.a.get('/api/workspace').json()['workspace']['cards'],[])
if __name__=='__main__':
unittest.main()
+23
View File
@@ -0,0 +1,23 @@
import unittest
from unittest.mock import patch
from fastapi import HTTPException
from server.auth import RateLimit, hash_password, verify_password
class AuthTests(unittest.TestCase):
def test_password_hash_is_salted_and_preserves_spaces(self):
a=hash_password(' password with spaces ')
b=hash_password(' password with spaces ')
self.assertNotEqual(a,b)
self.assertTrue(verify_password(' password with spaces ',a))
self.assertFalse(verify_password('password with spaces',a))
def test_other_routes_do_not_clear_longer_rate_limit(self):
rate=RateLimit()
with patch('server.auth.time.monotonic',return_value=100):
rate.check('registration',1,3600)
with patch('server.auth.time.monotonic',return_value=200):
rate.check('login',10,60)
with self.assertRaises(HTTPException) as error:
rate.check('registration',1,3600)
self.assertEqual(error.exception.status_code,429)
+42
View File
@@ -0,0 +1,42 @@
import unittest
try:
from server import domain
except ImportError:
domain = None
class DomainTests(unittest.TestCase):
def run_function(self, name, *args):
self.assertIsNotNone(domain, 'Service date rules must be implemented')
return getattr(domain, name)(*args)
def test_calendar_months_and_leap_year(self):
self.assertEqual(self.run_function('add_cycle','2026-01-31',1,'months'),'2026-02-28')
self.assertEqual(self.run_function('add_cycle','2028-01-31',1,'months'),'2028-02-29')
def test_fixed_months_restore_original_anchor(self):
rule = dict(dueDate='2026-02-28',anchorDate='2026-01-31',interval=1,unit='months',anchor='scheduled')
self.assertEqual(self.run_function('next_due',rule,'2026-02-28'),'2026-03-31')
def test_early_fixed_completion_advances_current_occurrence(self):
rule = dict(dueDate='2026-10-10',anchorDate='2026-10-10',interval=1,unit='months',anchor='scheduled')
self.assertEqual(self.run_function('next_due',rule,'2026-10-04'),'2026-11-10')
def test_overdue_fixed_completion_skips_elapsed_occurrences(self):
rule = dict(dueDate='2026-01-31',anchorDate='2026-01-31',interval=1,unit='months',anchor='scheduled')
self.assertEqual(self.run_function('next_due',rule,'2026-04-01'),'2026-04-30')
def test_completion_based_dates_start_from_actual_date(self):
rule = dict(dueDate='2026-10-03',interval=90,unit='days',anchor='completion')
self.assertEqual(self.run_function('next_due',rule,'2026-10-04'),'2027-01-02')
def test_bad_date_and_cycle_rejected(self):
self.assertIsNotNone(domain)
for value, interval, unit in [('2026-02-30',1,'months'),('2026-10-04',0,'days'),('2026-10-04',True,'days')]:
with self.assertRaises(ValueError):
domain.add_cycle(value,interval,unit)
if __name__ == '__main__':
unittest.main()
+42
View File
@@ -0,0 +1,42 @@
import unittest
try:
from server.money import normalize_amount, apply_balance
except ImportError:
normalize_amount = apply_balance = None
class MoneyTests(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(normalize_amount,'Exact multi-currency amounts must be implemented')
def test_normalizes_currency_precision_without_rounding(self):
self.assertEqual(normalize_amount('00019.9','USD'),'19.90')
self.assertEqual(normalize_amount('1000.00','JPY'),'1000')
self.assertEqual(normalize_amount('1.234','KWD'),'1.234')
self.assertEqual(normalize_amount('0','GBP'),'0.00')
def test_invalid_amounts_are_rejected(self):
for amount,currency in [('1.001','USD'),('0.1','JPY'),('-1','USD'),('NaN','USD'),('1e3','USD'),('1000000000','USD'),('1','ZZZ')]:
with self.subTest(amount=amount,currency=currency),self.assertRaises(ValueError):
normalize_amount(amount,currency)
def test_exact_subtraction_only_touches_matching_currency(self):
balances=[{'currency':'USD','amount':'0.30'},{'currency':'GBP','amount':'8.00'}]
updated,before,after=apply_balance(balances,{'currency':'USD','amount':'0.10'},'deduct')
self.assertEqual(updated,[{'currency':'USD','amount':'0.20'},{'currency':'GBP','amount':'8.00'}])
self.assertEqual(before,{'currency':'USD','amount':'0.30'})
self.assertEqual(after,{'currency':'USD','amount':'0.20'})
self.assertEqual(balances[0]['amount'],'0.30')
def test_insufficient_or_missing_balance_cannot_be_deducted(self):
for balances,cost in [([],{'currency':'USD','amount':'1.00'}),([{'currency':'USD','amount':'1.00'}],{'currency':'USD','amount':'1.01'})]:
with self.assertRaises(ValueError):
apply_balance(balances,cost,'deduct')
def test_topup_can_create_currency_and_credit_exactly(self):
balances=[{'currency':'JPY','amount':'100'}]
updated,before,after=apply_balance(balances,{'currency':'HKD','amount':'10.50'},'credit')
self.assertEqual(updated,[{'currency':'JPY','amount':'100'},{'currency':'HKD','amount':'10.50'}])
self.assertEqual(before,{'currency':'HKD','amount':'0.00'})
self.assertEqual(after,{'currency':'HKD','amount':'10.50'})
+161
View File
@@ -0,0 +1,161 @@
import tempfile
import unittest
from concurrent.futures import ThreadPoolExecutor
from datetime import date
from pathlib import Path
from fastapi.testclient import TestClient
from server.main import create_app
class MoneyApiTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path=Path(self.temp.name)/'test.db'
self.app=create_app(self.path,start_worker=False)
self.client=TestClient(self.app)
self.addCleanup(self.client.close)
response=self.client.post('/api/auth/register',json={'name':'Money Test','email':'money@example.com','password':'long-password-123'})
self.headers={'X-CSRF-Token':response.json()['csrfToken']}
def payload(self):
return {'name':'Money SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','balances':[{'currency':'USD','amount':'0.30'},{'currency':'JPY','amount':'1000'}],'rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':date.today().isoformat(),'cost':{'currency':'USD','amount':'0.10'}}]}
def create_card(self):
response=self.client.post('/api/cards',headers=self.headers,json=self.payload())
self.assertEqual(response.status_code,201,response.text)
return response.json()['workspace']['cards'][0]
def complete(self,card,request_id='money-operation',**extra):
rule=card['rules'][0]
payload={'completedAt':date.today().isoformat(),'note':'','requestId':request_id,'version':rule['version'],**extra}
return self.client.post(f"/api/rules/{rule['id']}/completions",headers=self.headers,json=payload)
def test_multiple_balances_and_rule_cost_persist_after_restart(self):
card=self.create_card()
with TestClient(create_app(self.path,start_worker=False)) as client:
client.post('/api/auth/login',json={'email':'money@example.com','password':'long-password-123'})
persisted=client.get('/api/workspace').json()['workspace']['cards'][0]
self.assertEqual(persisted['balances'],card['balances'])
self.assertEqual(persisted['rules'][0]['cost'],{'currency':'USD','amount':'0.10'})
def test_explicit_deduction_and_duplicate_submit_are_atomic(self):
card=self.create_card()
payload={'balanceAction':'deduct','cardVersion':card['version']}
first=self.complete(card,**payload)
self.assertEqual(first.status_code,200,first.text)
retry=self.complete(card,**payload)
self.assertEqual(retry.status_code,200,retry.text)
workspace=retry.json()['workspace']
self.assertEqual(workspace['cards'][0]['balances'],[{'currency':'USD','amount':'0.20'},{'currency':'JPY','amount':'1000'}])
self.assertEqual(len(workspace['events']),1)
event=workspace['events'][0]
self.assertEqual(event['cost'],{'currency':'USD','amount':'0.10'})
self.assertEqual(event['balanceBefore']['amount'],'0.30')
self.assertEqual(event['balanceAfter']['amount'],'0.20')
def test_record_only_keeps_balance_and_historical_actual_cost(self):
card=self.create_card()
response=self.complete(card,cost={'currency':'GBP','amount':'2.5'})
self.assertEqual(response.status_code,200,response.text)
workspace=response.json()['workspace']
self.assertEqual(workspace['cards'][0]['balances'],card['balances'])
self.assertEqual(workspace['events'][0]['cost'],{'currency':'GBP','amount':'2.50'})
rule=workspace['cards'][0]['rules'][0]
edited={k:rule[k] for k in ['action','interval','unit','anchor','dueDate','instructions','version']}
edited['cost']={'currency':'USD','amount':'5.00'}
updated=self.client.put(f"/api/rules/{rule['id']}",headers=self.headers,json=edited)
self.assertEqual(updated.status_code,200,updated.text)
self.assertEqual(updated.json()['workspace']['events'][0]['cost'],{'currency':'GBP','amount':'2.50'})
def test_changed_retry_cannot_silently_discard_cost_or_balance_action(self):
card=self.create_card()
first=self.complete(card,cost={'currency':'USD','amount':'0.10'})
self.assertEqual(first.status_code,200,first.text)
for changed in [
{'cost':{'currency':'USD','amount':'0.20'}},
{'cost':{'currency':'USD','amount':'0.10'},'balanceAction':'deduct','cardVersion':card['version']},
{'cost':None},
{'note':'changed after a lost response'},
]:
with self.subTest(changed=changed):
retry=self.complete(card,**changed)
self.assertEqual(retry.status_code,409,retry.text)
retry=self.complete(card,cost={'currency':'USD','amount':'0.100'})
self.assertEqual(retry.status_code,200,retry.text)
workspace=retry.json()['workspace']
self.assertEqual(workspace['cards'][0]['balances'],card['balances'])
self.assertEqual(len(workspace['events']),1)
self.assertEqual(workspace['events'][0]['cost'],{'currency':'USD','amount':'0.10'})
def test_insufficient_balance_rejects_entire_completion(self):
card=self.create_card()
response=self.complete(card,cost={'currency':'USD','amount':'0.31'},balanceAction='deduct',cardVersion=card['version'])
self.assertEqual(response.status_code,422,response.text)
workspace=self.client.get('/api/workspace').json()['workspace']
self.assertEqual(workspace['cards'][0],card)
self.assertEqual(workspace['events'],[])
def test_stale_card_balance_version_rejects_deduction(self):
card=self.create_card()
changed={**self.payload(),'rules':[],'version':card['version'],'balances':[{'currency':'USD','amount':'8.00'}]}
response=self.client.put(f"/api/cards/{card['id']}",headers=self.headers,json=changed)
self.assertEqual(response.status_code,200,response.text)
self.assertEqual(self.complete(card,balanceAction='deduct',cardVersion=card['version']).status_code,409)
def test_duplicate_currency_and_invalid_precision_are_rejected(self):
payload=self.payload()
payload['balances'].append({'currency':'USD','amount':'9.00'})
self.assertEqual(self.client.post('/api/cards',headers=self.headers,json=payload).status_code,422)
payload=self.payload()
payload['rules'][0]['cost']['amount']='0.001'
self.assertEqual(self.client.post('/api/cards',headers=self.headers,json=payload).status_code,422)
def test_legacy_card_edits_preserve_existing_money(self):
card=self.create_card()
changed={k:v for k,v in self.payload().items() if k not in ('balances','rules')}
changed.update(name='Renamed',version=card['version'])
response=self.client.put(f"/api/cards/{card['id']}",headers=self.headers,json=changed)
self.assertEqual(response.status_code,200,response.text)
updated=response.json()['workspace']['cards'][0]
self.assertEqual(updated['balances'],card['balances'])
rule=updated['rules'][0]
fields={k:rule[k] for k in ['action','interval','unit','anchor','dueDate','instructions','version']}
response=self.client.put(f"/api/rules/{rule['id']}",headers=self.headers,json=fields)
self.assertEqual(response.status_code,200,response.text)
self.assertEqual(response.json()['workspace']['cards'][0]['rules'][0]['cost'],card['rules'][0]['cost'])
def test_topup_creates_new_currency_and_saves_balance_snapshot(self):
payload=self.payload()
payload['rules'][0].update(action='topup',cost={'currency':'HKD','amount':'10.50'})
response=self.client.post('/api/cards',headers=self.headers,json=payload)
self.assertEqual(response.status_code,201,response.text)
card=response.json()['workspace']['cards'][0]
response=self.complete(card,balanceAction='credit',cardVersion=card['version'])
self.assertEqual(response.status_code,200,response.text)
workspace=response.json()['workspace']
self.assertEqual(workspace['cards'][0]['balances'][-1],{'currency':'HKD','amount':'10.50'})
self.assertEqual(workspace['events'][0]['balanceBefore'],{'currency':'HKD','amount':'0.00'})
def test_concurrent_requests_cannot_deduct_twice(self):
card=self.create_card()
def submit(request_id):
return self.complete(card,request_id,balanceAction='deduct',cardVersion=card['version']).status_code
with ThreadPoolExecutor(max_workers=2) as pool:
statuses=list(pool.map(submit,['money-concurrent-one','money-concurrent-two']))
self.assertEqual(sorted(statuses),[200,409])
workspace=self.client.get('/api/workspace').json()['workspace']
self.assertEqual(workspace['cards'][0]['balances'][0]['amount'],'0.20')
self.assertEqual(len(workspace['events']),1)
def test_other_account_cannot_change_or_deduct_balance(self):
card=self.create_card()
with TestClient(self.app) as other:
response=other.post('/api/auth/register',json={'name':'Other','email':'other-money@example.com','password':'long-password-123'})
headers={'X-CSRF-Token':response.json()['csrfToken']}
payload={**self.payload(),'rules':[],'version':card['version']}
self.assertEqual(other.put(f"/api/cards/{card['id']}",headers=headers,json=payload).status_code,404)
rule=card['rules'][0]
response=other.post(f"/api/rules/{rule['id']}/completions",headers=headers,json={'completedAt':date.today().isoformat(),'requestId':'foreign-deduction','version':rule['version'],'balanceAction':'deduct','cardVersion':card['version']})
self.assertEqual(response.status_code,404)
self.assertEqual(self.client.get('/api/workspace').json()['workspace']['cards'][0],card)
+282
View File
@@ -0,0 +1,282 @@
import asyncio
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from server.db import decode, encode
from server.main import create_app
from server.notifications import Config, Transport
from server.notification_config import TelegramConfigInput
TOKEN_A = '123456789:' + 'A' * 35
TOKEN_B = '987654321:' + 'B' * 35
SMTP_A = {'host':'smtp.example.com','port':587,'mode':'starttls','user':'alice@example.com','password':' password $ with spaces ','from':'alice@example.com'}
class NotificationConfigTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name)/'test.db'
self.app = create_app(self.path, start_worker=False, config=Config())
self.db = self.app.state.db
self.notifier = self.app.state.notifier
self.a, self.b = TestClient(self.app), TestClient(self.app)
self.addCleanup(self.a.close)
self.addCleanup(self.b.close)
self.people, self.headers = [], []
for client, name in [(self.a, 'Alice'), (self.b, 'Bob')]:
reply = client.post('/api/auth/register', json={'name':name,'email':f'{name.lower()}@example.com','password':'long-password-123'})
self.assertEqual(reply.status_code, 201, reply.text)
self.people.append(reply.json()['user']['id'])
self.headers.append({'X-CSRF-Token':reply.json()['csrfToken']})
def save(self, channel, payload, client=None, headers=None):
reply = (client or self.a).put(f'/api/notifications/config/{channel}', headers=headers or self.headers[0], json=payload)
self.assertEqual(reply.status_code, 200, reply.text)
return reply
def services(self, client=None):
return (client or self.a).get('/api/workspace').json()['workspace']['services']
def test_saved_configuration_is_owned_persistent_and_never_returns_secrets(self):
for channel, payload in [('telegram',{'token':TOKEN_A}), ('email',SMTP_A)]:
reply = self.save(channel, payload)
self.assertNotIn(TOKEN_A, reply.text)
self.assertNotIn(SMTP_A['password'], reply.text)
self.assertTrue(self.services()[channel]['configured'])
self.assertFalse(self.services(self.b)[channel]['configured'])
response = self.a.get('/api/notifications/config')
self.assertEqual(response.status_code, 200, response.text)
self.assertNotIn(TOKEN_A, response.text)
self.assertNotIn(SMTP_A['password'], response.text)
self.assertEqual(response.json()['services']['email']['host'], 'smtp.example.com')
self.assertTrue(response.json()['services']['telegram']['tokenSaved'])
restarted = create_app(self.path, start_worker=False, config=Config())
with TestClient(restarted) as client:
client.cookies.update(self.a.cookies)
self.assertTrue(self.services(client)['email']['configured'])
self.assertTrue(self.services(client)['telegram']['configured'])
def test_blank_secret_preserves_saved_credentials_and_password_whitespace(self):
self.save('telegram', {'token':TOKEN_A})
self.save('telegram', {'token':''})
self.save('email', SMTP_A)
self.save('email', {**SMTP_A, 'password':'', 'port':465, 'mode':'ssl'})
received = []
class SMTP:
def __init__(self, host, port, **kwargs):
received.append((host, port))
def __enter__(self): return self
def __exit__(self, *args): pass
def ehlo_or_helo_if_needed(self): pass
def ehlo(self): return 250, b'ok'
def login(self, user, password): received.append((user, password))
def send_message(self, message):
received.append((message['From'], message['To']))
return {}
with patch('server.notifications.smtplib.SMTP_SSL', SMTP):
reply = self.a.post('/api/notifications/email/verify', headers=self.headers[0])
self.assertEqual(reply.status_code, 200, reply.text)
self.assertEqual(received[:2], [('smtp.example.com',465), ('alice@example.com',' password $ with spaces ')])
self.assertEqual(received[2], ('alice@example.com','alice@example.com'))
def test_save_requires_session_csrf_and_does_not_accept_another_owner(self):
route = '/api/notifications/config/telegram'
with TestClient(self.app) as visitor:
self.assertEqual(visitor.put(route,json={'token':TOKEN_A}).status_code,401)
self.assertEqual(self.a.put(route,json={'token':TOKEN_A}).status_code,403)
reply = self.a.put(route,headers=self.headers[0],json={'token':TOKEN_A,'userId':self.people[1]})
self.assertEqual(reply.status_code,422)
self.assertFalse(self.services(self.b)['telegram']['configured'])
def test_invalid_configuration_never_echoes_credentials_and_keeps_previous_value(self):
self.save('telegram', {'token':TOKEN_A})
for secret in ['https://secret.example/token','1234:secret/malicious','secret\npassword']:
reply = self.a.put('/api/notifications/config/telegram',headers=self.headers[0],json={'token':secret})
self.assertEqual(reply.status_code,422,reply.text)
self.assertNotIn(secret,reply.text)
for change in [{'port':0},{'mode':'unsafe'},{'host':'https://smtp.example.com'},{'from':'alice@example.com\r\nBcc:secret@example.com'}]:
reply = self.a.put('/api/notifications/config/email',headers=self.headers[0],json={**SMTP_A,**change})
self.assertEqual(reply.status_code,422,reply.text)
self.assertNotIn(SMTP_A['password'],reply.text)
self.assertTrue(self.services()['telegram']['configured'])
self.assertFalse(self.services()['email']['configured'])
def test_telegram_bot_cannot_be_claimed_by_two_accounts(self):
self.save('telegram', {'token':TOKEN_A})
reply = self.b.put('/api/notifications/config/telegram',headers=self.headers[1],json={'token':TOKEN_A})
self.assertEqual(reply.status_code,409,reply.text)
self.assertNotIn(TOKEN_A,reply.text)
self.assertFalse(self.services(self.b)['telegram']['configured'])
def test_changing_bot_invalidates_old_binding_and_requires_rebinding(self):
self.save('telegram', {'token':TOKEN_A})
async def telegram(transport, method, payload):
return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'}
with patch.object(Transport,'telegram',telegram):
code = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0]).json()['code']
message = {'message':{'chat':{'id':12345,'type':'private'},'text':f'/start {code}'}}
self.assertTrue(self.notifier.process_update(message,user_id=self.people[0]))
self.save('telegram', {'token':TOKEN_B})
settings = self.a.get('/api/workspace').json()['workspace']['settings']
self.assertFalse(settings['telegramVerified'])
self.assertFalse(settings['telegram'])
self.assertEqual(settings['chatId'],'')
self.assertFalse(self.notifier.process_update(message,user_id=self.people[0]))
def test_personal_bot_cannot_consume_another_accounts_binding_code(self):
self.save('telegram', {'token':TOKEN_A})
self.save('telegram', {'token':TOKEN_B},self.b,self.headers[1])
async def telegram(transport, method, payload):
return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'}
with patch.object(Transport,'telegram',telegram):
code = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0]).json()['code']
message = {'message':{'chat':{'id':12345,'type':'private'},'text':f'/start {code}'}}
self.assertFalse(self.notifier.process_update(message,user_id=self.people[1]))
self.assertFalse(self.notifier.process_update(message))
self.assertTrue(self.notifier.process_update(message,user_id=self.people[0]))
def test_background_reminders_use_each_accounts_saved_token_immediately(self):
messages = []
for client, headers, token, chat in [(self.a,self.headers[0],TOKEN_A,'100'),(self.b,self.headers[1],TOKEN_B,'200')]:
self.save('telegram',{'token':token},client,headers)
reply = client.post('/api/cards',headers=headers,json={'name':'Personal SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':'2026-10-07'}]})
self.assertEqual(reply.status_code,201,reply.text)
owner = client.get('/api/auth/me').json()['user']['id']
with self.db.connect(write=True) as conn:
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(owner,)).fetchone()[0])
settings.update(telegram=True,telegramVerified=True,chatId=chat)
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),owner))
async def telegram(transport, method, payload):
messages.append((transport.config.telegram_token,method,payload['chat_id']))
return {'message_id':1}
now = datetime(2026,10,4,1,0,tzinfo=timezone.utc)
self.notifier.enqueue(now)
with patch.object(Transport,'telegram',telegram):
asyncio.run(self.notifier.deliver(now))
self.assertCountEqual(messages,[(TOKEN_A,'sendMessage','100'),(TOKEN_B,'sendMessage','200')])
self.assertEqual(self.a.get('/api/workspace').json()['workspace']['notifications'][0]['status'],'sent')
def test_connection_check_never_sends_a_message_and_redacts_network_errors(self):
self.save('telegram', {'token':TOKEN_A})
methods = []
async def telegram(transport, method, payload):
methods.append(method)
return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'}
with patch.object(Transport,'telegram',telegram):
reply = self.a.post('/api/notifications/config/telegram/check',headers=self.headers[0])
self.assertEqual(reply.status_code,200,reply.text)
self.assertTrue(reply.json()['checked'])
self.assertEqual(reply.json()['botUsername'],'AliceTestBot')
self.assertEqual(methods,['getMe'])
async def fail(transport, method, payload): raise RuntimeError(TOKEN_A)
with patch.object(Transport,'telegram',fail):
reply = self.a.post('/api/notifications/config/telegram/check',headers=self.headers[0])
self.assertEqual(reply.status_code,503,reply.text)
self.assertNotIn(TOKEN_A,reply.text)
self.assertTrue(self.services()['telegram']['configured'])
def test_clearing_configuration_only_affects_its_owner(self):
self.save('email',SMTP_A)
self.save('email',{**SMTP_A,'user':'bob@example.com','from':'bob@example.com'},self.b,self.headers[1])
reply = self.a.delete('/api/notifications/config/email',headers=self.headers[0])
self.assertEqual(reply.status_code,200,reply.text)
self.assertFalse(self.services()['email']['configured'])
self.assertTrue(self.services(self.b)['email']['configured'])
def test_bot_change_during_connection_cannot_create_a_stale_binding(self):
self.save('telegram', {'token':TOKEN_A})
async def telegram(transport, method, payload):
self.notifier.configs.save(self.people[0],'telegram',TelegramConfigInput(token=TOKEN_B),Config())
return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'}
with patch.object(Transport,'telegram',telegram):
reply = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0])
self.assertEqual(reply.status_code,409,reply.text)
with self.db.connect() as conn:
self.assertEqual(conn.execute('SELECT count(*) FROM bindings WHERE user_id=?',(self.people[0],)).fetchone()[0],0)
def test_personal_polling_keeps_separate_offsets_and_failed_bot_does_not_block_others(self):
self.save('telegram', {'token':TOKEN_A})
self.save('telegram', {'token':TOKEN_B},self.b,self.headers[1])
async def get_me(transport, method, payload):
return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'}
with patch.object(Transport,'telegram',get_me):
code = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0]).json()['code']
calls = []
async def updates(transport, method, payload):
calls.append((transport.config.telegram_token,payload['offset']))
if transport.config.telegram_token == TOKEN_B:
raise RuntimeError(TOKEN_B)
return [] if payload['offset'] else [{'update_id':21,'message':{'chat':{'id':12345,'type':'private'},'text':f'/start {code}'}}]
with patch.object(Transport,'telegram',updates), self.assertLogs('simkeep.notifications',level='WARNING') as logs:
asyncio.run(self.notifier.poll())
asyncio.run(self.notifier.poll())
self.assertCountEqual(calls,[(TOKEN_A,0),(TOKEN_B,0),(TOKEN_A,22),(TOKEN_B,0)])
self.assertTrue(self.a.get('/api/workspace').json()['workspace']['settings']['telegramVerified'])
self.assertNotIn(TOKEN_B,' '.join(logs.output))
def test_smtp_connection_check_authenticates_without_sending_email(self):
self.save('email',SMTP_A)
commands = []
class SMTP:
def __init__(self, host, port, **kwargs): commands.append(('connect',host,port))
def __enter__(self): return self
def __exit__(self, *args): commands.append(('quit',))
def ehlo_or_helo_if_needed(self): commands.append(('hello',))
def starttls(self, **kwargs): commands.append(('starttls',))
def ehlo(self): commands.append(('hello-tls',))
def login(self, user, password): commands.append(('login',user,password))
def send_message(self, message): raise AssertionError('Connection checking must not send email')
with patch('server.notifications.smtplib.SMTP',SMTP):
reply = self.a.post('/api/notifications/config/email/check',headers=self.headers[0])
self.assertEqual(reply.status_code,200,reply.text)
self.assertTrue(reply.json()['checked'])
self.assertEqual(commands,[('connect','smtp.example.com',587),('hello',),('starttls',),('hello-tls',),('login','alice@example.com',' password $ with spaces '),('quit',)])
def test_fake_token_with_same_bot_prefix_cannot_reserve_another_persons_credentials(self):
self.save('telegram',{'token':'123456789:'+'X'*35})
self.save('telegram',{'token':TOKEN_A},self.b,self.headers[1])
self.assertTrue(self.services(self.b)['telegram']['configured'])
# The actual full credential remains exclusive, including canonical ID spelling.
reply = self.a.put('/api/notifications/config/telegram',headers=self.headers[0],json={'token':'000'+TOKEN_A})
self.assertEqual(reply.status_code,409,reply.text)
def test_repaired_smtp_retries_failed_reminder_without_resending_a_sent_job(self):
self.save('email',SMTP_A)
reply = self.a.post('/api/cards',headers=self.headers[0],json={'name':'Personal SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':'2026-10-07'}]})
self.assertEqual(reply.status_code,201,reply.text)
with self.db.connect(write=True) as conn:
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(self.people[0],)).fetchone()[0])
settings.update(email=True,emailVerified=True)
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),self.people[0]))
now = datetime(2026,10,4,1,0,tzinfo=timezone.utc)
self.notifier.enqueue(now)
with patch.object(Transport,'smtp',side_effect=RuntimeError('old-password')):
for attempt in range(5):
asyncio.run(self.notifier.deliver(now+timedelta(minutes=10*attempt)))
self.assertEqual(self.a.get('/api/workspace').json()['workspace']['notifications'][0]['status'],'failed')
self.save('email',{**SMTP_A,'password':'corrected-password'})
delivered = []
def smtp(transport, destination, text): delivered.append(destination)
with patch.object(Transport,'smtp',smtp):
self.notifier.enqueue(now+timedelta(minutes=50))
asyncio.run(self.notifier.deliver(now+timedelta(minutes=50)))
self.save('email',{**SMTP_A,'password':'another-password'})
self.notifier.enqueue(now+timedelta(minutes=60))
asyncio.run(self.notifier.deliver(now+timedelta(minutes=60)))
self.assertEqual(delivered,['alice@example.com'])
jobs = self.a.get('/api/workspace').json()['workspace']['notifications']
self.assertEqual(len(jobs),1)
self.assertEqual(jobs[0]['status'],'sent')
if __name__ == '__main__':
unittest.main()
+261
View File
@@ -0,0 +1,261 @@
import asyncio
import re
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path
from fastapi.testclient import TestClient
from server.db import decode, encode
from server.main import create_app
try:
from server.notifications import Config, Notifier
except ImportError:
Config = Notifier = None
class RecordingTransport:
"""Only the external delivery boundary is replaced; queue and DB stay real."""
def __init__(self):
self.messages = []
self.failures = {}
async def send(self, channel, destination, text):
if self.failures.get(channel, 0):
self.failures[channel] -= 1
raise RuntimeError('secret-token-must-never-reach-browser')
self.messages.append((channel, destination, text))
async def bot_username(self):
return 'SimkeepTestBot'
async def updates(self, offset):
return []
class NotificationTests(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(Notifier, 'Real notification service must be implemented')
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name)/'test.db'
self.config = Config(telegram_token='server-secret-token', smtp_host='smtp.example.com', smtp_from='bot@example.com', public_url='http://example.com')
self.app = create_app(self.path, start_worker=False, config=self.config)
self.a = TestClient(self.app)
self.b = TestClient(self.app)
self.addCleanup(self.a.close)
self.addCleanup(self.b.close)
for client, name in [(self.a, 'Alice'), (self.b, 'Bob')]:
response = client.post('/api/auth/register', json={'name':name,'email':f'{name.lower()}@example.com','password':'long-password-123'})
self.assertEqual(response.status_code, 201, response.text)
self.db = self.app.state.db
self.notifier = self.app.state.notifier
self.transport = RecordingTransport()
self.notifier.transport = self.transport
self.owner = self.a.get('/api/auth/me').json()['user']['id']
self.headers = {'X-CSRF-Token': self.a.get('/api/auth/me').json()['csrfToken']}
self.now = datetime(2026, 10, 4, 1, 0, tzinfo=timezone.utc)
self.card = self.a.post('/api/cards', headers=self.headers, json={'name':'Private SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':'2026-10-07','instructions':'发送一条短信'}]}).json()['workspace']['cards'][0]
def enable(self, verified=True):
with self.db.connect(write=True) as conn:
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?', (self.owner,)).fetchone()[0])
settings.update(telegram=True, email=True, chatId='123456', telegramVerified=verified, emailVerified=verified)
conn.execute('UPDATE users SET settings=? WHERE id=?', (encode(settings), self.owner))
def tick(self, now=None):
self.notifier.enqueue(now or self.now)
asyncio.run(self.notifier.deliver(now or self.now))
def test_local_time_deduplication_and_sent_jobs_survive_restart(self):
self.enable()
self.tick(datetime(2026, 10, 4, 0, 59, tzinfo=timezone.utc))
self.assertEqual(self.transport.messages, [])
self.tick()
self.assertEqual(len(self.transport.messages), 2)
self.assertIn('Private SIM', self.transport.messages[0][2])
self.assertIn('2026-10-07', self.transport.messages[0][2])
self.tick()
restarted = Notifier(self.db, self.config, self.transport)
restarted.recover()
restarted.enqueue(self.now)
asyncio.run(restarted.deliver(self.now))
self.assertEqual(len(self.transport.messages), 2)
self.assertNotIn('server-secret-token', self.a.get('/api/workspace').text)
def test_unverified_and_archived_cards_never_send(self):
self.enable(verified=False)
self.tick()
self.assertEqual(self.transport.messages, [])
self.enable()
self.notifier.enqueue(self.now)
self.a.post(f"/api/cards/{self.card['id']}/archive", headers=self.headers, json={'archived':True,'version':self.card['version']})
asyncio.run(self.notifier.deliver(self.now))
self.assertEqual(self.transport.messages, [])
def test_old_rule_jobs_cancel_after_completion(self):
self.enable()
self.notifier.enqueue(self.now)
rule = self.card['rules'][0]
response = self.a.post(f"/api/rules/{rule['id']}/completions", headers=self.headers, json={'completedAt':'2026-10-04','note':'','requestId':'record-once-only','version':rule['version']})
self.assertEqual(response.status_code, 200, response.text)
asyncio.run(self.notifier.deliver(self.now))
self.assertEqual(self.transport.messages, [])
self.assertTrue(all(x['status']=='cancelled' for x in self.a.get('/api/workspace').json()['workspace']['notifications']))
def test_unconfigured_channel_does_not_starve_email_and_stale_jobs_cancel(self):
self.enable()
with self.db.connect(write=True) as conn:
original=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0])
settings=decode(conn.execute('SELECT settings FROM users WHERE id=?',(self.owner,)).fetchone()[0])
settings['email']=False
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),self.owner))
# Arrange 50 real active rules so the previous batch was fully occupied.
for index in range(49):
card={**original,'id':f'card-{index}','rules':[{**original['rules'][0],'id':f'rule-{index}'}]}
conn.execute('INSERT INTO cards VALUES(?,?,?)',(card['id'],self.owner,encode(card)))
self.notifier.enqueue(self.now)
self.notifier.config=Config(smtp_host='smtp.example.com',smtp_from='bot@example.com')
with self.db.connect(write=True) as conn:
settings['email']=True
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),self.owner))
self.tick()
self.assertEqual(len(self.transport.messages),50)
self.assertTrue(all(channel=='email' for channel,_,_ in self.transport.messages))
with self.db.connect(write=True) as conn:
conn.execute("UPDATE notification_jobs SET ready_at=ready_at-86400 WHERE channel='telegram'")
self.tick(datetime(2026,10,5,1,0,tzinfo=timezone.utc))
with self.db.connect() as conn:
self.assertEqual(conn.execute("SELECT count(*) FROM notification_jobs WHERE channel='telegram' AND status='cancelled'").fetchone()[0],50)
def test_rebind_check_waits_for_this_binding(self):
self.enable()
code=self.a.post('/api/notifications/telegram/binding',headers=self.headers).json()['code']
check=lambda:self.a.post('/api/notifications/telegram/binding/check',headers=self.headers,json={'code':code})
response=check()
self.assertEqual(response.status_code,200,response.text)
self.assertEqual(response.json()['bindingStatus'],'waiting')
self.assertTrue(self.notifier.process_update({'message':{'chat':{'id':654321,'type':'private'},'text':f'/start {code}'}}))
self.assertEqual(check().json()['bindingStatus'],'bound')
bheaders={'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']}
other=self.b.post('/api/notifications/telegram/binding/check',headers=bheaders,json={'code':code})
self.assertEqual(other.json()['bindingStatus'],'expired')
def test_real_message_matches_suffix_preview(self):
self.enable()
with self.db.connect(write=True) as conn:
card=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0])
card['phone']='+44 7700 900123'
conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id']))
self.tick()
for _,_,text in self.transport.messages:
self.assertIn('尾号 0123',text)
self.assertNotIn('+44 7700 900123',text)
def test_reminder_includes_estimated_cost_and_matching_balance(self):
self.enable()
with self.db.connect(write=True) as conn:
card=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0])
card['balances']=[{'currency':'USD','amount':'10.50'}]
card['rules'][0]['cost']={'currency':'USD','amount':'2.50'}
conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id']))
self.tick()
for _,_,text in self.transport.messages:
self.assertIn('续期金额:USD 2.50',text)
self.assertIn('同币种余额:USD 10.50',text)
def test_transport_failures_retry_without_blocking_other_channel(self):
self.enable()
self.transport.failures['telegram'] = 1
self.tick()
self.assertEqual([x[0] for x in self.transport.messages], ['email'])
self.assertNotIn('secret-token-must', self.a.get('/api/workspace').text)
self.tick(datetime(2026, 10, 4, 1, 3, tzinfo=timezone.utc))
self.assertEqual(sorted(x[0] for x in self.transport.messages), ['email','telegram'])
self.tick(datetime(2026, 10, 4, 1, 6, tzinfo=timezone.utc))
self.assertEqual(len(self.transport.messages), 2)
def test_cancelled_pending_job_can_resume_without_resending_sent_job(self):
self.enable()
self.notifier.enqueue(self.now)
settings = self.a.get('/api/workspace').json()['workspace']['settings']
fields = ['timezone','time','offsets','overdue','telegram','email','emailAddress']
payload = {k:settings[k] for k in fields}
payload['telegram'] = payload['email'] = False
self.a.put('/api/settings', headers=self.headers, json=payload)
payload['telegram'] = payload['email'] = True
self.a.put('/api/settings', headers=self.headers, json=payload)
self.tick()
self.assertEqual(len(self.transport.messages), 2)
self.a.put('/api/settings', headers=self.headers, json=payload)
self.tick()
self.assertEqual(len(self.transport.messages), 2)
def test_telegram_binding_is_private_expiring_and_single_use(self):
response = self.a.post('/api/notifications/telegram/binding', headers=self.headers)
self.assertEqual(response.status_code, 200, response.text)
code = response.json()['code']
group = {'message':{'chat':{'id':-123,'type':'group'},'text':f'/start {code}'}}
self.assertFalse(self.notifier.process_update(group))
private = {'message':{'chat':{'id':123456,'type':'private'},'text':f'/start {code}'}}
self.assertTrue(self.notifier.process_update(private))
self.assertFalse(self.notifier.process_update(private))
settings = self.a.get('/api/workspace').json()['workspace']['settings']
self.assertTrue(settings['telegramVerified'])
self.assertEqual(settings['chatId'], '123456')
bheaders = {'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']}
other = self.b.post('/api/notifications/telegram/binding', headers=bheaders).json()['code']
private['message']['text'] = f'/start {other}'
self.assertFalse(self.notifier.process_update(private))
self.assertFalse(self.b.get('/api/workspace').json()['workspace']['settings']['telegramVerified'])
expired = self.a.post('/api/notifications/telegram/binding', headers=self.headers).json()['code']
with self.db.connect(write=True) as conn:
conn.execute("UPDATE bindings SET expires_at=0 WHERE kind='telegram'")
private['message']['text'] = f'/start {expired}'
self.assertFalse(self.notifier.process_update(private))
def test_email_confirmation_is_owner_scoped_and_destination_bound(self):
response = self.a.post('/api/notifications/email/verify', headers=self.headers)
self.assertEqual(response.status_code, 200, response.text)
code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group()
bheaders = {'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']}
self.assertEqual(self.b.post('/api/notifications/email/confirm', headers=bheaders, json={'code':code}).status_code, 422)
self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 200)
self.assertTrue(self.a.get('/api/workspace').json()['workspace']['settings']['emailVerified'])
self.assertEqual(self.a.post('/api/notifications/test/email', headers=self.headers).status_code, 200)
self.assertEqual(self.b.post('/api/notifications/test/email', headers=bheaders).status_code, 422)
self.a.post('/api/notifications/email/verify', headers=self.headers)
code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group()
self.a.put('/api/settings', headers=self.headers, json={'timezone':'Asia/Shanghai','time':'09:00','offsets':[3],'overdue':True,'telegram':False,'email':True,'emailAddress':'new@example.com'})
self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 422)
def test_email_attempt_limit_and_unconfigured_service(self):
self.a.post('/api/notifications/email/verify', headers=self.headers)
code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group()
wrong = '000000' if code != '000000' else '111111'
for _ in range(5):
self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':wrong}).status_code, 422)
self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 422)
app = create_app(Path(self.temp.name)/'no-config.db', start_worker=False, config=Config())
with TestClient(app) as client:
client.post('/api/auth/register', json={'name':'Test','email':'test@example.com','password':'long-password-123'})
headers = {'X-CSRF-Token':client.get('/api/auth/me').json()['csrfToken']}
self.assertEqual(client.post('/api/notifications/email/verify', headers=headers).status_code, 503)
self.assertEqual(client.post('/api/notifications/telegram/binding', headers=headers).status_code, 503)
def test_overdue_notification_is_once_per_local_day(self):
self.enable()
with self.db.connect(write=True) as conn:
card = decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0])
card['rules'][0]['dueDate']='2026-10-03'
conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id']))
self.tick()
self.tick()
self.assertEqual(len(self.transport.messages),2)
self.tick(datetime(2026,10,5,1,0,tzinfo=timezone.utc))
self.assertEqual(len(self.transport.messages),4)
if __name__ == '__main__':
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
import io
import sqlite3
import tempfile
import unittest
from pathlib import Path
from server.db import Database
try:
from scripts.restore import restore_database
except ImportError:
restore_database=None
class RestoreTests(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(restore_database,'Container restore must work as the application user')
self.temp=tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root=Path(self.temp.name)
original=self.root/'backup.db'
db=Database(original)
with db.connect(write=True) as conn:
conn.execute('INSERT INTO system_state VALUES(?,?)',('restore-fixture','preserved'))
self.incoming=original.read_bytes()
self.destination=self.root/'data'/'simkeep.db'
def test_stream_import_preserves_database_contents_and_private_mode(self):
restore_database(self.destination,io.BytesIO(self.incoming))
with sqlite3.connect(self.destination) as conn:
self.assertEqual(conn.execute('SELECT value FROM system_state WHERE key=?',('restore-fixture',)).fetchone()[0],'preserved')
self.assertEqual(conn.execute('PRAGMA integrity_check').fetchone()[0],'ok')
self.assertEqual(self.destination.stat().st_mode&0o777,0o600)
def test_existing_database_requires_explicit_replace(self):
restore_database(self.destination,io.BytesIO(self.incoming))
before=self.destination.read_bytes()
with self.assertRaises(ValueError):
restore_database(self.destination,io.BytesIO(self.incoming))
self.assertEqual(self.destination.read_bytes(),before)
def test_invalid_or_unrelated_backup_preserves_existing_database(self):
restore_database(self.destination,io.BytesIO(self.incoming))
before=self.destination.read_bytes()
other=self.root/'other.db'
with sqlite3.connect(other) as conn:
conn.execute('CREATE TABLE unrelated(value TEXT)')
for incoming in (b'not a database',b'',other.read_bytes()):
with self.subTest(incoming_size=len(incoming)),self.assertRaises(ValueError):
restore_database(self.destination,io.BytesIO(incoming),replace=True)
self.assertEqual(self.destination.read_bytes(),before)
def test_explicit_restore_removes_old_sidecars_and_replaces_contents(self):
restore_database(self.destination,io.BytesIO(self.incoming))
with sqlite3.connect(self.destination) as conn:
conn.execute('DELETE FROM system_state')
for suffix in ('-wal','-shm'):
Path(str(self.destination)+suffix).write_bytes(b'old stopped database sidecar')
restore_database(self.destination,io.BytesIO(self.incoming),replace=True)
for suffix in ('-wal','-shm'):
self.assertFalse(Path(str(self.destination)+suffix).exists())
with sqlite3.connect(self.destination) as conn:
self.assertEqual(conn.execute('SELECT value FROM system_state').fetchone()[0],'preserved')