首次推送
build / build-and-test (push) Failing after 1m24s

This commit is contained in:
2026-10-18 19:44:38 +08:00
commit f047662129
64 changed files with 10454 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# 统一换行,避免 Windows 上提交后 CI 里脚本带 \r 报错
* text=auto
*.go text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.mod text eol=lf
*.sum text eol=lf
*.ps1 text eol=crlf
*.md text eol=lf
*.exe binary
*.db binary
*.png binary
+61
View File
@@ -0,0 +1,61 @@
name: build
# 触发:主干推送、版本标签、PR、手动执行
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
workflow_dispatch:
env:
# 纯 Go 依赖,交叉编译不需要 CGO
CGO_ENABLED: "0"
# 国内网络更稳的模块代理;如需官方源可改为 https://proxy.golang.org,direct
GOPROXY: https://goproxy.cn,direct
jobs:
build:
name: build-and-test
# 编译环境标签
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
- name: Go version
run: go version
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
- name: Build binaries
run: |
set -eu
mkdir -p dist
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o dist/daili-windows-amd64.exe .
GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o dist/daili-linux-amd64 .
cd dist
sha256sum daili-* > SHA256SUMS.txt
ls -lh
- name: Upload artifacts
uses: actions/upload-artifact@v4
env:
ACTIONS_RESULTS_URL: https://git.code.cq.cn/
with:
name: subtrack-${{ github.run_number }}
path: dist/
if-no-files-found: error
retention-days: 14
+40
View File
@@ -0,0 +1,40 @@
# ============================================================
# 入库范围约定:
# 允许 —— 源码、构建脚本、CI 配置、README.md
# 禁止 —— 除 README.md 以外的任何开发文档(PRD / 测试清单 /
# 测试证据 / 截图等)
# ============================================================
# 开发文档:除 README.md 外一律不入库
*.md
!README.md
/test-evidence/
/shots/
# 构建缓存(离线构建使用工作区内的缓存目录)
.gocache/
.gotmp/
# 运行期数据(数据库、备份、日志、自动生成的配置)
*.db
*.db-journal
*.db-wal
*.db-shm
backup/
logs/
daili.ini
testdata/
testrun/
testrun2/
freshtest/
cfgtest/
cfgtest-run/
portable-run/
# 截图辅助
.chrome-profile-*/
# 编译产物
*.exe
*.exe~
dist/
+256
View File
@@ -0,0 +1,256 @@
# 代打台 · 代打管理平台
一个给代打工作室用的单文件管理程序:**套餐 → 订阅 → 追加时长 → 日历打卡 → 大屏**。
整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。
---
## 1. 快速开始
1. 把 `daili.exe` 放进任意文件夹(建议路径不含空格,例如 `D:\代打台\`);
2. 双击 `daili.exe`:
- 首次运行会自动生成配置文件 `daili.ini`;
- 弹出一个黑色窗口,打印访问地址(默认 **17777** 端口);
- 默认会自动打开浏览器;
3. 浏览器打开 `http://127.0.0.1:17777` → 第一次会让你**创建管理员账号**;
4. 之后按「套餐 → 客户 → 订阅」的顺序录入即可。
```
┌───────────────────────────────────────────────┐
│ 代 打 台 · 代打管理平台 │
└───────────────────────────────────────────────┘
版本 1.1.0 配置文件 daili.ini
数据库 D:\代打台\daili.db
访问地址:
本机 http://127.0.0.1:17777
局域网 http://192.168.1.23:17777
大屏 http://127.0.0.1:17777/screen(免登录)
注意:大屏免登录且监听所有网卡,同局域网内任何人都能打开 /screen
按 Ctrl+C 停止服务
```
> 关掉黑窗口 = 停止服务。数据已经落盘,下次打开继续用。
---
## 2. 配置文件 daili.ini
配置文件固定放在 **exe 同级目录**,首次运行自动生成(带中文注释),改完**重启生效**。
写法:`key = value`,`#` 或 `;` 开头的整行是注释,不支持分节;**相对路径相对于 exe 所在目录**。
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| `listen_ip` | `0.0.0.0` | 监听 IP。`0.0.0.0` = 所有网卡(局域网可访问);`127.0.0.1` = 只允许本机 |
| `listen_port` | `17777` | 监听端口(1–65535) |
| `database_path` | `daili.db` | SQLite 数据库文件(单文件,含全部数据) |
| `backup_enabled` | `true` | 是否每日自动备份 |
| `backup_dir` | `backup` | 备份目录(留空则用 exe 同级 `backup/`) |
| `backup_keep` | `14` | 备份保留份数(1–365),超出自动清理 |
| `timezone` | 空 | 留空跟随系统;可固定 `Asia/Shanghai`。**影响所有“自然日”计算** |
| `open_browser` | `true` | 启动后自动打开浏览器 |
| `screen_public` | `true` | 大屏 `/screen` 免登录(投屏用);改 `false` 则需登录 |
| `log_enabled` | `true` | 运行日志写文件(控制台始终输出) |
| `log_dir` | `logs` | 日志目录,按天切分 `daili-YYYY-MM-DD.log` |
**行为约定**
- 文件不存在 → 自动生成默认配置,照常启动;
- 文件写错 → **报错退出并指出行号**,原文件不会被修改。例如:
```
[代打台] 配置文件有问题,已停止启动:
第 3 行: listen_port 必须在 1-65535 之间,当前是 99999
修好后重新启动即可(原文件未被修改)
```
- 未知配置项、重复配置项、布尔值写成 `maybe`、时区写错,都会带行号报错;
- 端口被占用不会自动换端口,而是明确报错并提示改 `listen_port`。
### 命令行参数(临时覆盖配置,优先级更高)
| 参数 | 说明 |
| --- | --- |
| `-config <路径>` | 指定别的配置文件(默认 exe 同级 `daili.ini`) |
| `-addr <ip:端口>` | 临时覆盖监听地址,如 `-addr 127.0.0.1:19000` |
| `-db <路径>` | 临时覆盖数据库路径 |
| `-backup-keep <n>` | 临时覆盖备份保留份数 |
| `-no-backup` | 本次运行关闭自动备份 |
| `-no-open` | 本次运行不自动打开浏览器 |
示例:`daili.exe -addr 127.0.0.1:19000 -no-open`
> 业务参数(到期预警天数、大屏刷新秒数)不在配置文件里,仍在网页「设置」中修改。
---
## 3. 第一次使用(5 分钟走完)
1. **建套餐**:`套餐 → 新增套餐`
- 「包月日常」按日 30 天,色签绿;
- 「深渊十次」按次 10 次,色签紫。
> 以后要加套餐 D/E/F,直接再加一行就行,**不会影响任何已有客户数据**。
2. **建客户**:`客户 → 新建客户`,填手机尾号 + 客户名(这两项组合唯一)。
3. **开通订阅**:进客户详情 → 选套餐、开始日、天数(按次则填次数)→ 开通订阅。
4. **日常使用**:打开「日历」,今天那格里就是这个客户今天要打的单,点「完成」打卡。
5. **续费**:客户转账后,点订阅上的「追加时长」→ 在日历上点新的终止日期 → 确认。
面板会实时显示「计算起点 / 原结束日 / 新结束日 / 本次追加几天 / 追加后剩余几天」,并在客户页留下追加记录。
> 想先看效果再录自己的数据?程序启动后运行 `powershell -ExecutionPolicy Bypass -File scripts\seed.ps1`,会写入 8 个演示客户和 4 个套餐(默认账号 `admin` / `daili123`)。
---
## 4. 业务规则(和 PRD 一致)
**剩余时长**
- 按日:`剩余天数 = 结束日 − 今天 + 1`,结束日当天仍算服务日;
- 按次:`剩余次数 = 总次数 − 已用次数`;按次套餐的「有效期」可留空,留空就一直有效直到次数用完。
**追加时长(核心)**
- 打开追加面板,**在月历上点选新的终止日期**,系统自动反算;
- 未到期的订阅:从原结束日的**次日**起算,追加天数 = 新结束日 − 原结束日;
- 已到期的订阅:**从今天起算**(不会把中间空档的天数白送给客户);
- 追加记录保留:计算起点、旧结束日、新结束日、天数、备注、操作人、时间。
**暂停 / 恢复**
- 暂停期间冻结剩余时长,日历上不出现待打任务;
- 恢复时按日套餐的结束日**自动顺延**暂停的自然日天数,时间轴上是灰色斜纹段。
**打卡**
- 按日套餐:每天一条「完成 / 跳过」,只做记录不扣天数(天数按自然日流逝);
- 按次套餐:打卡「完成」自动扣 1 次,撤销打卡返还 1 次;次数为 0 时不允许再打;
- 按次套餐如果没设有效期,它不会出现在日历里 —— 直接在客户详情点订阅卡片上的「今日打卡」扣次。
**预警**
- 剩余 ≤3 天橙色、今天到期红色、已过期灰色;顶栏常驻「今日到期 / 3 天内」两个数字。
**查询**
- 顶栏搜索框输入手机尾号即可定位客户(也支持姓名、游戏名、联系方式),按 `/` 快速聚焦。
---
## 5. 数据、备份与导出
| 文件 | 说明 |
| --- | --- |
| `daili.db` | **全部数据都在这一个文件里**(SQLite,单文件回滚日志模式,运行中也不会产生 `-wal`/`-shm`) |
| `backup/daili-YYYYMMDD-HHMMSS.db` | 每天首次启动时自动备份一份,保留份数见 `backup_keep` |
| `logs/daili-YYYY-MM-DD.log` | 运行日志,按天切分 |
### 迁移与重置(只说一次,很重要)
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
- **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。
- `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
- 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库;
- 导出:`设置 → 导出客户 / 订阅 / 日志 CSV`,UTF-8 BOM,Excel 直接打开不乱码。
---
## 6. 大屏
- 地址:`http://<本机IP>:17777/screen`
- 默认 `screen_public = true`:**免登录**,电视/副屏直接打开即可(仅建议局域网使用);
- 改成 `screen_public = false` 后大屏回到登录保护;
- 深色「记分板」风格:客户数 / 活跃订阅 / 今日到期 / 7 天内到期、到期热力月历、即将到期 TOP10、今日待打、最近追加;
- 默认 30 秒自动刷新(设置页可改),按 `F` 全屏。
---
## 7. 从源码构建
环境:Windows + Go 1.25(本项目在 `go1.25.6` 上验证)。
```powershell
.\build.ps1
```
`build.ps1` 会设置 `GOPROXY=off`、`GOCACHE=<项目>\.gocache`,用本地模块缓存离线构建,产物为 `daili.exe`(约 13.5 MB,内含 SQLite 引擎、时区库、模板与静态资源,不依赖任何 DLL)。
> **为什么有 `third_party/fileutil`**:SQLite 驱动 `modernc.org/sqlite` 的**测试代码**引用了 `modernc.org/fileutil`,但编译产物并不依赖它。本机无法访问任何 Go 模块源,因此用一个空模块占位,仅用于满足模块图解析。
>
> **为什么内嵌时区库**:Windows 没有 `/usr/share/zoneinfo`,为了 `timezone = Asia/Shanghai` 在任何机器上都可用,引入了 `time/tzdata`(约 0.5 MB)。
测试:
```powershell
$env:GOFLAGS='-mod=mod'; $env:GOPROXY='off'; $env:GOCACHE="$PWD\.gocache"
go test ./...
```
目录结构:
```
main.go 程序入口(读配置、日志、时区、监听、备份)
internal/config daili.ini 的生成/解析/校验(含单测)
internal/domain 纯业务计算(日期、剩余天数、状态、追加换算)+ 单元测试
internal/store SQLite 连接、迁移、全部读写 + 单元测试
internal/logx 控制台 + 按天切分的运行日志
internal/pw 口令散列(PBKDF2-SHA256,标准库实现)
internal/web 路由、会话、模板渲染、处理器
internal/web/templates 页面与布局(embed 进二进制)
internal/web/static app.css / screen.css / app.js / screen.js
scripts/acceptance.ps1 冒烟清单执行器(119 条断言)
scripts/config-test.ps1 配置文件专项测试(14 条断言)
scripts/smoke.ps1 端到端冒烟(早期版本,保留)
scripts/seed.ps1 写入演示数据
scripts/dbtool.py 测试预处理(暂停回溯、批量造数、完整性检查)
.gitea/workflows/build.yml Gitea Actions:vet → test → 交叉编译 → 上传产物
.gitattributes 统一换行(.go/.yml 用 LF,.ps1 用 CRLF)
PRD.md 产品需求文档(本地文档,不入库)
TEST-CHECKLIST.md 冒烟测试清单 + 执行结果 + 缺陷记录(本地,不入库)
test-evidence/ 测试证据(本地,不入库)
daili.ini 首次运行自动生成
```
### 持续集成(Gitea Actions)
仓库:<https://git.code.cq.cn/chun_qiu/subtrack.git>,CI 配置在 `.gitea/workflows/build.yml`。
- 触发:推送到 `main` / `master`、打 `v*` 标签、PR、手动执行;
- 运行环境:`runs-on: ubuntu-latest`;Go 版本由 `actions/setup-go@v5` 读取 `go.mod`(1.25);
- 步骤:`go vet ./...` → `go test ./...` → 交叉编译 Windows / Linux 两个二进制 → `sha256sum` → 用 `actions/upload-artifact@v4` 上传;
- 上传步骤带 `env: ACTIONS_RESULTS_URL: https://git.code.cq.cn/`;
- 产物:`subtrack-<run_number>`,内含 `daili-windows-amd64.exe`、`daili-linux-amd64`、`SHA256SUMS.txt`,保留 14 天;
- 模块代理默认 `https://goproxy.cn,direct`,如在海外或内网构建,改 workflow 顶部的 `GOPROXY` 即可。
> 仓库只入库源码、构建脚本、CI 配置和本 README;PRD、测试清单、截图与测试证据都在 `.gitignore` 里排除。
---
## 8. 常见问题
**Windows 提示「已保护你的电脑」**
点「更多信息 → 仍要运行」。这是 Windows 对未签名 exe 的默认拦截。
**端口被占用 / 想换端口**
改 `daili.ini` 里的 `listen_port` 后重启;临时换端口可用 `daili.exe -addr :19000`。
**只想自己用,不让局域网访问**
把 `listen_ip` 改成 `127.0.0.1`,启动横幅会提示「仅本机可访问」。
**手机打不开局域网地址**
1. 确认 `listen_ip` 是 `0.0.0.0`;
2. 确认手机和电脑在同一个 WiFi;
3. Windows 防火墙首次会弹窗,选「允许访问」(专用网络);
4. 用启动时打印的那个 `192.168.x.x` 地址,不要用 `127.0.0.1`。
**忘记密码**
让另一位管理员在 `设置 → 管理员` 里新增一个账户即可;如果只剩一个管理员且忘了密码,只能删掉 `daili.db` 重来。
**数据会不会因为程序崩溃丢?**
单文件 SQLite + 每次操作即时落盘,正常使用不会丢;每天的自动备份是双保险。仍然建议定期把 `backup/` 拷到别的盘。
**能放到公网吗?**
不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定,且大屏默认免登录。要放公网请套一层反向代理 + HTTPS + 访问控制,并把 `screen_public` 改成 `false`。
---
## 9. 安全说明
- 口令使用 PBKDF2-HMAC-SHA256(12 万次迭代 + 随机盐)存储,不可逆;
- 所有写操作记录操作人、时间、IP 与摘要,可在「操作日志」里查询;
- 服务端模板全部走 `html/template` 自动转义;
- 所有 POST 校验 `Origin`,会话 Cookie 使用 `SameSite=Lax` + `HttpOnly`;
- 配置文件里的数据库/备份/日志路径请自行确保目录权限,程序不会覆盖已有文件(备份重名会跳过)。
+20
View File
@@ -0,0 +1,20 @@
# 离线构建脚本:本机无法访问任何 Go 模块源,全部依赖取自本地模块缓存
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$env:GOFLAGS = '-mod=mod'
$env:GOPROXY = 'off'
$env:GOSUMDB = 'off'
$env:GOCACHE = Join-Path $root '.gocache'
$env:GOTMPDIR = Join-Path $root '.gotmp'
New-Item -ItemType Directory -Force -Path $env:GOCACHE, $env:GOTMPDIR | Out-Null
Push-Location $root
try {
Write-Host '正在编译…' -ForegroundColor Cyan
go build -trimpath -ldflags '-s -w' -o daili.exe .
if ($LASTEXITCODE -ne 0) { throw "编译失败(退出码 $LASTEXITCODE)" }
$f = Get-Item daili.exe
Write-Host ("编译完成: {0} {1:N1} MB" -f $f.Name, ($f.Length / 1MB)) -ForegroundColor Green
} finally {
Pop-Location
}
+22
View File
@@ -0,0 +1,22 @@
module daili
go 1.25.0
require modernc.org/sqlite v1.50.1
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.45.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
// 离线构建用占位模块:sqlite 的测试代码引用了 modernc.org/fileutil,
// 但编译产物不依赖它,此处用空模块满足模块图解析(本机无法访问任何 Go 模块源)。
replace modernc.org/fileutil => ./third_party/fileutil
+18
View File
@@ -0,0 +1,18 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
+328
View File
@@ -0,0 +1,328 @@
// Package config 负责读取/生成与可执行文件同级的 daili.ini。
//
// 设计约定:
// - 只放「部署类」参数;业务默认值(预警天数、大屏刷新秒数)仍在设置页(数据库)。
// - 文件不存在时自动生成带注释的默认配置;文件写错时报错并保留原文件。
// - 相对路径一律相对于可执行文件所在目录,而不是当前工作目录。
package config
import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
// 内嵌时区库:Windows 上没有 /usr/share/zoneinfo,靠它才能 LoadLocation("Asia/Shanghai")
_ "time/tzdata"
)
// FileName 是默认配置文件名。
const FileName = "daili.ini"
// Config 是最终生效的配置。
type Config struct {
ListenIP string
ListenPort int
DatabasePath string
BackupEnabled bool
BackupDir string
BackupKeep int
Timezone string
OpenBrowser bool
ScreenPublic bool
LogEnabled bool
LogDir string
// 运行时附加信息
Path string // 配置文件绝对路径
BaseDir string // 相对路径的基准目录(exe 所在目录)
Created bool // 本次是否自动生成了默认文件
keyLine map[string]int // 各配置项出现的行号,用于校验报错定位
}
// Defaults 返回内置默认值。
func Defaults() *Config {
return &Config{
ListenIP: "0.0.0.0",
ListenPort: 17777,
DatabasePath: "daili.db",
BackupEnabled: true,
BackupDir: "backup",
BackupKeep: 14,
Timezone: "",
OpenBrowser: true,
ScreenPublic: true,
LogEnabled: true,
LogDir: "logs",
}
}
// Addr 返回 net.Listen 用的监听地址。
func (c *Config) Addr() string {
return net.JoinHostPort(c.ListenIP, strconv.Itoa(c.ListenPort))
}
// LoopbackOnly 表示只监听本机回环地址。
func (c *Config) LoopbackOnly() bool {
ip := net.ParseIP(c.ListenIP)
return ip != nil && ip.IsLoopback()
}
// PublicBind 表示监听在所有网卡(局域网可访问)。
func (c *Config) PublicBind() bool {
ip := net.ParseIP(c.ListenIP)
return ip != nil && (ip.IsUnspecified())
}
// Load 读取配置;文件不存在时生成默认文件。baseDir 为空则取配置文件所在目录。
func Load(path, baseDir string) (*Config, error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, err
}
if baseDir == "" {
baseDir = filepath.Dir(abs)
}
c := Defaults()
c.Path = abs
c.BaseDir = baseDir
data, err := os.ReadFile(abs)
if errors.Is(err, os.ErrNotExist) {
if mkErr := os.MkdirAll(filepath.Dir(abs), 0o755); mkErr != nil {
return nil, fmt.Errorf("创建配置目录失败: %w", mkErr)
}
c.Created = true
if wErr := os.WriteFile(abs, []byte(templateText(c)), 0o644); wErr != nil {
return nil, fmt.Errorf("生成默认配置文件失败: %w", wErr)
}
return c.resolve(), nil
}
if err != nil {
return nil, fmt.Errorf("读取配置文件失败: %w", err)
}
if err := c.parse(string(data)); err != nil {
return nil, fmt.Errorf("%s\n修好后重新启动即可(原文件未被修改)", err)
}
return c.resolve(), nil
}
func (c *Config) resolve() *Config {
c.DatabasePath = absolute(c.BaseDir, c.DatabasePath)
c.BackupDir = absolute(c.BaseDir, c.BackupDir)
c.LogDir = absolute(c.BaseDir, c.LogDir)
return c
}
func absolute(base, p string) string {
if p == "" {
return ""
}
if filepath.IsAbs(p) {
return filepath.Clean(p)
}
return filepath.Join(base, p)
}
func (c *Config) parse(text string) error {
text = strings.TrimPrefix(text, "\ufeff")
lines := strings.Split(text, "\n")
seen := map[string]int{}
c.keyLine = map[string]int{}
for i, raw := range lines {
line := strings.TrimSpace(strings.TrimSuffix(raw, "\r"))
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if strings.HasPrefix(line, "[") {
return fmt.Errorf("第 %d 行: 不支持分节([xxx]),请直接写 key = value", i+1)
}
eq := strings.Index(line, "=")
if eq <= 0 {
return fmt.Errorf("第 %d 行: 缺少等号,正确格式是 key = value", i+1)
}
key := strings.ToLower(strings.TrimSpace(line[:eq]))
val := strings.TrimSpace(line[eq+1:])
if prev, ok := seen[key]; ok {
return fmt.Errorf("第 %d 行: 配置项 %s 与第 %d 行重复", i+1, key, prev)
}
seen[key] = i + 1
c.keyLine[key] = i + 1
if err := c.set(key, val); err != nil {
return fmt.Errorf("第 %d 行: %v", i+1, err)
}
}
if err := c.validate(); err != nil {
return err
}
return nil
}
func (c *Config) set(key, val string) error {
switch key {
case "listen_ip":
c.ListenIP = val
case "listen_port":
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("listen_port 必须是数字,当前是 %q", val)
}
c.ListenPort = n
case "database_path":
c.DatabasePath = val
case "backup_enabled":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("backup_enabled %v", err)
}
c.BackupEnabled = b
case "backup_dir":
c.BackupDir = val
case "backup_keep":
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("backup_keep 必须是数字,当前是 %q", val)
}
c.BackupKeep = n
case "timezone":
c.Timezone = val
case "open_browser":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("open_browser %v", err)
}
c.OpenBrowser = b
case "screen_public":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("screen_public %v", err)
}
c.ScreenPublic = b
case "log_enabled":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("log_enabled %v", err)
}
c.LogEnabled = b
case "log_dir":
c.LogDir = val
default:
return fmt.Errorf("未知配置项 %s(可用项见文件内的注释)", key)
}
return nil
}
func (c *Config) validate() error {
if c.ListenIP == "" {
return c.at("listen_ip", "listen_ip 不能为空(0.0.0.0 表示所有网卡,127.0.0.1 表示仅本机)")
}
if net.ParseIP(c.ListenIP) == nil {
return c.at("listen_ip", "listen_ip %q 不是合法 IP(请写 0.0.0.0 或 127.0.0.1)", c.ListenIP)
}
if c.ListenPort < 1 || c.ListenPort > 65535 {
return c.at("listen_port", "listen_port 必须在 1-65535 之间,当前是 %d", c.ListenPort)
}
if strings.TrimSpace(c.DatabasePath) == "" {
return c.at("database_path", "database_path 不能为空")
}
if c.BackupEnabled && strings.TrimSpace(c.BackupDir) == "" {
return c.at("backup_dir", "backup_enabled 为 true 时 backup_dir 不能为空")
}
if c.BackupKeep < 1 || c.BackupKeep > 365 {
return c.at("backup_keep", "backup_keep 必须在 1-365 之间,当前是 %d", c.BackupKeep)
}
if c.Timezone != "" {
if _, err := time.LoadLocation(c.Timezone); err != nil {
return c.at("timezone", "timezone %q 无法识别(示例:Asia/Shanghai;留空表示跟随系统)", c.Timezone)
}
}
if c.LogEnabled && strings.TrimSpace(c.LogDir) == "" {
return c.at("log_dir", "log_enabled 为 true 时 log_dir 不能为空")
}
return nil
}
// at 生成带行号的错误;没有行号信息(如默认值)时退化为普通错误。
func (c *Config) at(key, format string, args ...any) error {
msg := fmt.Sprintf(format, args...)
if n := c.keyLine[key]; n > 0 {
return fmt.Errorf("第 %d 行: %s", n, msg)
}
return errors.New(msg)
}
// Validate 校验配置(命令行覆盖之后也会调用一次)。
func (c *Config) Validate() error { return c.validate() }
func parseBool(v string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on", "y":
return true, nil
case "0", "false", "no", "off", "n":
return false, nil
}
return false, fmt.Errorf("必须是 true/false(也接受 1/0、yes/no、on/off),当前是 %q", v)
}
func templateText(c *Config) string {
return fmt.Sprintf(`# ============================================================
# 代打台 配置文件(daili.ini)
# 与 daili.exe 放在同一目录;修改后需要重启程序生效。
# 写法:key = value;以 # 或 ; 开头的整行是注释;不支持分节。
# 相对路径相对于 exe 所在目录,而不是当前工作目录。
# ============================================================
# ---------- 监听 ----------
# 0.0.0.0 = 所有网卡(同局域网可访问)
# 127.0.0.1 = 只允许本机访问
listen_ip = %s
listen_port = %d
# ---------- 数据与备份 ----------
database_path = %s
# 是否每日自动备份(另可在网页「设置 → 立即备份」手动备份)
backup_enabled = %t
# 备份目录;留空则使用 exe 同级 backup/
backup_dir = %s
# 备份保留份数(1-365),超出的旧备份会自动清理
backup_keep = %d
# ---------- 时间 ----------
# 留空 = 跟随系统时区;也可固定为 Asia/Shanghai
timezone = %s
# ---------- 界面 ----------
# 启动后自动打开浏览器
open_browser = %t
# 大屏 /screen 是否免登录(默认 true:适合投屏;改成 false 则需要登录)
screen_public = %t
# ---------- 日志 ----------
# 运行日志同时输出到控制台与 log_dir 下按天切分的文件
log_enabled = %t
log_dir = %s
`,
c.ListenIP,
c.ListenPort,
c.DatabasePath,
c.BackupEnabled,
c.BackupDir,
c.BackupKeep,
c.Timezone,
c.OpenBrowser,
c.ScreenPublic,
c.LogEnabled,
c.LogDir,
)
}
+168
View File
@@ -0,0 +1,168 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeFile(t *testing.T, dir, name, body string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatalf("写测试配置失败: %v", err)
}
return p
}
func TestLoadCreatesDefaultFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, FileName)
cfg, err := Load(path, dir)
if err != nil {
t.Fatalf("Load 失败: %v", err)
}
if !cfg.Created {
t.Fatal("首次加载应标记为已生成")
}
if cfg.ListenIP != "0.0.0.0" || cfg.ListenPort != 17777 {
t.Fatalf("默认监听地址不对: %s:%d", cfg.ListenIP, cfg.ListenPort)
}
if cfg.BackupKeep != 14 || !cfg.BackupEnabled || !cfg.ScreenPublic || !cfg.LogEnabled {
t.Fatalf("默认值不对: %+v", cfg)
}
// 相对路径应解析到 exe 目录
if cfg.DatabasePath != filepath.Join(dir, "daili.db") {
t.Fatalf("数据库路径=%s", cfg.DatabasePath)
}
if cfg.BackupDir != filepath.Join(dir, "backup") || cfg.LogDir != filepath.Join(dir, "logs") {
t.Fatalf("备份/日志目录=%s %s", cfg.BackupDir, cfg.LogDir)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("默认配置文件未生成: %v", err)
}
text := string(data)
for _, want := range []string{"listen_ip = 0.0.0.0", "listen_port = 17777", "代打台 配置文件(daili.ini)", "timezone ="} {
if !strings.Contains(text, want) {
t.Errorf("默认配置缺少 %q", want)
}
}
// 再读一次不应重新生成
cfg2, err := Load(path, dir)
if err != nil {
t.Fatal(err)
}
if cfg2.Created {
t.Fatal("第二次加载不应标记为已生成")
}
}
func TestLoadParsesValues(t *testing.T) {
dir := t.TempDir()
body := "\ufeff# 注释\n" +
"listen_ip = 127.0.0.1\r\n" +
"\r\n" +
"listen_port = 19000\r\n" +
" database_path = data/mydb.db \r\n" +
"backup_enabled = no\r\n" +
"backup_keep = 30\r\n" +
"timezone = Asia/Shanghai\r\n" +
"open_browser = 0\r\n" +
"screen_public = false\r\n" +
"log_enabled = on\r\n" +
"log_dir = D:\\logs\r\n"
path := writeFile(t, dir, FileName, body)
cfg, err := Load(path, dir)
if err != nil {
t.Fatalf("Load 失败: %v", err)
}
if cfg.ListenIP != "127.0.0.1" || cfg.ListenPort != 19000 {
t.Fatalf("监听=%s:%d", cfg.ListenIP, cfg.ListenPort)
}
if !cfg.LoopbackOnly() || cfg.PublicBind() {
t.Fatal("127.0.0.1 应判定为仅本机")
}
if cfg.DatabasePath != filepath.Join(dir, "data", "mydb.db") {
t.Fatalf("相对路径未按 exe 目录解析: %s", cfg.DatabasePath)
}
if cfg.BackupEnabled || cfg.BackupKeep != 30 {
t.Fatalf("备份配置=%v/%d", cfg.BackupEnabled, cfg.BackupKeep)
}
if cfg.Timezone != "Asia/Shanghai" || cfg.OpenBrowser || cfg.ScreenPublic || !cfg.LogEnabled {
t.Fatalf("其余配置不对: %+v", cfg)
}
if cfg.LogDir != "D:\\logs" {
t.Fatalf("绝对路径被改写: %s", cfg.LogDir)
}
if cfg.Addr() != "127.0.0.1:19000" {
t.Fatalf("Addr=%s", cfg.Addr())
}
}
func TestLoadRejectsBadInput(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"未知配置项", "listen_port = 1\nlisten_prt = 2\n", "未知配置项 listen_prt"},
{"端口非数字", "\nlisten_port = abc\n", "listen_port 必须是数字"},
{"端口越界", "listen_port = 70000\n", "必须在 1-65535 之间"},
{"非法IP", "listen_ip = 300.1.1.1\n", "不是合法 IP"},
{"布尔值非法", "open_browser = maybe\n", "必须是 true/false"},
{"保留份数越界", "backup_keep = 0\n", "backup_keep 必须在 1-365 之间"},
{"时区非法", "timezone = Mars/Base\n", "timezone"},
{"重复配置", "listen_port = 1\nlisten_port = 2\n", "重复"},
{"缺少等号", "listen_port 1\n", "缺少等号"},
{"不支持分节", "[net]\nlisten_port = 1\n", "不支持分节"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, FileName, c.body)
before, _ := os.ReadFile(path)
_, err := Load(path, dir)
if err == nil {
t.Fatalf("期望报错,实际通过")
}
if !strings.Contains(err.Error(), c.want) {
t.Fatalf("错误信息 %q 不含 %q", err.Error(), c.want)
}
after, _ := os.ReadFile(path)
if string(before) != string(after) {
t.Fatal("出错时不应修改原文件")
}
})
}
}
func TestErrorHasLineNumber(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, FileName, "# 注释\nlisten_ip = 0.0.0.0\nlisten_port = 99999\n")
_, err := Load(path, dir)
if err == nil {
t.Fatal("期望报错")
}
if !strings.Contains(err.Error(), "第 3 行") {
t.Fatalf("错误信息应带行号: %v", err)
}
}
func TestValidateAfterOverride(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, FileName, "listen_port = 18080\n")
cfg, err := Load(path, dir)
if err != nil {
t.Fatal(err)
}
cfg.ListenPort = 0
if err := cfg.Validate(); err == nil {
t.Fatal("覆盖后的非法端口应被拦截")
}
}
+206
View File
@@ -0,0 +1,206 @@
// Package domain 存放与存储无关的纯业务计算:日期、剩余时长、状态判定、追加换算。
package domain
import "time"
const (
DateLayout = "2006-01-02"
StampLayout = "2006-01-02 15:04:05"
)
// Today 返回本地时区当天零点。
func Today() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}
// ParseDate 解析 YYYY-MM-DD。
func ParseDate(s string) (time.Time, error) {
return time.ParseInLocation(DateLayout, s, time.Local)
}
// ParseDateOr 解析失败时返回默认值。
func ParseDateOr(s string, fallback time.Time) time.Time {
if t, err := ParseDate(s); err == nil {
return t
}
return fallback
}
// Date 格式化为 YYYY-MM-DD。
func Date(t time.Time) string { return t.Format(DateLayout) }
// DayDiff 返回 b-a 的自然日天数(不受夏令时影响)。
func DayDiff(a, b time.Time) int {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
ua := time.Date(ay, am, ad, 0, 0, 0, 0, time.UTC)
ub := time.Date(by, bm, bd, 0, 0, 0, 0, time.UTC)
return int(ub.Sub(ua).Hours() / 24.0)
}
// AddDays 加 n 天。
func AddDays(t time.Time, n int) time.Time { return t.AddDate(0, 0, n) }
// MonthStart 返回当月 1 日。
func MonthStart(y int, m time.Month) time.Time {
return time.Date(y, m, 1, 0, 0, 0, 0, time.Local)
}
// MonthEnd 返回当月最后一天。
func MonthEnd(y int, m time.Month) time.Time {
return time.Date(y, m, 1, 0, 0, 0, 0, time.Local).AddDate(0, 1, -1)
}
// WeekdayIndex 返回以周一为 0 的星期索引。
func WeekdayIndex(t time.Time) int {
wd := int(t.Weekday())
if wd == 0 {
return 6
}
return wd - 1
}
// 订阅状态(存储值)
const (
StatusActive = "active"
StatusPaused = "paused"
StatusClosed = "closed"
)
// 套餐类型
const (
UnitDay = "day"
UnitCount = "count"
)
// 派生展示状态
const (
StateActive = "active" // 进行中
StatePending = "pending" // 未开始
StatePaused = "paused" // 暂停中
StateExpired = "expired" // 已到期
StateUsedUp = "usedup" // 次数用尽
StateClosed = "closed" // 已结束
)
// 到期预警等级
const (
LevelOK = "ok"
LevelWarn = "warn"
LevelToday = "today"
LevelExpired = "expired"
LevelNone = "none" // 无期限(按次且未设有效期)
)
// Eval 是订阅的计算结果。
type Eval struct {
State string
DaysLeft int // 距结束日的天数:0=今天到期,>0=未来,-1=无结束日
RemainingDays int
RemainingCount int
Level string
}
// EvalSub 计算订阅的展示状态与剩余量。
// - unit=day:剩余天数 = end - today + 1(含当天)
// - unit=count:剩余次数 = total - used;end 为可选有效期
func EvalSub(unit, status, startDate, endDate string, total, used int, today time.Time, warnDays int) Eval {
e := Eval{Level: LevelNone, DaysLeft: -1}
if warnDays <= 0 {
warnDays = 3
}
start := ParseDateOr(startDate, today)
end, hasEnd := time.Time{}, false
if endDate != "" {
if t, err := ParseDate(endDate); err == nil {
end, hasEnd = t, true
e.DaysLeft = DayDiff(today, end)
}
}
if unit == UnitDay {
if hasEnd {
e.RemainingDays = e.DaysLeft + 1
}
if e.RemainingDays < 0 {
e.RemainingDays = 0
}
} else {
e.RemainingCount = total - used
if e.RemainingCount < 0 {
e.RemainingCount = 0
}
}
expired := false
if hasEnd && end.Before(today) {
expired = true
}
if unit == UnitCount && e.RemainingCount <= 0 {
expired = true
}
switch {
case status == StatusClosed:
e.State = StateClosed
case status == StatusPaused:
e.State = StatePaused
case expired:
if unit == UnitCount && e.RemainingCount <= 0 {
e.State = StateUsedUp
} else {
e.State = StateExpired
}
case start.After(today):
e.State = StatePending
default:
e.State = StateActive
}
// 预警等级只对「进行中 / 未开始」有意义
switch e.State {
case StateExpired:
e.Level = LevelExpired
case StateActive, StatePending:
switch {
case !hasEnd:
e.Level = LevelNone
case e.DaysLeft <= 0:
e.Level = LevelToday
case e.DaysLeft <= warnDays:
e.Level = LevelWarn
default:
e.Level = LevelOK
}
}
return e
}
// ExtendPlan 描述一次「按日追加」的换算结果。
type ExtendPlan struct {
Base time.Time // 计算起点
OldEnd time.Time
NewEnd time.Time
Days int
SameDay bool // 新结束日等于原结束日(未增加)
Backwards bool // 新结束日早于计算起点
}
// PlanExtend 根据原结束日、今天与新结束日,算出计算起点与追加天数。
// 规则(PRD R4):已到期从今天起算,未到期从原结束日次日起算。
func PlanExtend(oldEnd, newEnd, today time.Time) ExtendPlan {
p := ExtendPlan{OldEnd: oldEnd, NewEnd: newEnd}
if !oldEnd.Before(today) {
p.Base = AddDays(oldEnd, 1)
} else {
p.Base = today
}
p.Days = DayDiff(p.Base, newEnd) + 1
p.SameDay = DayDiff(oldEnd, newEnd) == 0
p.Backwards = p.Days <= 0
if p.Days < 0 {
p.Days = 0
}
return p
}
+152
View File
@@ -0,0 +1,152 @@
package domain
import (
"testing"
"time"
)
func d(y int, m time.Month, day int) time.Time {
return time.Date(y, m, day, 0, 0, 0, 0, time.Local)
}
func TestDayDiff(t *testing.T) {
cases := []struct {
a, b time.Time
want int
}{
{d(2026, 1, 1), d(2026, 1, 1), 0},
{d(2026, 1, 1), d(2026, 1, 31), 30},
{d(2026, 2, 28), d(2026, 3, 1), 1},
{d(2024, 2, 28), d(2024, 3, 1), 2}, // 闰年
{d(2026, 12, 31), d(2027, 1, 1), 1},
{d(2026, 1, 10), d(2026, 1, 1), -9},
}
for _, c := range cases {
if got := DayDiff(c.a, c.b); got != c.want {
t.Errorf("DayDiff(%s,%s)=%d want %d", Date(c.a), Date(c.b), got, c.want)
}
}
}
func TestEvalDay(t *testing.T) {
today := d(2026, 10, 18)
cases := []struct {
name string
status string
end string
wantState string
wantLevel string
wantDays int
}{
{"进行中", "active", "2026-11-16", StateActive, LevelOK, 30},
{"今天到期", "active", "2026-10-18", StateActive, LevelToday, 1},
{"3天内", "active", "2026-10-20", StateActive, LevelWarn, 3},
{"已到期", "active", "2026-10-17", StateExpired, LevelExpired, 0},
{"暂停中", "paused", "2026-11-16", StatePaused, LevelNone, 30},
{"已结束", "closed", "2026-11-16", StateClosed, LevelNone, 30},
}
for _, c := range cases {
e := EvalSub(UnitDay, c.status, "2026-10-01", c.end, 0, 0, today, 3)
if e.State != c.wantState {
t.Errorf("%s: state=%s want %s", c.name, e.State, c.wantState)
}
if e.Level != c.wantLevel {
t.Errorf("%s: level=%s want %s", c.name, e.Level, c.wantLevel)
}
if e.RemainingDays != c.wantDays {
t.Errorf("%s: remaining=%d want %d", c.name, e.RemainingDays, c.wantDays)
}
}
}
func TestEvalDayNotStarted(t *testing.T) {
today := d(2026, 10, 18)
e := EvalSub(UnitDay, "active", "2026-10-20", "2026-11-18", 0, 0, today, 3)
if e.State != StatePending {
t.Fatalf("state=%s want pending", e.State)
}
if e.RemainingDays != 32 {
t.Fatalf("remaining=%d want 32", e.RemainingDays)
}
}
func TestEvalCount(t *testing.T) {
today := d(2026, 10, 18)
// 有次数、有有效期,10 天后到期 → 正常
e := EvalSub(UnitCount, "active", "2026-10-01", "2026-10-28", 10, 4, today, 3)
if e.State != StateActive || e.Level != LevelOK || e.RemainingCount != 6 {
t.Fatalf("got state=%s level=%s remain=%d", e.State, e.Level, e.RemainingCount)
}
// 有效期就是今天 → 今天到期(而不是误判成 warn/ok)
e = EvalSub(UnitCount, "active", "2026-10-01", "2026-10-18", 10, 4, today, 3)
if e.Level != LevelToday {
t.Fatalf("level=%s want today", e.Level)
}
// 只有次数,没有有效期 → 无预警等级
e = EvalSub(UnitCount, "active", "2026-10-01", "", 10, 4, today, 3)
if e.Level != LevelNone || e.DaysLeft != -1 {
t.Fatalf("level=%s daysLeft=%d", e.Level, e.DaysLeft)
}
// 次数用尽
e = EvalSub(UnitCount, "active", "2026-10-01", "", 10, 10, today, 3)
if e.State != StateUsedUp {
t.Fatalf("state=%s want usedup", e.State)
}
// 次数还有,但有效期已过
e = EvalSub(UnitCount, "active", "2026-10-01", "2026-10-10", 10, 4, today, 3)
if e.State != StateExpired {
t.Fatalf("state=%s want expired", e.State)
}
}
func TestPlanExtend(t *testing.T) {
today := d(2026, 10, 18)
// 未到期:从原结束日次日顺延,追加天数 = 新旧结束日之差
oldEnd := d(2026, 11, 16)
newEnd := d(2026, 12, 2)
p := PlanExtend(oldEnd, newEnd, today)
if Date(p.Base) != "2026-11-17" {
t.Fatalf("base=%s", Date(p.Base))
}
if p.Days != 16 {
t.Fatalf("days=%d want 16", p.Days)
}
// 已到期:从今天起算
oldEnd = d(2026, 10, 5)
newEnd = d(2026, 11, 3)
p = PlanExtend(oldEnd, newEnd, today)
if Date(p.Base) != "2026-10-18" {
t.Fatalf("base=%s", Date(p.Base))
}
if p.Days != 17 {
t.Fatalf("days=%d want 17", p.Days)
}
// 选的日期早于计算起点 → 无效
p = PlanExtend(d(2026, 11, 16), d(2026, 11, 10), today)
if !p.Backwards || p.Days != 0 {
t.Fatalf("backwards=%v days=%d", p.Backwards, p.Days)
}
}
func TestMonthHelpers(t *testing.T) {
if got := Date(MonthEnd(2026, time.February)); got != "2026-02-28" {
t.Fatalf("MonthEnd=%s", got)
}
if got := Date(MonthEnd(2024, time.February)); got != "2024-02-29" {
t.Fatalf("MonthEnd=%s", got)
}
if got := WeekdayIndex(d(2026, 10, 18)); got != 6 { // 周日
t.Fatalf("WeekdayIndex=%d want 6", got)
}
if got := WeekdayIndex(d(2026, 10, 19)); got != 0 { // 周一
t.Fatalf("WeekdayIndex=%d want 0", got)
}
}
+72
View File
@@ -0,0 +1,72 @@
// Package logx 提供「控制台 + 按天切分文件」的运行日志。
package logx
import (
"io"
"log"
"os"
"path/filepath"
"sync"
"time"
)
type dailyWriter struct {
dir string
mu sync.Mutex
f *os.File
day string
}
func (w *dailyWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
day := time.Now().Format("2006-01-02")
if w.f == nil || w.day != day {
if w.f != nil {
_ = w.f.Close()
w.f = nil
}
if err := os.MkdirAll(w.dir, 0o755); err != nil {
return 0, err
}
f, err := os.OpenFile(filepath.Join(w.dir, "daili-"+day+".log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return 0, err
}
w.f = f
w.day = day
}
// 写文件失败不能影响控制台输出
n, err := w.f.Write(p)
return n, err
}
func (w *dailyWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.f == nil {
return nil
}
err := w.f.Close()
w.f = nil
return err
}
// Setup 把标准日志同时写到控制台与 dir 下按天切分的文件。
// 返回的 Closer 在进程退出前关闭即可。
func Setup(dir string) (io.Closer, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
w := &dailyWriter{dir: dir}
// 预热:启动即创建当天文件,避免“刚启动就崩、日志却没落盘”
if _, err := w.Write(nil); err != nil {
return nil, err
}
log.SetOutput(io.MultiWriter(os.Stderr, w))
return w, nil
}
+48
View File
@@ -0,0 +1,48 @@
package logx
import (
"log"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSetupWritesFile(t *testing.T) {
dir := t.TempDir()
logDir := filepath.Join(dir, "logs")
closer, err := Setup(logDir)
if err != nil {
t.Fatalf("Setup 失败: %v", err)
}
log.Printf("hello-logx-%d", 42)
if err := closer.Close(); err != nil {
t.Fatalf("关闭失败: %v", err)
}
entries, err := os.ReadDir(logDir)
if err != nil || len(entries) != 1 {
t.Fatalf("日志目录应有 1 个文件, 实际 %d: %v", len(entries), err)
}
data, err := os.ReadFile(filepath.Join(logDir, entries[0].Name()))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "hello-logx-42") {
t.Fatalf("日志未落盘,文件内容: %q", string(data))
}
}
func TestSetupCreatesFileImmediately(t *testing.T) {
dir := t.TempDir()
closer, err := Setup(dir)
if err != nil {
t.Fatal(err)
}
defer closer.Close()
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 {
t.Fatalf("Setup 后应立刻存在当天日志文件: %v %d", err, len(entries))
}
}
+81
View File
@@ -0,0 +1,81 @@
// Package pw 提供口令散列(PBKDF2-HMAC-SHA256),仅用标准库。
package pw
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"strconv"
"strings"
)
const iterations = 120000
// Hash 生成 `pbkdf2-sha256$迭代次数$盐$摘要` 形式的字符串。
func Hash(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
dk := pbkdf2Key([]byte(password), salt, iterations, 32)
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
iterations,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(dk)), nil
}
// Verify 校验口令,常量时间比较。
func Verify(password, encoded string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
iter, err := strconv.Atoi(parts[1])
if err != nil || iter <= 0 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil || len(want) == 0 {
return false
}
got := pbkdf2Key([]byte(password), salt, iter, len(want))
return subtle.ConstantTimeCompare(got, want) == 1
}
func pbkdf2Key(password, salt []byte, iter, keyLen int) []byte {
prf := hmac.New(sha256.New, password)
hashLen := prf.Size()
numBlocks := (keyLen + hashLen - 1) / hashLen
var buf [4]byte
dk := make([]byte, 0, numBlocks*hashLen)
u := make([]byte, hashLen)
for block := 1; block <= numBlocks; block++ {
prf.Reset()
prf.Write(salt)
buf[0] = byte(block >> 24)
buf[1] = byte(block >> 16)
buf[2] = byte(block >> 8)
buf[3] = byte(block)
prf.Write(buf[:4])
dk = prf.Sum(dk)
t := dk[len(dk)-hashLen:]
copy(u, t)
for n := 2; n <= iter; n++ {
prf.Reset()
prf.Write(u)
u = u[:0]
u = prf.Sum(u)
for x := range u {
t[x] ^= u[x]
}
}
}
return dk[:keyLen]
}
+113
View File
@@ -0,0 +1,113 @@
package store
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// SetBackupDir 设置备份目录(来自配置文件)。
func (db *DB) SetBackupDir(dir string) { db.BackupPath = dir }
// BackupDir 返回备份目录。
func (db *DB) BackupDir() string {
if db.BackupPath != "" {
return db.BackupPath
}
return filepath.Join(filepath.Dir(db.Path), "backup")
}
// BackupDaily 当天首次调用时生成一份备份;已有今日备份则跳过。
// 空库(还没有客户)不生成备份,避免第一天的备份是空壳。
func (db *DB) BackupDaily(keep int) (string, error) {
dir := db.BackupDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
now := time.Now()
prefix := "daili-" + now.Format("20060102") + "-"
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), prefix) {
return "", nil
}
}
if n, err := db.CustomerCount(); err != nil || n == 0 {
return "", err
}
target := filepath.Join(dir, fmt.Sprintf("daili-%s.db", now.Format("20060102-150405")))
return db.BackupTo(target, keep)
}
// BackupNow 立即生成一份备份。
func (db *DB) BackupNow(keep int) (string, error) {
dir := db.BackupDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
target := filepath.Join(dir, fmt.Sprintf("daili-%s.db", time.Now().Format("20060102-150405")))
return db.BackupTo(target, keep)
}
// BackupTo 用 VACUUM INTO 生成一致性备份,并按保留份数清理。
func (db *DB) BackupTo(target string, keep int) (string, error) {
abs, err := filepath.Abs(target)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return "", fmt.Errorf("创建备份目录失败: %w", err)
}
if _, err := os.Stat(abs); err == nil {
return abs, fmt.Errorf("备份文件已存在: %s", filepath.Base(abs))
}
escaped := strings.ReplaceAll(strings.ReplaceAll(abs, "\\", "/"), "'", "''")
if _, err := db.sql.Exec("VACUUM INTO '" + escaped + "'"); err != nil {
return "", err
}
if keep > 0 {
_ = db.pruneBackups(keep)
}
return abs, nil
}
// ListBackups 列出备份(新→旧)。
func (db *DB) ListBackups() ([]string, error) {
dir := db.BackupDir()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".db") {
out = append(out, e.Name())
}
}
sort.Sort(sort.Reverse(sort.StringSlice(out)))
return out, nil
}
func (db *DB) pruneBackups(keep int) error {
list, err := db.ListBackups()
if err != nil {
return err
}
if len(list) <= keep {
return nil
}
dir := db.BackupDir()
for _, name := range list[keep:] {
_ = os.Remove(filepath.Join(dir, name))
}
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package store
import (
"database/sql"
"errors"
"daili/internal/domain"
)
// Checkin 每日服务打卡。
type Checkin struct {
ID int64
SubID int64
ServiceDate string
Status string
Note string
OperatorID int64
OperatorName string
CreatedAt string
UpdatedAt string
}
// SaveCheckin 打卡(同订阅同日覆盖),按次套餐完成时扣次。
func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int64) error {
if status != "done" && status != "skipped" {
return errors.New("打卡状态不正确")
}
if _, err := domain.ParseDate(date); err != nil {
return errors.New("日期格式不正确")
}
return db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,无法打卡")
}
var oldStatus string
err = tx.QueryRow(`SELECT status FROM checkins WHERE sub_id=? AND service_date=?`, subID, date).Scan(&oldStatus)
hasOld := err == nil
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
delta := 0
if s.Unit == domain.UnitCount {
oldDone := hasOld && oldStatus == "done"
newDone := status == "done"
switch {
case newDone && !oldDone:
if s.UsedCount+1 > s.TotalCount {
return errors.New("剩余次数不足,请先追加次数")
}
delta = 1
case !newDone && oldDone:
delta = -1
}
}
now := nowStamp()
if hasOld {
if _, err := tx.Exec(`UPDATE checkins SET status=?, note=?, operator_id=?, updated_at=? WHERE sub_id=? AND service_date=?`,
status, note, nullIfZero(operatorID), now, subID, date); err != nil {
return err
}
} else {
if _, err := tx.Exec(`INSERT INTO checkins(sub_id,service_date,status,note,operator_id,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)`, subID, date, status, note, nullIfZero(operatorID), now, now); err != nil {
return err
}
}
if delta != 0 {
newUsed := s.UsedCount + delta
if newUsed < 0 {
newUsed = 0
}
if _, err := tx.Exec(`UPDATE subscriptions SET used_count=?, updated_at=? WHERE id=?`, newUsed, now, subID); err != nil {
return err
}
}
return nil
})
}
// DeleteCheckin 撤销打卡(按次套餐返还次数),返回所属订阅 ID。
func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) {
var subID int64
var status string
var date string
err := db.withTx(func(tx *sql.Tx) error {
if err := tx.QueryRow(`SELECT sub_id,status,service_date FROM checkins WHERE id=?`, id).
Scan(&subID, &status, &date); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.Exec(`DELETE FROM checkins WHERE id=?`, id); err != nil {
return err
}
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit == domain.UnitCount && status == "done" {
newUsed := s.UsedCount - 1
if newUsed < 0 {
newUsed = 0
}
if _, err := tx.Exec(`UPDATE subscriptions SET used_count=?, updated_at=? WHERE id=?`,
newUsed, nowStamp(), subID); err != nil {
return err
}
}
return nil
})
return subID, err
}
// CheckinsBetween 取区间内打卡,索引为 [subID][date]。
func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, error) {
out := map[int64]map[string]Checkin{}
rows, err := db.sql.Query(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
WHERE c.service_date BETWEEN ? AND ?`, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var c Checkin
if err := rows.Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID,
&c.OperatorName, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
if out[c.SubID] == nil {
out[c.SubID] = map[string]Checkin{}
}
out[c.SubID][c.ServiceDate] = c
}
return out, rows.Err()
}
// CheckinsForSub 某订阅的打卡历史。
func (db *DB) CheckinsForSub(subID int64, limit int) ([]Checkin, error) {
rows, err := db.sql.Query(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
WHERE c.sub_id=? ORDER BY c.service_date DESC LIMIT ?`, subID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Checkin{}
for rows.Next() {
var c Checkin
if err := rows.Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID,
&c.OperatorName, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// CheckinFor 取某订阅某天的打卡。
func (db *DB) CheckinFor(subID int64, date string) (*Checkin, error) {
var c Checkin
err := db.sql.QueryRow(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
WHERE c.sub_id=? AND c.service_date=?`, subID, date).
Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID, &c.OperatorName, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &c, nil
}
+201
View File
@@ -0,0 +1,201 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"daili/internal/domain"
)
// Customer 客户。
type Customer struct {
ID int64
Tail string
Name string
GameName string
GameServer string
Contact string
Tags string
Source string
Note string
CreatedAt string
UpdatedAt string
// 列表聚合
SubCount int
Summary string
Level string // ok | warn | today | expired | none
}
// TagList 拆分标签。
func (c *Customer) TagList() []string {
var out []string
for _, t := range strings.Split(c.Tags, ",") {
t = strings.TrimSpace(t)
if t != "" {
out = append(out, t)
}
}
return out
}
const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at`
func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
var c Customer
err := row.Scan(&c.ID, &c.Tail, &c.Name, &c.GameName, &c.GameServer, &c.Contact,
&c.Tags, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &c, nil
}
// CreateCustomer 新建客户(尾号+姓名唯一)。
func (db *DB) CreateCustomer(c *Customer) (int64, error) {
now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, now, now)
if err != nil {
if isUnique(err) {
return 0, ErrDuplicate
}
return 0, err
}
return res.LastInsertId()
}
// UpdateCustomer 更新客户。
func (db *DB) UpdateCustomer(c *Customer) error {
_, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,updated_at=?
WHERE id=?`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, nowStamp(), c.ID)
if err != nil && isUnique(err) {
return ErrDuplicate
}
return err
}
// Customer 取单个客户。
func (db *DB) Customer(id int64) (*Customer, error) {
return scanCustomer(db.sql.QueryRow(`SELECT `+customerCols+` FROM customers WHERE id=?`, id))
}
// ListCustomers 分页查询客户,并聚合订阅摘要。
func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays int) ([]Customer, int, error) {
where := "1=1"
args := []any{}
if q = strings.TrimSpace(q); q != "" {
like := "%" + q + "%"
where = "(tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)"
args = append(args, like, like, like, like, like)
}
var total int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := db.sql.Query(`SELECT `+customerCols+` FROM customers WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`,
append(args, limit, offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []Customer{}
ids := []int64{}
for rows.Next() {
c, err := scanCustomer(rows)
if err != nil {
return nil, 0, err
}
out = append(out, *c)
ids = append(ids, c.ID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if len(ids) > 0 {
subs, err := db.SubsByCustomers(ids)
if err != nil {
return nil, 0, err
}
byCustomer := map[int64][]Subscription{}
for _, s := range subs {
byCustomer[s.CustomerID] = append(byCustomer[s.CustomerID], s)
}
for i := range out {
list := byCustomer[out[i].ID]
out[i].SubCount = len(list)
out[i].Summary, out[i].Level = summarizeSubs(list, today, warnDays)
}
}
return out, total, nil
}
// summarizeSubs 生成“包月日常 剩12天 · 深渊 剩3次”式的摘要与最紧急等级。
func summarizeSubs(subs []Subscription, today string, warnDays int) (string, string) {
if len(subs) == 0 {
return "暂无订阅", domain.LevelNone
}
t := domain.ParseDateOr(today, domain.Today())
rank := map[string]int{domain.LevelExpired: 4, domain.LevelToday: 3, domain.LevelWarn: 2, domain.LevelOK: 1, domain.LevelNone: 0}
worst := domain.LevelNone
parts := []string{}
for _, s := range subs {
e := s.Eval(t, warnDays)
if e.State == domain.StateClosed {
continue
}
var text string
if s.Unit == domain.UnitDay {
switch {
case e.State == domain.StateExpired:
text = s.PackageName + " 已过期"
case e.DaysLeft == 0:
text = s.PackageName + " 今天到期"
default:
text = fmt.Sprintf("%s 剩%d天", s.PackageName, e.RemainingDays)
}
} else {
switch {
case e.RemainingCount <= 0:
text = s.PackageName + " 已用尽"
case e.State == domain.StateExpired:
text = s.PackageName + " 已过期"
default:
text = fmt.Sprintf("%s 剩%d次", s.PackageName, e.RemainingCount)
}
}
parts = append(parts, text)
if rank[e.Level] > rank[worst] {
worst = e.Level
}
}
if len(parts) == 0 {
return "仅历史订阅", domain.LevelNone
}
if len(parts) > 2 {
parts = append(parts[:2], fmt.Sprintf("等%d项", len(parts)))
}
return strings.Join(parts, " · "), worst
}
// FindCustomerByTailName 精确查找(用于查重提示)。
func (db *DB) FindCustomerByTailName(tail, name string) (*Customer, error) {
return scanCustomer(db.sql.QueryRow(`SELECT `+customerCols+` FROM customers WHERE tail=? AND name=?`,
strings.TrimSpace(tail), strings.TrimSpace(name)))
}
// CustomerCount 客户总数。
func (db *DB) CustomerCount() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers`).Scan(&n)
return n, err
}
+259
View File
@@ -0,0 +1,259 @@
// Package store 负责 SQLite 连接、迁移与全部数据读写。
package store
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
"daili/internal/domain"
)
// 常见错误
var (
ErrDuplicate = errors.New("记录已存在")
ErrNotFound = errors.New("记录不存在")
)
// DB 是数据库句柄。
type DB struct {
sql *sql.DB
Path string
// BackupPath 由配置文件指定;为空时回落到数据库同级的 backup/ 目录。
BackupPath string
}
// Open 打开或创建数据库文件。
//
// 采用 DELETE 回滚日志模式而不是 WAL:这样全部数据始终在 daili.db 这一个文件里,
// 运行中不会产生 -wal/-shm 旁路文件,用户复制一个 daili.db 就能无损迁移;
// 删掉 daili.db 也就等于清空全部数据。
func Open(path string) (*DB, error) {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("创建数据目录失败: %w", err)
}
}
// 数据库文件不存在(用户删除 = 重置)时,清掉可能是旧版本残留的旁路文件,
// 避免一个孤立的 -wal 干扰新建的库。
if _, statErr := os.Stat(path); errors.Is(statErr, os.ErrNotExist) {
_ = os.Remove(path + "-wal")
_ = os.Remove(path + "-shm")
}
dsn := strings.ReplaceAll(path, "\\", "/") +
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(DELETE)&_pragma=foreign_keys(ON)&_pragma=synchronous(FULL)"
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
if err := sqlDB.Ping(); err != nil {
sqlDB.Close()
return nil, err
}
// 兜底确认:旧版本的 WAL 库在打开时会被自动 checkpoint 并切回 DELETE 模式
var mode string
if err := sqlDB.QueryRow("PRAGMA journal_mode").Scan(&mode); err == nil && !strings.EqualFold(mode, "delete") {
sqlDB.Close()
return nil, fmt.Errorf("数据库日志模式切换失败(当前 %s),请确认 daili.db 没有被其他程序占用", mode)
}
return &DB{sql: sqlDB, Path: path}, nil
}
// JournalMode 返回当前日志模式(正常应为 delete)。
func (db *DB) JournalMode() (string, error) {
var mode string
err := db.sql.QueryRow("PRAGMA journal_mode").Scan(&mode)
return mode, err
}
// Close 关闭数据库。
func (db *DB) Close() error { return db.sql.Close() }
// SQL 暴露底层连接(供备份等使用)。
func (db *DB) SQL() *sql.DB { return db.sql }
func nowStamp() string { return time.Now().Format(domain.StampLayout) }
func (db *DB) withTx(fn func(*sql.Tx) error) error {
tx, err := db.sql.Begin()
if err != nil {
return err
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
func isUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
func normalizeUnit(u string) string {
if u == domain.UnitCount {
return domain.UnitCount
}
return domain.UnitDay
}
// Migrate 幂等建表。
func (db *DB) Migrate() error {
for _, stmt := range schemaStatements {
if _, err := db.sql.Exec(stmt); err != nil {
return fmt.Errorf("迁移失败: %w\nSQL: %s", err, stmt)
}
}
return nil
}
var schemaStatements = []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
last_login_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
ip TEXT
)`,
`CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
default_amount INTEGER NOT NULL DEFAULT 30,
price_cents INTEGER,
color TEXT NOT NULL DEFAULT '#2F5D50',
note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tail TEXT NOT NULL,
name TEXT NOT NULL,
game_name TEXT NOT NULL DEFAULT '',
game_server TEXT NOT NULL DEFAULT '',
contact TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (tail, name)
)`,
`CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
package_id INTEGER NOT NULL REFERENCES packages(id),
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','closed')),
start_date TEXT NOT NULL,
end_date TEXT,
total_count INTEGER NOT NULL DEFAULT 0,
used_count INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
closed_at TEXT,
UNIQUE (customer_id, package_id)
)`,
`CREATE TABLE IF NOT EXISTS sub_pauses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
start_at TEXT NOT NULL,
end_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS sub_adjustments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
base_date TEXT,
old_end_date TEXT,
new_end_date TEXT,
days_added INTEGER NOT NULL DEFAULT 0,
count_added INTEGER NOT NULL DEFAULT 0,
old_total INTEGER NOT NULL DEFAULT 0,
new_total INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
operator_id INTEGER REFERENCES users(id),
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
service_date TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('done','skipped')),
note TEXT NOT NULL DEFAULT '',
operator_id INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (sub_id, service_date)
)`,
`CREATE TABLE IF NOT EXISTS op_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_id INTEGER REFERENCES users(id),
operator_name TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
entity TEXT NOT NULL DEFAULT '',
entity_id INTEGER,
summary TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_sub_status_end ON subscriptions(status, end_date)`,
`CREATE INDEX IF NOT EXISTS idx_sub_customer ON subscriptions(customer_id)`,
`CREATE INDEX IF NOT EXISTS idx_sub_package ON subscriptions(package_id)`,
`CREATE INDEX IF NOT EXISTS idx_cust_tail ON customers(tail)`,
`CREATE INDEX IF NOT EXISTS idx_adj_sub ON sub_adjustments(sub_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_ci_date ON checkins(service_date)`,
`CREATE INDEX IF NOT EXISTS idx_ci_sub ON checkins(sub_id, service_date)`,
`CREATE INDEX IF NOT EXISTS idx_pause_sub ON sub_pauses(sub_id)`,
`CREATE INDEX IF NOT EXISTS idx_log_created ON op_logs(created_at)`,
}
// ---------- 设置 ----------
// Setting 读取设置,缺失时返回默认值。
func (db *DB) Setting(key, def string) string {
var v string
err := db.sql.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v)
if err != nil || v == "" {
return def
}
return v
}
// SetSetting 写入设置。
func (db *DB) SetSetting(key, value string) error {
_, err := db.sql.Exec(`INSERT INTO settings(key,value) VALUES(?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
return err
}
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"fmt"
"time"
"daili/internal/domain"
)
// ExportRowSet 是导出的表头与数据行。
type ExportRowSet struct {
Header []string
Rows [][]string
}
// ExportCustomers 导出客户。
func (db *DB) ExportCustomers() (*ExportRowSet, error) {
rows, err := db.sql.Query(`SELECT id,tail,name,game_name,game_server,contact,tags,source,note,created_at FROM customers ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := &ExportRowSet{Header: []string{"ID", "手机尾号", "客户名", "游戏名", "区服/UID", "联系方式", "标签", "来源", "备注", "创建时间"}}
for rows.Next() {
var id int64
var tail, name, game, server, contact, tags, source, note, created string
if err := rows.Scan(&id, &tail, &name, &game, &server, &contact, &tags, &source, &note, &created); err != nil {
return nil, err
}
out.Rows = append(out.Rows, []string{fmt.Sprint(id), tail, name, game, server, contact, tags, source, note, created})
}
return out, rows.Err()
}
// ExportSubscriptions 导出订阅(含派生剩余量)。
func (db *DB) ExportSubscriptions(today time.Time, warnDays int) (*ExportRowSet, error) {
subs, _, err := db.ListSubs(SubFilter{Today: today, Limit: 100000})
if err != nil {
return nil, err
}
out := &ExportRowSet{Header: []string{"订阅ID", "手机尾号", "客户名", "套餐", "类型", "状态", "开始日", "结束日/有效期",
"总次数", "已用", "剩余", "原文状态", "备注", "更新时间"}}
stateLabel := map[string]string{
domain.StateActive: "进行中", domain.StatePending: "未开始", domain.StatePaused: "暂停中",
domain.StateExpired: "已到期", domain.StateUsedUp: "次数用尽", domain.StateClosed: "已结束",
}
for _, s := range subs {
e := s.Eval(today, warnDays)
remaining := ""
if s.Unit == domain.UnitDay {
remaining = fmt.Sprintf("%d 天", e.RemainingDays)
} else {
remaining = fmt.Sprintf("%d 次", e.RemainingCount)
}
out.Rows = append(out.Rows, []string{
fmt.Sprint(s.ID), s.CustomerTail, s.CustomerName, s.PackageName,
map[string]string{domain.UnitDay: "按日", domain.UnitCount: "按次"}[s.Unit],
stateLabel[e.State], s.StartDate, s.EndDate,
fmt.Sprint(s.TotalCount), fmt.Sprint(s.UsedCount), remaining, s.Status, s.Note, s.UpdatedAt,
})
}
return out, nil
}
// ExportLogs 导出操作日志。
func (db *DB) ExportLogs() (*ExportRowSet, error) {
rows, err := db.sql.Query(`SELECT created_at,operator_name,action,entity,summary,ip FROM op_logs ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := &ExportRowSet{Header: []string{"时间", "操作人", "动作", "对象", "摘要", "IP"}}
for rows.Next() {
var created, operator, action, entity, summary, ip string
if err := rows.Scan(&created, &operator, &action, &entity, &summary, &ip); err != nil {
return nil, err
}
out.Rows = append(out.Rows, []string{created, operator, action, entity, summary, ip})
}
return out, rows.Err()
}
+73
View File
@@ -0,0 +1,73 @@
package store
import (
"strings"
)
// OpLog 操作日志。
type OpLog struct {
ID int64
OperatorID int64
OperatorName string
Action string
Entity string
EntityID int64
Summary string
Detail string
IP string
CreatedAt string
}
// Log 写一条操作日志。
func (db *DB) Log(l OpLog) error {
_, err := db.sql.Exec(`INSERT INTO op_logs(operator_id,operator_name,action,entity,entity_id,summary,detail,ip,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
nullIfZero(l.OperatorID), l.OperatorName, l.Action, l.Entity, nullIfZero(l.EntityID),
l.Summary, l.Detail, l.IP, nowStamp())
return err
}
func nullIfZero(v int64) any {
if v == 0 {
return nil
}
return v
}
// ListLogs 分页查询日志。
func (db *DB) ListLogs(q string, limit, offset int) ([]OpLog, int, error) {
where := "1=1"
args := []any{}
if q = strings.TrimSpace(q); q != "" {
like := "%" + q + "%"
where = "(summary LIKE ? OR action LIKE ? OR operator_name LIKE ? OR entity LIKE ?)"
args = append(args, like, like, like, like)
}
var total int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM op_logs WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := db.sql.Query(`SELECT id,IFNULL(operator_id,0),operator_name,action,entity,IFNULL(entity_id,0),summary,detail,ip,created_at
FROM op_logs WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, append(args, limit, offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []OpLog{}
for rows.Next() {
var l OpLog
if err := rows.Scan(&l.ID, &l.OperatorID, &l.OperatorName, &l.Action, &l.Entity, &l.EntityID,
&l.Summary, &l.Detail, &l.IP, &l.CreatedAt); err != nil {
return nil, 0, err
}
out = append(out, l)
}
return out, total, rows.Err()
}
// LogCount 日志总数。
func (db *DB) LogCount() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM op_logs`).Scan(&n)
return n, err
}
+146
View File
@@ -0,0 +1,146 @@
package store
import (
"database/sql"
"errors"
"strings"
"daili/internal/domain"
)
// Package 服务套餐。
type Package struct {
ID int64
Name string
Unit string
DefaultAmount int
PriceCents int
HasPrice bool
Color string
Note string
Archived bool
SortOrder int
CreatedAt string
UpdatedAt string
SubCount int
ActiveCount int
}
// UnitLabel 返回类型中文名。
func (p *Package) UnitLabel() string {
if p.Unit == domain.UnitCount {
return "按次"
}
return "按日"
}
func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
var p Package
var price sql.NullInt64
var archived int
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
p.Archived = archived == 1
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
}
return &p, nil
}
// ListPackages 列出套餐(含订阅计数)。
func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.created_at,p.updated_at,
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id),
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed')
FROM packages p`
if !includeArchived {
q += ` WHERE p.archived=0`
}
q += ` ORDER BY p.archived, p.sort_order, p.id`
rows, err := db.sql.Query(q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Package{}
for rows.Next() {
var p Package
var price sql.NullInt64
var archived int
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
return nil, err
}
p.Archived = archived == 1
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
}
out = append(out, p)
}
return out, rows.Err()
}
// Package 取单个套餐。
func (db *DB) Package(id int64) (*Package, error) {
return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,created_at,updated_at
FROM packages WHERE id=?`, id))
}
// CreatePackage 新建套餐。
func (db *DB) CreatePackage(p *Package) (int64, error) {
now := nowStamp()
p.Unit = normalizeUnit(p.Unit)
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#2F5D50"
}
var price any
if p.HasPrice {
price = p.PriceCents
}
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,?,?,?)`,
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, now, now)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// UpdatePackage 更新套餐。
func (db *DB) UpdatePackage(p *Package) error {
p.Unit = normalizeUnit(p.Unit)
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#2F5D50"
}
var price any
if p.HasPrice {
price = p.PriceCents
}
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,updated_at=?
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, nowStamp(), p.ID)
return err
}
// SetPackageArchived 归档/取消归档。
func (db *DB) SetPackageArchived(id int64, archived bool) error {
v := 0
if archived {
v = 1
}
_, err := db.sql.Exec(`UPDATE packages SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id)
return err
}
+114
View File
@@ -0,0 +1,114 @@
package store
import (
"time"
"daili/internal/domain"
)
// Stats 大屏统计数据。
type Stats struct {
Customers int
ActiveSubs int
PausedSubs int
DueToday int
Due7 int
DueTodaySubs []Subscription
Heat map[string]int // 结束日 -> 该日到期数量
MonthDue int
Top []Subscription
TodayTasks int
TodayDone int
}
// Badges 顶栏常驻徽标。
func (db *DB) Badges(today time.Time) (dueToday, due3 int, err error) {
t := domain.Date(today)
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`, t).Scan(&dueToday); err != nil {
return
}
err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`,
t, domain.Date(domain.AddDays(today, 2))).Scan(&due3)
return
}
// ScreenStats 汇总大屏所需数据。
func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) {
st := &Stats{Heat: map[string]int{}}
var err error
if st.Customers, err = db.CustomerCount(); err != nil {
return nil, err
}
if st.ActiveSubs, err = db.CountActiveSubs(domain.Date(today)); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status='paused'`).Scan(&st.PausedSubs); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`,
domain.Date(today)).Scan(&st.DueToday); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`,
domain.Date(today), domain.Date(domain.AddDays(today, 6))).Scan(&st.Due7); err != nil {
return nil, err
}
if st.DueTodaySubs, err = db.DueOn(domain.Date(today)); err != nil {
return nil, err
}
// 本月到期热力
ms := domain.MonthStart(today.Year(), today.Month())
me := domain.MonthEnd(today.Year(), today.Month())
rows, err := db.sql.Query(`SELECT end_date, COUNT(*) FROM subscriptions
WHERE status<>'closed' AND end_date BETWEEN ? AND ? GROUP BY end_date`,
domain.Date(ms), domain.Date(me))
if err != nil {
return nil, err
}
for rows.Next() {
var d string
var n int
if err := rows.Scan(&d, &n); err != nil {
rows.Close()
return nil, err
}
st.Heat[d] = n
st.MonthDue += n
}
rows.Close()
// TOP:按日按剩余天数、按次按剩余次数,各自排序后交替取前 10
t := domain.Date(today)
daySubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.unit='day' AND s.end_date>=?
ORDER BY s.end_date ASC LIMIT 12`, t)
if err != nil {
return nil, err
}
cntSubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.unit='count' AND s.used_count < s.total_count
AND (IFNULL(s.end_date,'')='' OR s.end_date>=?)
ORDER BY (s.total_count - s.used_count) ASC LIMIT 12`, t)
if err != nil {
return nil, err
}
for i := 0; i < 10 && len(st.Top) < 10; i++ {
if i < len(daySubs) {
st.Top = append(st.Top, daySubs[i])
}
if len(st.Top) < 10 && i < len(cntSubs) {
st.Top = append(st.Top, cntSubs[i])
}
if i >= len(daySubs) && i >= len(cntSubs) {
break
}
}
_ = warnDays
return st, nil
}
// RecentDue 返回未来 n 天内到期的订阅(按到期日排序)。
func (db *DB) RecentDue(from string, days int) ([]Subscription, error) {
to := domain.Date(domain.AddDays(domain.ParseDateOr(from, domain.Today()), days-1))
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date BETWEEN ? AND ?
ORDER BY s.end_date, s.id`, from, to)
}
+649
View File
@@ -0,0 +1,649 @@
package store
import (
"database/sql"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"daili/internal/domain"
)
func newTestDB(t *testing.T) *DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("打开数据库失败: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("迁移失败: %v", err)
}
return db
}
func mustPackage(t *testing.T, db *DB, name, unit string, amount int) *Package {
t.Helper()
id, err := db.CreatePackage(&Package{Name: name, Unit: unit, DefaultAmount: amount, Color: "#2F5D50"})
if err != nil {
t.Fatalf("创建套餐失败: %v", err)
}
p, err := db.Package(id)
if err != nil {
t.Fatalf("读取套餐失败: %v", err)
}
return p
}
func mustCustomer(t *testing.T, db *DB, tail, name string) int64 {
t.Helper()
id, err := db.CreateCustomer(&Customer{Tail: tail, Name: name})
if err != nil {
t.Fatalf("创建客户失败: %v", err)
}
return id
}
func TestCreateSubAndDuplicate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
if err != nil {
t.Fatalf("创建订阅失败: %v", err)
}
if sub.EndDate != domain.Date(domain.AddDays(today, 29)) {
t.Fatalf("结束日=%s", sub.EndDate)
}
if sub.Unit != domain.UnitDay || sub.Status != domain.StatusActive {
t.Fatalf("unit=%s status=%s", sub.Unit, sub.Status)
}
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
}); err == nil {
t.Fatal("同一客户同一套餐应禁止重复订阅")
}
// 初始记录
adj, err := db.AdjustmentsBySub(sub.ID)
if err != nil {
t.Fatalf("读取追加记录失败: %v", err)
}
if len(adj) != 1 || adj[0].Kind != "initial" || adj[0].DaysAdded != 30 {
t.Fatalf("初始追加记录不正确: %+v", adj)
}
}
func TestExtendDaysFromTodayWhenExpired(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.AddDays(today, -40)), Days: 30,
})
if err != nil {
t.Fatal(err)
}
if sub.EndDate != domain.Date(domain.AddDays(today, -11)) {
t.Fatalf("end=%s", sub.EndDate)
}
newEnd := domain.AddDays(today, 29)
plan, err := db.ExtendDays(sub.ID, domain.Date(newEnd), "续费", 0)
if err != nil {
t.Fatalf("追加失败: %v", err)
}
if domain.Date(plan.Base) != domain.Date(today) {
t.Fatalf("已到期订阅应从今天起算,实际 %s", domain.Date(plan.Base))
}
if plan.Days != 30 {
t.Fatalf("追加天数=%d want 30", plan.Days)
}
got, _ := db.Sub(sub.ID)
if got.EndDate != domain.Date(newEnd) {
t.Fatalf("新结束日=%s", got.EndDate)
}
if got.Status != domain.StatusActive {
t.Fatalf("状态=%s", got.Status)
}
adj, _ := db.AdjustmentsBySub(sub.ID)
if len(adj) != 2 || adj[0].Kind != "extend_days" || adj[0].DaysAdded != 30 {
t.Fatalf("追加记录不正确: %+v", adj[0])
}
if adj[0].BaseDate != domain.Date(today) {
t.Fatalf("记录起点=%s", adj[0].BaseDate)
}
}
func TestExtendDaysFromOldEndWhenActive(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
newEnd := domain.AddDays(today, 39)
plan, err := db.ExtendDays(sub.ID, domain.Date(newEnd), "", 0)
if err != nil {
t.Fatal(err)
}
if domain.Date(plan.Base) != domain.Date(domain.AddDays(today, 30)) {
t.Fatalf("未到期应从原结束日次日起算,实际 %s", domain.Date(plan.Base))
}
if plan.Days != 10 {
t.Fatalf("追加天数=%d want 10", plan.Days)
}
}
func TestExtendDaysRejectsBackwards(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
if _, err := db.ExtendDays(sub.ID, domain.Date(domain.AddDays(today, 10)), "", 0); err == nil {
t.Fatal("早于计算起点的日期应被拒绝")
}
}
func TestAddCountAndCheckinConsumption(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "深渊十次", domain.UnitCount, 10)
cid := mustCustomer(t, db, "5678", "大鹏")
today := domain.Today()
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), TotalCount: 10,
})
if err != nil {
t.Fatal(err)
}
if sub.TotalCount != 10 || sub.UsedCount != 0 {
t.Fatalf("total=%d used=%d", sub.TotalCount, sub.UsedCount)
}
// 打卡完成 → 扣 1 次
if err := db.SaveCheckin(sub.ID, domain.Date(today), "done", "", 0); err != nil {
t.Fatal(err)
}
got, _ := db.Sub(sub.ID)
if got.UsedCount != 1 {
t.Fatalf("used=%d want 1", got.UsedCount)
}
// 同一天改成「跳过」→ 返还
if err := db.SaveCheckin(sub.ID, domain.Date(today), "skipped", "", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 0 {
t.Fatalf("used=%d want 0", got.UsedCount)
}
// 再改回完成 → 再扣
if err := db.SaveCheckin(sub.ID, domain.Date(today), "done", "", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 1 {
t.Fatalf("used=%d want 1", got.UsedCount)
}
// 撤销打卡 → 返还
ci, err := db.CheckinFor(sub.ID, domain.Date(today))
if err != nil {
t.Fatal(err)
}
if _, err := db.DeleteCheckin(ci.ID, 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 0 {
t.Fatalf("used=%d want 0", got.UsedCount)
}
// 次数用尽后不能再打卡
_, _ = db.sql.Exec(`UPDATE subscriptions SET used_count=total_count WHERE id=?`, sub.ID)
if err := db.SaveCheckin(sub.ID, domain.Date(domain.AddDays(today, -1)), "done", "", 0); err == nil {
t.Fatal("次数用尽时打卡应被拒绝")
}
// 追加次数
if err := db.AddCount(sub.ID, 5, "", "加购", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.TotalCount != 15 {
t.Fatalf("total=%d want 15", got.TotalCount)
}
if err := db.SaveCheckin(sub.ID, domain.Date(domain.AddDays(today, -1)), "done", "", 0); err != nil {
t.Fatalf("追加后应可打卡: %v", err)
}
}
func TestPauseResumeShiftsEndDate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
if err := db.PauseSub(sub.ID, 0); err != nil {
t.Fatal(err)
}
got, _ := db.Sub(sub.ID)
if got.Status != domain.StatusPaused {
t.Fatalf("status=%s", got.Status)
}
// 把暂停开始时间改成 5 天前,模拟暂停了 5 天
oldStart := domain.Date(domain.AddDays(today, -5)) + " 09:00:00"
if _, err := db.sql.Exec(`UPDATE sub_pauses SET start_at=? WHERE sub_id=? AND end_at IS NULL`, oldStart, sub.ID); err != nil {
t.Fatal(err)
}
shift, err := db.ResumeSub(sub.ID, 0)
if err != nil {
t.Fatal(err)
}
if shift != 5 {
t.Fatalf("顺延天数=%d want 5", shift)
}
got, _ = db.Sub(sub.ID)
if got.EndDate != domain.Date(domain.AddDays(today, 34)) {
t.Fatalf("end=%s want %s", got.EndDate, domain.Date(domain.AddDays(today, 34)))
}
if got.Status != domain.StatusActive {
t.Fatalf("status=%s", got.Status)
}
}
func TestCalendarSubsExcludesOpenEndedCount(t *testing.T) {
db := newTestDB(t)
dayPkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cntPkg := mustPackage(t, db, "深渊十次", domain.UnitCount, 10)
today := domain.Today()
cid := mustCustomer(t, db, "1234", "老张")
daySub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: dayPkg.ID, StartDate: domain.Date(today), Days: 30,
})
cid2 := mustCustomer(t, db, "5678", "大鹏")
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid2, PackageID: cntPkg.ID, StartDate: domain.Date(today), TotalCount: 10,
}); err != nil {
t.Fatal(err)
}
subs, err := db.CalendarSubs(domain.Date(today), domain.Date(today))
if err != nil {
t.Fatal(err)
}
if len(subs) != 1 || subs[0].ID != daySub.ID {
t.Fatalf("无有效期的按次订阅不应出现在日历: %+v", subs)
}
}
func TestBackup(t *testing.T) {
db := newTestDB(t)
mustPackage(t, db, "包月日常", domain.UnitDay, 30)
path, err := db.BackupNow(14)
if err != nil {
t.Fatalf("备份失败: %v", err)
}
list, err := db.ListBackups()
if err != nil {
t.Fatal(err)
}
if len(list) != 1 {
t.Fatalf("备份数量=%d want 1 (%s)", len(list), path)
}
// 同日再备份:文件名按秒生成,允许重复
if _, err := db.BackupNow(1); err != nil {
// 同一秒内会重名,算作已存在,不视为失败
t.Logf("第二次备份返回: %v", err)
}
list, _ = db.ListBackups()
if len(list) > 1 {
t.Fatalf("保留份数未生效: %d", len(list))
}
}
func TestSettingsAndBadges(t *testing.T) {
db := newTestDB(t)
_ = db.SetSetting("warn_days", "5")
if got := db.Setting("warn_days", "3"); got != "5" {
t.Fatalf("setting=%s", got)
}
if got := db.Setting("missing", "def"); got != "def" {
t.Fatalf("默认值=%s", got)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
_, _ = db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.AddDays(today, -30)), Days: 31,
})
due, due3, err := db.Badges(today)
if err != nil {
t.Fatal(err)
}
if due != 1 || due3 != 1 {
t.Fatalf("due=%d due3=%d want 1/1", due, due3)
}
}
func TestSearchByTail(t *testing.T) {
db := newTestDB(t)
mustCustomer(t, db, "8899", "阿飞")
mustCustomer(t, db, "8890", "阿飞二号")
today := domain.Today()
list, total, err := db.ListCustomers("8899", 20, 0, domain.Date(today), 3)
if err != nil {
t.Fatal(err)
}
if total != 1 || len(list) != 1 || list[0].Name != "阿飞" {
t.Fatalf("搜索命中 %d 条", total)
}
list, total, _ = db.ListCustomers("889", 20, 0, domain.Date(today), 3)
if total != 2 {
t.Fatalf("前缀搜索应命中 2 条,实际 %d", total)
}
}
func TestUniqueCustomer(t *testing.T) {
db := newTestDB(t)
mustCustomer(t, db, "1234", "老张")
_, err := db.CreateCustomer(&Customer{Tail: "1234", Name: "老张"})
if err == nil {
t.Fatal("重复的尾号+姓名应被拒绝")
}
if _, err := db.CreateCustomer(&Customer{Tail: "1234", Name: "另一位"}); err != nil {
t.Fatalf("同尾号不同姓名应允许: %v", err)
}
}
var _ = time.Now
func TestScreenTopLimitedToTen(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
today := domain.Today()
for i := 0; i < 12; i++ {
cid, err := db.CreateCustomer(&Customer{Tail: "T" + strconv.Itoa(i), Name: "客户" + strconv.Itoa(i)})
if err != nil {
t.Fatalf("建客户失败: %v", err)
}
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30 - i,
}); err != nil {
t.Fatalf("建订阅失败: %v", err)
}
}
st, err := db.ScreenStats(today, 3)
if err != nil {
t.Fatalf("统计失败: %v", err)
}
if len(st.Top) != 10 {
t.Fatalf("TOP 榜应为 10 条,实际 %d 条", len(st.Top))
}
}
func TestBackupKeepPrune(t *testing.T) {
db := newTestDB(t)
mustPackage(t, db, "包月日常", domain.UnitDay, 30)
dir := db.BackupDir()
names := []string{
"daili-20260101-000001.db",
"daili-20260102-000001.db",
"daili-20260103-000001.db",
}
for _, n := range names {
if _, err := db.BackupTo(filepath.Join(dir, n), 0); err != nil {
t.Fatalf("备份失败: %v", err)
}
}
if err := db.pruneBackups(2); err != nil {
t.Fatalf("清理失败: %v", err)
}
list, err := db.ListBackups()
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("保留 2 份,实际 %d 份: %v", len(list), list)
}
if list[0] != names[2] || list[1] != names[1] {
t.Fatalf("保留的不是最新两份: %v", list)
}
}
func TestBackupDirOverride(t *testing.T) {
db := newTestDB(t)
custom := filepath.Join(t.TempDir(), "自定义备份")
db.SetBackupDir(custom)
if db.BackupDir() != custom {
t.Fatalf("备份目录未生效: %s", db.BackupDir())
}
if _, err := db.BackupNow(5); err != nil {
t.Fatalf("备份失败: %v", err)
}
if _, err := os.Stat(custom); err != nil {
t.Fatalf("备份目录未创建: %v", err)
}
}
// copyFile 用于模拟“用户只拷贝数据库文件”。
func copyFile(t *testing.T, src, dst string) {
t.Helper()
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("读取 %s 失败: %v", src, err)
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
t.Fatalf("写入 %s 失败: %v", dst, err)
}
}
func TestSingleFileDatabase(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "daili.db")
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
if mode, err := db.JournalMode(); err != nil || !strings.EqualFold(mode, "delete") {
t.Fatalf("journal_mode=%s err=%v", mode, err)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
}); err != nil {
t.Fatal(err)
}
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(path + suffix); err == nil {
t.Fatalf("运行中不应出现 %s", suffix)
}
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
if _, err := os.Stat(path + suffix); err == nil {
t.Fatalf("关闭后不应残留 %s", suffix)
}
}
if fi, err := os.Stat(path); err != nil || fi.Size() < 8192 {
t.Fatalf("主库大小异常: %v", err)
}
}
func TestCopyDatabaseIsLossless(t *testing.T) {
srcDir := t.TempDir()
srcPath := filepath.Join(srcDir, "daili.db")
db, err := Open(srcPath)
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
})
if err != nil {
t.Fatal(err)
}
if err := db.SaveCheckin(sub.ID, domain.Date(domain.Today()), "done", "迁移前打卡", 0); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
// 只拷贝 daili.db 一个文件
dstDir := t.TempDir()
dstPath := filepath.Join(dstDir, "daili.db")
copyFile(t, srcPath, dstPath)
cp, err := Open(dstPath)
if err != nil {
t.Fatalf("打开副本失败: %v", err)
}
defer cp.Close()
if n, err := cp.CustomerCount(); err != nil || n != 1 {
t.Fatalf("客户数=%d err=%v", n, err)
}
got, err := cp.Sub(sub.ID)
if err != nil || got.CustomerTail != "1234" || got.EndDate != sub.EndDate {
t.Fatalf("订阅丢失: %+v err=%v", got, err)
}
ci, err := cp.CheckinFor(sub.ID, domain.Date(domain.Today()))
if err != nil || ci.Note != "迁移前打卡" {
t.Fatalf("打卡记录丢失: %+v err=%v", ci, err)
}
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(dstPath + suffix); err == nil {
t.Fatalf("副本目录出现了 %s", suffix)
}
}
}
func TestDeleteDatabaseResets(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "daili.db")
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
mustCustomer(t, db, "1234", "老张")
if err := db.Close(); err != nil {
t.Fatal(err)
}
for _, p := range []string{path, path + "-wal", path + "-shm", path + "-journal"} {
_ = os.Remove(p)
}
fresh, err := Open(path)
if err != nil {
t.Fatalf("删除后重建失败: %v", err)
}
defer fresh.Close()
if err := fresh.Migrate(); err != nil {
t.Fatal(err)
}
if n, _ := fresh.CustomerCount(); n != 0 {
t.Fatalf("删库后应无客户,实际 %d", n)
}
if n, _ := fresh.CountUsers(); n != 0 {
t.Fatalf("删库后应无管理员,实际 %d", n)
}
}
func TestStaleSidecarsCleanup(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "daili.db")
// 模拟“删了主库但留下旧版本 WAL”的情况
if err := os.WriteFile(path+"-wal", []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path+"-shm", []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
db, err := Open(path)
if err != nil {
t.Fatalf("打开失败: %v", err)
}
defer db.Close()
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(path + suffix); err == nil {
t.Fatalf("残留的 %s 应被清理", suffix)
}
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
}
func TestOpenLegacyWALDatabaseKeepsData(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "legacy.db")
// 模拟 v1.1 的 WAL 库
dsn := strings.ReplaceAll(path, "\\", "/") + "?_pragma=journal_mode(WAL)"
old, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := old.Exec(`CREATE TABLE legacy(x INTEGER)`); err != nil {
t.Fatal(err)
}
if _, err := old.Exec(`INSERT INTO legacy VALUES(42)`); err != nil {
t.Fatal(err)
}
if err := old.Close(); err != nil {
t.Fatal(err)
}
db, err := Open(path)
if err != nil {
t.Fatalf("打开旧库失败: %v", err)
}
defer db.Close()
var x int
if err := db.sql.QueryRow(`SELECT x FROM legacy`).Scan(&x); err != nil || x != 42 {
t.Fatalf("旧库数据丢失: x=%d err=%v", x, err)
}
if mode, _ := db.JournalMode(); !strings.EqualFold(mode, "delete") {
t.Fatalf("旧库未切到单文件模式: %s", mode)
}
}
+597
View File
@@ -0,0 +1,597 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"daili/internal/domain"
)
// Subscription 服务订阅。
type Subscription struct {
ID int64
CustomerID int64
PackageID int64
Unit string
Status string
StartDate string
EndDate string
TotalCount int
UsedCount int
Note string
CreatedBy int64
CreatedAt string
UpdatedAt string
ClosedAt string
// 联表
PackageName string
PackageColor string
CustomerTail string
CustomerName string
CustomerContact string
}
// Eval 计算展示状态。
func (s *Subscription) Eval(today time.Time, warnDays int) domain.Eval {
return domain.EvalSub(s.Unit, s.Status, s.StartDate, s.EndDate, s.TotalCount, s.UsedCount, today, warnDays)
}
// IsCount 是否按次套餐。
func (s *Subscription) IsCount() bool { return s.Unit == domain.UnitCount }
// Pause 暂停分段。
type Pause struct {
ID int64
SubID int64
StartAt string
EndAt string
StartDate string
EndDate string
}
// Adjustment 追加记录。
type Adjustment struct {
ID int64
SubID int64
Kind string
BaseDate string
OldEndDate string
NewEndDate string
DaysAdded int
CountAdded int
OldTotal int
NewTotal int
Note string
OperatorID int64
OperatorName string
CreatedAt string
// 联表
CustomerTail string
CustomerName string
PackageName string
}
// KindLabel 返回追加类型的中文名。
func (a *Adjustment) KindLabel() string {
switch a.Kind {
case "initial":
return "开通"
case "extend_days":
return "追加天数"
case "add_count":
return "追加次数"
case "set_end_date":
return "调整结束日"
}
return "调整"
}
const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,s.status,s.start_date,IFNULL(s.end_date,''),
s.total_count,s.used_count,s.note,IFNULL(s.created_by,0),s.created_at,s.updated_at,IFNULL(s.closed_at,''),
p.name,p.color,c.tail,c.name,IFNULL(c.contact,'')
FROM subscriptions s
JOIN packages p ON p.id=s.package_id
JOIN customers c ON c.id=s.customer_id`
func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
var s Subscription
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate,
&s.TotalCount, &s.UsedCount, &s.Note, &s.CreatedBy, &s.CreatedAt, &s.UpdatedAt, &s.ClosedAt,
&s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &s, nil
}
// Sub 取单个订阅。
func (db *DB) Sub(id int64) (*Subscription, error) {
return scanSub(db.sql.QueryRow(subSelect+` WHERE s.id=?`, id))
}
// SubsByCustomer 取某客户的订阅。
func (db *DB) SubsByCustomer(customerID int64) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.customer_id=? ORDER BY s.status, s.id`, customerID)
}
// SubsByCustomers 批量取订阅(客户列表聚合用)。
func (db *DB) SubsByCustomers(ids []int64) ([]Subscription, error) {
if len(ids) == 0 {
return nil, nil
}
ph := placeholders(len(ids))
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
return db.querySubs(subSelect+` WHERE s.customer_id IN (`+ph+`) ORDER BY s.id`, args...)
}
func (db *DB) querySubs(q string, args ...any) ([]Subscription, error) {
rows, err := db.sql.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Subscription{}
for rows.Next() {
s, err := scanSub(rows)
if err != nil {
return nil, err
}
out = append(out, *s)
}
return out, rows.Err()
}
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
// SubFilter 订阅列表筛选。
type SubFilter struct {
Q string
State string
PackageID int64
Limit int
Offset int
Today time.Time
}
const expiredExpr = `((s.unit='day' AND s.end_date < ?) OR (s.unit='count' AND (s.used_count>=s.total_count OR (IFNULL(s.end_date,'')<>'' AND s.end_date < ?))))`
// ListSubs 分页查询订阅。
func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
where := []string{"1=1"}
args := []any{}
today := domain.Date(f.Today)
if q := strings.TrimSpace(f.Q); q != "" {
like := "%" + q + "%"
where = append(where, "(c.tail LIKE ? OR c.name LIKE ? OR p.name LIKE ?)")
args = append(args, like, like, like)
}
if f.PackageID > 0 {
where = append(where, "s.package_id=?")
args = append(args, f.PackageID)
}
switch f.State {
case "active":
where = append(where, "s.status='active'", "NOT "+expiredExpr)
args = append(args, today, today)
case "paused":
where = append(where, "s.status='paused'")
case "closed":
where = append(where, "s.status='closed'")
case "expired":
where = append(where, "s.status<>'closed'", expiredExpr)
args = append(args, today, today)
case "due7":
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
args = append(args, today, domain.Date(domain.AddDays(f.Today, 6)))
case "due3":
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
args = append(args, today, domain.Date(domain.AddDays(f.Today, 2)))
case "due_today":
where = append(where, "s.status<>'closed'", "s.end_date = ?")
args = append(args, today)
}
w := strings.Join(where, " AND ")
var total int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s
JOIN packages p ON p.id=s.package_id JOIN customers c ON c.id=s.customer_id WHERE `+w, args...).Scan(&total); err != nil {
return nil, 0, err
}
lim := f.Limit
if lim <= 0 {
lim = 30
}
q := subSelect + ` WHERE ` + w + ` ORDER BY s.status, IFNULL(s.end_date,'9999-12-31'), s.id DESC LIMIT ? OFFSET ?`
subs, err := db.querySubs(q, append(args, lim, f.Offset)...)
return subs, total, err
}
// CalendarSubs 取与 [from,to] 日期区间相交的订阅(用于日历)。
func (db *DB) CalendarSubs(from, to string) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.status<>'closed'
AND s.start_date <= ? AND IFNULL(s.end_date,'')<>'' AND s.end_date >= ?
ORDER BY s.id`, to, from)
}
// DueOn 某天到期的订阅。
func (db *DB) DueOn(date string) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date=? ORDER BY s.id`, date)
}
// CreateSubInput 新建订阅入参。
type CreateSubInput struct {
CustomerID int64
PackageID int64
StartDate string
EndDate string
Days int
TotalCount int
Note string
OperatorID int64
}
// CreateSub 新建订阅,并写入初始追加记录。
func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
pkg, err := db.Package(in.PackageID)
if err != nil {
return nil, errors.New("套餐不存在")
}
if pkg.Archived {
return nil, errors.New("该套餐已归档,不能新建订阅")
}
start, err := domain.ParseDate(in.StartDate)
if err != nil {
return nil, errors.New("开始日期格式不正确")
}
unit := pkg.Unit
endDate := ""
days := 0
total := 0
if unit == domain.UnitDay {
days = in.Days
if days <= 0 {
days = pkg.DefaultAmount
}
if days <= 0 {
days = 30
}
if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate)
if err != nil {
return nil, errors.New("结束日期格式不正确")
}
if domain.DayDiff(start, end) < 0 {
return nil, errors.New("结束日期不能早于开始日期")
}
endDate = domain.Date(end)
days = domain.DayDiff(start, end) + 1
} else {
endDate = domain.Date(domain.AddDays(start, days-1))
}
} else {
total = in.TotalCount
if total <= 0 {
total = pkg.DefaultAmount
}
if total <= 0 {
return nil, errors.New("总次数必须大于 0")
}
if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate)
if err != nil {
return nil, errors.New("有效期格式不正确")
}
if domain.DayDiff(start, end) < 0 {
return nil, errors.New("有效期不能早于开始日期")
}
endDate = domain.Date(end)
}
}
now := nowStamp()
var id int64
err = db.withTx(func(tx *sql.Tx) error {
var existing int64
err := tx.QueryRow(`SELECT id FROM subscriptions WHERE customer_id=? AND package_id=?`,
in.CustomerID, in.PackageID).Scan(&existing)
if err == nil {
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,note,created_by,created_at,updated_at)
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?)`,
in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now)
if err != nil {
if isUnique(err) {
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
}
return err
}
id, _ = res.LastInsertId()
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
id, "initial", domain.Date(start), "", nullIfEmpty(endDate), days, 0, 0, total, "新建订阅", nullIfZero(in.OperatorID), now)
return err
})
if err != nil {
return nil, err
}
return db.Sub(id)
}
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
// ExtendDays 按日追加:手动指定新的结束日,自动换算计算起点与追加天数。
func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64) (domain.ExtendPlan, error) {
var plan domain.ExtendPlan
today := domain.Today()
newEnd, err := domain.ParseDate(newEndDate)
if err != nil {
return plan, errors.New("请选择新的终止日期")
}
err = db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit != domain.UnitDay {
return errors.New("该订阅按次计费,请使用「追加次数」")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」")
}
oldEnd := domain.ParseDateOr(s.EndDate, today)
plan = domain.PlanExtend(oldEnd, newEnd, today)
if plan.Backwards {
return fmt.Errorf("所选日期早于计算起点 %s,不会增加时长", domain.Date(plan.Base))
}
status := s.Status
if status == domain.StatusPaused {
status = domain.StatusActive
}
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=?, status=?, updated_at=? WHERE id=?`,
domain.Date(newEnd), status, nowStamp(), subID); err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,0,0,0,?,?,?)`,
subID, "extend_days", domain.Date(plan.Base), s.EndDate, domain.Date(newEnd), plan.Days, note, nullIfZero(operatorID), nowStamp())
return err
})
return plan, err
}
// AddCount 按次追加次数(可同时调整有效期)。
func (db *DB) AddCount(subID int64, add int, newEndDate, note string, operatorID int64) error {
if add <= 0 {
return errors.New("追加次数必须大于 0")
}
err := db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit != domain.UnitCount {
return errors.New("该订阅按日计费,请使用「追加天数」")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」")
}
endDate := s.EndDate
if newEndDate != "" {
end, err := domain.ParseDate(newEndDate)
if err != nil {
return errors.New("有效期格式不正确")
}
if end.Before(domain.ParseDateOr(s.StartDate, domain.Today())) {
return errors.New("有效期不能早于开始日期")
}
endDate = domain.Date(end)
}
status := s.Status
if status == domain.StatusPaused {
status = domain.StatusActive
}
newTotal := s.TotalCount + add
if _, err := tx.Exec(`UPDATE subscriptions SET total_count=?, end_date=?, status=?, updated_at=? WHERE id=?`,
newTotal, nullIfEmpty(endDate), status, nowStamp(), subID); err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
VALUES(?,?,?,?,0,?,?,?,?,?,?)`,
subID, "add_count", s.EndDate, endDate, add, s.TotalCount, newTotal, note, nullIfZero(operatorID), nowStamp())
return err
})
return err
}
// PauseSub 暂停订阅。
func (db *DB) PauseSub(subID, operatorID int64) error {
return db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status == domain.StatusPaused {
return errors.New("订阅已处于暂停状态")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,无法暂停")
}
now := nowStamp()
if _, err := tx.Exec(`INSERT INTO sub_pauses(sub_id,start_at) VALUES(?,?)`, subID, now); err != nil {
return err
}
_, err = tx.Exec(`UPDATE subscriptions SET status='paused', updated_at=? WHERE id=?`, now, subID)
return err
})
}
// ResumeSub 恢复订阅,按日套餐顺延结束日,返回顺延天数。
func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
var shift int
today := domain.Today()
err := db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status != domain.StatusPaused {
return errors.New("订阅未处于暂停状态")
}
var startAt string
err = tx.QueryRow(`SELECT start_at FROM sub_pauses WHERE sub_id=? AND end_at IS NULL ORDER BY id DESC LIMIT 1`, subID).Scan(&startAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil {
ps := domain.ParseDateOr(startAt[:10], today)
shift = domain.DayDiff(ps, today)
if shift < 0 {
shift = 0
}
}
now := nowStamp()
if _, err := tx.Exec(`UPDATE sub_pauses SET end_at=? WHERE sub_id=? AND end_at IS NULL`, now, subID); err != nil {
return err
}
if s.Unit == domain.UnitDay && shift > 0 {
oldEnd := domain.ParseDateOr(s.EndDate, today)
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=? WHERE id=?`,
domain.Date(domain.AddDays(oldEnd, shift)), subID); err != nil {
return err
}
}
_, err = tx.Exec(`UPDATE subscriptions SET status='active', updated_at=? WHERE id=?`, now, subID)
return err
})
return shift, err
}
// CloseSub 结束订阅。
func (db *DB) CloseSub(subID int64) error {
now := nowStamp()
_, err := db.sql.Exec(`UPDATE subscriptions SET status='closed', closed_at=?, updated_at=? WHERE id=? AND status<>'closed'`,
now, now, subID)
return err
}
// ReopenSub 重新启用已结束的订阅。
func (db *DB) ReopenSub(subID int64) error {
_, err := db.sql.Exec(`UPDATE subscriptions SET status='active', closed_at=NULL, updated_at=? WHERE id=?`, nowStamp(), subID)
return err
}
// AdjustmentsBySub 取某订阅的追加记录(新→旧)。
func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name
FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id
JOIN subscriptions s ON s.id=a.sub_id
JOIN customers c ON c.id=s.customer_id
JOIN packages p ON p.id=s.package_id
WHERE a.sub_id=? ORDER BY a.id DESC`, subID)
}
// RecentAdjustments 最近追加记录。
func (db *DB) RecentAdjustments(limit int) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name
FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id
JOIN subscriptions s ON s.id=a.sub_id
JOIN customers c ON c.id=s.customer_id
JOIN packages p ON p.id=s.package_id
ORDER BY a.id DESC LIMIT ?`, limit)
}
func (db *DB) queryAdjustments(q string, args ...any) ([]Adjustment, error) {
rows, err := db.sql.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Adjustment{}
for rows.Next() {
var a Adjustment
if err := rows.Scan(&a.ID, &a.SubID, &a.Kind, &a.BaseDate, &a.OldEndDate, &a.NewEndDate,
&a.DaysAdded, &a.CountAdded, &a.OldTotal, &a.NewTotal, &a.Note, &a.OperatorID, &a.OperatorName, &a.CreatedAt,
&a.CustomerTail, &a.CustomerName, &a.PackageName); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// PausesForSubs 批量取暂停分段。
func (db *DB) PausesForSubs(ids []int64) (map[int64][]Pause, error) {
out := map[int64][]Pause{}
if len(ids) == 0 {
return out, nil
}
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
rows, err := db.sql.Query(`SELECT id,sub_id,start_at,IFNULL(end_at,'') FROM sub_pauses
WHERE sub_id IN (`+placeholders(len(ids))+`) ORDER BY id`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var p Pause
if err := rows.Scan(&p.ID, &p.SubID, &p.StartAt, &p.EndAt); err != nil {
return nil, err
}
if len(p.StartAt) >= 10 {
p.StartDate = p.StartAt[:10]
}
if len(p.EndAt) >= 10 {
p.EndDate = p.EndAt[:10]
}
out[p.SubID] = append(out[p.SubID], p)
}
return out, rows.Err()
}
// CountActiveSubs 活跃订阅数。
func (db *DB) CountActiveSubs(today string) (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active' AND (
(s.unit='day' AND s.end_date >= ?) OR
(s.unit='count' AND (IFNULL(s.end_date,'')='' OR s.end_date >= ?) AND s.used_count < s.total_count)
)`, today, today).Scan(&n)
return n, err
}
// SubTitle 生成「1234 老张 · 包月日常」式标题。
func (s *Subscription) SubTitle() string {
return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName)
}
+179
View File
@@ -0,0 +1,179 @@
package store
import (
"database/sql"
"errors"
"time"
"daili/internal/pw"
)
// User 管理员账户。
type User struct {
ID int64
Username string
DisplayName string
IsActive bool
CreatedAt string
LastLoginAt string
}
func scanUser(row interface {
Scan(dest ...any) error
}) (*User, error) {
var u User
var active int
var last sql.NullString
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.IsActive = active == 1
u.LastLoginAt = last.String
return &u, nil
}
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,'')`
// CountUsers 返回账户数量。
func (db *DB) CountUsers() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// CreateUser 新建账户。
func (db *DB) CreateUser(username, displayName, password string) (*User, error) {
hash, err := pw.Hash(password)
if err != nil {
return nil, err
}
now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, created_at)
VALUES(?,?,?,1,?)`, username, displayName, hash, now)
if err != nil {
if isUnique(err) {
return nil, ErrDuplicate
}
return nil, err
}
id, _ := res.LastInsertId()
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, CreatedAt: now}, nil
}
// Authenticate 校验用户名口令。
func (db *DB) Authenticate(username, password string) (*User, error) {
var hash string
var active int
var id int64
err := db.sql.QueryRow(`SELECT id, password_hash, is_active FROM users WHERE username=?`, username).
Scan(&id, &hash, &active)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if active != 1 || !pw.Verify(password, hash) {
return nil, ErrNotFound
}
_, _ = db.sql.Exec(`UPDATE users SET last_login_at=? WHERE id=?`, nowStamp(), id)
return db.UserByID(id)
}
// UserByID 按 ID 取账户。
func (db *DB) UserByID(id int64) (*User, error) {
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id))
}
// UserByUsername 按用户名取账户。
func (db *DB) UserByUsername(name string) (*User, error) {
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE username=?`, name))
}
// ListUsers 列出账户。
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.sql.Query(`SELECT ` + userCols + ` FROM users ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
// UpdatePassword 修改口令。
func (db *DB) UpdatePassword(userID int64, password string) error {
hash, err := pw.Hash(password)
if err != nil {
return err
}
_, err = db.sql.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID)
return err
}
// SetUserActive 启用/停用账户。
func (db *DB) SetUserActive(userID int64, active bool) error {
v := 0
if active {
v = 1
}
_, err := db.sql.Exec(`UPDATE users SET is_active=? WHERE id=?`, v, userID)
return err
}
// ---------- 会话 ----------
// CreateSession 新建登录会话。
func (db *DB) CreateSession(token string, userID int64, ttl time.Duration, ip string) error {
now := time.Now()
_, err := db.sql.Exec(`INSERT INTO sessions(token,user_id,created_at,expires_at,ip) VALUES(?,?,?,?,?)`,
token, userID, now.Format("2006-01-02 15:04:05"), now.Add(ttl).Format("2006-01-02 15:04:05"), ip)
return err
}
// SessionUser 通过令牌取账户,过期则返回 ErrNotFound。
func (db *DB) SessionUser(token string) (*User, error) {
var userID int64
var expires string
err := db.sql.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token=?`, token).Scan(&userID, &expires)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
exp, err := time.ParseInLocation("2006-01-02 15:04:05", expires, time.Local)
if err != nil || exp.Before(time.Now()) {
_ = db.DeleteSession(token)
return nil, ErrNotFound
}
u, err := db.UserByID(userID)
if err != nil {
return nil, err
}
if !u.IsActive {
return nil, ErrNotFound
}
return u, nil
}
// DeleteSession 删除会话。
func (db *DB) DeleteSession(token string) error {
_, err := db.sql.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err
}
// PurgeSessions 清理过期会话。
func (db *DB) PurgeSessions() {
_, _ = db.sql.Exec(`DELETE FROM sessions WHERE expires_at < ?`, nowStamp())
}
+119
View File
@@ -0,0 +1,119 @@
package web
import (
"net/http"
"strings"
"daili/internal/store"
)
func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
n, err := s.db.CountUsers()
if err != nil {
http.Error(w, "数据库异常", http.StatusInternalServerError)
return
}
if n > 0 {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
p := s.newPage(r, "初始化管理员", "")
s.render(w, r, "setup", p)
}
func (s *Server) handleSetupPost(w http.ResponseWriter, r *http.Request) {
n, _ := s.db.CountUsers()
if n > 0 {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if !sameOrigin(r) {
http.Error(w, "跨站请求被拒绝", http.StatusForbidden)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
display := strings.TrimSpace(r.PostFormValue("display_name"))
pw := r.PostFormValue("password")
pw2 := r.PostFormValue("password2")
p := s.newPage(r, "初始化管理员", "")
switch {
case len(username) < 3:
s.fail(w, r, "setup", "用户名至少 3 个字符", p)
return
case len(pw) < 6:
s.fail(w, r, "setup", "密码至少 6 位", p)
return
case pw != pw2:
s.fail(w, r, "setup", "两次输入的密码不一致", p)
return
}
if display == "" {
display = username
}
u, err := s.db.CreateUser(username, display, pw)
if err != nil {
s.fail(w, r, "setup", "创建失败:"+err.Error(), p)
return
}
_ = s.db.Log(store.OpLog{OperatorID: u.ID, OperatorName: u.DisplayName, Action: "创建管理员",
Entity: "user", EntityID: u.ID, Summary: "初始化管理员账号 " + username, IP: clientIP(r)})
s.startSession(w, r, u)
s.redirect(w, r, "/packages", "管理员已创建,先添加一个服务套餐吧")
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
n, _ := s.db.CountUsers()
if n == 0 {
http.Redirect(w, r, "/setup", http.StatusFound)
return
}
if s.userFromRequest(r) != nil {
http.Redirect(w, r, "/calendar", http.StatusFound)
return
}
p := s.newPage(r, "登录", "")
s.render(w, r, "login", p)
}
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if !sameOrigin(r) {
http.Error(w, "跨站请求被拒绝", http.StatusForbidden)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
pw := r.PostFormValue("password")
next := r.PostFormValue("next")
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
next = "/calendar"
}
u, err := s.db.Authenticate(username, pw)
if err != nil {
p := s.newPage(r, "登录", "")
s.fail(w, r, "login", "用户名或密码不正确", p)
return
}
_ = s.db.Log(store.OpLog{OperatorID: u.ID, OperatorName: u.DisplayName, Action: "登录",
Entity: "session", Summary: "登录成功", IP: clientIP(r)})
s.startSession(w, r, u)
s.redirect(w, r, next, "欢迎回来,"+u.DisplayName)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieSession); err == nil && c.Value != "" {
_ = s.db.DeleteSession(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: cookieSession, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// logOp 是写操作日志的便捷方法。
func (s *Server) logOp(r *http.Request, action, entity string, entityID int64, summary string) {
u := s.currentUser(r)
if u == nil {
return
}
_ = s.db.Log(store.OpLog{
OperatorID: u.ID, OperatorName: u.DisplayName, Action: action,
Entity: entity, EntityID: entityID, Summary: summary, IP: clientIP(r),
})
}
+348
View File
@@ -0,0 +1,348 @@
package web
import (
"fmt"
"net/http"
"sort"
"time"
"daili/internal/domain"
"daili/internal/store"
)
// DayCell 是日历格。
type DayCell struct {
Date string
Day int
InMonth bool
IsToday bool
IsWeekend bool
IsSelected bool
IsPast bool
DueCount int
TaskCount int
DoneCount int
Heat int
Tickets []Ticket
More int
}
// Ticket 是日历格里的色签。
type Ticket struct {
SubID int64
Tail string
Name string
Pkg string
Color string
Done bool
Skipped bool
IsDue bool
}
// TaskRow 是当日待打清单的一行。
type TaskRow struct {
SubView
CheckinID int64
Done bool
Skipped bool
Note string
Operator string
}
type calendarData struct {
Year, Month int
Title string
PrevY, PrevM int
NextY, NextM int
TodayStr string
Weekdays []string
Weeks [][]DayCell
Selected string
SelectedLabel string
SelectedIsToday bool
SelectedIsPast bool
Tasks []TaskRow
Due []SubView
OtherDue []SubView
Extend *ExtendPanel
MonthDue int
MonthTasks int
MonthDone int
NoPackages bool
NoCustomers bool
Back string
}
// calSub 是日历计算用的订阅上下文。
type calSub struct {
sub store.Subscription
start time.Time
end time.Time
hasEnd bool
eval domain.Eval
pauses []store.Pause
}
func (c calSub) pausedOn(date string) bool {
for _, p := range c.pauses {
if p.StartDate == "" {
continue
}
if date >= p.StartDate && (p.EndDate == "" || date < p.EndDate) {
return true
}
}
return false
}
func (c calSub) covers(date string) bool {
if c.sub.Status == domain.StatusClosed || !c.hasEnd {
return false
}
if date < c.sub.StartDate || date > c.sub.EndDate {
return false
}
if c.sub.Unit == domain.UnitCount && c.eval.RemainingCount <= 0 {
return false
}
return !c.pausedOn(date)
}
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
y := s.queryInt(r, "y", today.Year())
m := s.queryInt(r, "m", int(today.Month()))
if m < 1 || m > 12 {
y, m = today.Year(), int(today.Month())
}
monthStart := domain.MonthStart(y, time.Month(m))
monthEnd := domain.MonthEnd(y, time.Month(m))
sel := r.URL.Query().Get("d")
selT, err := domain.ParseDate(sel)
if sel != "" && err != nil {
sel = ""
}
if sel == "" {
if today.After(monthEnd) || today.Before(monthStart) {
sel = domain.Date(monthStart)
} else {
sel = domain.Date(today)
}
selT = domain.ParseDateOr(sel, today)
}
// 网格:以周一为首日,铺满整月所需的周数
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
gridEnd := domain.AddDays(gridStart, weeks*7-1)
subs, err := s.db.CalendarSubs(domain.Date(gridStart), domain.Date(gridEnd))
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, err := s.db.PausesForSubs(ids)
if err != nil {
http.Error(w, "读取暂停记录失败", http.StatusInternalServerError)
return
}
checkins, err := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(gridEnd))
if err != nil {
http.Error(w, "读取打卡记录失败", http.StatusInternalServerError)
return
}
warn := s.warnDays()
list := make([]calSub, 0, len(subs))
for _, sub := range subs {
cs := calSub{
sub: sub,
start: domain.ParseDateOr(sub.StartDate, today),
eval: sub.Eval(today, warn),
pauses: pauses[sub.ID],
}
if sub.EndDate != "" {
cs.end = domain.ParseDateOr(sub.EndDate, today)
cs.hasEnd = true
}
list = append(list, cs)
}
data := &calendarData{
Year: y, Month: m,
Title: fmt.Sprintf("%d 年 %d 月", y, m),
TodayStr: domain.Date(today),
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
Selected: sel,
SelectedIsToday: sel == domain.Date(today),
Back: r.URL.RequestURI(),
}
data.PrevY, data.PrevM = m-1, y
if data.PrevM < 1 {
data.PrevM = 12
data.PrevY = y - 1
}
data.NextY, data.NextM = y, m+1
if data.NextM > 12 {
data.NextM = 1
data.NextY = y + 1
}
label := selT.Format("1 月 2 日")
data.SelectedLabel = label + " 周" + dowCN(sel)
data.SelectedIsPast = selT.Before(today)
// 铺格子
for wi := 0; wi < weeks; wi++ {
week := make([]DayCell, 0, 7)
for di := 0; di < 7; di++ {
day := domain.AddDays(gridStart, wi*7+di)
ds := domain.Date(day)
cell := DayCell{
Date: ds,
Day: day.Day(),
InMonth: day.Month() == time.Month(m),
IsToday: ds == domain.Date(today),
IsWeekend: di >= 5,
IsSelected: ds == sel,
IsPast: day.Before(today),
}
tickets := []Ticket{}
for _, cs := range list {
if cs.sub.EndDate == ds {
cell.DueCount++
}
if !cell.InMonth || !cs.covers(ds) {
continue
}
cell.TaskCount++
ci := checkins[cs.sub.ID][ds]
t := Ticket{
SubID: cs.sub.ID,
Tail: cs.sub.CustomerTail,
Name: cs.sub.CustomerName,
Pkg: shortName(cs.sub.PackageName),
Color: cs.sub.PackageColor,
Done: ci.Status == "done",
Skipped: ci.Status == "skipped",
IsDue: cs.sub.EndDate == ds,
}
if t.Done || t.Skipped {
cell.DoneCount++
}
tickets = append(tickets, t)
}
sort.SliceStable(tickets, func(i, j int) bool {
if tickets[i].Done != tickets[j].Done {
return !tickets[i].Done
}
return tickets[i].SubID < tickets[j].SubID
})
if len(tickets) > 3 {
cell.More = len(tickets) - 3
tickets = tickets[:3]
}
cell.Tickets = tickets
cell.Heat = heatLevel(cell.TaskCount + cell.DueCount)
if cell.InMonth {
data.MonthDue += cell.DueCount
data.MonthTasks += cell.TaskCount
data.MonthDone += cell.DoneCount
}
week = append(week, cell)
}
data.Weeks = append(data.Weeks, week)
}
// 当日清单
due := []SubView{}
tasks := []TaskRow{}
for _, cs := range list {
if cs.sub.EndDate == sel {
due = append(due, newSubView(cs.sub, today, warn))
}
if !cs.covers(sel) {
continue
}
v := newSubView(cs.sub, today, warn)
row := TaskRow{SubView: v}
if ci, ok := checkins[cs.sub.ID][sel]; ok {
row.CheckinID = ci.ID
row.Done = ci.Status == "done"
row.Skipped = ci.Status == "skipped"
row.Note = ci.Note
row.Operator = ci.OperatorName
}
tasks = append(tasks, row)
}
sort.SliceStable(tasks, func(i, j int) bool {
ai := tasks[i].Done || tasks[i].Skipped
aj := tasks[j].Done || tasks[j].Skipped
if ai != aj {
return !ai
}
return tasks[i].EndDate < tasks[j].EndDate
})
data.Tasks = tasks
data.Due = due
// 其它日期到期(不在当天的 7 天内)
if upcoming, err := s.db.RecentDue(domain.Date(today), 7); err == nil {
for _, u := range upcoming {
if u.EndDate == sel {
continue
}
data.OtherDue = append(data.OtherDue, newSubView(u, today, warn))
}
}
// 追加面板
if eid := s.queryInt(r, "extend", 0); eid > 0 {
if sub, err := s.db.Sub(int64(eid)); err == nil {
if pkg, err := s.db.Package(sub.PackageID); err == nil {
back := r.URL.Path + "?" + r.URL.RawQuery
data.Extend = buildExtendPanel(sub, pkg, today, back, warn)
}
}
}
// 空态提示
if n, _ := s.db.CustomerCount(); n == 0 {
data.NoCustomers = true
}
if pkgs, _ := s.db.ListPackages(false); len(pkgs) == 0 {
data.NoPackages = true
}
p := s.newPage(r, "日历", "calendar")
p.D = data
s.render(w, r, "calendar", p)
}
func heatLevel(n int) int {
switch {
case n <= 0:
return 0
case n <= 2:
return 1
case n <= 5:
return 2
case n <= 9:
return 3
default:
return 4
}
}
// shortName 取套餐名前两个字符,用作日历色签上的短标签。
func shortName(s string) string {
r := []rune(s)
if len(r) <= 2 {
return s
}
return string(r[:2])
}
+305
View File
@@ -0,0 +1,305 @@
package web
import (
"errors"
"net/http"
"sort"
"strconv"
"strings"
"daili/internal/domain"
"daili/internal/store"
)
type customersData struct {
List []store.Customer
Pager Pager
Q string
Total int
PackageCount int
NoPackages bool
}
func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
warn := s.warnDays()
q := r.URL.Query().Get("q")
pageNo := s.queryInt(r, "page", 1)
if pageNo < 1 {
pageNo = 1
}
list, total, err := s.db.ListCustomers(q, pageSize, (pageNo-1)*pageSize, domain.Date(today), warn)
if err != nil {
http.Error(w, "读取客户失败", http.StatusInternalServerError)
return
}
pager := newPager(r, pageNo, pageSize, total)
if pager.Page != pageNo {
list, total, err = s.db.ListCustomers(q, pageSize, (pager.Page-1)*pageSize, domain.Date(today), warn)
if err != nil {
http.Error(w, "读取客户失败", http.StatusInternalServerError)
return
}
}
pkgs, _ := s.db.ListPackages(false)
data := &customersData{List: list, Pager: pager, Q: q, Total: total, PackageCount: len(pkgs), NoPackages: len(pkgs) == 0}
p := s.newPage(r, "客户", "customers")
p.D = data
s.render(w, r, "customers", p)
}
type customerFormData struct {
Customer *store.Customer
IsNew bool
}
func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新建客户", "customers")
p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true}
s.render(w, r, "customer_form", p)
}
func readCustomerForm(r *http.Request) *store.Customer {
return &store.Customer{
Tail: strings.TrimSpace(r.PostFormValue("tail")),
Name: strings.TrimSpace(r.PostFormValue("name")),
GameName: strings.TrimSpace(r.PostFormValue("game_name")),
GameServer: strings.TrimSpace(r.PostFormValue("game_server")),
Contact: strings.TrimSpace(r.PostFormValue("contact")),
Tags: normalizeTags(r.PostFormValue("tags")),
Source: strings.TrimSpace(r.PostFormValue("source")),
Note: strings.TrimSpace(r.PostFormValue("note")),
}
}
func normalizeTags(v string) string {
parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ',' || r == ' ' || r == ';' || r == ';' })
seen := map[string]bool{}
out := []string{}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" || seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
return strings.Join(out, ",")
}
func validateCustomer(c *store.Customer) string {
if c.Tail == "" {
return "手机尾号不能为空"
}
if len([]rune(c.Tail)) > 8 {
return "手机尾号最长 8 位"
}
if c.Name == "" {
return "客户名不能为空"
}
if len([]rune(c.Name)) > 32 {
return "客户名最长 32 字"
}
return ""
}
func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
c := readCustomerForm(r)
p := s.newPage(r, "新建客户", "customers")
if msg := validateCustomer(c); msg != "" {
p.D = &customerFormData{Customer: c, IsNew: true}
s.fail(w, r, "customer_form", msg, p)
return
}
id, err := s.db.CreateCustomer(c)
if err != nil {
msg := "创建失败:" + err.Error()
if errors.Is(err, store.ErrDuplicate) {
msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅"
}
p.D = &customerFormData{Customer: c, IsNew: true}
s.fail(w, r, "customer_form", msg, p)
return
}
s.logOp(r, "新建客户", "customer", id, "新建客户 "+c.Tail+" "+c.Name)
s.redirect(w, r, "/customers/"+itoa(id), "客户已创建,接着为他添加订阅")
}
func (s *Server) handleCustomerEditPage(w http.ResponseWriter, r *http.Request) {
c, err := s.db.Customer(pathID(r))
if err != nil {
http.NotFound(w, r)
return
}
p := s.newPage(r, "编辑客户", "customers")
p.D = &customerFormData{Customer: c, IsNew: false}
s.render(w, r, "customer_form", p)
}
func (s *Server) handleCustomerUpdate(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
old, err := s.db.Customer(id)
if err != nil {
http.NotFound(w, r)
return
}
c := readCustomerForm(r)
c.ID = id
p := s.newPage(r, "编辑客户", "customers")
if msg := validateCustomer(c); msg != "" {
p.D = &customerFormData{Customer: c, IsNew: false}
s.fail(w, r, "customer_form", msg, p)
return
}
if err := s.db.UpdateCustomer(c); err != nil {
msg := "保存失败:" + err.Error()
if errors.Is(err, store.ErrDuplicate) {
msg = "该「尾号 + 姓名」的客户已存在"
}
p.D = &customerFormData{Customer: c, IsNew: false}
s.fail(w, r, "customer_form", msg, p)
return
}
s.logOp(r, "编辑客户", "customer", id, "修改客户 "+c.Tail+" "+c.Name+"(原 "+old.Tail+" "+old.Name+")")
s.redirect(w, r, "/customers/"+itoa(id), "客户资料已保存")
}
// CheckinRow 是打卡历史的展示行。
type CheckinRow struct {
store.Checkin
SubName string
SubColor string
}
type customerDetailData struct {
Customer *store.Customer
Subs []SubView
Adjustments []store.Adjustment
AdjSubName map[int64]string
AdjSubColor map[int64]string
Checkins []CheckinRow
Packages []store.Package
Timeline *Timeline
Extend *ExtendPanel
TotalRemain string
}
func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
c, err := s.db.Customer(id)
if err != nil {
http.NotFound(w, r)
return
}
today := domain.Today()
warn := s.warnDays()
subs, err := s.db.SubsByCustomer(id)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
pkgs, _ := s.db.ListPackages(false)
ids := make([]int64, 0, len(subs))
subByID := map[int64]store.Subscription{}
for _, sub := range subs {
ids = append(ids, sub.ID)
subByID[sub.ID] = sub
}
pauses, _ := s.db.PausesForSubs(ids)
data := &customerDetailData{
Customer: c,
Subs: subViews(subs, today, warn),
Packages: pkgs,
Timeline: buildTimeline(subs, pauses, today),
AdjSubName: map[int64]string{},
AdjSubColor: map[int64]string{},
}
for _, sub := range subs {
data.AdjSubName[sub.ID] = sub.PackageName
data.AdjSubColor[sub.ID] = sub.PackageColor
adj, err := s.db.AdjustmentsBySub(sub.ID)
if err == nil {
data.Adjustments = append(data.Adjustments, adj...)
}
if ci, err := s.db.CheckinsForSub(sub.ID, 30); err == nil {
for _, one := range ci {
data.Checkins = append(data.Checkins, CheckinRow{Checkin: one, SubName: sub.PackageName, SubColor: sub.PackageColor})
}
}
}
// 按时间排序(新→旧)
sortAdjustments(data.Adjustments)
sortCheckins(data.Checkins)
if eid := s.queryInt(r, "extend", 0); eid > 0 {
if sub, ok := subByID[int64(eid)]; ok {
if pkg, err := s.db.Package(sub.PackageID); err == nil {
back := r.URL.Path + "?" + r.URL.RawQuery
data.Extend = buildExtendPanel(&sub, pkg, today, back, warn)
}
}
}
p := s.newPage(r, c.Tail+" "+c.Name, "customers")
p.D = data
s.render(w, r, "customer_detail", p)
}
func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
customerID := pathID(r)
c, err := s.db.Customer(customerID)
if err != nil {
http.NotFound(w, r)
return
}
in := store.CreateSubInput{
CustomerID: customerID,
PackageID: int64(s.formInt(r, "package_id", 0)),
StartDate: r.PostFormValue("start_date"),
EndDate: r.PostFormValue("end_date"),
Days: s.formInt(r, "days", 0),
TotalCount: s.formInt(r, "total_count", 0),
Note: strings.TrimSpace(r.PostFormValue("note")),
}
u := s.currentUser(r)
if u != nil {
in.OperatorID = u.ID
}
sub, err := s.db.CreateSub(in)
if err != nil {
s.redirect(w, r, "/customers/"+itoa(customerID), "创建订阅失败:"+err.Error())
return
}
s.logOp(r, "新建订阅", "subscription", sub.ID,
"为 "+c.Tail+" "+c.Name+" 开通 "+sub.PackageName+"("+unitLabel(sub.Unit)+")")
s.redirect(w, r, "/customers/"+itoa(customerID), "已开通「"+sub.PackageName+"」")
}
func unitLabel(u string) string {
if u == domain.UnitCount {
return "按次"
}
return "按日"
}
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
func sortAdjustments(list []store.Adjustment) {
sort.SliceStable(list, func(i, j int) bool {
if list[i].CreatedAt != list[j].CreatedAt {
return list[i].CreatedAt > list[j].CreatedAt
}
return list[i].ID > list[j].ID
})
}
func sortCheckins(list []CheckinRow) {
sort.SliceStable(list, func(i, j int) bool {
if list[i].ServiceDate != list[j].ServiceDate {
return list[i].ServiceDate > list[j].ServiceDate
}
return list[i].ID > list[j].ID
})
}
+299
View File
@@ -0,0 +1,299 @@
package web
import (
"encoding/csv"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
"daili/internal/domain"
"daili/internal/store"
)
type logsData struct {
List []store.OpLog
Pager Pager
Q string
Total int
}
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
pageNo := s.queryInt(r, "page", 1)
if pageNo < 1 {
pageNo = 1
}
list, total, err := s.db.ListLogs(q, pageSize, (pageNo-1)*pageSize)
if err != nil {
http.Error(w, "读取日志失败", http.StatusInternalServerError)
return
}
pager := newPager(r, pageNo, pageSize, total)
if pager.Page != pageNo {
list, total, err = s.db.ListLogs(q, pageSize, (pager.Page-1)*pageSize)
if err != nil {
http.Error(w, "读取日志失败", http.StatusInternalServerError)
return
}
}
p := s.newPage(r, "操作日志", "logs")
p.D = &logsData{List: list, Pager: pager, Q: q, Total: total}
s.render(w, r, "logs", p)
}
type settingsData struct {
WarnDays int
ScreenRefresh int
Users []store.User
Backups []string
DBPath string
BackupDir string
BackupKeep int
BackupEnabled bool
ConfigPath string
ScreenPublic bool
Addrs []string
Port string
Uptime string
LogCount int
Version string
CustomerCount int
PackageCount int
}
func humanDuration(d time.Duration) string {
secs := int(d.Seconds())
days := secs / 86400
hours := (secs % 86400) / 3600
mins := (secs % 3600) / 60
switch {
case days > 0:
return fmt.Sprintf("%d 天 %d 小时", days, hours)
case hours > 0:
return fmt.Sprintf("%d 小时 %d 分钟", hours, mins)
default:
return fmt.Sprintf("%d 分钟", mins)
}
}
func LocalAddrs() []string {
out := []string{}
ifaces, err := net.Interfaces()
if err != nil {
return out
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
var ip net.IP
switch v := a.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil {
continue
}
ip4 := ip.To4()
if ip4 == nil || ip4.IsLoopback() {
continue
}
ipStr := ip4.String()
if strings.HasPrefix(ipStr, "169.254.") {
continue
}
out = append(out, ipStr)
}
}
return out
}
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
users, _ := s.db.ListUsers()
backups, _ := s.db.ListBackups()
custN, _ := s.db.CustomerCount()
pkgs, _ := s.db.ListPackages(true)
logN, _ := s.db.LogCount()
port := s.cfg.Addr
if strings.HasPrefix(port, ":") {
port = port[1:]
}
data := &settingsData{
WarnDays: s.warnDays(),
ScreenRefresh: s.screenRefresh(),
Users: users,
Backups: backups,
DBPath: s.db.Path,
BackupDir: s.cfg.BackupDir,
BackupKeep: s.cfg.BackupKeep,
BackupEnabled: s.cfg.BackupEnabled,
ConfigPath: s.cfg.ConfigPath,
ScreenPublic: s.cfg.ScreenPublic,
Addrs: LocalAddrs(),
Port: port,
Uptime: humanDuration(time.Since(s.started)),
LogCount: logN,
Version: s.cfg.Version,
CustomerCount: custN,
PackageCount: len(pkgs),
}
p := s.newPage(r, "设置", "settings")
p.D = data
s.render(w, r, "settings", p)
}
func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
warn := s.formInt(r, "warn_days", 3)
if warn < 0 {
warn = 0
}
if warn > 60 {
warn = 60
}
refresh := s.formInt(r, "screen_refresh", 30)
if refresh < 5 {
refresh = 5
}
if refresh > 600 {
refresh = 600
}
_ = s.db.SetSetting("warn_days", strconv.Itoa(warn))
_ = s.db.SetSetting("screen_refresh", strconv.Itoa(refresh))
s.logOp(r, "修改设置", "settings", 0,
fmt.Sprintf("预警 %d 天、大屏刷新 %d 秒", warn, refresh))
s.redirect(w, r, "/settings", "设置已保存")
}
func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
old := r.PostFormValue("old_password")
pw := r.PostFormValue("password")
pw2 := r.PostFormValue("password2")
if u == nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if _, err := s.db.Authenticate(u.Username, old); err != nil {
s.redirect(w, r, "/settings", "原密码不正确")
return
}
if len(pw) < 6 {
s.redirect(w, r, "/settings", "新密码至少 6 位")
return
}
if pw != pw2 {
s.redirect(w, r, "/settings", "两次输入的新密码不一致")
return
}
if err := s.db.UpdatePassword(u.ID, pw); err != nil {
s.redirect(w, r, "/settings", "修改失败:"+err.Error())
return
}
s.logOp(r, "修改密码", "user", u.ID, "管理员 "+u.Username+" 修改了自己的密码")
s.redirect(w, r, "/settings", "密码已更新")
}
func (s *Server) handleUserCreate(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PostFormValue("username"))
display := strings.TrimSpace(r.PostFormValue("display_name"))
pw := r.PostFormValue("password")
switch {
case len(username) < 3:
s.redirect(w, r, "/settings", "用户名至少 3 个字符")
return
case len(pw) < 6:
s.redirect(w, r, "/settings", "密码至少 6 位")
return
}
if display == "" {
display = username
}
u, err := s.db.CreateUser(username, display, pw)
if err != nil {
if err == store.ErrDuplicate {
s.redirect(w, r, "/settings", "用户名已存在")
return
}
s.redirect(w, r, "/settings", "创建失败:"+err.Error())
return
}
s.logOp(r, "新增管理员", "user", u.ID, "新增管理员 "+username)
s.redirect(w, r, "/settings", "管理员 "+username+" 已创建")
}
func (s *Server) handleUserToggle(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
me := s.currentUser(r)
if me != nil && me.ID == id {
s.redirect(w, r, "/settings", "不能停用自己的账户")
return
}
target, err := s.db.UserByID(id)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.db.SetUserActive(id, !target.IsActive); err != nil {
s.redirect(w, r, "/settings", "操作失败:"+err.Error())
return
}
action := "停用账户"
if !target.IsActive {
action = "启用账户"
}
s.logOp(r, action, "user", id, action+" "+target.Username)
s.redirect(w, r, "/settings", action+"成功")
}
func (s *Server) handleBackupNow(w http.ResponseWriter, r *http.Request) {
path, err := s.db.BackupNow(s.cfg.BackupKeep)
if err != nil {
s.redirect(w, r, "/settings", "备份失败:"+err.Error())
return
}
s.logOp(r, "备份数据", "backup", 0, "手动备份 "+path)
s.redirect(w, r, "/settings", "已备份:"+path)
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
kind := strings.TrimSuffix(r.PathValue("kind"), ".csv")
var set *store.ExportRowSet
var err error
switch kind {
case "customers":
set, err = s.db.ExportCustomers()
case "subscriptions":
set, err = s.db.ExportSubscriptions(domain.Today(), s.warnDays())
case "logs":
set, err = s.db.ExportLogs()
default:
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "导出失败:"+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=\"daili-%s-%s.csv\"", kind, time.Now().Format("20060102")))
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
cw := csv.NewWriter(w)
_ = cw.Write(set.Header)
for _, row := range set.Rows {
_ = cw.Write(row)
}
cw.Flush()
s.logOp(r, "导出数据", "export", 0, "导出 "+kind+"("+strconv.Itoa(len(set.Rows))+" 行)")
}
+156
View File
@@ -0,0 +1,156 @@
package web
import (
"net/http"
"strconv"
"strings"
"daili/internal/domain"
"daili/internal/store"
)
type packagesData struct {
List []store.Package
}
func (s *Server) handlePackages(w http.ResponseWriter, r *http.Request) {
list, err := s.db.ListPackages(true)
if err != nil {
http.Error(w, "读取套餐失败", http.StatusInternalServerError)
return
}
p := s.newPage(r, "套餐", "packages")
p.D = &packagesData{List: list}
s.render(w, r, "packages", p)
}
type packageFormData struct {
Package *store.Package
IsNew bool
}
func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新增套餐", "packages")
p.D = &packageFormData{
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#2F5D50"},
IsNew: true,
}
s.render(w, r, "package_form", p)
}
func readPackageForm(r *http.Request) *store.Package {
p := &store.Package{
Name: strings.TrimSpace(r.PostFormValue("name")),
Unit: strings.TrimSpace(r.PostFormValue("unit")),
DefaultAmount: 0,
Color: strings.TrimSpace(r.PostFormValue("color")),
Note: strings.TrimSpace(r.PostFormValue("note")),
SortOrder: 0,
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("default_amount"))); err == nil {
p.DefaultAmount = v
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("sort_order"))); err == nil {
p.SortOrder = v
}
if price := strings.TrimSpace(r.PostFormValue("price")); price != "" {
if f, err := strconv.ParseFloat(price, 64); err == nil {
p.PriceCents = int(f*100 + 0.5)
p.HasPrice = true
}
}
if p.Unit == "" {
p.Unit = domain.UnitDay
}
return p
}
func validatePackage(p *store.Package) string {
if p.Name == "" {
return "套餐名称不能为空"
}
if len([]rune(p.Name)) > 32 {
return "套餐名称最长 32 字"
}
if p.Unit != domain.UnitDay && p.Unit != domain.UnitCount {
return "套餐类型不正确"
}
if p.DefaultAmount <= 0 {
return "默认天数/次数必须大于 0"
}
if p.Color == "" || !strings.HasPrefix(p.Color, "#") {
return "请选择套餐颜色"
}
return ""
}
func (s *Server) handlePackageCreate(w http.ResponseWriter, r *http.Request) {
pkg := readPackageForm(r)
p := s.newPage(r, "新增套餐", "packages")
if msg := validatePackage(pkg); msg != "" {
p.D = &packageFormData{Package: pkg, IsNew: true}
s.fail(w, r, "package_form", msg, p)
return
}
id, err := s.db.CreatePackage(pkg)
if err != nil {
p.D = &packageFormData{Package: pkg, IsNew: true}
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
return
}
s.logOp(r, "新增套餐", "package", id, "新增套餐 "+pkg.Name)
s.redirect(w, r, "/packages", "套餐已创建,现在可以给客户开通了")
}
func (s *Server) handlePackageEditPage(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
p := s.newPage(r, "编辑套餐", "packages")
p.D = &packageFormData{Package: pkg, IsNew: false}
s.render(w, r, "package_form", p)
}
func (s *Server) handlePackageUpdate(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg := readPackageForm(r)
pkg.ID = id
p := s.newPage(r, "编辑套餐", "packages")
if msg := validatePackage(pkg); msg != "" {
p.D = &packageFormData{Package: pkg, IsNew: false}
s.fail(w, r, "package_form", msg, p)
return
}
if err := s.db.UpdatePackage(pkg); err != nil {
p.D = &packageFormData{Package: pkg, IsNew: false}
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
return
}
s.logOp(r, "编辑套餐", "package", id, "修改套餐 "+pkg.Name)
s.redirect(w, r, "/packages", "套餐已保存")
}
func (s *Server) handlePackageArchive(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
archived := !pkg.Archived
if err := s.db.SetPackageArchived(id, archived); err != nil {
s.redirect(w, r, "/packages", "操作失败:"+err.Error())
return
}
action := "归档套餐"
msg := "套餐已归档,不再允许新订阅(已有订阅不受影响)"
if !archived {
action = "取消归档"
msg = "套餐已恢复"
}
s.logOp(r, action, "package", id, action+" "+pkg.Name)
s.redirect(w, r, "/packages", msg)
}
+134
View File
@@ -0,0 +1,134 @@
package web
import (
"fmt"
"net/http"
"time"
"daili/internal/domain"
"daili/internal/store"
)
type screenDay struct {
Date string
Day int
InMonth bool
IsToday bool
IsPast bool
Due int
Heat int
}
type screenData struct {
Stats *store.Stats
MonthTitle string
Year, Month int
Weeks [][]screenDay
Weekdays []string
Top []SubView
DueToday []SubView
TodayList []SubView
Recent []store.Adjustment
TodayTasks int
TodayDone int
Refresh int
Version string
}
func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
warn := s.warnDays()
stats, err := s.db.ScreenStats(today, warn)
if err != nil {
http.Error(w, "读取大屏数据失败", http.StatusInternalServerError)
return
}
data := &screenData{
Stats: stats,
MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())),
Year: today.Year(),
Month: int(today.Month()),
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
Top: subViews(stats.Top, today, warn),
DueToday: subViews(stats.DueTodaySubs, today, warn),
Refresh: s.screenRefresh(),
Version: s.cfg.Version,
}
if len(data.DueToday) > 6 {
data.DueToday = data.DueToday[:6]
}
// 本月热力
monthStart := domain.MonthStart(today.Year(), today.Month())
monthEnd := domain.MonthEnd(today.Year(), today.Month())
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
for wi := 0; wi < weeks; wi++ {
week := make([]screenDay, 0, 7)
for di := 0; di < 7; di++ {
d := domain.AddDays(gridStart, wi*7+di)
ds := domain.Date(d)
due := stats.Heat[ds]
week = append(week, screenDay{
Date: ds,
Day: d.Day(),
InMonth: d.Month() == today.Month(),
IsToday: ds == domain.Date(today),
IsPast: d.Before(today),
Due: due,
Heat: heatLevel(due),
})
}
data.Weeks = append(data.Weeks, week)
}
if adj, err := s.db.RecentAdjustments(6); err == nil {
data.Recent = adj
}
list, done := s.todayTaskList(today)
data.TodayTasks = len(list)
data.TodayDone = done
data.TodayList = subViews(list, today, warn)
p := s.newPage(r, "大屏", "screen")
p.D = data
s.render(w, r, "screen", p)
}
// todayTaskList 返回今天应服务的订阅与已完成数量。
func (s *Server) todayTaskList(today time.Time) ([]store.Subscription, int) {
ts := domain.Date(today)
subs, err := s.db.CalendarSubs(ts, ts)
if err != nil {
return nil, 0
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, _ := s.db.PausesForSubs(ids)
checkins, _ := s.db.CheckinsBetween(ts, ts)
warn := s.warnDays()
out := []store.Subscription{}
done := 0
for _, sub := range subs {
if sub.Status == domain.StatusClosed {
continue
}
if sub.StartDate > ts || sub.EndDate == "" || sub.EndDate < ts {
continue
}
cs := calSub{sub: sub, pauses: pauses[sub.ID], hasEnd: true, eval: sub.Eval(today, warn)}
if sub.Unit == domain.UnitCount && cs.eval.RemainingCount <= 0 {
continue
}
if cs.pausedOn(ts) {
continue
}
out = append(out, sub)
if ci, ok := checkins[sub.ID][ts]; ok && ci.Status == "done" {
done++
}
}
return out, done
}
+185
View File
@@ -0,0 +1,185 @@
package web
import (
"fmt"
"net/http"
"strings"
"daili/internal/domain"
"daili/internal/store"
)
type subsData struct {
List []SubView
Pager Pager
State string
PackageID int64
Q string
Packages []store.Package
Total int
}
func (s *Server) handleSubscriptions(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
warn := s.warnDays()
state := r.URL.Query().Get("state")
if state == "" {
state = "active"
}
packageID := int64(s.queryInt(r, "package_id", 0))
q := r.URL.Query().Get("q")
pageNo := s.queryInt(r, "page", 1)
if pageNo < 1 {
pageNo = 1
}
f := store.SubFilter{Q: q, State: state, PackageID: packageID, Limit: pageSize, Offset: (pageNo - 1) * pageSize, Today: today}
list, total, err := s.db.ListSubs(f)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
pager := newPager(r, pageNo, pageSize, total)
if pager.Page != pageNo {
f.Offset = (pager.Page - 1) * pageSize
list, total, err = s.db.ListSubs(f)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
}
pkgs, _ := s.db.ListPackages(true)
data := &subsData{
List: subViews(list, today, warn), Pager: pager, State: state,
PackageID: packageID, Q: q, Packages: pkgs, Total: total,
}
p := s.newPage(r, "订阅", "subscriptions")
p.D = data
s.render(w, r, "subscriptions", p)
}
func (s *Server) handleSubExtend(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
u := s.currentUser(r)
var opID int64
if u != nil {
opID = u.ID
}
note := strings.TrimSpace(r.PostFormValue("note"))
back := backURL(r, "/customers/"+itoa(sub.CustomerID))
if sub.Unit == domain.UnitDay {
newEnd := strings.TrimSpace(r.PostFormValue("new_end_date"))
plan, err := s.db.ExtendDays(id, newEnd, note, opID)
if err != nil {
s.redirect(w, r, back+"#extend", "追加失败:"+err.Error())
return
}
s.logOp(r, "追加时长", "subscription", id, fmt.Sprintf("%s 追加 %d 天:%s → %s(起点 %s)",
sub.SubTitle(), plan.Days, sub.EndDate, domain.Date(plan.NewEnd), domain.Date(plan.Base)))
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 天,新结束日 %s", plan.Days, domain.Date(plan.NewEnd)))
return
}
add := s.formInt(r, "add_count", 0)
optEnd := strings.TrimSpace(r.PostFormValue("new_end_opt"))
if err := s.db.AddCount(id, add, optEnd, note, opID); err != nil {
s.redirect(w, r, back+"#extend", "追加失败:"+err.Error())
return
}
s.logOp(r, "追加次数", "subscription", id, fmt.Sprintf("%s 追加 %d 次", sub.SubTitle(), add))
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 次", add))
}
func (s *Server) handleSubAction(action string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
back := backURL(r, "/customers/"+itoa(sub.CustomerID))
switch action {
case "pause":
if err := s.db.PauseSub(id, 0); err != nil {
s.redirect(w, r, back, "暂停失败:"+err.Error())
return
}
s.logOp(r, "暂停订阅", "subscription", id, sub.SubTitle()+" 暂停")
s.redirect(w, r, back, "已暂停,剩余时长将冻结")
case "resume":
shift, err := s.db.ResumeSub(id, 0)
if err != nil {
s.redirect(w, r, back, "恢复失败:"+err.Error())
return
}
msg := "已恢复"
if shift > 0 {
msg = fmt.Sprintf("已恢复,结束日顺延 %d 天", shift)
}
s.logOp(r, "恢复订阅", "subscription", id, fmt.Sprintf("%s 恢复,顺延 %d 天", sub.SubTitle(), shift))
s.redirect(w, r, back, msg)
case "close":
if err := s.db.CloseSub(id); err != nil {
s.redirect(w, r, back, "结束失败:"+err.Error())
return
}
s.logOp(r, "结束订阅", "subscription", id, sub.SubTitle()+" 结束")
s.redirect(w, r, back, "订阅已结束")
case "reopen":
if err := s.db.ReopenSub(id); err != nil {
s.redirect(w, r, back, "启用失败:"+err.Error())
return
}
s.logOp(r, "重新启用", "subscription", id, sub.SubTitle()+" 重新启用")
s.redirect(w, r, back, "订阅已重新启用")
default:
http.NotFound(w, r)
}
}
}
func (s *Server) handleCheckinSave(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
back := backURL(r, "/calendar?d="+r.PostFormValue("date"))
date := strings.TrimSpace(r.PostFormValue("date"))
status := strings.TrimSpace(r.PostFormValue("status"))
note := strings.TrimSpace(r.PostFormValue("note"))
u := s.currentUser(r)
var opID int64
if u != nil {
opID = u.ID
}
if err := s.db.SaveCheckin(id, date, status, note, opID); err != nil {
s.redirect(w, r, back, "打卡失败:"+err.Error())
return
}
label := "已完成"
if status == "skipped" {
label = "已跳过"
}
s.logOp(r, "打卡", "subscription", id, fmt.Sprintf("%s %s 打卡:%s", sub.SubTitle(), date, label))
s.redirect(w, r, back, "已记录:"+sub.CustomerTail+" "+sub.CustomerName+" "+date)
}
func (s *Server) handleCheckinDelete(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
back := backURL(r, "/calendar")
subID, err := s.db.DeleteCheckin(id, 0)
if err != nil {
s.redirect(w, r, back, "撤销失败:"+err.Error())
return
}
s.logOp(r, "撤销打卡", "subscription", subID, "撤销打卡记录 #"+itoa(id))
s.redirect(w, r, back, "已撤销打卡")
}
+477
View File
@@ -0,0 +1,477 @@
// Package web 提供 HTTP 路由、会话、模板渲染与页面处理器。
package web
import (
"bytes"
"context"
"crypto/rand"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"daili/internal/domain"
"daili/internal/store"
)
//go:embed templates/_layout.html templates/_screen_layout.html templates/_partials.html templates/pages/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
const (
cookieSession = "daili_sid"
cookieFlash = "daili_flash"
sessionTTL = 7 * 24 * time.Hour
pageSize = 20
)
// Config 是服务配置。
type Config struct {
Addr string
DBPath string
BackupKeep int
BackupDir string
BackupEnabled bool
ScreenPublic bool
ConfigPath string
Version string
}
// Server 是应用服务。
type Server struct {
db *store.DB
cfg Config
tpl *renderer
handler http.Handler
started time.Time
}
type ctxKey int
const userCtxKey ctxKey = 1
// New 构造服务。
func New(db *store.DB, cfg Config) (*Server, error) {
r, err := newRenderer()
if err != nil {
return nil, err
}
s := &Server{db: db, cfg: cfg, tpl: r, started: time.Now()}
s.handler = s.routes()
return s, nil
}
// Handler 返回 HTTP 处理器。
func (s *Server) Handler() http.Handler { return s.handler }
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic(err)
}
mux.Handle("GET /static/", cacheControl(http.StripPrefix("/static/", http.FileServerFS(sub))))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginPost)
mux.HandleFunc("GET /setup", s.handleSetupPage)
mux.HandleFunc("POST /setup", s.handleSetupPost)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/calendar", http.StatusFound)
})
mux.HandleFunc("GET /calendar", s.auth(s.handleCalendar))
mux.HandleFunc("GET /customers", s.auth(s.handleCustomers))
mux.HandleFunc("GET /customers/new", s.auth(s.handleCustomerNewPage))
mux.HandleFunc("POST /customers", s.auth(s.handleCustomerCreate))
mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail))
mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage))
mux.HandleFunc("POST /customers/{id}/edit", s.auth(s.handleCustomerUpdate))
mux.HandleFunc("POST /customers/{id}/subscriptions", s.auth(s.handleSubCreate))
mux.HandleFunc("GET /subscriptions", s.auth(s.handleSubscriptions))
mux.HandleFunc("POST /subscriptions/{id}/extend", s.auth(s.handleSubExtend))
mux.HandleFunc("POST /subscriptions/{id}/pause", s.auth(s.handleSubAction("pause")))
mux.HandleFunc("POST /subscriptions/{id}/resume", s.auth(s.handleSubAction("resume")))
mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close")))
mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen")))
mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave))
mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete))
mux.HandleFunc("GET /packages", s.auth(s.handlePackages))
mux.HandleFunc("GET /packages/new", s.auth(s.handlePackageNewPage))
mux.HandleFunc("POST /packages", s.auth(s.handlePackageCreate))
mux.HandleFunc("GET /packages/{id}/edit", s.auth(s.handlePackageEditPage))
mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate))
mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive))
if s.cfg.ScreenPublic {
// 大屏免登录:仅局域网部署时使用,内容为只读看板
mux.HandleFunc("GET /screen", s.handleScreen)
} else {
mux.HandleFunc("GET /screen", s.auth(s.handleScreen))
}
mux.HandleFunc("GET /logs", s.auth(s.handleLogs))
mux.HandleFunc("GET /settings", s.auth(s.handleSettings))
mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave))
mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange))
mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate))
mux.HandleFunc("POST /settings/users/{id}/toggle", s.auth(s.handleUserToggle))
mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow))
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
return s.noCacheHTML(mux)
}
func cacheControl(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=300")
h.ServeHTTP(w, r)
})
}
func (s *Server) noCacheHTML(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/static/") {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
}
h.ServeHTTP(w, r)
})
}
// ---------- 会话与鉴权 ----------
func randomToken() string {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
return hex.EncodeToString(b)
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User) {
token := randomToken()
if err := s.db.CreateSession(token, u.ID, sessionTTL, clientIP(r)); err != nil {
log.Printf("创建会话失败: %v", err)
}
http.SetCookie(w, &http.Cookie{
Name: cookieSession, Value: token, Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()),
})
}
func (s *Server) userFromRequest(r *http.Request) *store.User {
c, err := r.Cookie(cookieSession)
if err != nil || c.Value == "" {
return nil
}
u, err := s.db.SessionUser(c.Value)
if err != nil {
return nil
}
return u
}
func (s *Server) currentUser(r *http.Request) *store.User {
if u, ok := r.Context().Value(userCtxKey).(*store.User); ok {
return u
}
return nil
}
func sameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return strings.EqualFold(u.Host, r.Host)
}
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && !sameOrigin(r) {
http.Error(w, "跨站请求被拒绝", http.StatusForbidden)
return
}
u := s.userFromRequest(r)
if u == nil {
if r.Method == http.MethodGet {
next := url.QueryEscape(r.URL.RequestURI())
http.Redirect(w, r, "/login?next="+next, http.StatusFound)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), userCtxKey, u)
h(w, r.WithContext(ctx))
}
}
// ---------- 渲染 ----------
type renderer struct {
pages map[string]*template.Template
}
func newRenderer() (*renderer, error) {
funcs := template.FuncMap{
"seq": func(n int) []int {
out := make([]int, 0, n)
for i := 0; i < n; i++ {
out = append(out, i)
}
return out
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"money": func(cents int) string {
return fmt.Sprintf("%.0f", float64(cents)/100)
},
"pct": func(f float64) string { return fmt.Sprintf("%.2f", f) },
"md": func(s string) string { return mdOf(s) },
"dow": dowCN,
}
base, err := template.New("base").Funcs(funcs).ParseFS(templateFS,
"templates/_layout.html", "templates/_partials.html")
if err != nil {
return nil, err
}
screenBase, err := template.New("screen").Funcs(funcs).ParseFS(templateFS,
"templates/_screen_layout.html", "templates/_partials.html")
if err != nil {
return nil, err
}
pages := map[string]*template.Template{}
entries, err := fs.Glob(templateFS, "templates/pages/*.html")
if err != nil {
return nil, err
}
for _, p := range entries {
name := strings.TrimSuffix(strings.TrimPrefix(p, "templates/pages/"), ".html")
var baseSet *template.Template
if name == "screen" {
baseSet = screenBase
} else {
baseSet = base
}
clone, err := baseSet.Clone()
if err != nil {
return nil, err
}
if _, err := clone.ParseFS(templateFS, p); err != nil {
return nil, fmt.Errorf("解析模板 %s 失败: %w", p, err)
}
pages[name] = clone
}
return &renderer{pages: pages}, nil
}
func mdOf(s string) string {
if len(s) >= 10 {
return s[5:10]
}
return s
}
func dowCN(s string) string {
t, err := domain.ParseDate(s)
if err != nil {
return ""
}
return [...]string{"一", "二", "三", "四", "五", "六", "日"}[domain.WeekdayIndex(t)]
}
// page 是所有页面模板的根数据。
type page struct {
User *store.User
Title string
Nav string
Flash string
Error string
Today time.Time
TodayStr string
WarnDays int
Version string
Query string
Next string
DueToday int
Due3 int
Addr string
D any
}
func (s *Server) warnDays() int {
v, err := strconv.Atoi(s.db.Setting("warn_days", "3"))
if err != nil || v <= 0 {
return 3
}
return v
}
func (s *Server) screenRefresh() int {
v, err := strconv.Atoi(s.db.Setting("screen_refresh", "30"))
if err != nil || v < 5 {
return 30
}
return v
}
func (s *Server) newPage(r *http.Request, title, nav string) *page {
u := s.currentUser(r)
today := domain.Today()
p := &page{
User: u,
Title: title,
Nav: nav,
Today: today,
TodayStr: domain.Date(today),
WarnDays: s.warnDays(),
Version: s.cfg.Version,
Query: r.URL.Query().Get("q"),
Next: r.URL.Query().Get("next"),
}
if u != nil {
if due, due3, err := s.db.Badges(today); err == nil {
p.DueToday, p.Due3 = due, due3
}
}
return p
}
func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(cookieFlash)
if err != nil || c.Value == "" {
return ""
}
msg, _ := url.QueryUnescape(c.Value)
http.SetCookie(w, &http.Cookie{Name: cookieFlash, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
return msg
}
func setFlash(w http.ResponseWriter, msg string) {
http.SetCookie(w, &http.Cookie{
Name: cookieFlash, Value: url.QueryEscape(msg), Path: "/",
MaxAge: 30, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) redirect(w http.ResponseWriter, r *http.Request, to, flash string) {
if flash != "" {
setFlash(w, flash)
}
http.Redirect(w, r, to, http.StatusSeeOther)
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, d *page) {
if d.User == nil {
d.User = s.currentUser(r)
}
if d.Flash == "" {
d.Flash = s.popFlash(w, r)
}
if d.Today.IsZero() {
d.Today = domain.Today()
d.TodayStr = domain.Date(d.Today)
}
if d.WarnDays == 0 {
d.WarnDays = s.warnDays()
}
tpl, ok := s.tpl.pages[name]
if !ok {
log.Printf("模板不存在: %s", name)
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
layout := "layout"
if name == "screen" {
layout = "screenlayout"
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, layout, d); err != nil {
log.Printf("渲染 %s 失败: %v", name, err)
http.Error(w, "页面渲染失败: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = buf.WriteTo(w)
}
func (s *Server) fail(w http.ResponseWriter, r *http.Request, name string, msg string, d *page) {
if d == nil {
d = s.newPage(r, "出错了", "")
}
d.Error = msg
w.WriteHeader(http.StatusBadRequest)
s.render(w, r, name, d)
}
// ---------- 参数 ----------
func (s *Server) queryInt(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil {
return def
}
return v
}
func (s *Server) formInt(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue(key)))
if err != nil {
return def
}
return v
}
func pathID(r *http.Request) int64 {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
return 0
}
return id
}
// backURL 取安全的返回地址。
func backURL(r *http.Request, def string) string {
if b := strings.TrimSpace(r.PostFormValue("back")); strings.HasPrefix(b, "/") && !strings.HasPrefix(b, "//") {
return b
}
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil && strings.EqualFold(u.Host, r.Host) {
p := u.RequestURI()
if strings.HasPrefix(p, "/") {
return p
}
}
}
return def
}
+688
View File
@@ -0,0 +1,688 @@
/* ============================================================
代打台 · 管理端(浅色「接单台」)
纸感底色 + 墨色文字 + 松墨绿主色;色签(ticket)是唯一强装饰
============================================================ */
:root {
--paper: #f7f6f2;
--surface: #ffffff;
--ink: #1a1c21;
--ink-2: #43474f;
--muted: #7a7e86;
--line: #e3e0d6;
--line-2: #efede6;
--accent: #2f5d50;
--accent-ink: #22453b;
--accent-soft: #e7efea;
--danger: #b4231f;
--danger-soft: #fbeceb;
--warn: #a85e12;
--warn-soft: #fbf1e3;
--info: #41556b;
--info-soft: #eaeef2;
--radius: 3px;
--mono: "Bahnschrift", "Consolas", "SF Mono", ui-monospace, monospace;
--sans: "Microsoft YaHei UI", "PingFang SC", "Hiragino Sans GB", system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; padding: 0; }
body {
background:
linear-gradient(var(--line-2) 1px, transparent 1px) 0 0 / 100% 32px,
var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 2px; }
h1, h2, h3 { margin: 0; font-weight: 700; letter-spacing: -0.01em; }
h1 { font-size: 22px; }
h2 { font-size: 15px; }
h3 { font-size: 13px; }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
.small { font-size: 12px; }
.muted { color: var(--muted); }
.right { text-align: right; }
/* ---------- 顶栏 ---------- */
.topbar {
position: sticky; top: 0; z-index: 40;
display: flex; align-items: center; gap: 14px;
padding: 0 20px; height: 56px;
background: rgba(247, 246, 242, 0.94);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 8px; color: var(--ink); text-decoration: none; }
.brand:hover { text-decoration: none; }
.brand-mark {
display: grid; place-items: center;
width: 26px; height: 26px;
background: var(--accent); color: #fff;
font-size: 14px; font-weight: 700;
border-radius: 2px;
box-shadow: 2px 2px 0 rgba(47, 93, 80, 0.18);
}
.brand-name { font-weight: 700; letter-spacing: 0.06em; }
.search { position: relative; flex: 1 1 260px; max-width: 380px; }
.search input {
width: 100%; height: 34px;
padding: 0 34px 0 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
font: inherit; color: var(--ink);
}
.search input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.search kbd {
position: absolute; right: 8px; top: 7px;
font-family: var(--mono); font-size: 11px;
color: var(--muted);
border: 1px solid var(--line); border-bottom-width: 2px;
border-radius: 3px; padding: 0 5px; background: var(--paper);
}
.nav { display: flex; gap: 2px; }
.nav a {
padding: 6px 10px; border-radius: var(--radius);
color: var(--ink-2); font-size: 13px;
}
.nav a:hover { background: var(--surface); text-decoration: none; }
.nav a.on { background: var(--accent); color: #fff; }
.nav a.nav-screen { color: var(--accent); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.pill {
display: inline-flex; align-items: center; gap: 5px;
height: 28px; padding: 0 10px;
border: 1px solid var(--line); border-radius: 999px;
background: var(--surface);
font-size: 12px; color: var(--ink-2); white-space: nowrap;
}
.pill b { font-family: var(--mono); font-size: 13px; }
.pill:hover { text-decoration: none; border-color: var(--accent); }
.pill-due b { color: var(--danger); }
.pill-warn b { color: var(--warn); }
.me { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.me a { color: var(--ink-2); }
.link-btn {
border: 0; background: none; padding: 0; cursor: pointer;
color: var(--muted); font: inherit; font-size: 12px;
}
.link-btn:hover { color: var(--danger); }
/* ---------- 提示条 ---------- */
.flash {
position: fixed; z-index: 90;
left: 50%; top: 68px; transform: translateX(-50%);
max-width: min(680px, calc(100vw - 32px));
padding: 9px 16px;
background: var(--accent); color: #fff;
border-radius: var(--radius);
box-shadow: 0 6px 24px rgba(26, 28, 33, 0.16);
font-size: 13px;
animation: flash-in 0.22s ease-out;
}
.flash-error { background: var(--danger); }
.flash.hide { opacity: 0; transition: opacity 0.4s ease; }
@keyframes flash-in { from { opacity: 0; transform: translate(-50%, -6px); } }
/* ---------- 布局 ---------- */
main { padding: 0 20px 40px; }
main.with-top { padding-top: 22px; }
.page { max-width: 1280px; margin: 0 auto; }
.page.narrow { max-width: 860px; }
.page-head {
display: flex; align-items: flex-end; justify-content: space-between;
gap: 16px; flex-wrap: wrap; margin-bottom: 16px;
}
.page-head p { margin: 4px 0 0; font-size: 12px; }
.page-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.foot {
max-width: 1280px; margin: 0 auto; padding: 18px 20px 28px;
font-size: 12px; color: var(--muted);
border-top: 1px solid var(--line);
}
.notice {
margin: 0 0 14px; padding: 10px 14px;
background: var(--warn-soft); border-left: 3px solid var(--warn);
font-size: 13px; border-radius: 0 var(--radius) var(--radius) 0;
}
.notice.note { background: var(--accent-soft); border-left-color: var(--accent); white-space: pre-wrap; }
/* ---------- 按钮与表单 ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
height: 34px; padding: 0 14px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); color: var(--ink);
font: inherit; font-size: 13px; cursor: pointer;
white-space: nowrap; text-decoration: none;
}
.btn:hover { border-color: var(--ink-2); text-decoration: none; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-ink); border-color: var(--accent-ink); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--ink-2); }
.btn-ghost:hover { background: var(--surface); border-color: var(--line); }
.btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
.btn-square { width: 34px; padding: 0; font-size: 18px; line-height: 1; }
.btn-block { width: 100%; }
.btn.disabled { opacity: 0.4; pointer-events: none; }
form { margin: 0; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 14px; }
.field > span { font-size: 12px; color: var(--ink-2); font-weight: 600; }
.field > span i { color: var(--danger); font-style: normal; }
.field input[type="text"], .field input[type="password"], .field input[type="number"],
.field input[type="date"], .field input[type="search"], .field select, .field textarea {
width: 100%; min-height: 34px; padding: 6px 10px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); font: inherit; color: var(--ink);
}
.field textarea { resize: vertical; line-height: 1.6; }
.field input:focus, .field select:focus, .field textarea:focus {
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
.field input[type="color"] { width: 100%; height: 34px; padding: 2px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); cursor: pointer; }
.fieldset { border: 0; padding: 0; }
.radios { display: grid; gap: 8px; }
.radios label {
display: grid; grid-template-columns: 18px 1fr; grid-template-rows: auto auto;
column-gap: 10px; align-items: center;
padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); cursor: pointer;
}
.radios label:hover { border-color: var(--accent); }
.radios input { grid-row: span 2; margin: 0; accent-color: var(--accent); }
.radios b { font-size: 13px; }
.radios small { grid-column: 2; color: var(--muted); font-size: 12px; }
.input-suffix { position: relative; }
.input-suffix input { padding-right: 40px; }
.input-suffix b {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
font-size: 12px; color: var(--muted); font-weight: 400;
}
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 14px; }
.actions { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.actions.wrap { flex-wrap: wrap; }
.inline-form {
display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap;
margin-top: 14px; padding: 12px;
background: var(--surface); border: 1px dashed var(--line); border-radius: var(--radius);
}
.inline-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--ink-2); }
.inline-field input, .inline-field select {
height: 32px; padding: 0 8px; min-width: 130px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); font: inherit; font-size: 13px; color: var(--ink);
}
.inline-form select[name="package_id"] { min-width: 220px; }
.form.card, .section.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
}
.filterbar { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
.filterbar input[type="search"], .filterbar select {
height: 34px; padding: 0 10px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); font: inherit; color: var(--ink);
}
.filterbar input[type="search"] { flex: 1 1 260px; max-width: 420px; }
/* ---------- 认证页 ---------- */
.auth { min-height: 100vh; display: grid; place-items: center; padding: 32px 16px; }
.auth-card {
width: 100%; max-width: 380px;
background: var(--surface); border: 1px solid var(--line);
border-top: 3px solid var(--accent);
padding: 28px;
box-shadow: 0 18px 40px -32px rgba(26, 28, 33, 0.5);
}
.auth-brand { display: flex; align-items: center; gap: 8px; font-weight: 700; letter-spacing: 0.06em; margin-bottom: 18px; }
.auth-card h1 { font-size: 20px; margin-bottom: 4px; }
.auth-card > p { font-size: 12px; margin: 0 0 18px; }
.auth-foot { margin-top: 16px !important; text-align: center; }
.form .btn-block { margin-top: 4px; }
/* ---------- 日历 ---------- */
.cal-page { max-width: 1400px; margin: 0 auto; }
.cal-head {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap; margin-bottom: 14px;
}
.cal-title { display: flex; align-items: center; gap: 10px; }
.cal-title h1 { font-size: 20px; font-family: var(--mono); letter-spacing: 0.02em; }
.cal-stats { display: flex; align-items: center; gap: 16px; font-size: 12px; color: var(--muted); flex-wrap: wrap; }
.cal-stats em {
font-family: var(--mono); font-style: normal; font-size: 16px;
color: var(--ink); margin-right: 4px;
}
.cal-body { display: grid; grid-template-columns: minmax(0, 1fr) 372px; gap: 16px; align-items: start; }
.cal-grid {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.cal-dow {
display: grid; grid-template-columns: repeat(7, 1fr);
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.cal-dow span {
padding: 7px 0; text-align: center;
font-size: 12px; color: var(--muted);
}
.cal-week { display: grid; grid-template-columns: repeat(7, 1fr); }
.cal-cell {
position: relative;
display: flex; flex-direction: column; gap: 3px;
min-height: 112px; padding: 6px 7px 8px;
border-right: 1px solid var(--line-2);
border-bottom: 1px solid var(--line-2);
color: var(--ink); text-decoration: none;
transition: background 0.12s ease;
}
.cal-cell:nth-child(7n) { border-right: 0; }
.cal-cell:hover { background: #f2f7f4; text-decoration: none; }
.cal-cell.out { opacity: 0.4; }
.cal-cell.weekend .dnum { color: var(--muted); }
.cal-cell.sel { box-shadow: inset 0 0 0 2px var(--accent); z-index: 2; }
.cal-cell.today .dnum {
background: var(--accent); color: #fff;
border-radius: 50%; width: 22px; height: 22px;
display: inline-grid; place-items: center;
}
.cal-cell.has-due::after {
content: ""; position: absolute; left: 6px; right: 6px; bottom: 3px;
border-bottom: 1px dashed rgba(180, 35, 31, 0.45);
}
.cal-cell.heat-1 { background: rgba(47, 93, 80, 0.045); }
.cal-cell.heat-2 { background: rgba(47, 93, 80, 0.085); }
.cal-cell.heat-3 { background: rgba(47, 93, 80, 0.13); }
.cal-cell.heat-4 { background: rgba(47, 93, 80, 0.19); }
.cal-cell.heat-0:hover, .cal-cell.heat-1:hover { background: #f2f7f4; }
.cell-top { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.dnum { font-family: var(--mono); font-size: 13px; font-weight: 700; line-height: 1.4; }
.due-badge {
display: inline-flex; align-items: baseline; gap: 2px;
font-size: 10px; padding: 0 5px; line-height: 16px;
background: var(--danger); color: #fff; border-radius: 2px;
white-space: nowrap;
}
.due-badge em { font-style: normal; opacity: 0.85; }
.cell-load { font-family: var(--mono); font-size: 10px; color: var(--accent); }
.cell-tickets { display: flex; flex-direction: column; gap: 2px; margin-top: auto; overflow: hidden; }
.ticket {
display: inline-flex; align-items: baseline; gap: 3px;
max-width: 100%; min-width: 0;
padding: 1px 5px;
font-size: 10.5px; line-height: 16px;
border-left: 3px solid var(--tag, var(--accent));
background: color-mix(in srgb, var(--tag, var(--accent)) 11%, #fff);
border-radius: 1px 2px 2px 1px;
white-space: nowrap;
color: var(--ink-2);
}
.ticket b { font-family: var(--mono); font-size: 11px; color: var(--ink); flex: 0 0 auto; }
.ticket .tk-name { overflow: hidden; text-overflow: ellipsis; }
.ticket i { font-style: normal; font-size: 9.5px; color: var(--muted); flex: 0 0 auto; }
.ticket.done { opacity: 0.55; }
.ticket.done::after { content: "✓"; color: var(--accent); font-size: 10px; }
.ticket.skipped { opacity: 0.4; text-decoration: line-through; }
.ticket.more { border-left-color: var(--line); background: var(--paper); color: var(--muted); }
/* ---------- 当日抽屉 ---------- */
.drawer {
position: sticky; top: 72px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface);
max-height: calc(100vh - 100px);
overflow-y: auto;
}
.drawer-head {
position: sticky; top: 0; z-index: 2;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 12px 14px;
background: var(--paper); border-bottom: 1px solid var(--line);
}
.drawer-head h2 { font-size: 14px; font-family: var(--mono); }
.drawer-tags { display: flex; gap: 4px; }
.drawer-block { padding: 12px 14px; border-bottom: 1px solid var(--line-2); }
.drawer-block:last-child { border-bottom: 0; }
.drawer-block h3 {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--ink-2); margin-bottom: 8px;
text-transform: none; letter-spacing: 0.02em;
}
.drawer-block h3 em {
font-family: var(--mono); font-style: normal; font-size: 11px;
background: var(--paper); border: 1px solid var(--line);
border-radius: 999px; padding: 0 6px; color: var(--muted);
}
.drawer-block p { margin: 0; font-size: 12px; }
.due-list, .task-list, .mini-due, .checkin-list { list-style: none; margin: 0; padding: 0; }
.due-list li {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 6px 0; border-bottom: 1px dashed var(--line-2);
}
.due-list li:last-child { border-bottom: 0; }
.due-main { display: flex; align-items: center; gap: 7px; min-width: 0; color: var(--ink); }
.due-main .pkg, .task-meta .pkg { font-size: 11px; color: var(--muted); }
.due-main .rem { font-family: var(--mono); font-size: 11px; color: var(--danger); }
.task { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; padding: 8px 0; border-bottom: 1px dashed var(--line-2); }
.task:last-child { border-bottom: 0; }
.task-main { min-width: 0; flex: 1 1 auto; }
.task-title { display: flex; align-items: center; gap: 6px; color: var(--ink); }
.task-meta { display: flex; gap: 6px; font-size: 11px; color: var(--muted); margin-top: 2px; }
.task-note { font-size: 11px; color: var(--ink-2); margin-top: 2px; }
.task-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; max-width: 170px; }
.task.done .task-title, .task.skipped .task-title { opacity: 0.5; }
.task.skipped .task-title .ticket { text-decoration: line-through; }
.mini-due li { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; align-items: center; padding: 4px 0; font-size: 12px; }
.sm-date { font-family: var(--mono); font-size: 11px; color: var(--muted); }
/* ---------- 状态标签 ---------- */
.chip {
display: inline-flex; align-items: center;
height: 20px; padding: 0 7px;
font-size: 11px; line-height: 1;
border-radius: 2px; border: 1px solid transparent;
white-space: nowrap;
}
.chip-active { background: var(--accent-soft); color: var(--accent-ink); border-color: rgba(47, 93, 80, 0.2); }
.chip-pending { background: var(--info-soft); color: var(--info); border-color: rgba(65, 85, 107, 0.2); }
.chip-paused { background: var(--info-soft); color: var(--info); border-color: rgba(65, 85, 107, 0.2); }
.chip-expired { background: var(--danger-soft); color: var(--danger); border-color: rgba(180, 35, 31, 0.22); }
.chip-usedup { background: var(--warn-soft); color: var(--warn); border-color: rgba(168, 94, 18, 0.22); }
.chip-closed { background: #f0efeb; color: var(--muted); border-color: var(--line); }
.chip-plain { background: var(--paper); color: var(--ink-2); border-color: var(--line); }
.tagchip {
display: inline-block; margin-right: 4px;
padding: 0 6px; height: 19px; line-height: 19px;
font-size: 11px; color: var(--ink-2);
background: var(--paper); border: 1px solid var(--line); border-radius: 2px;
}
.due { font-size: 11px; }
.due-today { color: var(--danger); font-weight: 700; }
.due-warn { color: var(--warn); }
.due-expired { color: var(--muted); }
.due-ok, .due-none { color: var(--muted); }
.plus { color: var(--accent); font-family: var(--mono); }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; vertical-align: 1px; }
.dot.big { width: 12px; height: 12px; margin: 0; }
/* ---------- 表格 ---------- */
.table-wrap { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th {
text-align: left; padding: 9px 12px;
font-size: 11px; font-weight: 600; color: var(--muted);
letter-spacing: 0.04em;
background: var(--paper);
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.table td { padding: 9px 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr:hover { background: #fbfcfb; }
.table.tight td, .table.tight th { padding: 7px 10px; }
.table .col-dot { width: 28px; }
.table tr.archived { opacity: 0.6; }
.actions-cell { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }
.tail {
display: inline-block; font-family: var(--mono); font-size: 15px; font-weight: 700;
color: var(--accent-ink); letter-spacing: 0.04em;
}
.name { font-weight: 600; color: var(--ink); }
.summary { font-size: 12px; color: var(--ink-2); }
.pager { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 16px; }
.pager-info { font-size: 12px; color: var(--muted); font-family: var(--mono); }
.empty {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 56px 20px; text-align: center;
border: 1px dashed var(--line); border-radius: var(--radius);
background: rgba(255, 255, 255, 0.55);
}
.empty p { margin: 0; color: var(--muted); font-size: 13px; }
.empty-mark {
width: 46px; height: 30px; display: grid; place-items: center;
border: 1px solid var(--line); border-left: 4px solid var(--accent);
background: var(--surface); color: var(--muted); font-size: 12px;
transform: rotate(-4deg);
}
/* ---------- 客户详情 ---------- */
.cust-head { display: grid; grid-template-columns: auto 1fr auto; gap: 20px; align-items: start; margin-bottom: 16px; }
.cust-id { display: flex; align-items: center; gap: 14px; }
.cust-tail {
font-family: var(--mono); font-size: 30px; font-weight: 700; line-height: 1;
padding: 8px 12px;
background: var(--surface); border: 1px solid var(--line);
border-left: 4px solid var(--accent);
letter-spacing: 0.06em;
}
.cust-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
.cust-meta { display: flex; flex-wrap: wrap; gap: 4px 20px; margin: 0; }
.cust-meta dt { font-size: 11px; color: var(--muted); }
.cust-meta dd { margin: 0; font-size: 12px; color: var(--ink-2); }
.section { margin-top: 22px; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
.section-head h2 { font-size: 14px; }
.section-head .muted { font-size: 11px; }
.section.card h2 { margin-bottom: 12px; }
.subgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(268px, 1fr)); gap: 12px; }
.subcard {
background: var(--surface);
border: 1px solid var(--line);
border-left: 4px solid var(--tag, var(--accent));
border-radius: var(--radius);
display: flex; flex-direction: column;
}
.subcard-head { display: flex; align-items: center; gap: 6px; padding: 10px 12px 6px; }
.subcard-head h3 { font-size: 13px; flex: 1 1 auto; }
.subcard-head .unit { font-size: 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 2px; padding: 0 4px; }
.subcard-body { padding: 0 12px 10px; }
.subcard-big { font-family: var(--mono); font-size: 20px; font-weight: 700; letter-spacing: 0.01em; }
.subcard-meta { font-family: var(--mono); font-size: 11px; color: var(--muted); margin-top: 2px; }
.bar { height: 5px; background: var(--line-2); border-radius: 999px; overflow: hidden; margin-top: 8px; }
.bar i { display: block; height: 100%; background: var(--tag, var(--accent)); }
.subcard-foot {
display: flex; gap: 4px; flex-wrap: wrap;
padding: 8px 12px; border-top: 1px dashed var(--line);
background: #fcfcfa;
}
.timeline { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 16px 16px 10px; }
.tl-axis { position: relative; height: 18px; margin-left: 92px; margin-right: 150px; border-bottom: 1px solid var(--line-2); }
.tl-tick { position: absolute; top: 0; font-family: var(--mono); font-size: 10px; color: var(--muted); transform: translateX(-50%); }
.tl-row { display: grid; grid-template-columns: 84px 1fr 142px; gap: 8px; align-items: center; padding: 5px 0; }
.tl-name { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-track { position: relative; height: 20px; background: var(--line-2); border-radius: 2px; }
.tl-bar { position: absolute; top: 4px; height: 12px; background: var(--tag); border-radius: 2px; }
.tl-bar.st-paused { opacity: 0.45; }
.tl-bar.st-closed { background: var(--muted); opacity: 0.6; }
.tl-pause {
position: absolute; top: 4px; height: 12px;
background: repeating-linear-gradient(135deg, #c9c7c0 0 4px, #e6e4dd 4px 8px);
border-radius: 1px;
}
.tl-due { position: absolute; top: 1px; bottom: 1px; width: 2px; background: var(--danger); }
.tl-legend { font-family: var(--mono); font-size: 10px; color: var(--muted); text-align: right; }
.checkin-list li { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px dashed var(--line-2); font-size: 12px; }
.checkin-list li:last-child { border-bottom: 0; }
/* ---------- 追加面板 ---------- */
.extend {
margin-top: 18px;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 12px 32px -26px rgba(26, 28, 33, 0.8);
scroll-margin-top: 80px;
}
.drawer .extend { margin: 12px 14px; }
.extend-head {
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
padding: 12px 14px;
background: var(--accent-soft);
border-bottom: 1px solid rgba(47, 93, 80, 0.2);
}
.extend-head h3 { font-size: 14px; }
.extend-head p { margin: 2px 0 0; font-size: 11px; }
.extend-close { font-size: 20px; line-height: 1; color: var(--ink-2); padding: 0 4px; }
.extend-form { padding: 14px; }
.extend-preview {
display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px;
padding: 10px; margin-bottom: 10px;
background: var(--paper); border: 1px dashed var(--line); border-radius: var(--radius);
}
.extend-preview div { display: flex; flex-direction: column; gap: 1px; }
.extend-preview span { font-size: 10px; color: var(--muted); }
.extend-preview b { font-family: var(--mono); font-size: 13px; font-weight: 600; }
.extend-preview b i { font-style: normal; font-size: 18px; color: var(--accent); }
.extend-preview .add b { color: var(--accent); }
.extend-hint { margin: 0 0 10px; font-size: 11px; color: var(--muted); }
.minis { display: grid; grid-template-columns: repeat(auto-fit, minmax(196px, 1fr)); gap: 12px; margin-bottom: 12px; }
.minimonth { border: 1px solid var(--line); border-radius: var(--radius); padding: 8px; }
.minimonth-title { font-size: 11px; color: var(--ink-2); font-weight: 600; margin-bottom: 6px; text-align: center; }
.minimonth-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.minimonth-grid .dow { text-align: center; font-size: 10px; color: var(--muted); }
.miniday {
height: 28px; border: 1px solid transparent; border-radius: 2px;
background: transparent; font: inherit; font-size: 11px; font-family: var(--mono);
color: var(--ink-2); cursor: pointer; padding: 0;
}
.miniday:hover:not(:disabled) { background: var(--accent-soft); border-color: rgba(47, 93, 80, 0.3); }
.miniday.out { color: #c3c1ba; }
.miniday.old { background: var(--warn-soft); border-color: rgba(168, 94, 18, 0.35); font-weight: 700; }
.miniday.today { outline: 1px solid var(--accent); }
.miniday.picked { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 700; }
.miniday:disabled { color: #d6d4cc; cursor: not-allowed; }
/* ---------- 设置页杂项 ---------- */
.kv { display: grid; gap: 6px; margin: 0 0 14px; }
.kv > div { display: grid; grid-template-columns: 88px 1fr; gap: 10px; align-items: baseline; }
.kv dt { font-size: 11px; color: var(--muted); }
.kv dd { margin: 0; font-size: 12px; word-break: break-all; }
.backups { margin-top: 12px; font-size: 12px; }
.backups summary { cursor: pointer; color: var(--ink-2); }
.backups ul { margin: 8px 0 0; padding-left: 18px; color: var(--muted); }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 14px; flex-wrap: wrap; }
.tabs a {
padding: 7px 12px; font-size: 13px; color: var(--ink-2);
border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.tabs a:hover { text-decoration: none; color: var(--ink); }
.tabs a.on { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
/* ---------- 响应式 ---------- */
@media (max-width: 1180px) {
.cal-body { grid-template-columns: 1fr; }
.drawer { position: static; max-height: none; }
.cust-head { grid-template-columns: 1fr; }
}
@media (max-width: 860px) {
.topbar { height: auto; flex-wrap: wrap; padding: 8px 12px; gap: 8px; }
.search { order: 3; flex-basis: 100%; max-width: none; }
.nav { order: 4; flex-basis: 100%; overflow-x: auto; }
.topbar-right { order: 2; margin-left: auto; }
main { padding: 0 12px 32px; }
.cal-grid { overflow-x: auto; }
.cal-week, .cal-dow { min-width: 680px; }
.cal-cell { min-height: 96px; }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.cust-tail { font-size: 24px; }
.tl-row { grid-template-columns: 70px 1fr; }
.tl-legend { display: none; }
.tl-axis { margin-right: 0; }
.extend-preview { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 560px) {
.pill { display: none; }
.cal-week, .cal-dow { min-width: 0; }
.cal-cell { min-height: 64px; padding: 3px; }
.dnum { font-size: 12px; }
.due-badge { font-size: 9px; padding: 0 4px; line-height: 14px; }
.due-badge em { display: none; }
.cell-load { display: none; }
.cell-tickets { display: none; }
.task { flex-direction: column; }
.task-actions { max-width: none; justify-content: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
+108
View File
@@ -0,0 +1,108 @@
/* 代打台 · 交互增强(无框架,全部渐进增强) */
(function () {
'use strict';
/* 提示条自动淡出 */
document.querySelectorAll('[data-flash]').forEach(function (el) {
setTimeout(function () {
el.classList.add('hide');
setTimeout(function () { el.remove(); }, 450);
}, 4200);
});
/* 按 / 聚焦搜索框 */
document.addEventListener('keydown', function (e) {
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
var tag = (document.activeElement && document.activeElement.tagName) || '';
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag)) return;
var s = document.querySelector('[data-search]');
if (s) { e.preventDefault(); s.focus(); s.select(); }
});
/* 危险操作确认 */
document.querySelectorAll('form[data-confirm]').forEach(function (f) {
f.addEventListener('submit', function (e) {
if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault();
});
});
/* 追加时长面板:点日历挑终止日 + 实时反算 */
function parseDay(s) { return new Date(s + 'T00:00:00'); }
function diffDays(a, b) { return Math.round((b.getTime() - a.getTime()) / 86400000); }
document.querySelectorAll('form[data-extend]').forEach(function (form) {
var base = parseDay(form.getAttribute('data-base'));
var today = parseDay(form.getAttribute('data-today'));
var input = form.querySelector('[data-end-input]');
var endLabel = form.querySelector('[data-end-label]');
var daysEl = form.querySelector('[data-days]');
var remainEl = form.querySelector('[data-remain]');
var buttons = form.querySelectorAll('.miniday');
if (!input) return;
function paint(date) {
if (!date) return;
var d = parseDay(date);
input.value = date;
if (endLabel) endLabel.textContent = date;
var days = diffDays(base, d) + 1;
if (daysEl) daysEl.textContent = days > 0 ? days : 0;
var remain = diffDays(today, d) + 1;
if (remainEl) remainEl.textContent = remain > 0 ? remain : 0;
buttons.forEach(function (b) {
b.classList.toggle('picked', b.getAttribute('data-date') === date);
});
}
buttons.forEach(function (b) {
b.addEventListener('click', function () {
if (!b.disabled) paint(b.getAttribute('data-date'));
});
});
input.addEventListener('change', function () { paint(input.value); });
if (input.value) paint(input.value);
});
/* 新订阅表单:按套餐类型切换「天数 / 次数 / 终止日」 */
document.querySelectorAll('form[data-sub-form]').forEach(function (form) {
var sel = form.querySelector('[data-pkg-select]');
var daysWrap = form.querySelector('[data-days-wrap]');
var countWrap = form.querySelector('[data-count-wrap]');
var endWrap = form.querySelector('[data-end-wrap]');
if (!sel) return;
function sync() {
var opt = sel.options[sel.selectedIndex];
var unit = opt && opt.getAttribute('data-unit');
var amount = opt && opt.getAttribute('data-amount');
if (unit === 'count') {
if (daysWrap) daysWrap.hidden = true;
if (countWrap) { countWrap.hidden = false; if (amount) countWrap.querySelector('input').value = amount; }
if (endWrap) endWrap.hidden = true;
} else if (unit === 'day') {
if (daysWrap) { daysWrap.hidden = false; if (amount) daysWrap.querySelector('input').value = amount; }
if (countWrap) countWrap.hidden = true;
if (endWrap) endWrap.hidden = false;
} else {
if (daysWrap) daysWrap.hidden = false;
if (countWrap) countWrap.hidden = true;
if (endWrap) endWrap.hidden = true;
}
}
sel.addEventListener('change', sync);
sync();
});
/* 套餐表单:切换默认量单位文案 */
document.querySelectorAll('form[data-package-form]').forEach(function (form) {
var label = form.querySelector('[data-unit-label]');
var radios = form.querySelectorAll('input[name="unit"]');
function sync() {
var v = 'day';
radios.forEach(function (r) { if (r.checked) v = r.value; });
if (label) label.textContent = v === 'count' ? '次' : '天';
}
radios.forEach(function (r) { r.addEventListener('change', sync); });
sync();
});
})();
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="6" fill="#2F5D50"/>
<rect x="6" y="14" width="52" height="36" rx="3" fill="none" stroke="#F7F6F2" stroke-width="3"/>
<path d="M10 34h44" stroke="#F7F6F2" stroke-width="2" stroke-dasharray="4 4" opacity="0.55"/>
<rect x="12" y="20" width="14" height="7" fill="#F7F6F2"/>
<rect x="32" y="20" width="20" height="3" fill="#F7F6F2" opacity="0.8"/>
<rect x="32" y="26" width="12" height="3" fill="#F7F6F2" opacity="0.5"/>
<rect x="12" y="42" width="24" height="3" fill="#F7F6F2" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 627 B

+212
View File
@@ -0,0 +1,212 @@
/* ============================================================
代打台 · 可视化大屏(深色「记分板」)
============================================================ */
:root {
--bg: #070a0f;
--panel: #0e141c;
--panel-2: #121a24;
--line: #1b2530;
--ink: #e8eef2;
--dim: #7d8b99;
--mint: #35e0c6;
--mint-dim: rgba(53, 224, 198, 0.14);
--amber: #f5a524;
--red: #f2555a;
--mono: "Bahnschrift", "Consolas", "SF Mono", ui-monospace, monospace;
--sans: "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body.screen-body {
background:
radial-gradient(1200px 600px at 15% -10%, rgba(53, 224, 198, 0.07), transparent 60%),
radial-gradient(900px 500px at 100% 0%, rgba(245, 165, 36, 0.05), transparent 55%),
var(--bg);
color: var(--ink);
font-family: var(--sans);
font-size: 14px;
min-height: 100vh;
overflow-x: hidden;
}
a { color: var(--mint); text-decoration: none; }
a:hover { opacity: 0.8; }
.scr {
display: flex; flex-direction: column;
min-height: 100vh;
padding: clamp(12px, 1.4vw, 22px) clamp(14px, 1.8vw, 28px);
gap: clamp(10px, 1.1vw, 16px);
}
.scr-head { display: flex; align-items: center; gap: 16px; }
.scr-brand {
display: flex; align-items: center; gap: 10px;
font-size: clamp(15px, 1.2vw, 19px); font-weight: 700; letter-spacing: 0.1em;
}
.scr-brand .mark {
display: grid; place-items: center;
width: 30px; height: 30px; border-radius: 3px;
background: var(--mint); color: #04211d; font-size: 15px;
box-shadow: 0 0 22px rgba(53, 224, 198, 0.45);
}
.scr-brand em { font-style: normal; color: var(--dim); font-weight: 400; letter-spacing: 0.24em; font-size: 0.7em; }
.scr-clock { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; }
.scr-clock b {
font-family: var(--mono); font-size: clamp(24px, 2.4vw, 36px);
font-weight: 600; line-height: 1;
color: var(--ink); letter-spacing: 0.04em;
text-shadow: 0 0 22px rgba(53, 224, 198, 0.25);
}
.scr-clock span { font-family: var(--mono); font-size: 11px; color: var(--dim); letter-spacing: 0.1em; }
.scr-links { font-size: 11px; color: var(--dim); }
/* KPI */
.scr-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(8px, 0.9vw, 14px); }
.kpi {
position: relative; overflow: hidden;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line);
border-radius: 4px;
padding: clamp(10px, 1vw, 16px) clamp(12px, 1.2vw, 20px);
}
.kpi::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--mint); opacity: 0.75;
}
.kpi-warm::before { background: var(--amber); }
.kpi-hot::before { background: var(--red); }
.kpi span { display: block; font-size: 11px; color: var(--dim); letter-spacing: 0.16em; }
.kpi b {
font-family: var(--mono); font-size: clamp(28px, 3.4vw, 52px); font-weight: 700;
line-height: 1.15; letter-spacing: -0.01em;
}
.kpi i { font-style: normal; font-size: 11px; color: var(--dim); margin-left: 4px; }
.kpi-hot b { color: var(--red); }
.kpi-warm b { color: var(--amber); }
/* 主区 */
.scr-main { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); flex: 1 1 auto; }
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 4px;
padding: clamp(10px, 1vw, 16px);
display: flex; flex-direction: column; min-height: 0;
}
.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.panel-head h2 {
margin: 0; font-size: clamp(12px, 0.95vw, 14px); font-weight: 600; letter-spacing: 0.08em;
color: var(--ink);
}
.panel-head h2::before {
content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 7px;
background: var(--mint); vertical-align: 2px;
}
.panel-head span { font-size: 11px; color: var(--dim); font-family: var(--mono); }
/* 热力月历 */
.heat-grid { display: flex; flex-direction: column; gap: 4px; flex: 1 1 auto; }
.heat-dow, .heat-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.heat-row { flex: 1 1 0; min-height: 0; }
.heat-dow { flex: 0 0 auto; }
.heat-dow span { text-align: center; font-size: 10px; color: var(--dim); }
.heat-cell {
position: relative;
display: flex; flex-direction: column; justify-content: space-between;
min-height: 34px;
padding: 5px 6px;
border: 1px solid var(--line);
border-radius: 3px;
background: #0a1017;
}
.heat-cell span { font-family: var(--mono); font-size: 10px; color: var(--dim); }
.heat-cell b {
align-self: flex-end; font-family: var(--mono); font-size: clamp(12px, 1.1vw, 16px);
color: var(--mint); font-weight: 700;
}
.heat-cell.out { opacity: 0.28; }
.heat-cell.today { border-color: var(--mint); box-shadow: 0 0 0 1px rgba(53, 224, 198, 0.35), 0 0 18px rgba(53, 224, 198, 0.16); }
.heat-cell.h1 { background: rgba(53, 224, 198, 0.08); border-color: rgba(53, 224, 198, 0.22); }
.heat-cell.h2 { background: rgba(53, 224, 198, 0.15); border-color: rgba(53, 224, 198, 0.3); }
.heat-cell.h3 { background: rgba(53, 224, 198, 0.24); border-color: rgba(53, 224, 198, 0.42); }
.heat-cell.h4 { background: rgba(53, 224, 198, 0.36); border-color: rgba(53, 224, 198, 0.6); }
.heat-cell.h3 b, .heat-cell.h4 b { color: #eafffb; }
.heat-legend { display: flex; align-items: center; gap: 5px; margin-top: 10px; font-size: 10px; color: var(--dim); flex: 0 0 auto; }
.heat-legend i { width: 18px; height: 9px; border-radius: 2px; border: 1px solid #26313d; }
.heat-legend .h0 { background: #0a1017; }
.heat-legend .h1 { background: rgba(53, 224, 198, 0.16); }
.heat-legend .h2 { background: rgba(53, 224, 198, 0.28); }
.heat-legend .h3 { background: rgba(53, 224, 198, 0.42); }
.heat-legend .h4 { background: rgba(53, 224, 198, 0.6); }
/* TOP10 */
.top-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; overflow: hidden; }
.top-item {
display: grid; grid-template-columns: 22px auto 1fr auto auto;
grid-template-areas: "rank tail name pkg remain" "rank bar bar bar bar";
column-gap: 8px; row-gap: 3px;
align-items: center;
padding: 5px 8px;
border-radius: 3px;
background: #0a1017;
border-left: 2px solid transparent;
}
.top-item .rank { grid-area: rank; font-family: var(--mono); font-size: 11px; color: var(--dim); }
.top-tail { grid-area: tail; font-family: var(--mono); font-size: clamp(13px, 1.15vw, 17px); font-weight: 700; letter-spacing: 0.03em; }
.top-name { grid-area: name; font-size: 12px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-pkg {
grid-area: pkg; font-size: 10px; color: var(--dim);
border-left: 2px solid var(--tag); padding-left: 5px;
}
.top-remain { grid-area: remain; font-family: var(--mono); font-size: 12px; color: var(--mint); }
.top-bar { grid-area: bar; height: 3px; background: #16202b; border-radius: 999px; overflow: hidden; }
.top-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--mint), rgba(53, 224, 198, 0.35)); }
.top-item.lv-warn { border-left-color: var(--amber); }
.top-item.lv-warn .top-remain { color: var(--amber); }
.top-item.lv-warn .top-bar i { background: linear-gradient(90deg, var(--amber), rgba(245, 165, 36, 0.3)); }
.top-item.lv-today, .top-item.lv-expired { border-left-color: var(--red); }
.top-item.lv-today .top-remain, .top-item.lv-expired .top-remain { color: var(--red); }
.top-item.lv-today .top-bar i, .top-item.lv-expired .top-bar i { background: linear-gradient(90deg, var(--red), rgba(242, 85, 90, 0.3)); }
/* 底部 */
.scr-bottom { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); }
.today-list, .recent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; max-height: 22vh; overflow: hidden; }
.today-list li {
display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center;
padding: 4px 6px; border-bottom: 1px solid rgba(27, 37, 48, 0.6);
}
.tchip {
display: inline-flex; align-items: baseline; gap: 3px;
padding: 1px 6px; font-size: 11px;
border-left: 3px solid var(--tag);
background: rgba(255, 255, 255, 0.04);
border-radius: 1px 2px 2px 1px;
}
.tchip b { font-family: var(--mono); font-size: 12px; color: var(--ink); }
.today-list .pkg, .recent-list .pkg { font-size: 11px; color: var(--dim); }
.today-list .rem { font-family: var(--mono); font-size: 11px; color: var(--mint); }
.recent-list li { display: flex; align-items: center; gap: 8px; padding: 3px 4px; font-size: 11px; border-bottom: 1px solid rgba(27, 37, 48, 0.6); }
.recent-list .time { color: var(--dim); font-size: 10px; }
.recent-list .who b { font-family: var(--mono); font-size: 12px; margin-right: 4px; }
.recent-list .plus { margin-left: auto; color: var(--mint); font-family: var(--mono); font-style: normal; }
.recent-list .op { color: var(--dim); }
.scr-foot { font-size: 10px; color: #47535f; text-align: right; letter-spacing: 0.12em; }
.scr-empty { margin: auto; color: var(--dim); font-size: 12px; text-align: center; }
@media (max-width: 1100px) {
.scr-kpis { grid-template-columns: repeat(2, 1fr); }
.scr-main, .scr-bottom { grid-template-columns: 1fr; }
.heat-cell { min-height: 34px; }
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
+29
View File
@@ -0,0 +1,29 @@
/* 大屏:时钟 + 全屏快捷键 */
(function () {
'use strict';
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function tick() {
var d = new Date();
var clock = document.querySelector('[data-clock]');
if (clock) clock.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
var dateEl = document.querySelector('[data-date]');
if (dateEl) {
dateEl.textContent = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
' 周' + '日一二三四五六'.charAt(d.getDay());
}
}
tick();
setInterval(tick, 1000);
document.addEventListener('keydown', function (e) {
if (e.key === 'f' || e.key === 'F') {
if (document.fullscreenElement) {
document.exitFullscreen();
} else if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen();
}
}
});
})();
+41
View File
@@ -0,0 +1,41 @@
{{define "layout"}}<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · 代打台</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
{{if .User}}
<header class="topbar">
<a class="brand" href="/calendar"><span class="brand-mark">代</span><span class="brand-name">代打台</span></a>
<form class="search" action="/customers" method="get" role="search">
<input type="search" name="q" value="{{.Query}}" placeholder="手机尾号 / 姓名 / 游戏名" aria-label="搜索客户" autocomplete="off" data-search>
<kbd>/</kbd>
</form>
<nav class="nav">
<a href="/calendar" class="{{if eq .Nav "calendar"}}on{{end}}">日历</a>
<a href="/customers" class="{{if eq .Nav "customers"}}on{{end}}">客户</a>
<a href="/subscriptions" class="{{if eq .Nav "subscriptions"}}on{{end}}">订阅</a>
<a href="/packages" class="{{if eq .Nav "packages"}}on{{end}}">套餐</a>
<a href="/screen" target="_blank" rel="noopener" class="nav-screen">大屏 ↗</a>
</nav>
<div class="topbar-right">
<a class="pill pill-due" href="/subscriptions?state=due_today" title="今天到期的订阅">今日到期 <b>{{.DueToday}}</b></a>
<a class="pill pill-warn" href="/subscriptions?state=due3" title="3 天内到期">3 天内 <b>{{.Due3}}</b></a>
<div class="me">
<a href="/settings">{{if .User.DisplayName}}{{.User.DisplayName}}{{else}}{{.User.Username}}{{end}}</a>
<form method="post" action="/logout"><button type="submit" class="link-btn">退出</button></form>
</div>
</div>
</header>
{{end}}
{{if .Flash}}<div class="flash" data-flash>{{.Flash}}</div>{{end}}
{{if .Error}}<div class="flash flash-error">{{.Error}}</div>{{end}}
<main{{if .User}} class="with-top"{{end}}>{{template "content" .}}</main>
{{if .User}}<footer class="foot">代打台 v{{.Version}} · <a href="/logs">操作日志</a> · <a href="/settings">设置</a> · <a href="/screen" target="_blank" rel="noopener">大屏</a></footer>{{end}}
<script src="/static/app.js" defer></script>
</body>
</html>{{end}}
+135
View File
@@ -0,0 +1,135 @@
{{define "state-chip"}}<span class="chip chip-{{.StateClass}}">{{.StateLabel}}</span>{{end}}
{{define "pager"}}
{{if gt .Pages 1}}
<nav class="pager">
{{if .Prev}}<a class="btn btn-sm" href="{{.Prev}}">← 上一页</a>{{else}}<span class="btn btn-sm disabled">← 上一页</span>{{end}}
<span class="pager-info">{{.Page}} / {{.Pages}} 页 · 共 {{.Total}} 条</span>
{{if .Next}}<a class="btn btn-sm" href="{{.Next}}">下一页 →</a>{{else}}<span class="btn btn-sm disabled">下一页 →</span>{{end}}
</nav>
{{end}}
{{end}}
{{define "empty"}}
<div class="empty">
<div class="empty-mark">票</div>
<p>{{.Text}}</p>
{{if .Action}}<a class="btn btn-primary" href="{{.Action}}">{{.ActionText}}</a>{{end}}
</div>
{{end}}
{{define "sub-card"}}
<article class="subcard" id="sub-{{.ID}}" style="--tag:{{.PackageColor}}">
<div class="subcard-head">
<span class="tag"></span>
<h3>{{.PackageName}}</h3>
{{template "state-chip" .}}
<span class="unit">{{.UnitLabel}}</span>
</div>
<div class="subcard-body">
<div class="subcard-big">{{.RemainText}}</div>
<div class="subcard-meta">{{.PeriodText}}</div>
{{if .DueText}}<div class="due due-{{.Eval.Level}}">{{.DueText}}</div>{{end}}
<div class="bar"><i style="width:{{.Progress}}%"></i></div>
</div>
<div class="subcard-foot">
{{if ne .Status "closed"}}
{{if .CanCheckinToday}}
<form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{.Today}}">
<input type="hidden" name="status" value="done">
<button class="btn btn-sm">今日打卡</button>
</form>
{{end}}
<a class="btn btn-primary btn-sm" href="?extend={{.ID}}#extend">{{if eq .Unit "day"}}追加时长{{else}}追加次数{{end}}</a>
{{if eq .Status "active"}}
<form method="post" action="/subscriptions/{{.ID}}/pause" data-confirm="暂停后剩余时长冻结,确认暂停?">
<button class="btn btn-sm">暂停</button>
</form>
{{else if eq .Status "paused"}}
<form method="post" action="/subscriptions/{{.ID}}/resume">
<button class="btn btn-sm">恢复</button>
</form>
{{end}}
<form method="post" action="/subscriptions/{{.ID}}/close" data-confirm="结束后该订阅不再出现在待打清单,确认结束?">
<button class="btn btn-sm btn-ghost">结束</button>
</form>
{{else}}
<form method="post" action="/subscriptions/{{.ID}}/reopen">
<button class="btn btn-sm">重新启用</button>
</form>
{{end}}
</div>
</article>
{{end}}
{{define "extend-panel"}}
<section class="extend" id="extend" style="--tag:{{.Sub.PackageColor}}">
<header class="extend-head">
<div>
<h3>给 {{.Sub.CustomerTail}} {{.Sub.CustomerName}} 追加{{if .IsDay}}时长{{else}}次数{{end}}</h3>
<p class="muted">{{.Sub.PackageName}} · {{.View.RemainText}} · {{.View.PeriodText}}</p>
</div>
<a class="extend-close" href="{{.Back}}" aria-label="关闭">×</a>
</header>
{{if .IsDay}}
<form method="post" action="/subscriptions/{{.Sub.ID}}/extend" class="extend-form"
data-extend data-base="{{.Base}}" data-old-end="{{.OldEnd}}" data-today="{{.Today}}">
<input type="hidden" name="back" value="{{.Back}}">
<input type="hidden" name="new_end_date" value="{{.Suggested}}" data-end-input>
<div class="extend-preview">
<div><span>计算起点</span><b data-base-label>{{.Base}}</b></div>
<div><span>原结束日</span><b>{{.OldEnd}}</b></div>
<div><span>新结束日</span><b data-end-label>{{.Suggested}}</b></div>
<div class="add"><span>本次追加</span><b><i data-days>{{.SuggestedDays}}</i> 天</b></div>
<div><span>追加后剩余</span><b><i data-remain>—</i> 天</b></div>
</div>
<p class="extend-hint">在下面的日历上点选新的终止日期;未到期的订阅从原结束日次日起算,已到期的从今天起算。</p>
<div class="minis">
{{range .Months}}
<div class="minimonth">
<div class="minimonth-title">{{.Title}}</div>
<div class="minimonth-grid">
<span class="dow">一</span><span class="dow">二</span><span class="dow">三</span><span class="dow">四</span><span class="dow">五</span><span class="dow">六</span><span class="dow">日</span>
{{range .Weeks}}{{range .}}
<button type="button" class="miniday{{if not .InMonth}} out{{end}}{{if .IsOldEnd}} old{{end}}{{if .IsToday}} today{{end}}" data-date="{{.Date}}"{{if .Disabled}} disabled{{end}}>{{.Day}}</button>
{{end}}{{end}}
</div>
</div>
{{end}}
</div>
<label class="field"><span>备注</span>
<input type="text" name="note" maxlength="120" placeholder="例如:微信转账 30 天 / 补送 3 天">
</label>
<div class="actions">
<button type="submit" class="btn btn-primary">确认追加</button>
<a class="btn" href="{{.Back}}">取消</a>
</div>
</form>
{{else}}
<form method="post" action="/subscriptions/{{.Sub.ID}}/extend" class="extend-form">
<input type="hidden" name="back" value="{{.Back}}">
<div class="extend-preview">
<div><span>当前剩余</span><b>{{.View.RemainText}}</b></div>
<div><span>已用 / 总数</span><b>{{.Sub.UsedCount}} / {{.Sub.TotalCount}}</b></div>
{{if .Sub.EndDate}}<div><span>有效期至</span><b>{{.Sub.EndDate}}</b></div>{{end}}
</div>
<div class="grid-2">
<label class="field"><span>追加次数</span>
<input type="number" name="add_count" min="1" max="10000" value="{{.DefaultAmount}}" required>
</label>
<label class="field"><span>新的有效期(留空不改)</span>
<input type="date" name="new_end_opt">
</label>
</div>
<label class="field"><span>备注</span>
<input type="text" name="note" maxlength="120" placeholder="例如:加购 10 次副本">
</label>
<div class="actions">
<button type="submit" class="btn btn-primary">确认追加</button>
<a class="btn" href="{{.Back}}">取消</a>
</div>
</form>
{{end}}
</section>
{{end}}
@@ -0,0 +1,15 @@
{{define "screenlayout"}}<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="{{.D.Refresh}}">
<title>{{.Title}} · 代打台</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/screen.css">
</head>
<body class="screen-body">
{{template "content" .}}
<script src="/static/screen.js" defer></script>
</body>
</html>{{end}}
+146
View File
@@ -0,0 +1,146 @@
{{define "content"}}
<div class="cal-page">
<header class="cal-head">
<div class="cal-title">
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.PrevY}}&m={{.D.PrevM}}" aria-label="上个月">‹</a>
<h1>{{.D.Title}}</h1>
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.NextY}}&m={{.D.NextM}}" aria-label="下个月">›</a>
<a class="btn btn-sm" href="/calendar">回到今天</a>
</div>
<div class="cal-stats">
<span><em>{{.D.MonthDue}}</em>本月到期</span>
<span><em>{{.D.MonthTasks}}</em>本月待打</span>
<span><em>{{.D.MonthDone}}</em>已完成</span>
<a class="btn btn-primary btn-sm" href="/customers/new">+ 新建客户</a>
</div>
</header>
{{if .D.NoPackages}}
<div class="notice">还没有服务套餐,先去 <a href="/packages/new">新建套餐</a>,再给客户开通。</div>
{{else if .D.NoCustomers}}
<div class="notice">还没有客户,<a href="/customers/new">新建第一位客户</a>。</div>
{{end}}
<div class="cal-body">
<div class="cal-grid" role="grid">
<div class="cal-dow" role="row">
{{range .D.Weekdays}}<span>{{.}}</span>{{end}}
</div>
{{range .D.Weeks}}
<div class="cal-week" role="row">
{{range .}}
<a class="cal-cell heat-{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsSelected}} sel{{end}}{{if .IsWeekend}} weekend{{end}}{{if .DueCount}} has-due{{end}}"
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}" role="gridcell">
<div class="cell-top">
<span class="dnum">{{.Day}}</span>
{{if .DueCount}}<span class="due-badge"><em>到期</em>{{.DueCount}}</span>{{end}}
</div>
{{if .TaskCount}}
<div class="cell-load">{{.DoneCount}}/{{.TaskCount}} 单</div>
{{end}}
<div class="cell-tickets">
{{range .Tickets}}
<span class="ticket{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}" style="--tag:{{.Color}}"><b>{{.Tail}}</b><span class="tk-name">{{.Name}}</span><i>{{.Pkg}}</i></span>
{{end}}
{{if gt .More 0}}<span class="ticket more">+{{.More}}</span>{{end}}
</div>
</a>
{{end}}
</div>
{{end}}
</div>
<aside class="drawer">
<header class="drawer-head">
<h2>{{.D.SelectedLabel}}</h2>
<div class="drawer-tags">
{{if .D.SelectedIsToday}}<span class="chip chip-active">今天</span>{{end}}
{{if .D.SelectedIsPast}}<span class="chip chip-closed">已过去</span>{{end}}
<span class="chip chip-plain">{{len .D.Tasks}} 待打</span>
</div>
</header>
<section class="drawer-block">
<h3>这天到期 <em>{{len .D.Due}}</em></h3>
{{if .D.Due}}
<ul class="due-list">
{{range .D.Due}}
<li>
<a class="due-main" href="/customers/{{.CustomerID}}">
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
<span class="rem">{{.RemainShort}}</span>
</a>
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&extend={{.ID}}#extend">追加</a>
</li>
{{end}}
</ul>
{{else}}<p class="muted">没有到期订阅。</p>{{end}}
</section>
<section class="drawer-block">
<h3>当日待打 <em>{{len .D.Tasks}}</em></h3>
{{if .D.Tasks}}
<ul class="task-list">
{{range .D.Tasks}}
<li class="task{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}">
<div class="task-main">
<a class="task-title" href="/customers/{{.CustomerID}}">
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
</a>
<div class="task-meta">
<span>{{.RemainText}}</span>
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
</div>
{{if .Note}}<div class="task-note">{{.Note}}{{if .Operator}} · {{.Operator}}{{end}}</div>{{end}}
</div>
<div class="task-actions">
{{if or .Done .Skipped}}
<span class="chip {{if .Done}}chip-active{{else}}chip-paused{{end}}">{{if .Done}}已完成{{else}}已跳过{{end}}</span>
<form method="post" action="/checkins/{{.CheckinID}}/delete">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-ghost">撤销</button>
</form>
{{else}}
<form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="status" value="done">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-primary">完成</button>
</form>
<form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="status" value="skipped">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-ghost">跳过</button>
</form>
{{end}}
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&extend={{.ID}}#extend">追加</a>
</div>
</li>
{{end}}
</ul>
{{else}}<p class="muted">这天没有待打任务。</p>{{end}}
</section>
{{if .D.Extend}}{{template "extend-panel" .D.Extend}}{{end}}
<section class="drawer-block">
<h3>近 7 天到期</h3>
{{if .D.OtherDue}}
<ul class="mini-due">
{{range .D.OtherDue}}
<li>
<a href="/customers/{{.CustomerID}}"><span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span></a>
<span class="muted">{{.PackageName}}</span>
<span class="sm-date">{{.EndDate}}</span>
</li>
{{end}}
</ul>
{{else}}<p class="muted">7 天内没有其它到期。</p>{{end}}
</section>
</aside>
</div>
</div>
{{end}}
@@ -0,0 +1,152 @@
{{define "content"}}
<div class="page">
<header class="cust-head">
<div class="cust-id">
<span class="cust-tail">{{.D.Customer.Tail}}</span>
<div>
<h1>{{.D.Customer.Name}}</h1>
<div class="cust-tags">
{{range .D.Customer.TagList}}<span class="tagchip">{{.}}</span>{{end}}
</div>
</div>
</div>
<dl class="cust-meta">
{{if .D.Customer.GameName}}<div><dt>游戏</dt><dd>{{.D.Customer.GameName}}</dd></div>{{end}}
{{if .D.Customer.GameServer}}<div><dt>区服</dt><dd>{{.D.Customer.GameServer}}</dd></div>{{end}}
{{if .D.Customer.Contact}}<div><dt>联系</dt><dd>{{.D.Customer.Contact}}</dd></div>{{end}}
{{if .D.Customer.Source}}<div><dt>来源</dt><dd>{{.D.Customer.Source}}</dd></div>{{end}}
<div><dt>建档</dt><dd>{{.D.Customer.CreatedAt}}</dd></div>
</dl>
<div class="page-actions">
<a class="btn" href="/customers/{{.D.Customer.ID}}/edit">编辑资料</a>
<a class="btn btn-ghost" href="/customers">返回列表</a>
</div>
</header>
{{if .D.Customer.Note}}<div class="notice note">{{.D.Customer.Note}}</div>{{end}}
<section class="section">
<div class="section-head">
<h2>订阅</h2>
<span class="muted">同一套餐只能有一条订阅,需要更多时长请用「追加」</span>
</div>
{{if .D.Subs}}
<div class="subgrid">
{{range .D.Subs}}{{template "sub-card" .}}{{end}}
</div>
{{else}}
<p class="muted">还没有订阅。用下面的表单为他开通第一个套餐。</p>
{{end}}
{{if .D.Packages}}
<form method="post" action="/customers/{{.D.Customer.ID}}/subscriptions" class="inline-form" data-sub-form>
<select name="package_id" required data-pkg-select>
<option value="">选择套餐…</option>
{{range .D.Packages}}
<option value="{{.ID}}" data-unit="{{.Unit}}" data-amount="{{.DefaultAmount}}"{{if .Archived}} disabled{{end}}>
{{.Name}} · {{.UnitLabel}} {{.DefaultAmount}}{{if eq .Unit "day"}}天{{else}}次{{end}}{{if .Archived}}(已归档){{end}}
</option>
{{end}}
</select>
<label class="inline-field">开始日
<input type="date" name="start_date" value="{{.TodayStr}}" required>
</label>
<label class="inline-field" data-days-wrap>{{/* 默认显示 */}}
天数 <input type="number" name="days" min="1" max="3650" value="30">
</label>
<label class="inline-field" data-count-wrap hidden>
次数 <input type="number" name="total_count" min="1" max="10000" value="10">
</label>
<label class="inline-field" data-end-wrap>
终止日(可留空)<input type="date" name="end_date">
</label>
<button class="btn btn-primary" type="submit">开通订阅</button>
</form>
{{else}}
<p class="muted">还没有可用套餐,<a href="/packages/new">先新建套餐</a>。</p>
{{end}}
</section>
{{if .D.Extend}}{{template "extend-panel" .D.Extend}}{{end}}
{{if .D.Timeline}}
<section class="section">
<div class="section-head">
<h2>时间轴</h2>
<span class="muted">{{.D.Timeline.Start}} → {{.D.Timeline.End}} · 灰色为暂停段</span>
</div>
<div class="timeline">
<div class="tl-axis">
{{range .D.Timeline.Ticks}}<span class="tl-tick" style="left:{{pct .Left}}%">{{.Label}}</span>{{end}}
</div>
{{range .D.Timeline.Bars}}
<div class="tl-row">
<span class="tl-name">{{.Name}}</span>
<div class="tl-track">
<i class="tl-bar st-{{.State}}" style="--tag:{{.Color}};left:{{pct .Left}}%;width:{{pct .Width}}%"></i>
{{range .Pauses}}<u class="tl-pause" style="left:{{pct .Left}}%;width:{{pct .Width}}%"></u>{{end}}
{{if .HasDue}}<em class="tl-due" style="left:{{pct .DueLeft}}%"></em>{{end}}
</div>
<span class="tl-legend">{{.Label}}</span>
</div>
{{end}}
</div>
</section>
{{end}}
<section class="section">
<div class="section-head">
<h2>服务时长追加记录</h2>
<span class="muted">每次追加都留痕,可作为对账依据</span>
</div>
{{if .D.Adjustments}}
<div class="table-wrap">
<table class="table tight">
<thead>
<tr><th>时间</th><th>套餐</th><th>类型</th><th>计算起点</th><th>结束日变化</th><th>增量</th><th>备注</th><th>操作人</th></tr>
</thead>
<tbody>
{{range .D.Adjustments}}
<tr>
<td class="mono small">{{.CreatedAt}}</td>
<td><span class="dot" style="background:{{index $.D.AdjSubColor .SubID}}"></span>{{index $.D.AdjSubName .SubID}}</td>
<td>{{.KindLabel}}</td>
<td class="mono small">{{if .BaseDate}}{{.BaseDate}}{{else}}—{{end}}</td>
<td class="mono small">{{if .OldEndDate}}{{.OldEndDate}} → {{end}}{{if .NewEndDate}}{{.NewEndDate}}{{else}}—{{end}}</td>
<td class="mono">
{{if gt .DaysAdded 0}}<span class="plus">+{{.DaysAdded}} 天</span>{{end}}
{{if gt .CountAdded 0}}<span class="plus">+{{.CountAdded}} 次</span>{{end}}
{{if and (le .DaysAdded 0) (le .CountAdded 0)}}—{{end}}
</td>
<td class="small">{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
<td class="small">{{if .OperatorName}}{{.OperatorName}}{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}<p class="muted">还没有追加记录。</p>{{end}}
</section>
<section class="section">
<div class="section-head">
<h2>打卡历史</h2>
<span class="muted">最近 30 条</span>
</div>
{{if .D.Checkins}}
<ul class="checkin-list">
{{range .D.Checkins}}
<li>
<span class="mono">{{.ServiceDate}}</span>
<span class="dot" style="background:{{.SubColor}}"></span>
<span>{{.SubName}}</span>
<span class="chip {{if eq .Status "done"}}chip-active{{else}}chip-paused{{end}}">{{if eq .Status "done"}}完成{{else}}跳过{{end}}</span>
{{if .Note}}<span class="muted small">{{.Note}}</span>{{end}}
<span class="muted small">{{if .OperatorName}}{{.OperatorName}}{{end}}</span>
</li>
{{end}}
</ul>
{{else}}<p class="muted">还没有打卡记录。</p>{{end}}
</section>
</div>
{{end}}
@@ -0,0 +1,48 @@
{{define "content"}}
<div class="page narrow">
<header class="page-head">
<div>
<h1>{{if .D.IsNew}}新建客户{{else}}编辑客户{{end}}</h1>
<p class="muted">手机尾号 + 客户名 唯一;尾号是日常查询的主键。</p>
</div>
</header>
<form method="post" class="form card"
action="{{if .D.IsNew}}/customers{{else}}/customers/{{.D.Customer.ID}}/edit{{end}}">
<div class="grid-2">
<label class="field"><span>手机尾号 <i>*</i></span>
<input name="tail" value="{{.D.Customer.Tail}}" required maxlength="8" placeholder="例如 1234" autofocus>
</label>
<label class="field"><span>客户名 <i>*</i></span>
<input name="name" value="{{.D.Customer.Name}}" required maxlength="32" placeholder="例如 老张">
</label>
</div>
<div class="grid-2">
<label class="field"><span>游戏名</span>
<input name="game_name" value="{{.D.Customer.GameName}}" maxlength="32" placeholder="例如 王者荣耀">
</label>
<label class="field"><span>区服 / UID</span>
<input name="game_server" value="{{.D.Customer.GameServer}}" maxlength="32" placeholder="例如 微信区 123456">
</label>
</div>
<div class="grid-2">
<label class="field"><span>联系方式</span>
<input name="contact" value="{{.D.Customer.Contact}}" maxlength="64" placeholder="微信 / QQ">
</label>
<label class="field"><span>来源渠道</span>
<input name="source" value="{{.D.Customer.Source}}" maxlength="32" placeholder="抖音 / 朋友介绍">
</label>
</div>
<label class="field"><span>标签</span>
<input name="tags" value="{{.D.Customer.Tags}}" maxlength="120" placeholder="老客户,包月,难缠(逗号分隔)">
</label>
<label class="field"><span>备注</span>
<textarea name="note" rows="3" maxlength="500" placeholder="打法偏好、沟通记录等">{{.D.Customer.Note}}</textarea>
</label>
<div class="actions">
<button class="btn btn-primary" type="submit">保存</button>
<a class="btn" href="{{if .D.IsNew}}/customers{{else}}/customers/{{.D.Customer.ID}}{{end}}">取消</a>
</div>
</form>
</div>
{{end}}
@@ -0,0 +1,58 @@
{{define "content"}}
<div class="page">
<header class="page-head">
<div>
<h1>客户</h1>
<p class="muted">共 {{.D.Total}} 位 · 输入手机尾号可快速定位</p>
</div>
<div class="page-actions">
<a class="btn btn-primary" href="/customers/new">+ 新建客户</a>
</div>
</header>
<form class="filterbar" method="get" action="/customers">
<input type="search" name="q" value="{{.D.Q}}" placeholder="手机尾号 / 姓名 / 游戏名 / 联系方式" autocomplete="off">
<button class="btn" type="submit">搜索</button>
{{if .D.Q}}<a class="btn btn-ghost" href="/customers">清除</a>{{end}}
</form>
{{if .D.List}}
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>尾号</th><th>客户</th><th>联系方式</th><th>订阅与剩余</th><th>标签</th><th></th></tr>
</thead>
<tbody>
{{range .D.List}}
<tr>
<td><a class="tail" href="/customers/{{.ID}}">{{.Tail}}</a></td>
<td>
<a class="name" href="/customers/{{.ID}}">{{.Name}}</a>
{{if .GameName}}<div class="muted small">{{.GameName}}{{if .GameServer}} · {{.GameServer}}{{end}}</div>{{end}}
</td>
<td class="muted small">{{if .Contact}}{{.Contact}}{{else}}—{{end}}</td>
<td>
<span class="summary due-{{.Level}}">{{.Summary}}</span>
</td>
<td>{{range .TagList}}<span class="tagchip">{{.}}</span>{{end}}</td>
<td class="right"><a class="btn btn-sm" href="/customers/{{.ID}}">详情</a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pager" .D.Pager}}
{{else}}
<div class="empty">
<div class="empty-mark">票</div>
{{if .D.Q}}
<p>没有匹配「{{.D.Q}}」的客户。</p>
<a class="btn" href="/customers">看全部客户</a>
{{else}}
<p>还没有客户。先建一位,再给他开套餐。</p>
<a class="btn btn-primary" href="/customers/new">+ 新建客户</a>
{{end}}
</div>
{{end}}
</div>
{{end}}
+20
View File
@@ -0,0 +1,20 @@
{{define "content"}}
<div class="auth">
<div class="auth-card">
<div class="auth-brand"><span class="brand-mark">代</span><span>代打台</span></div>
<h1>登录</h1>
<p class="muted">所有账户均为管理员,任何操作都会留痕。</p>
<form method="post" action="/login" class="form">
<input type="hidden" name="next" value="{{.Next}}">
<label class="field"><span>用户名</span>
<input type="text" name="username" required autofocus autocomplete="username">
</label>
<label class="field"><span>密码</span>
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit" class="btn btn-primary btn-block">登录</button>
</form>
<p class="auth-foot muted">代打台 · 单文件部署 · 数据只在本机</p>
</div>
</div>
{{end}}
+39
View File
@@ -0,0 +1,39 @@
{{define "content"}}
<div class="page">
<header class="page-head">
<div>
<h1>操作日志</h1>
<p class="muted">共 {{.D.Total}} 条 · 谁在什么时候改了什么,都在这里</p>
</div>
</header>
<form class="filterbar" method="get" action="/logs">
<input type="search" name="q" value="{{.D.Q}}" placeholder="搜索摘要 / 动作 / 操作人" autocomplete="off">
<button class="btn" type="submit">搜索</button>
{{if .D.Q}}<a class="btn btn-ghost" href="/logs">清除</a>{{end}}
</form>
{{if .D.List}}
<div class="table-wrap">
<table class="table tight">
<thead><tr><th>时间</th><th>操作人</th><th>动作</th><th>对象</th><th>摘要</th><th>IP</th></tr></thead>
<tbody>
{{range .D.List}}
<tr>
<td class="mono small">{{.CreatedAt}}</td>
<td>{{if .OperatorName}}{{.OperatorName}}{{else}}系统{{end}}</td>
<td><span class="chip chip-plain">{{.Action}}</span></td>
<td class="small muted">{{if .Entity}}{{.Entity}}{{if .EntityID}} #{{.EntityID}}{{end}}{{else}}—{{end}}</td>
<td class="small">{{.Summary}}</td>
<td class="mono small muted">{{.IP}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pager" .D.Pager}}
{{else}}
<div class="empty"><div class="empty-mark">票</div><p>没有符合条件的日志。</p></div>
{{end}}
</div>
{{end}}
@@ -0,0 +1,57 @@
{{define "content"}}
<div class="page narrow">
<header class="page-head">
<div>
<h1>{{if .D.IsNew}}新增套餐{{else}}编辑套餐{{end}}</h1>
<p class="muted">按日套餐用起止日期计算剩余天数;按次套餐记录剩余次数。</p>
</div>
</header>
<form method="post" class="form card" data-package-form
action="{{if .D.IsNew}}/packages{{else}}/packages/{{.D.Package.ID}}/edit{{end}}">
<label class="field"><span>套餐名称 <i>*</i></span>
<input name="name" required maxlength="32" value="{{.D.Package.Name}}" placeholder="例如 包月日常 / 赛季深渊 / 10 次副本">
</label>
<fieldset class="field">
<span>计费方式 <i>*</i></span>
<div class="radios">
<label><input type="radio" name="unit" value="day"{{if eq .D.Package.Unit "day"}} checked{{end}}>
<b>按日</b><small>有开始日与结束日,每天一条待打任务,可打卡</small></label>
<label><input type="radio" name="unit" value="count"{{if eq .D.Package.Unit "count"}} checked{{end}}>
<b>按次</b><small>记录剩余次数,排期完成时自动扣 1 次,有效期可选</small></label>
</div>
</fieldset>
<div class="grid-2">
<label class="field"><span>默认量 <i>*</i></span>
<div class="input-suffix">
<input type="number" name="default_amount" min="1" max="10000" value="{{.D.Package.DefaultAmount}}" required>
<b data-unit-label>{{if eq .D.Package.Unit "count"}}次{{else}}天{{end}}</b>
</div>
</label>
<label class="field"><span>参考价(元,可空)</span>
<input type="number" name="price" min="0" step="1" value="{{if .D.Package.HasPrice}}{{money .D.Package.PriceCents}}{{end}}" placeholder="例如 300">
</label>
</div>
<div class="grid-2">
<label class="field"><span>日历色签</span>
<input type="color" name="color" value="{{.D.Package.Color}}">
</label>
<label class="field"><span>排序(小的靠前)</span>
<input type="number" name="sort_order" value="{{.D.Package.SortOrder}}">
</label>
</div>
<label class="field"><span>说明</span>
<textarea name="note" rows="2" maxlength="200" placeholder="套餐包含什么、怎么个打法">{{.D.Package.Note}}</textarea>
</label>
<div class="actions">
<button class="btn btn-primary" type="submit">保存</button>
<a class="btn" href="/packages">取消</a>
</div>
</form>
</div>
{{end}}
@@ -0,0 +1,49 @@
{{define "content"}}
<div class="page">
<header class="page-head">
<div>
<h1>套餐</h1>
<p class="muted">新增套餐只需加一行配置,不会影响任何已有客户与订阅</p>
</div>
<div class="page-actions"><a class="btn btn-primary" href="/packages/new">+ 新增套餐</a></div>
</header>
{{if .D.List}}
<div class="table-wrap">
<table class="table">
<thead>
<tr><th class="col-dot"></th><th>名称</th><th>类型</th><th>默认量</th><th>参考价</th><th>订阅中 / 总数</th><th>状态</th><th></th></tr>
</thead>
<tbody>
{{range .D.List}}
<tr{{if .Archived}} class="archived"{{end}}>
<td><span class="dot big" style="background:{{.Color}}"></span></td>
<td>
<b>{{.Name}}</b>
{{if .Note}}<div class="muted small">{{.Note}}</div>{{end}}
</td>
<td>{{.UnitLabel}}</td>
<td class="mono">{{.DefaultAmount}}{{if eq .Unit "day"}} 天{{else}} 次{{end}}</td>
<td class="mono">{{if .HasPrice}}¥{{money .PriceCents}}{{else}}—{{end}}</td>
<td class="mono">{{.ActiveCount}} / {{.SubCount}}</td>
<td>{{if .Archived}}<span class="chip chip-closed">已归档</span>{{else}}<span class="chip chip-active">启用中</span>{{end}}</td>
<td class="actions-cell">
<a class="btn btn-sm" href="/packages/{{.ID}}/edit">编辑</a>
<form method="post" action="/packages/{{.ID}}/archive"{{if not .Archived}} data-confirm="归档后不能再新建该套餐的订阅,已有订阅不受影响。继续?"{{end}}>
<button class="btn btn-sm btn-ghost">{{if .Archived}}恢复{{else}}归档{{end}}</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty">
<div class="empty-mark">票</div>
<p>还没有套餐。先建「包月日常」这类常用套餐,之后客户开通就一键完成。</p>
<a class="btn btn-primary" href="/packages/new">+ 新增套餐</a>
</div>
{{end}}
</div>
{{end}}
+113
View File
@@ -0,0 +1,113 @@
{{define "content"}}
<div class="scr">
<header class="scr-head">
<div class="scr-brand"><span class="mark">代</span>代打台<em>控制台</em></div>
<div class="scr-clock"><b data-clock>--:--:--</b><span data-date></span></div>
<div class="scr-links">每 {{.D.Refresh}} 秒刷新 · <a href="/calendar">进入工作台</a></div>
</header>
<section class="scr-kpis">
<div class="kpi">
<span>客户</span><b>{{.D.Stats.Customers}}</b><i>位</i>
</div>
<div class="kpi">
<span>活跃订阅</span><b>{{.D.Stats.ActiveSubs}}</b><i>条</i>
</div>
<div class="kpi kpi-hot">
<span>今日到期</span><b>{{.D.Stats.DueToday}}</b><i>条</i>
</div>
<div class="kpi kpi-warm">
<span>7 天内到期</span><b>{{.D.Stats.Due7}}</b><i>条</i>
</div>
</section>
<section class="scr-main">
<div class="panel panel-cal">
<div class="panel-head">
<h2>到期热力</h2>
<span>{{.D.MonthTitle}} · 本月 {{.D.Stats.MonthDue}} 条到期</span>
</div>
<div class="heat-grid">
<div class="heat-dow">{{range .D.Weekdays}}<span>{{.}}</span>{{end}}</div>
{{range .D.Weeks}}
<div class="heat-row">
{{range .}}
<div class="heat-cell h{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}">
<span>{{.Day}}</span>
{{if .Due}}<b>{{.Due}}</b>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="heat-legend">
<span>到期数</span><i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i>
</div>
</div>
<div class="panel panel-top">
<div class="panel-head"><h2>即将到期 TOP 10</h2><span>按剩余量排序</span></div>
{{if .D.Top}}
<ol class="top-list">
{{range $i, $s := .D.Top}}
<li class="top-item lv-{{$s.Eval.Level}}">
<span class="rank">{{add $i 1}}</span>
<span class="top-tail">{{$s.CustomerTail}}</span>
<span class="top-name">{{$s.CustomerName}}</span>
<span class="top-pkg" style="--tag:{{$s.PackageColor}}">{{$s.PackageName}}</span>
<span class="top-remain">{{$s.RemainShort}}</span>
<span class="top-bar"><i style="width:{{$s.Progress}}%"></i></span>
</li>
{{end}}
</ol>
{{else}}
<p class="scr-empty">暂无订阅数据</p>
{{end}}
</div>
</section>
<section class="scr-bottom">
<div class="panel">
<div class="panel-head">
<h2>今日待打</h2>
<span>{{.D.TodayDone}} / {{.D.TodayTasks}} 已完成</span>
</div>
{{if .D.TodayList}}
<ul class="today-list">
{{range .D.TodayList}}
<li class="lv-{{.Eval.Level}}">
<span class="tchip" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
<span class="rem">{{.RemainShort}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="scr-empty">今天没有待打任务</p>
{{end}}
</div>
<div class="panel">
<div class="panel-head"><h2>最近追加</h2><span>服务时长变更流水</span></div>
{{if .D.Recent}}
<ul class="recent-list">
{{range .D.Recent}}
<li>
<span class="time mono">{{.CreatedAt}}</span>
<span class="who"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
{{if gt .DaysAdded 0}}<em class="plus">+{{.DaysAdded}} 天</em>{{end}}
{{if gt .CountAdded 0}}<em class="plus">+{{.CountAdded}} 次</em>{{end}}
<span class="op">{{.OperatorName}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="scr-empty">还没有追加记录</p>
{{end}}
</div>
</section>
<footer class="scr-foot">代打台 v{{.D.Version}} · 数据每 {{.D.Refresh}} 秒自动刷新 · 按 F 进入全屏</footer>
</div>
{{end}}
+102
View File
@@ -0,0 +1,102 @@
{{define "content"}}
<div class="page narrow">
<header class="page-head">
<div>
<h1>设置</h1>
<p class="muted">预警天数、备份、账户与数据导出</p>
</div>
</header>
<section class="section card">
<h2>基础设置</h2>
<form method="post" action="/settings" class="form">
<div class="grid-2">
<label class="field"><span>到期预警(天)</span>
<input type="number" name="warn_days" min="0" max="60" value="{{.D.WarnDays}}">
</label>
<label class="field"><span>大屏自动刷新(秒)</span>
<input type="number" name="screen_refresh" min="5" max="600" value="{{.D.ScreenRefresh}}">
</label>
</div>
<p class="muted small">监听地址、端口、数据库、备份、日志、时区等部署参数在 <code>daili.ini</code> 里配置,改完重启生效。</p>
<div class="actions"><button class="btn btn-primary" type="submit">保存设置</button></div>
</form>
</section>
<section class="section card">
<h2>管理员</h2>
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。</p>
<div class="table-wrap">
<table class="table tight">
<thead><tr><th>用户名</th><th>显示名称</th><th>状态</th><th>创建时间</th><th>最近登录</th><th></th></tr></thead>
<tbody>
{{$me := .User}}
{{range .D.Users}}
<tr>
<td class="mono">{{.Username}}</td>
<td>{{.DisplayName}}</td>
<td>{{if .IsActive}}<span class="chip chip-active">启用</span>{{else}}<span class="chip chip-closed">停用</span>{{end}}</td>
<td class="mono small">{{.CreatedAt}}</td>
<td class="mono small">{{if .LastLoginAt}}{{.LastLoginAt}}{{else}}—{{end}}</td>
<td class="actions-cell">
{{if eq .ID $me.ID}}
<span class="muted small">当前账户</span>
{{else}}
<form method="post" action="/settings/users/{{.ID}}/toggle">
<button class="btn btn-sm btn-ghost">{{if .IsActive}}停用{{else}}启用{{end}}</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<form method="post" action="/settings/users" class="inline-form">
<label class="inline-field">用户名 <input name="username" required minlength="3" maxlength="32" autocomplete="off"></label>
<label class="inline-field">显示名称 <input name="display_name" maxlength="32"></label>
<label class="inline-field">初始密码 <input type="password" name="password" required minlength="6" autocomplete="new-password"></label>
<button class="btn btn-primary" type="submit">新增管理员</button>
</form>
</section>
<section class="section card">
<h2>修改我的密码</h2>
<form method="post" action="/settings/password" class="form">
<div class="grid-3">
<label class="field"><span>原密码</span><input type="password" name="old_password" required autocomplete="current-password"></label>
<label class="field"><span>新密码(≥6 位)</span><input type="password" name="password" required minlength="6" autocomplete="new-password"></label>
<label class="field"><span>再输一次</span><input type="password" name="password2" required minlength="6" autocomplete="new-password"></label>
</div>
<div class="actions"><button class="btn btn-primary" type="submit">更新密码</button></div>
</form>
</section>
<section class="section card">
<h2>数据</h2>
<dl class="kv">
<div><dt>配置文件</dt><dd class="mono small">{{.D.ConfigPath}} <span class="muted">(修改后重启生效)</span></dd></div>
<div><dt>数据库</dt><dd class="mono small">{{.D.DBPath}}</dd></div>
<div><dt>备份</dt><dd class="mono small">{{.D.BackupDir}} · 保留 {{.D.BackupKeep}} 份 · {{if .D.BackupEnabled}}自动备份已开启{{else}}自动备份已关闭{{end}}</dd></div>
<div><dt>大屏</dt><dd class="mono small">/screen {{if .D.ScreenPublic}}免登录{{else}}需登录{{end}}</dd></div>
<div><dt>局域网地址</dt><dd class="mono small">http://{{if .D.Addrs}}{{index .D.Addrs 0}}{{else}}127.0.0.1{{end}}:{{.D.Port}}/screen</dd></div>
<div><dt>已运行</dt><dd class="mono">{{.D.Uptime}}</dd></div>
<div><dt>数据量</dt><dd>{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志</dd></div>
</dl>
<div class="actions wrap">
<form method="post" action="/settings/backup"><button class="btn" type="submit">立即备份</button></form>
<a class="btn" href="/export/customers.csv">导出客户 CSV</a>
<a class="btn" href="/export/subscriptions.csv">导出订阅 CSV</a>
<a class="btn" href="/export/logs.csv">导出日志 CSV</a>
</div>
{{if .D.Backups}}
<details class="backups">
<summary>最近备份({{len .D.Backups}} 份)</summary>
<ul class="mono small">
{{range .D.Backups}}<li>{{.}}</li>{{end}}
</ul>
</details>
{{end}}
</section>
</div>
{{end}}
+25
View File
@@ -0,0 +1,25 @@
{{define "content"}}
<div class="auth">
<div class="auth-card">
<div class="auth-brand"><span class="brand-mark">代</span><span>代打台</span></div>
<h1>创建第一个管理员</h1>
<p class="muted">数据库里还没有账户。设置好账号后即可开始录入套餐与客户。</p>
<form method="post" action="/setup" class="form">
<label class="field"><span>用户名</span>
<input type="text" name="username" required minlength="3" maxlength="32" autocomplete="username" placeholder="建议用拼音或英文名">
</label>
<label class="field"><span>显示名称</span>
<input type="text" name="display_name" maxlength="32" placeholder="例如:老王(可留空)">
</label>
<label class="field"><span>密码(至少 6 位)</span>
<input type="password" name="password" required minlength="6" autocomplete="new-password">
</label>
<label class="field"><span>再输一次密码</span>
<input type="password" name="password2" required minlength="6" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary btn-block">创建并登录</button>
</form>
<p class="auth-foot muted">忘记密码只能手动改数据库,请妥善保管。</p>
</div>
</div>
{{end}}
@@ -0,0 +1,76 @@
{{define "content"}}
<div class="page">
<header class="page-head">
<div>
<h1>订阅</h1>
<p class="muted">共 {{.D.Total}} 条 · 剩余时长各自独立计算</p>
</div>
</header>
<div class="tabs">
<a href="/subscriptions?state=active" class="{{if eq .D.State "active"}}on{{end}}">进行中</a>
<a href="/subscriptions?state=due7" class="{{if eq .D.State "due7"}}on{{end}}">7 天内到期</a>
<a href="/subscriptions?state=due_today" class="{{if eq .D.State "due_today"}}on{{end}}">今天到期</a>
<a href="/subscriptions?state=expired" class="{{if eq .D.State "expired"}}on{{end}}">已到期</a>
<a href="/subscriptions?state=paused" class="{{if eq .D.State "paused"}}on{{end}}">暂停中</a>
<a href="/subscriptions?state=closed" class="{{if eq .D.State "closed"}}on{{end}}">已结束</a>
<a href="/subscriptions?state=all" class="{{if eq .D.State "all"}}on{{end}}">全部</a>
</div>
<form class="filterbar" method="get" action="/subscriptions">
<input type="hidden" name="state" value="{{.D.State}}">
<input type="search" name="q" value="{{.D.Q}}" placeholder="尾号 / 姓名 / 套餐" autocomplete="off">
<select name="package_id">
<option value="0">全部套餐</option>
{{range .D.Packages}}
<option value="{{.ID}}"{{if eq $.D.PackageID .ID}} selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<button class="btn" type="submit">筛选</button>
</form>
{{if .D.List}}
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>客户</th><th>套餐</th><th>状态</th><th>剩余</th><th>时间</th><th>操作</th></tr>
</thead>
<tbody>
{{range .D.List}}
<tr>
<td>
<a class="tail" href="/customers/{{.CustomerID}}">{{.CustomerTail}}</a>
<a class="name" href="/customers/{{.CustomerID}}">{{.CustomerName}}</a>
</td>
<td><span class="dot" style="background:{{.PackageColor}}"></span>{{.PackageName}}
<span class="muted small">{{.UnitLabel}}</span></td>
<td>{{template "state-chip" .}}</td>
<td class="mono">{{.RemainText}}{{if .DueText}}<div class="due due-{{.Eval.Level}} small">{{.DueText}}</div>{{end}}</td>
<td class="mono small">{{.PeriodText}}</td>
<td class="actions-cell">
{{if ne .Status "closed"}}
<a class="btn btn-sm" href="/customers/{{.CustomerID}}?extend={{.ID}}#extend">追加</a>
{{if eq .Status "active"}}
<form method="post" action="/subscriptions/{{.ID}}/pause"><button class="btn btn-sm btn-ghost">暂停</button></form>
{{else if eq .Status "paused"}}
<form method="post" action="/subscriptions/{{.ID}}/resume"><button class="btn btn-sm">恢复</button></form>
{{end}}
{{else}}
<form method="post" action="/subscriptions/{{.ID}}/reopen"><button class="btn btn-sm">重新启用</button></form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pager" .D.Pager}}
{{else}}
<div class="empty">
<div class="empty-mark">票</div>
<p>这个筛选下没有订阅。</p>
<a class="btn" href="/subscriptions?state=all">看全部</a>
</div>
{{end}}
</div>
{{end}}
+424
View File
@@ -0,0 +1,424 @@
package web
import (
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"time"
"daili/internal/domain"
"daili/internal/store"
)
// SubView 是订阅的展示视图(已算好状态、文案与进度)。
type SubView struct {
store.Subscription
Eval domain.Eval
StateLabel string
StateClass string
RemainText string
RemainShort string
PeriodText string
DueText string
Progress int
Checkin *store.Checkin
CheckinState string
UnitLabel string
Today string
// CanCheckinToday 表示今天可以直接打卡(订阅在有效状态与区间内)
CanCheckinToday bool
}
func stateLabel(state string) (string, string) {
switch state {
case domain.StateActive:
return "进行中", "active"
case domain.StatePending:
return "未开始", "pending"
case domain.StatePaused:
return "暂停中", "paused"
case domain.StateExpired:
return "已到期", "expired"
case domain.StateUsedUp:
return "次数用尽", "usedup"
case domain.StateClosed:
return "已结束", "closed"
}
return "未知", "unknown"
}
func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
e := s.Eval(today, warnDays)
label, class := stateLabel(e.State)
v := SubView{
Subscription: s,
Eval: e,
StateLabel: label,
StateClass: class,
Today: domain.Date(today),
}
if s.Unit == domain.UnitDay {
v.UnitLabel = "按日"
v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays)
v.RemainShort = fmt.Sprintf("%d 天", e.RemainingDays)
v.PeriodText = s.StartDate + " → " + s.EndDate
total := domain.DayDiff(domain.ParseDateOr(s.StartDate, today), domain.ParseDateOr(s.EndDate, today)) + 1
if total > 0 {
v.Progress = clampPct(e.RemainingDays * 100 / total)
}
} else {
v.UnitLabel = "按次"
v.RemainText = fmt.Sprintf("剩 %d / %d 次", e.RemainingCount, s.TotalCount)
v.RemainShort = fmt.Sprintf("%d 次", e.RemainingCount)
v.PeriodText = s.StartDate + " 起"
if s.EndDate != "" {
v.PeriodText += " · 有效期至 " + s.EndDate
} else {
v.PeriodText += " · 无有效期"
}
if s.TotalCount > 0 {
v.Progress = clampPct(e.RemainingCount * 100 / s.TotalCount)
}
}
switch e.Level {
case domain.LevelToday:
v.DueText = "今天到期"
case domain.LevelWarn:
switch {
case e.DaysLeft == 1:
v.DueText = "明天到期"
case e.DaysLeft > 1:
v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft)
case s.Unit == domain.UnitDay:
v.DueText = fmt.Sprintf("还剩 %d 天", e.RemainingDays)
default:
v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft)
}
case domain.LevelExpired:
if s.EndDate != "" {
d := domain.DayDiff(domain.ParseDateOr(s.EndDate, today), today)
if d > 0 {
v.DueText = fmt.Sprintf("已过期 %d 天", d)
} else {
v.DueText = "已到期"
}
} else {
v.DueText = "次数已用尽"
}
}
// 今天能否打卡:进行中且(按日或有剩余次数)
v.CanCheckinToday = e.State == domain.StateActive &&
(s.Unit == domain.UnitDay || e.RemainingCount > 0)
return v
}
func clampPct(v int) int {
if v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}
func subViews(subs []store.Subscription, today time.Time, warnDays int) []SubView {
out := make([]SubView, 0, len(subs))
for _, s := range subs {
out = append(out, newSubView(s, today, warnDays))
}
return out
}
// ---------- 追加面板 ----------
// ExtendPanel 是「追加时长/次数」面板数据。
type ExtendPanel struct {
Sub store.Subscription
View SubView
IsDay bool
Today string
OldEnd string
Base string
Suggested string
SuggestedDays int
Back string
Months []MiniMonth
DefaultAmount int
}
// MiniMonth 是面板内的迷你月历。
type MiniMonth struct {
Title string
Weeks [][]MiniDay
}
// MiniDay 是迷你月历的一天。
type MiniDay struct {
Date string
Day int
InMonth bool
IsOldEnd bool
IsToday bool
Disabled bool
IsWeekend bool
}
func buildExtendPanel(s *store.Subscription, pkg *store.Package, today time.Time, back string, warnDays int) *ExtendPanel {
p := &ExtendPanel{
Sub: *s,
View: newSubView(*s, today, warnDays),
IsDay: s.Unit == domain.UnitDay,
Today: domain.Date(today),
Back: back,
DefaultAmount: pkg.DefaultAmount,
}
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if s.Unit != domain.UnitDay {
return p
}
oldEnd := domain.ParseDateOr(s.EndDate, today)
plan := domain.PlanExtend(oldEnd, oldEnd, today)
suggested := domain.AddDays(plan.Base, p.DefaultAmount-1)
p.OldEnd = domain.Date(oldEnd)
p.Base = domain.Date(plan.Base)
p.Suggested = domain.Date(suggested)
p.SuggestedDays = p.DefaultAmount
anchor := oldEnd
if anchor.Before(today) {
anchor = today
}
sy, sm := anchor.Year(), anchor.Month()
for i := 0; i < 3; i++ {
mm := time.Month(int(sm) + i)
y := sy
for mm > 12 {
mm -= 12
y++
}
p.Months = append(p.Months, buildMiniMonth(y, mm, plan.Base, oldEnd, today))
}
return p
}
func buildMiniMonth(y int, m time.Month, base, oldEnd, today time.Time) MiniMonth {
first := domain.MonthStart(y, m)
last := domain.MonthEnd(y, m)
start := domain.AddDays(first, -domain.WeekdayIndex(first))
end := domain.AddDays(last, 7-domain.WeekdayIndex(last)-1)
if domain.WeekdayIndex(last) == 6 {
end = last
}
mm := MiniMonth{Title: fmt.Sprintf("%d 年 %d 月", y, int(m))}
var week []MiniDay
for d := start; !d.After(end); d = domain.AddDays(d, 1) {
md := MiniDay{
Date: domain.Date(d),
Day: d.Day(),
InMonth: d.Month() == m,
IsOldEnd: domain.Date(d) == domain.Date(oldEnd),
IsToday: domain.Date(d) == domain.Date(today),
Disabled: d.Before(base),
IsWeekend: d.Weekday() == time.Saturday || d.Weekday() == time.Sunday,
}
week = append(week, md)
if len(week) == 7 {
mm.Weeks = append(mm.Weeks, week)
week = nil
}
}
if len(week) > 0 {
mm.Weeks = append(mm.Weeks, week)
}
return mm
}
// ---------- 时间轴 ----------
// Timeline 是客户订阅的时间轴(签带)。
type Timeline struct {
Start string
End string
Ticks []Tick
Bars []Bar
}
// Tick 是时间轴刻度。
type Tick struct {
Label string
Left float64
}
// Bar 是一条订阅签带。
type Bar struct {
SubID int64
Name string
Color string
Left float64
Width float64
DueLeft float64
Label string
State string
Pauses []BarPause
HasDue bool
}
// BarPause 是签带上的暂停灰段。
type BarPause struct {
Left float64
Width float64
}
func buildTimeline(subs []store.Subscription, pauses map[int64][]store.Pause, today time.Time) *Timeline {
if len(subs) == 0 {
return nil
}
start := today
end := today
for _, s := range subs {
st := domain.ParseDateOr(s.StartDate, today)
if st.Before(start) {
start = st
}
en := st.AddDate(0, 0, 30)
if s.EndDate != "" {
en = domain.ParseDateOr(s.EndDate, en)
}
if en.After(end) {
end = en
}
}
start = domain.AddDays(start, -7)
end = domain.AddDays(end, 14)
totalDays := domain.DayDiff(start, end) + 1
if totalDays <= 0 {
return nil
}
pos := func(t time.Time) float64 {
return float64(domain.DayDiff(start, t)) / float64(totalDays) * 100
}
tl := &Timeline{Start: domain.Date(start), End: domain.Date(end)}
// 月刻度
cur := domain.MonthStart(start.Year(), start.Month())
if cur.Before(start) {
cur = cur.AddDate(0, 1, 0)
}
for !cur.After(end) {
tl.Ticks = append(tl.Ticks, Tick{
Label: fmt.Sprintf("%d.%02d", cur.Year(), int(cur.Month())),
Left: pos(cur),
})
cur = cur.AddDate(0, 1, 0)
}
for _, s := range subs {
st := domain.ParseDateOr(s.StartDate, today)
en := st.AddDate(0, 0, 30)
if s.EndDate != "" {
en = domain.ParseDateOr(s.EndDate, en)
}
if en.Before(st) {
en = st
}
b := Bar{
SubID: s.ID,
Name: s.PackageName,
Color: s.PackageColor,
Left: pos(st),
Width: float64(domain.DayDiff(st, en)+1) / float64(totalDays) * 100,
Label: domain.Date(st) + " → " + domain.Date(en),
State: s.Status,
HasDue: s.EndDate != "",
}
b.DueLeft = pos(en)
if b.Width < 0.8 {
b.Width = 0.8
}
for _, p := range pauses[s.ID] {
ps := domain.ParseDateOr(p.StartDate, st)
var pe time.Time
if p.EndDate != "" {
pe = domain.ParseDateOr(p.EndDate, today)
} else {
pe = today
}
if pe.Before(ps) {
pe = ps
}
if ps.Before(st) {
ps = st
}
if pe.After(en) {
pe = en
}
if pe.Before(ps) {
continue
}
b.Pauses = append(b.Pauses, BarPause{
Left: pos(ps),
Width: float64(domain.DayDiff(ps, pe)+1) / float64(totalDays) * 100,
})
}
tl.Bars = append(tl.Bars, b)
}
sort.Slice(tl.Bars, func(i, j int) bool { return tl.Bars[i].SubID < tl.Bars[j].SubID })
return tl
}
// ---------- 分页 ----------
// Pager 是分页控件数据。
type Pager struct {
Page int
Pages int
Total int
Prev string
Next string
From int
To int
}
func newPager(r *http.Request, pageNo, perPage, total int) Pager {
pages := (total + perPage - 1) / perPage
if pages < 1 {
pages = 1
}
if pageNo < 1 {
pageNo = 1
}
if pageNo > pages {
pageNo = pages
}
mk := func(n int) string {
q := url.Values{}
for k, v := range r.URL.Query() {
q[k] = v
}
q.Set("page", strconv.Itoa(n))
return r.URL.Path + "?" + q.Encode()
}
p := Pager{Page: pageNo, Pages: pages, Total: total}
p.From = (pageNo-1)*perPage + 1
if total == 0 {
p.From = 0
}
p.To = pageNo * perPage
if p.To > total {
p.To = total
}
if pageNo > 1 {
p.Prev = mk(pageNo - 1)
}
if pageNo < pages {
p.Next = mk(pageNo + 1)
}
return p
}
+293
View File
@@ -0,0 +1,293 @@
// 代打管理平台 · 单文件服务
//
// 双击本程序即可启动:内嵌 SQLite、模板与静态资源,无需安装任何环境。
// 部署参数读取同目录的 daili.ini(不存在会自动生成)。
package main
import (
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"syscall"
"time"
"daili/internal/config"
"daili/internal/logx"
"daili/internal/store"
"daili/internal/web"
)
const version = "1.1.0"
func main() {
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
addrFlag := flag.String("addr", "", "临时覆盖监听地址,例如 127.0.0.1:19000")
dbFlag := flag.String("db", "", "临时覆盖数据库路径")
keepFlag := flag.Int("backup-keep", 0, "临时覆盖备份保留份数")
noBackup := flag.Bool("no-backup", false, "本次运行关闭自动备份")
noOpen := flag.Bool("no-open", false, "本次运行不自动打开浏览器")
flag.Parse()
flagsSet := map[string]bool{}
flag.Visit(func(f *flag.Flag) { flagsSet[f.Name] = true })
log.SetFlags(log.LstdFlags)
log.SetPrefix("[代打台] ")
exeDir := executableDir()
cfgPath := *cfgFlag
if cfgPath == "" {
cfgPath = filepath.Join(exeDir, config.FileName)
}
cfg, err := config.Load(cfgPath, exeDir)
if err != nil {
// 配置写错时不要吞掉,直接指向具体行
fmt.Fprintf(os.Stderr, "\n[代打台] 配置文件有问题,已停止启动:\n\n%v\n\n", err)
os.Exit(1)
}
// 命令行覆盖(临时调试用,优先于配置文件)
if flagsSet["addr"] {
if err := applyAddr(cfg, *addrFlag); err != nil {
fatal("%v", err)
}
}
if flagsSet["db"] {
p := *dbFlag
if !filepath.IsAbs(p) {
p = filepath.Join(exeDir, p)
}
cfg.DatabasePath = filepath.Clean(p)
}
if flagsSet["backup-keep"] {
cfg.BackupKeep = *keepFlag
}
if *noBackup {
cfg.BackupEnabled = false
}
if *noOpen {
cfg.OpenBrowser = false
}
if err := cfg.Validate(); err != nil {
fatal("命令行参数不合法:%v", err)
}
// 运行日志:控制台 + logs/daili-YYYY-MM-DD.log
if cfg.LogEnabled {
closer, err := logx.Setup(cfg.LogDir)
if err != nil {
log.Printf("运行日志无法写入 %s:%v(继续只输出到控制台)", cfg.LogDir, err)
} else {
defer closer.Close()
log.Printf("运行日志:%s", filepath.Join(cfg.LogDir, "daili-"+time.Now().Format("2006-01-02")+".log"))
}
}
// 时区:留空跟随系统,否则固定(会影响全部“自然日”计算)
if cfg.Timezone != "" {
loc, err := time.LoadLocation(cfg.Timezone)
if err != nil {
fatal("时区 %q 无法加载:%v", cfg.Timezone, err)
}
time.Local = loc
log.Printf("时区已固定为 %s(当前时间 %s)", cfg.Timezone, time.Now().Format("2006-01-02 15:04:05"))
}
if cfg.Created {
log.Printf("未找到配置文件,已生成默认配置:%s(修改后重启生效)", cfg.Path)
} else {
log.Printf("配置文件:%s", cfg.Path)
}
db, err := store.Open(cfg.DatabasePath)
if err != nil {
fatal("打开数据库失败:%v", err)
}
defer db.Close()
if err := db.Migrate(); err != nil {
fatal("初始化数据库结构失败:%v", err)
}
db.PurgeSessions()
if cfg.BackupEnabled {
db.SetBackupDir(cfg.BackupDir)
}
if mode, err := db.JournalMode(); err == nil {
log.Printf("数据库已就绪(%s,journal_mode=%s:全部数据都在这一个文件里,复制它即可迁移)", db.Path, mode)
}
srv, err := web.New(db, web.Config{
Addr: cfg.Addr(),
DBPath: db.Path,
BackupKeep: cfg.BackupKeep,
BackupDir: cfg.BackupDir,
BackupEnabled: cfg.BackupEnabled,
ScreenPublic: cfg.ScreenPublic,
ConfigPath: cfg.Path,
Version: version,
})
if err != nil {
fatal("初始化服务失败:%v", err)
}
if cfg.BackupEnabled {
go func() {
run := func() {
path, err := db.BackupDaily(cfg.BackupKeep)
if err != nil {
log.Printf("自动备份失败:%v", err)
return
}
if path != "" {
log.Printf("已自动备份数据库:%s", path)
}
}
run()
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
run()
}
}()
}
ln, err := net.Listen("tcp", cfg.Addr())
if err != nil {
fatal("无法监听 %s:%v\n提示:端口被占用时,可修改 %s 里的 listen_port", cfg.Addr(), err, cfg.Path)
}
httpSrv := &http.Server{
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
primaryURL := printBanner(cfg, db.Path)
if cfg.OpenBrowser {
go func() {
time.Sleep(400 * time.Millisecond)
openBrowser(primaryURL)
}()
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Printf("HTTP 服务异常退出:%v", err)
}
}()
if n, _ := db.CountUsers(); n == 0 {
log.Printf("首次运行:请在浏览器中打开上面的地址,按提示创建管理员账号")
}
<-ctx.Done()
log.Printf("正在关闭服务…")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
log.Printf("关闭服务时出错:%v", err)
}
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "\n[代打台] "+format+"\n\n", args...)
os.Exit(1)
}
// applyAddr 解析 -addr(支持 ":19000" 与 "127.0.0.1:19000")。
func applyAddr(cfg *config.Config, v string) error {
host, port, err := net.SplitHostPort(v)
if err != nil {
return fmt.Errorf("-addr 格式应为 ip:端口 或 :端口,当前是 %q", v)
}
if host != "" {
cfg.ListenIP = host
}
if port != "" {
n, err := strconv.Atoi(port)
if err != nil {
return fmt.Errorf("-addr 的端口必须是数字,当前是 %q", port)
}
cfg.ListenPort = n
}
return nil
}
func executableDir() string {
exe, err := os.Executable()
if err != nil {
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return filepath.Dir(exe)
}
func printBanner(cfg *config.Config, dbPath string) string {
port := strconv.Itoa(cfg.ListenPort)
fmt.Println()
fmt.Println(" ┌───────────────────────────────────────────────┐")
fmt.Println(" │ 代 打 台 · 代打管理平台 │")
fmt.Println(" └───────────────────────────────────────────────┘")
fmt.Printf(" 版本 %s 配置文件 %s\n", version, filepath.Base(cfg.Path))
fmt.Printf(" 数据库 %s\n", dbPath)
fmt.Println(" 访问地址:")
primary := ""
switch {
case cfg.LoopbackOnly():
primary = "http://127.0.0.1:" + port
fmt.Printf(" 本机 %s\n", primary)
fmt.Println(" 仅本机可访问(listen_ip = 127.0.0.1)")
case cfg.PublicBind():
primary = "http://127.0.0.1:" + port
fmt.Printf(" 本机 %s\n", primary)
for _, ip := range web.LocalAddrs() {
fmt.Printf(" 局域网 http://%s:%s\n", ip, port)
}
default:
primary = "http://" + net.JoinHostPort(cfg.ListenIP, port)
fmt.Printf(" 地址 %s\n", primary)
}
if cfg.ScreenPublic {
fmt.Printf(" 大屏 %s/screen(免登录)\n", primary)
} else {
fmt.Printf(" 大屏 %s/screen(需登录)\n", primary)
}
if cfg.PublicBind() && cfg.ScreenPublic {
fmt.Println(" 注意:大屏免登录且监听所有网卡,同局域网内任何人都能打开 /screen")
}
fmt.Println(" 按 Ctrl+C 停止服务")
fmt.Println()
return primary
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "darwin":
cmd = exec.Command("open", url)
default:
cmd = exec.Command("xdg-open", url)
}
if err := cmd.Start(); err != nil {
log.Printf("自动打开浏览器失败(可手动访问 %s):%v", url, err)
}
}
+560
View File
@@ -0,0 +1,560 @@
# 冒烟测试执行脚本(HTTP 层逐条断言)
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\acceptance.ps1 -Base http://127.0.0.1:18090 -Db <测试库路径>
param(
[string]$Base = 'http://127.0.0.1:18090',
[string]$Db = 'F:\hupeE的代肝平台\testrun\daili.db',
[string]$Python = 'E:\Miniconda3\python.exe',
[string]$Tools = 'F:\hupeE的代肝平台\scripts\dbtool.py',
[string]$LogFile = 'F:\hupeE的代肝平台\testrun\acceptance-result.txt'
)
try { Add-Type -AssemblyName System.Net.Http -ErrorAction Stop } catch { }
$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$env:PYTHONIOENCODING = 'utf-8'
$script:results = @()
$script:fail = 0
$T = (Get-Date).Date
$today = $T.ToString('yyyy-MM-dd')
function D([int]$n) { $T.AddDays($n).ToString('yyyy-MM-dd') }
function New-Sess {
$handler = New-Object System.Net.Http.HttpClientHandler
$handler.AllowAutoRedirect = $true
$handler.UseCookies = $true
$handler.CookieContainer = New-Object System.Net.CookieContainer
$client = New-Object System.Net.Http.HttpClient($handler)
$client.Timeout = [TimeSpan]::FromSeconds(30)
return $client
}
function Send {
param($Sess, [string]$Method, [string]$Path, $Body = $null, $Headers = $null)
$req = New-Object System.Net.Http.HttpRequestMessage
$req.RequestUri = [Uri]("$Base$Path")
if ($Method -eq 'POST') { $req.Method = [System.Net.Http.HttpMethod]::Post }
else { $req.Method = [System.Net.Http.HttpMethod]::Get }
if ($null -ne $Body) {
$pairs = New-Object 'System.Collections.Generic.List[System.Collections.Generic.KeyValuePair[string,string]]'
foreach ($k in $Body.Keys) {
$kv = New-Object 'System.Collections.Generic.KeyValuePair[string,string]'($k, [string]$Body[$k])
$pairs.Add($kv)
}
$req.Content = [System.Net.Http.FormUrlEncodedContent]::new([System.Collections.Generic.IEnumerable[System.Collections.Generic.KeyValuePair[string,string]]]$pairs)
}
if ($null -ne $Headers) {
foreach ($k in $Headers.Keys) { [void]$req.Headers.TryAddWithoutValidation($k, [string]$Headers[$k]) }
}
try {
$resp = $Sess.SendAsync($req).Result
$text = $resp.Content.ReadAsStringAsync().Result
$final = $resp.RequestMessage.RequestUri.AbsolutePath
$resp.Dispose()
return @{ Status = [int]$resp.StatusCode; Content = $text; Path = $final }
} catch {
return @{ Status = 0; Content = ''; Path = ''; Err = $_.Exception.Message }
}
}
function Check {
param([string]$Id, [string]$Name, $Cond, [string]$Detail = '')
$ok = ($Cond -eq $true)
if ($ok) {
Write-Host (" [PASS] {0} {1}" -f $Id, $Name) -ForegroundColor Green
$script:results += [pscustomobject]@{ ID = $Id; Name = $Name; Result = 'PASS'; Detail = $Detail }
} else {
Write-Host (" [FAIL] {0} {1} {2}" -f $Id, $Name, $Detail) -ForegroundColor Red
$script:results += [pscustomobject]@{ ID = $Id; Name = $Name; Result = 'FAIL'; Detail = $Detail }
$script:fail++
}
}
function Has { param($Text, [string]$Pattern) return ([string]$Text) -match $Pattern }
function Flash { param($Text) $m = [regex]::Match([string]$Text, '<div class="flash[^>]*>([^<]*)</div>'); return $m.Groups[1].Value }
function SubIDs { param($Text) return @([regex]::Matches([string]$Text, 'id="sub-(\d+)"') | ForEach-Object { [int]$_.Groups[1].Value }) }
function RowCount { param($Text) return [regex]::Matches([string]$Text, 'class="tail"').Count }
function Dbtool { param([string[]]$ToolArgs) return (& $Python $Tools @ToolArgs) -join ' ' }
$admin = New-Sess
$anon = New-Sess
Write-Host "`n=== 1. 启动与部署(S) ===" -ForegroundColor Cyan
$r = Send $anon 'GET' '/healthz'
Check 'S-02' '健康检查 /healthz 返回 ok' ($r.Status -eq 200 -and $r.Content -match 'ok')
$r = Send $anon 'GET' '/'
Check 'S-03' '首访跳 /setup' ($r.Path -eq '/setup' -and (Has $r.Content '创建第一个管理员')) "path=$($r.Path)"
$okStatic = $true; $detail = @()
foreach ($p in '/static/app.css', '/static/screen.css', '/static/app.js', '/static/screen.js', '/static/favicon.svg') {
$r = Send $anon 'GET' $p
$okStatic = $okStatic -and ($r.Status -eq 200) -and ($r.Content.Length -gt 100)
$detail += "$p=$($r.Status)/$($r.Content.Length)"
}
Check 'S-04' '静态资源可访问' $okStatic ($detail -join ' ')
Write-Host "`n=== 2. 账户与权限(A) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' '/setup' @{ username = 'ab'; display_name = 'x'; password = 'daili123'; password2 = 'daili123' }
Check 'A-01' '用户名过短被拒' ($r.Status -eq 400 -and (Has $r.Content '用户名至少 3 个字符')) "status=$($r.Status)"
$r = Send $admin 'POST' '/setup' @{ username = 'admin'; display_name = '老板'; password = '123'; password2 = '123' }
Check 'A-02' '密码过短被拒' ($r.Status -eq 400 -and (Has $r.Content '密码至少 6 位')) "status=$($r.Status)"
$r = Send $admin 'POST' '/setup' @{ username = 'admin'; display_name = '老板'; password = 'daili123'; password2 = 'daili999' }
Check 'A-03' '两次密码不一致被拒' ($r.Status -eq 400 -and (Has $r.Content '两次输入的密码不一致')) "status=$($r.Status)"
$r = Send $admin 'POST' '/setup' @{ username = 'admin'; display_name = '老板'; password = 'daili123'; password2 = 'daili123' }
Check 'A-04' '初始化管理员成功并自动登录' ($r.Status -eq 200 -and $r.Path -eq '/packages') "path=$($r.Path)"
Write-Host "`n=== 3. 套餐(P) ===" -ForegroundColor Cyan
$r = Send $admin 'GET' '/packages'
Check 'P-01' '套餐空态提示' ((Has $r.Content '还没有套餐'))
$r = Send $admin 'POST' '/packages' @{ name = '包月日常'; unit = 'day'; default_amount = '30'; price = '300'; color = '#2F5D50'; note = '每天日常 + 周常'; sort_order = '1' }
Check 'P-02' '新增按日套餐' ((Has $r.Content '套餐已创建')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' '/packages' @{ name = '深渊十次'; unit = 'count'; default_amount = '10'; price = '180'; color = '#6B4E9B'; note = '按次结算'; sort_order = '2' }
Check 'P-03' '新增按次套餐' ((Has $r.Content '套餐已创建')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' '/packages' @{ name = ''; unit = 'day'; default_amount = '0'; color = ''; sort_order = '0' }
Check 'P-04' '套餐必填校验' ($r.Status -eq 400 -and (Has $r.Content '套餐名称不能为空')) "status=$($r.Status)"
Write-Host "`n=== 4. 登录与会话(A) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' '/logout'
$r = Send $admin 'GET' '/calendar'
Check 'A-05' '登出后受保护页面跳登录' ($r.Path -like '/login*')
$r = Send $admin 'POST' '/login' @{ username = 'admin'; password = 'wrongpass'; next = '/calendar' }
Check 'A-06' '错误口令被拒' ($r.Status -eq 400 -and (Has $r.Content '用户名或密码不正确')) "status=$($r.Status)"
$r = Send $admin 'POST' '/login' @{ username = 'admin'; password = 'daili123'; next = '/calendar' }
Check 'A-04b' '重新登录成功' ($r.Path -eq '/calendar' -and (Has $r.Content '欢迎回来')) "path=$($r.Path)"
$screenPublic = ((Send $admin 'GET' '/settings').Content -match '(?s)大屏</dt>.*?免登录')
$r = Send $anon 'GET' '/customers'
$p1 = $r.Path
$r = Send $anon 'GET' '/screen'
$p2 = $r.Path
$screenOk = if ($screenPublic) { $p2 -eq '/screen' } else { $p2 -like '/login*' }
Check 'A-07' '未登录拦截后台页(大屏按配置)' (($p1 -like '/login*') -and $screenOk) "customers=$p1 screen=$p2 public=$screenPublic"
$r = Send $admin 'POST' '/packages' @{ name = '恶意套餐'; unit = 'day'; default_amount = '1'; color = '#2F5D50' } @{ Origin = 'http://evil.example' }
Check 'A-12' '跨站 POST 被拒绝' ($r.Status -eq 403) "status=$($r.Status)"
Write-Host "`n=== 5. 客户(C) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' '/customers' @{ tail = '1001'; name = '张三'; game_name = '王者荣耀'; game_server = '微信区'; contact = 'wx:1001'; tags = '老客户,包月 老客户'; source = '抖音'; note = '主用例' }
$custPath = $r.Path
$custID = ($custPath -split '/')[-1]
Check 'C-01' '新建客户成功' ($custPath -like '/customers/*') "path=$custPath"
$r = Send $admin 'POST' '/customers' @{ tail = '1001'; name = '张三' }
Check 'C-02' '尾号+姓名重复被拒' ($r.Status -eq 400 -and (Has $r.Content '已存在')) "status=$($r.Status)"
$r = Send $admin 'POST' '/customers' @{ tail = '1001'; name = '李四'; game_name = '王者荣耀' }
$custLiID = ($r.Path -split '/')[-1]
Check 'C-03' '同尾号不同姓名允许' ($r.Path -like '/customers/*') "path=$($r.Path)"
$r = Send $admin 'POST' '/customers' @{ tail = ''; name = '无尾号' }
Check 'C-04' '尾号为空被拒' ($r.Status -eq 400 -and (Has $r.Content '手机尾号不能为空')) "status=$($r.Status)"
$r = Send $admin 'POST' '/customers' @{ tail = '123456789'; name = '超长尾号' }
Check 'C-05' '尾号超长被拒' ($r.Status -eq 400 -and (Has $r.Content '最长 8 位')) "status=$($r.Status)"
$r = Send $admin 'GET' '/customers?q=1001'
Check 'C-06' '尾号搜索命中两人' ((Has $r.Content '张三') -and (Has $r.Content '李四') -and (RowCount $r.Content) -eq 2) "rows=$(RowCount $r.Content)"
$r = Send $admin 'GET' '/customers?q=%E7%8E%8B%E8%80%85'
Check 'C-07a' '按游戏名搜索' ((RowCount $r.Content) -ge 2) "rows=$(RowCount $r.Content)"
$r = Send $admin 'GET' '/customers?q=wx%3A1001'
Check 'C-07b' '按联系方式搜索' ((Has $r.Content '张三')) "rows=$(RowCount $r.Content)"
$r = Send $admin 'GET' '/customers?q=%E6%9D%8E%E5%9B%9B'
Check 'C-07c' '按姓名搜索' ((RowCount $r.Content) -eq 1) "rows=$(RowCount $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
$tagVal = Dbtool @('scalar', $Db, "SELECT tags FROM customers WHERE id=$custID")
Check 'C-08' '标签归一化(逗号/空格/去重)' ($tagVal.Trim() -eq '老客户,包月') "tags=$tagVal"
$r = Send $admin 'POST' "/customers/$custID/edit" @{ tail = '1001'; name = '张三'; game_name = '王者荣耀'; game_server = '安卓微信区'; contact = 'wx:1001'; tags = '老客户,包月'; source = '抖音'; note = '已编辑' }
$r = Send $admin 'GET' "/customers/$custID"
Check 'C-09' '编辑客户生效' ((Has $r.Content '安卓微信区') -and (Has $r.Content '已编辑')) "flash=$(Flash $r.Content)"
# 批量客户:用于分页
for ($i = 1; $i -le 22; $i++) {
$tail = 'P' + $i.ToString('000')
Send $admin 'POST' '/customers' @{ tail = $tail; name = "分页客户$i" } | Out-Null
}
$r = Send $admin 'GET' '/customers'
$total = [int][regex]::Match($r.Content, '共 (\d+) 位').Groups[1].Value
$r2 = Send $admin 'GET' '/customers?page=2'
$expect = [Math]::Min(20, $total - 20)
Check 'C-10' '客户分页正常' (($r2.Status -eq 200) -and (RowCount $r2.Content) -eq $expect -and $total -ge 24) "total=$total page2=$(RowCount $r2.Content) expect=$expect"
Write-Host "`n=== 6. 订阅(U) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30'; note = '首单' }
Check 'U-01' '开通按日订阅(结束日=T+29)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
$p1ID = (SubIDs $r.Content)[0]
$detail = (Send $admin 'GET' "/customers/$custID").Content
Check 'U-01b' '按日剩余 30 天' ((Has $detail '剩 30 天')) ""
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '2'; start_date = $today; total_count = '10'; end_date = (D 10) }
Check 'U-02' '开通按次订阅(10 次 / 有效期 T+10)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
$p2ID = (SubIDs $r.Content)[1]
Check 'U-02b' '按次剩余 10/10 次' ((Has $r.Content '剩 10 / 10 次')) ""
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30' }
Check 'U-03' '重复订阅被拦截' ((Has $r.Content '已订阅此套餐')) "flash=$(Flash $r.Content)"
# P-05:新增套餐 D(id=3),验证既有订阅不受影响
$before = (Send $admin 'GET' "/customers/$custID").Content
$r = Send $admin 'POST' '/packages' @{ name = '副本五次'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; note = '无有效期'; sort_order = '3' }
Check 'P-05' '新增套餐 D 成功' ((Has $r.Content '套餐已创建')) ""
$after = (Send $admin 'GET' "/customers/$custID").Content
Check 'P-05b' '新增套餐后既有订阅剩余量不变' ((Has $after '剩 30 天') -and (Has $after '剩 10 / 10 次')) ""
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '3'; start_date = $today; total_count = '5' }
Check 'U-04' '开通第三个套餐(无有效期按次)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
$ids = SubIDs $r.Content
$p3ID = $ids[2]
Check 'U-04b' '三条订阅并存' ($ids.Count -eq 3) "count=$($ids.Count)"
$r = Send $admin 'GET' '/subscriptions?state=active'
Check 'U-05a' '筛选:进行中' ((Has $r.Content '张三') -and (Has $r.Content '进行中')) ""
$r = Send $admin 'GET' '/subscriptions?state=due7'
Check 'U-05b' '筛选:7 天内到期' ($r.Status -eq 200) "status=$($r.Status)"
$r = Send $admin 'GET' '/subscriptions?state=closed'
Check 'U-05c' '筛选:已结束(应为空)' ($r.Status -eq 200 -and (Has $r.Content '没有订阅')) "flash=$(Has $r.Content '没有订阅')"
$r = Send $admin 'GET' '/subscriptions?package_id=2'
Check 'U-06' '按套餐筛选' ((Has $r.Content '张三') -and -not (Has $r.Content '阿豪')) ""
# 过期与未开始
$r = Send $admin 'POST' '/customers' @{ tail = '9001'; name = '阿豪' }
$custHaoID = ($r.Path -split '/')[-1]
Send $admin 'POST' "/customers/$custHaoID/subscriptions" @{ package_id = '1'; start_date = (D -40); days = '30' } | Out-Null
$r = Send $admin 'GET' "/customers/$custHaoID"
$haoSubID = (SubIDs $r.Content)[0]
Check 'U-07a' '已过期订阅显示「已到期」' ((Has $r.Content '已到期')) ""
$r = Send $admin 'POST' "/customers/$custLiID/subscriptions" @{ package_id = '1'; start_date = (D 5); days = '30' } | Out-Null
$r = Send $admin 'GET' "/customers/$custLiID"
$liSubID = (SubIDs $r.Content)[0]
Check 'U-07b' '未开始订阅显示「未开始」' ((Has $r.Content '未开始')) ""
Write-Host "`n=== 7. 套餐编辑与归档(P) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' '/packages/3/edit' @{ name = '副本五次(改)'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; sort_order = '3' }
$r = Send $admin 'GET' "/customers/$custID"
Check 'P-06' '编辑套餐后订阅同步显示新名称' ((Has $r.Content '副本五次\(改\)')) "flash=$(Flash $r.Content)"
Send $admin 'POST' '/packages/3/edit' @{ name = '副本五次'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; sort_order = '3' } | Out-Null
$r = Send $admin 'POST' '/packages/2/archive'
Check 'P-07a' '归档套餐' ((Has $r.Content '已归档')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' "/customers/$custLiID/subscriptions" @{ package_id = '2'; start_date = $today; total_count = '10' }
Check 'P-07b' '归档后不能新建该套餐订阅' ((Has $r.Content '已归档,不能新建')) "flash=$(Flash $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
Check 'P-08' '归档后已有订阅照常显示' ((Has $r.Content '深渊十次')) ""
Send $admin 'POST' '/packages/2/archive' | Out-Null
Write-Host "`n=== 8. 追加时长(E) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' "/subscriptions/$p1ID/extend" @{ new_end_date = (D 39); note = '未到期续费'; back = "/customers/$custID" }
Check 'E-01' '未到期追加:起点=T+30,追加 10 天' ($r -ne $null -and (Has $r.Content '已追加 10 天')) "flash=$(Flash $r.Content)"
$detail = (Send $admin 'GET' "/customers/$custID").Content
Check 'E-01b' '追加后剩余 40 天' ((Has $detail '剩 40 天')) ""
$r = Send $admin 'POST' "/subscriptions/$haoSubID/extend" @{ new_end_date = (D 29); note = '过期续费'; back = "/customers/$custHaoID" }
Check 'E-02' '已到期追加:从今天起算 30 天' ((Has $r.Content '已追加 30 天')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' "/subscriptions/$p1ID/extend" @{ new_end_date = (D 10); back = "/customers/$custID" }
$d2 = (Send $admin 'GET' "/customers/$custID").Content
Check 'E-03' '早于计算起点被拒且数据不变' ((Has $r.Content '追加失败') -and (Has $r.Content '早于计算起点') -and (Has $d2 '剩 40 天')) "flash=$(Flash $r.Content)"
Check 'E-04' '追加留痕(起点/旧→新/天数/备注/操作人)' ((Has $detail '未到期续费') -and (Has $detail '追加天数') -and (Has $detail (D 30)) -and (Has $detail '老板')) ""
Check 'E-05' '新建订阅有初始「开通」记录' ((Has $detail '开通') -and (Has $detail '\+30 天')) ""
$r = Send $admin 'POST' "/subscriptions/$p1ID/pause" @{ back = "/customers/$custID" }
$r = Send $admin 'POST' "/subscriptions/$p1ID/extend" @{ new_end_date = (D 49); note = '冻结期续费'; back = "/customers/$custID" }
$d3 = (Send $admin 'GET' "/customers/$custID").Content
Check 'E-06' '暂停中追加后回到进行中' ((Has $r.Content '已追加 10 天') -and -not (Has $d3 '暂停中')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' "/subscriptions/$liSubID/close"
$r = Send $admin 'POST' "/subscriptions/$liSubID/extend" @{ new_end_date = (D 40); back = "/customers/$custLiID" }
Check 'E-07' '已结束订阅不可追加' ((Has $r.Content '请先「重新启用」')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; note = '加购 5 次'; back = "/customers/$custID" }
$d4 = (Send $admin 'GET' "/customers/$custID").Content
Check 'E-08' '按次追加次数' ((Has $r.Content '已追加 5 次') -and (Has $d4 '剩 15 / 15 次')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; new_end_opt = (D 30); note = '加次并延期'; back = "/customers/$custID" }
$d5 = (Send $admin 'GET' "/customers/$custID").Content
Check 'E-09' '按次追加可同时改有效期' ((Has $d5 ('有效期至 ' + (D 30)))) ""
$r = Send $admin 'GET' '/logs?q=%E8%BF%BD%E5%8A%A0'
Check 'E-10' '操作日志可查追加记录' ((Has $r.Content '追加时长') -or (Has $r.Content '追加次数')) ""
Write-Host "`n=== 9. 暂停 / 恢复(G) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' "/subscriptions/$haoSubID/pause" @{ back = "/customers/$custHaoID" }
$d = (Send $admin 'GET' "/customers/$custHaoID").Content
Check 'G-01' '暂停后状态为暂停中' ((Has $r.Content '已暂停') -and (Has $d '暂停中')) "flash=$(Flash $r.Content)"
Dbtool @('backdate-pause', $Db, "$haoSubID", '5') | Out-Null
$r = Send $admin 'POST' "/subscriptions/$haoSubID/resume" @{ back = "/customers/$custHaoID" }
$d = (Send $admin 'GET' "/customers/$custHaoID").Content
Check 'G-02' '恢复后结束日顺延 5 天' ((Has $r.Content '顺延 5 天')) "flash=$(Flash $r.Content)"
Check 'G-03' '暂停期间的 5 天被补回(剩余 35 天)' ((Has $d '剩 35 天')) ""
$r = Send $admin 'POST' "/subscriptions/$haoSubID/pause" @{ back = "/customers/$custHaoID" }
$r = Send $admin 'POST' "/subscriptions/$haoSubID/pause" @{ back = "/customers/$custHaoID" }
Check 'G-04' '重复暂停被拒' ((Has $r.Content '已处于暂停状态')) "flash=$(Flash $r.Content)"
Send $admin 'POST' "/subscriptions/$haoSubID/resume" @{ back = "/customers/$custHaoID" } | Out-Null
$r = Send $admin 'POST' "/subscriptions/$liSubID/pause" @{ back = "/customers/$custLiID" }
Check 'G-05' '已结束订阅不可暂停' ((Has $r.Content '无法暂停')) "flash=$(Flash $r.Content)"
# 专用客户:验证暂停后不出现在日历
$r = Send $admin 'POST' '/customers' @{ tail = '7001'; name = '单测' }
$custCalID = ($r.Path -split '/')[-1]
Send $admin 'POST' "/customers/$custCalID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30' } | Out-Null
$r = Send $admin 'GET' "/customers/$custCalID"
$calSubID = (SubIDs $r.Content)[0]
$before = (Send $admin 'GET' "/calendar?d=$today").Content
Send $admin 'POST' "/subscriptions/$calSubID/pause" @{ back = "/customers/$custCalID" } | Out-Null
$after = (Send $admin 'GET' "/calendar?d=$today").Content
$link = "/subscriptions/$calSubID/checkin"
Check 'G-06' '暂停后不出现在日历待打清单' ((Has $before $link) -and (-not (Has $after $link))) "before=$(Has $before $link) after=$(Has $after $link)"
Send $admin 'POST' "/subscriptions/$calSubID/resume" @{ back = "/customers/$custCalID" } | Out-Null
Write-Host "`n=== 10. 打卡(K) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' "/subscriptions/$calSubID/checkin" @{ date = $today; status = 'done'; note = '今日已打'; back = "/calendar?d=$today" }
$cal = (Send $admin 'GET' "/calendar?d=$today").Content
Check 'K-01' '按日打卡生成记录并标记完成' ((Has $r.Content '已记录') -and (Has $cal '已完成') -and (-not (Has $cal ("/subscriptions/$calSubID/checkin")))) "flash=$(Flash $r.Content)"
$d = (Send $admin 'GET' "/customers/$custCalID").Content
Check 'K-02' '按日打卡不扣天数' ((Has $d '剩 30 天')) ""
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-03' '按次打卡扣 1 次' ((Has $d '剩 19 / 20 次')) ""
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-04' '同日重复打卡不重复扣次' ((Has $d '剩 19 / 20 次')) ""
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'skipped'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-05' '改为跳过返还 1 次' ((Has $d '剩 20 / 20 次')) ""
Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" } | Out-Null
$d = (Send $admin 'GET' "/customers/$custID").Content
$ciID = (Dbtool @('scalar', $Db, "SELECT id FROM checkins WHERE sub_id=$p2ID AND service_date='$today'")).Trim()
$r = Send $admin 'POST' "/checkins/$ciID/delete" @{ back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-06' '撤销打卡返还次数' ((Has $r.Content '已撤销') -and (Has $d '剩 20 / 20 次')) "flash=$(Flash $r.Content)"
Dbtool @('exec', $Db, "UPDATE subscriptions SET used_count=total_count WHERE id=$p2ID") | Out-Null
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
Check 'K-07' '次数用尽时打卡被拒' ((Has $r.Content '剩余次数不足')) "flash=$(Flash $r.Content)"
Dbtool @('exec', $Db, "UPDATE subscriptions SET used_count=0 WHERE id=$p2ID") | Out-Null
$r = Send $admin 'POST' "/subscriptions/$liSubID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custLiID" }
Check 'K-08' '已结束订阅不可打卡' ((Has $r.Content '订阅已结束,无法打卡')) "flash=$(Flash $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
Check 'K-11' '无有效期订阅卡片有「今日打卡」按钮' ((Has $r.Content "/subscriptions/$p3ID/checkin") -and (Has $r.Content '今日打卡')) ""
$r = Send $admin 'POST' "/subscriptions/$p3ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-09' '无有效期按次订阅可打卡扣次' ((Has $d '剩 4 / 5 次')) "flash=$(Flash $r.Content)"
Check 'K-10' '打卡历史含日期/备注/操作人' ((Has $d '今日已打') -or (Has $d '完成')) ""
Write-Host "`n=== 11. 日历(L) ===" -ForegroundColor Cyan
$cal = (Send $admin 'GET' '/calendar').Content
Check 'L-01' '默认当月且今天选中' ((Has $cal '今天</span>') -and (Has $cal ($T.ToString('M 月 d 日')))) ""
$r = Send $admin 'GET' '/calendar?y=2026&m=12'
Check 'L-02a' '月份切换到 2026-12' ((Has $r.Content '2026 年 12 月')) ""
$r = Send $admin 'GET' '/calendar?y=2027&m=1'
Check 'L-02b' '跨年切换到 2027-01' ((Has $r.Content '2027 年 1 月')) ""
$r = Send $admin 'GET' '/calendar?y=2020&m=1'
Check 'L-03' '「回到今天」入口存在' ((Has $r.Content '回到今天')) ""
$cal = (Send $admin 'GET' "/calendar?d=$today").Content
Check 'L-04' '格子显示色签与负荷' ((Has $cal '单测') -and (Has $cal '包月') -and (Has $cal 'heat-')) ""
Check 'L-05' '点日期切换抽屉' ((Has $cal ($T.ToString('M 月 d 日'))) -and (Has $cal '当日待打')) ""
$r = Send $admin 'GET' "/calendar?d=$(D -1)"
Check 'L-06' '过去日期标记已过去' ((Has $r.Content '已过去')) ""
Check 'L-07' '近 7 天到期区块' ((Has $cal '近 7 天到期')) ""
Check 'L-08' '无有效期按次订阅不进日历' (-not (Has $cal ("/subscriptions/$p3ID/checkin"))) ""
$css = (Send $admin 'GET' '/static/app.css').Content
Check 'L-09' '移动端样式规则存在' ((Has $css '@media \(max-width: 560px\)') -and (Has $cal 'viewport')) ""
Write-Host "`n=== 12. 大屏(D) ===" -ForegroundColor Cyan
$r = Send $anon 'GET' '/screen'
$d1Ok = if ($screenPublic) { $r.Status -eq 200 -and $r.Path -eq '/screen' } else { $r.Path -like '/login*' }
Check 'D-01' '大屏访问符合 screen_public 配置' $d1Ok "path=$($r.Path) public=$screenPublic"
$screen = (Send $admin 'GET' '/screen').Content
$customers = (Send $admin 'GET' '/customers').Content
$custN = [regex]::Match($customers, '共 (\d+) 位').Groups[1].Value
$dueToday = [regex]::Match((Send $admin 'GET' '/subscriptions?state=due_today').Content, '共 (\d+) 条').Groups[1].Value
$due7 = [regex]::Match((Send $admin 'GET' '/subscriptions?state=due7').Content, '共 (\d+) 条').Groups[1].Value
$kpiCust = [regex]::Match($screen, '(?s)客户</span>\s*<b>(\d+)</b>').Groups[1].Value
$kpiDue = [regex]::Match($screen, '(?s)今日到期</span>\s*<b>(\d+)</b>').Groups[1].Value
$kpi7 = [regex]::Match($screen, '(?s)7 天内到期</span>\s*<b>(\d+)</b>').Groups[1].Value
Check 'D-02' '四张 KPI 与后台一致' (($kpiCust -eq $custN) -and ($kpiDue -eq $dueToday) -and ($kpi7 -eq $due7)) "cust=$kpiCust/$custN due=$kpiDue/$dueToday 7=$kpi7/$due7"
Check 'D-03' '热力月历渲染' ((Has $screen '到期热力') -and (Has $screen 'heat-cell')) ""
$topN = [regex]::Matches($screen, 'class="top-item').Count
Check 'D-04' 'TOP 榜不超过 10 条' ((Has $screen '即将到期 TOP 10') -and ($topN -ge 1) -and ($topN -le 10)) "count=$topN"
$todayTasks = [regex]::Match($cal, '(\d+) 待打').Groups[1].Value
$screenTasks = [regex]::Match($screen, '<span>(\d+) / (\d+) 已完成</span>').Groups[2].Value
Check 'D-05' '今日待打数与日历一致' ($todayTasks -eq $screenTasks -and $todayTasks -ne '') "calendar=$todayTasks screen=$screenTasks"
Check 'D-06' '最近追加流水渲染' ((Has $screen '最近追加')) ""
Check 'D-07' '自动刷新秒数取自设置' ((Has $screen '<meta http-equiv="refresh" content="30">')) ""
Write-Host "`n=== 13. 日志 / 导出 / 备份(R) ===" -ForegroundColor Cyan
$r = Send $admin 'GET' '/logs'
Check 'R-01' '日志列表倒序分页' ((Has $r.Content '共 ') -and (Has $r.Content '操作人')) ""
$r = Send $admin 'GET' '/logs?q=%E6%89%93%E5%8D%A1'
Check 'R-02' '日志搜索命中' ((Has $r.Content '打卡')) ""
$r = Send $admin 'GET' '/logs'
Check 'R-03' '关键动作均入库' ((Has $r.Content '追加时长') -and (Has $r.Content '暂停订阅') -and (Has $r.Content '打卡') -and (Has $r.Content '新建客户')) ""
$respCsv = $admin.GetAsync("$Base/export/customers.csv").Result
$bytes = $respCsv.Content.ReadAsByteArrayAsync().Result
$csv = [System.Text.Encoding]::UTF8.GetString($bytes)
$bom = ($bytes.Length -gt 2) -and ($bytes[0] -eq 0xEF) -and ($bytes[1] -eq 0xBB) -and ($bytes[2] -eq 0xBF)
$lines = ($csv -split "`n").Count
$custNow = [int][regex]::Match((Send $admin 'GET' '/customers').Content, '共 (\d+) 位').Groups[1].Value
Check 'R-04' '客户 CSV:BOM + 表头 + 行数匹配' ($bom -and (Has $csv '手机尾号') -and ($lines -ge ($custNow + 1))) "bom=$bom lines=$lines customers=$custNow"
$csv2 = (Send $admin 'GET' '/export/subscriptions.csv').Content
Check 'R-05' '订阅 CSV 含剩余/类型/状态' ((Has $csv2 '剩余') -and (Has $csv2 '类型') -and (Has $csv2 '原文状态')) ""
$csv3 = (Send $admin 'GET' '/export/logs.csv').Content
Check 'R-06' '日志 CSV 含操作人/动作' ((Has $csv3 '操作人') -and (Has $csv3 '动作')) ""
$r = Send $admin 'POST' '/settings/backup'
Check 'R-07' '手动备份生成文件' ((Has $r.Content '已备份')) "flash=$(Flash $r.Content)"
$verify = (& $Python 'F:\hupeE的代肝平台\scripts\verify-backup.py' 'F:\hupeE的代肝平台\testrun\backup') -join ' '
Check 'R-07b' '备份文件可被标准 SQLite 打开且有数据' (($verify -match 'OK') -and ($verify -match 'customers\s+(\d+)')) $verify
Send $admin 'POST' '/settings' @{ warn_days = '3'; screen_refresh = '30' } | Out-Null
$keepShown = [int][regex]::Match((Send $admin 'GET' '/settings').Content, '保留 (\d+) 份').Groups[1].Value
for ($i = 0; $i -lt 3; $i++) {
Start-Sleep -Milliseconds 1200
Send $admin 'POST' '/settings/backup' | Out-Null
}
$bk = @(Get-ChildItem 'F:\hupeE的代肝平台\testrun\backup' -Filter 'daili-*.db')
$pruneOk = ($keepShown -ge 1) -and ($bk.Count -le $keepShown) -and (($keepShown -gt 3) -or ($bk.Count -eq $keepShown))
Check 'R-08' "备份保留份数生效(配置 $keepShown 份)" $pruneOk "count=$($bk.Count) keep=$keepShown"
Write-Host "`n=== 14. 非功能 / 安全(N) ===" -ForegroundColor Cyan
function TimeOf([string]$path) {
$sw = [Diagnostics.Stopwatch]::StartNew()
Send $admin 'GET' $path | Out-Null
$sw.Stop()
return $sw.ElapsedMilliseconds
}
$tCal = @(1..3 | ForEach-Object { TimeOf "/calendar?d=$today" } | Measure-Object -Minimum).Minimum
$tCust = @(1..3 | ForEach-Object { TimeOf '/customers' } | Measure-Object -Minimum).Minimum
$tScr = @(1..3 | ForEach-Object { TimeOf '/screen' } | Measure-Object -Minimum).Minimum
Check 'N-01' '页面响应时间达标' (($tCal -lt 150) -and ($tCust -lt 100) -and ($tScr -lt 100)) "calendar=${tCal}ms customers=${tCust}ms screen=${tScr}ms"
Write-Host " (造 300 条订阅后复测日历)" -ForegroundColor DarkGray
Dbtool @('bulk', $Db, '300') | Out-Null
$tBulk = @(1..3 | ForEach-Object { TimeOf "/calendar?d=$today" } | Measure-Object -Minimum).Minimum
Check 'N-02' '300 条订阅下日历仍快' ($tBulk -lt 500) "calendar=${tBulk}ms"
$r = Send $admin 'POST' '/customers' @{ tail = '0002'; name = '<script>alert(1)</script>' }
$xssID = ($r.Path -split '/')[-1]
$xss = (Send $admin 'GET' "/customers/$xssID").Content
Check 'N-03' 'XSS 内容被转义' ((-not (Has $xss '<script>alert\(1\)</script>')) -and (Has $xss '&lt;script&gt;')) ""
$r = Send $admin 'GET' '/customers/999999'
Check 'N-04' '不存在的客户返回 404' ($r.Status -eq 404) "status=$($r.Status)"
$r = Send $admin 'GET' '/nope'
Check 'N-07' '未知路径返回 404' ($r.Status -eq 404) "status=$($r.Status)"
$hdr = (& curl.exe -s -i -X POST -d 'username=admin&password=daili123&next=/calendar' "$Base/login") -join "`n"
$hasHttpOnly = $hdr -match 'Set-Cookie: daili_sid=[^;]+;[^\r\n]*HttpOnly'
$hasSameSite = $hdr -match 'SameSite=Lax'
Check 'N-05' '会话 Cookie 含 HttpOnly + SameSite' ($hasHttpOnly -and $hasSameSite) "HttpOnly=$hasHttpOnly SameSite=$hasSameSite"
Write-Host "`n=== 15. 账户收尾(A) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' '/settings/users' @{ username = 'helper'; display_name = '小工'; password = 'helper123' }
Check 'A-08a' '新增第二个管理员' ((Has $r.Content '已创建')) "flash=$(Flash $r.Content)"
$helper = New-Sess
$r = Send $helper 'POST' '/login' @{ username = 'helper'; password = 'helper123'; next = '/customers' }
$helperView = (Send $helper 'GET' '/customers').Content
$helperN = [regex]::Match($helperView, '共 (\d+) 位').Groups[1].Value
$adminView = (Send $admin 'GET' '/customers').Content
$adminN = [regex]::Match($adminView, '共 (\d+) 位').Groups[1].Value
Check 'A-08b' '多管理员共享同一套数据' ($helperN -eq $adminN -and $helperN -ne '') "helper=$helperN admin=$adminN"
$settingsHtml = (Send $admin 'GET' '/settings').Content
$toggles = [regex]::Matches($settingsHtml, '/settings/users/\d+/toggle').Count
Check 'A-10' '不能停用自己的账户' ((Has $settingsHtml '当前账户') -and $toggles -eq 1) "toggles=$toggles"
$helperID = [regex]::Match($settingsHtml, '/settings/users/(\d+)/toggle').Groups[1].Value
$r = Send $admin 'POST' "/settings/users/$helperID/toggle"
Check 'A-09a' '停用账户' ((Has $r.Content '停用账户成功')) "flash=$(Flash $r.Content)"
$helper2 = New-Sess
$r = Send $helper2 'POST' '/login' @{ username = 'helper'; password = 'helper123'; next = '/calendar' }
Check 'A-09b' '停用后无法登录' ($r.Status -eq 400 -and (Has $r.Content '用户名或密码不正确')) "status=$($r.Status)"
Send $admin 'POST' "/settings/users/$helperID/toggle" | Out-Null
$r = Send $admin 'POST' '/settings/password' @{ old_password = 'nope'; password = 'daili456'; password2 = 'daili456' }
Check 'A-11a' '原密码错误被拒' ((Has $r.Content '原密码不正确')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' '/settings/password' @{ old_password = 'daili123'; password = 'daili456'; password2 = 'daili456' }
Check 'A-11b' '修改密码成功' ((Has $r.Content '密码已更新')) "flash=$(Flash $r.Content)"
$old = New-Sess
$r = Send $old 'POST' '/login' @{ username = 'admin'; password = 'daili123'; next = '/calendar' }
$oldFail = ($r.Status -eq 400 -and (Has $r.Content '用户名或密码不正确'))
$new = New-Sess
$r = Send $new 'POST' '/login' @{ username = 'admin'; password = 'daili456'; next = '/calendar' }
Check 'A-11c' '旧密码失效、新密码可用' ($oldFail -and $r.Path -eq '/calendar') "oldFail=$oldFail path=$($r.Path)"
# 回滚口令,避免影响后续人工验证
Send $admin 'POST' '/settings/password' @{ old_password = 'daili456'; password = 'daili123'; password2 = 'daili123' } | Out-Null
Write-Host "`n=== 16. 配置文件(CF) ===" -ForegroundColor Cyan
$settingsHtml = (Send $admin 'GET' '/settings').Content
Check 'CF-01' '设置页展示配置文件路径' ((Has $settingsHtml 'daili.ini')) ""
Check 'CF-02' '设置页展示备份目录与保留份数' ((Has $settingsHtml '备份') -and (Has $settingsHtml '保留')) ""
$anon2 = New-Sess
$r = Send $anon2 'GET' '/screen'
Check 'CF-03' '大屏免登录(screen_public = true)' ($r.Status -eq 200 -and $r.Path -eq '/screen') "status=$($r.Status) path=$($r.Path)"
$logs = @(Get-ChildItem 'F:\hupeE的代肝平台\testrun\logs' -Filter '*.log' -ErrorAction SilentlyContinue)
$logText = ''
if ($logs.Count -ge 1) { $logText = Get-Content $logs[0].FullName -Raw -Encoding UTF8 }
Check 'CF-04' '运行日志落盘' ($logs.Count -ge 1 -and $logText.Length -gt 20) "files=$($logs.Count) bytes=$($logText.Length)"
Write-Host "`n=== 汇总 ===" -ForegroundColor Cyan
$pass = @($script:results | Where-Object { $_.Result -eq 'PASS' }).Count
$fail = @($script:results | Where-Object { $_.Result -eq 'FAIL' }).Count
Write-Host (" 通过 {0} 项,失败 {1} 项,共 {2} 项" -f $pass, $fail, $script:results.Count) -ForegroundColor ($(if ($fail -eq 0) { 'Green' } else { 'Red' }))
$script:results | ForEach-Object { "{0}|{1}|{2}|{3}" -f $_.ID, $_.Result, $_.Name, $_.Detail } |
Set-Content -Path $LogFile -Encoding UTF8
Write-Host (" 结果已写入 {0}" -f $LogFile) -ForegroundColor DarkGray
if ($fail -gt 0) {
Write-Host "`n失败明细:" -ForegroundColor Red
$script:results | Where-Object { $_.Result -eq 'FAIL' } | ForEach-Object {
Write-Host (" {0} {1} {2}" -f $_.ID, $_.Name, $_.Detail) -ForegroundColor Red
}
exit 1
}
exit 0
+233
View File
@@ -0,0 +1,233 @@
# 配置文件专项测试:验证 daili.ini 的生成、解析、覆盖与各项生效
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\config-test.ps1
param(
[string]$Exe = 'F:\hupeE的代肝平台\daili.exe',
[string]$Work = 'F:\hupeE的代肝平台\cfgtest-run',
[string]$LanIp = ''
)
$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Add-Type -AssemblyName System.Net.Http
$script:fail = 0
$script:results = @()
function Check {
param([string]$Id, [string]$Name, $Cond, [string]$Detail = '')
if ($Cond -eq $true) {
Write-Host (" [PASS] {0} {1}" -f $Id, $Name) -ForegroundColor Green
$script:results += "PASS|$Id|$Name|$Detail"
} else {
Write-Host (" [FAIL] {0} {1} {2}" -f $Id, $Name, $Detail) -ForegroundColor Red
$script:results += "FAIL|$Id|$Name|$Detail"
$script:fail++
}
}
function New-Client {
$h = New-Object System.Net.Http.HttpClientHandler
$h.UseCookies = $true
$h.AllowAutoRedirect = $true
$c = New-Object System.Net.Http.HttpClient($h)
$c.Timeout = [TimeSpan]::FromSeconds(10)
return $c
}
function Post-Form($client, $url, $kv) {
$pairs = New-Object 'System.Collections.Generic.List[System.Collections.Generic.KeyValuePair[string,string]]'
foreach ($k in $kv.Keys) {
$pairs.Add([System.Collections.Generic.KeyValuePair[string,string]]::new($k, [string]$kv[$k]))
}
return $client.PostAsync($url, [System.Net.Http.FormUrlEncodedContent]::new($pairs)).Result
}
function Get-Status($client, $url) {
try {
$r = $client.GetAsync($url).Result
return @{ Code = [int]$r.StatusCode; Path = $r.RequestMessage.RequestUri.AbsolutePath; Body = $r.Content.ReadAsStringAsync().Result }
} catch {
return @{ Code = 0; Path = ''; Body = '' }
}
}
function Wait-Up([int]$port, [int]$seconds = 15) {
$c = New-Client
for ($i = 0; $i -lt ($seconds * 4); $i++) {
$r = Get-Status $c "http://127.0.0.1:$port/healthz"
if ($r.Code -eq 200) { return $true }
Start-Sleep -Milliseconds 250
}
return $false
}
function Start-App([string]$dir, [string[]]$extra, [int]$port) {
$out = Join-Path $dir 'stdout.log'
$err = Join-Path $dir 'stderr.log'
$p = Start-Process -FilePath (Join-Path $dir 'daili.exe') -ArgumentList $extra -WorkingDirectory $dir `
-WindowStyle Hidden -PassThru -RedirectStandardOutput $out -RedirectStandardError $err
$ok = Wait-Up $port
return @{ Proc = $p; Out = $out; Err = $err; Up = $ok }
}
function Stop-App($h) {
if ($h -and $h.Proc) { Stop-Process -Id $h.Proc.Id -Force -ErrorAction SilentlyContinue }
Start-Sleep -Milliseconds 600
}
function New-CaseDir([string]$name) {
$dir = Join-Path $Work $name
Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $dir | Out-Null
Copy-Item $Exe (Join-Path $dir 'daili.exe') -Force
return $dir
}
function Write-Conf([string]$dir, [string]$text) {
[System.IO.File]::WriteAllText((Join-Path $dir 'daili.ini'), $text, (New-Object System.Text.UTF8Encoding($false)))
}
if (-not $LanIp) {
$LanIp = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.PrefixOrigin -ne 'WellKnown' } |
Select-Object -First 1 -ExpandProperty IPAddress)
}
if (-not $LanIp) { $LanIp = '192.168.1.1' }
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Work | Out-Null
Write-Host "`n=== 配置文件专项测试(LAN IP: $LanIp) ===" -ForegroundColor Cyan
$c = New-Client
# ---------- CFG-01 / CFG-02:首启生成默认配置 + 默认端口 17777 ----------
Write-Host "`n-- CFG-01/02 首次启动生成默认配置 --" -ForegroundColor Cyan
$d1 = New-CaseDir 'default'
$h = Start-App $d1 @('-no-open') 17777
$ini = Join-Path $d1 'daili.ini'
Check 'CFG-01a' '首次启动自动生成 daili.ini' (Test-Path $ini)
$iniText = Get-Content $ini -Raw -Encoding UTF8
Check 'CFG-01b' '默认配置含注释与默认监听' (($iniText -match '代打台 配置文件') -and ($iniText -match 'listen_ip = 0\.0\.0\.0') -and ($iniText -match 'listen_port = 17777') -and ($iniText -match 'screen_public = true'))
$r = Get-Status $c 'http://127.0.0.1:17777/healthz'
Check 'CFG-02' '默认监听 17777 可用' ($h.Up -and $r.Code -eq 200) "health=$($r.Code)"
Stop-App $h
$logDir = Join-Path $d1 'logs'
$logs = @(Get-ChildItem $logDir -Filter 'daili-*.log' -ErrorAction SilentlyContinue)
$logText = ''
if ($logs.Count -ge 1) { $logText = Get-Content $logs[0].FullName -Raw -Encoding UTF8 }
Check 'CFG-07' '运行日志按天落盘' ($logs.Count -ge 1 -and $logText.Length -gt 20) "files=$($logs.Count) bytes=$($logText.Length)"
# ---------- CFG-03:只监听本机 ----------
Write-Host "`n-- CFG-03 仅本机监听 --" -ForegroundColor Cyan
$d2 = New-CaseDir 'loopback'
Write-Conf $d2 @"
listen_ip = 127.0.0.1
listen_port = 17901
open_browser = false
log_enabled = false
backup_enabled = false
"@
$h = Start-App $d2 @('-no-open') 17901
$local = (Test-NetConnection -ComputerName 127.0.0.1 -Port 17901 -InformationLevel Quiet -WarningAction SilentlyContinue)
$lan = (Test-NetConnection -ComputerName $LanIp -Port 17901 -InformationLevel Quiet -WarningAction SilentlyContinue)
Check 'CFG-03' '127.0.0.1 只开本机,局域网不可连' ($h.Up -and $local -and -not $lan) "local=$local lan=$lan"
$banner = Get-Content (Join-Path $d2 'stdout.log') -Raw -Encoding UTF8
Check 'CFG-03b' '横幅不再打印局域网地址' (-not ($banner -match '局域网')) ""
Stop-App $h
# ---------- CFG-04:非法配置报错退出且不改文件 ----------
Write-Host "`n-- CFG-04 非法配置 --" -ForegroundColor Cyan
$d3 = New-CaseDir 'invalid'
Write-Conf $d3 "# 故意写错`nlisten_ip = 0.0.0.0`nlisten_port = 99999`n"
$badIni = Join-Path $d3 'daili.ini'
$hashBefore = (Get-FileHash $badIni -Algorithm SHA256).Hash
$outText = & (Join-Path $d3 'daili.exe') -no-open 2>&1 | Out-String
$code = $LASTEXITCODE
$hashAfter = (Get-FileHash $badIni -Algorithm SHA256).Hash
Check 'CFG-04a' '非法配置退出码为 1' ($code -eq 1) "code=$code"
Check 'CFG-04b' '错误信息指出行号' ($outText -match '第 3 行' -and $outText -match '1-65535') ""
Check 'CFG-04c' '原配置文件未被改动' ($hashBefore -eq $hashAfter) ""
# ---------- CFG-05:命令行覆盖配置 ----------
Write-Host "`n-- CFG-05 命令行覆盖 --" -ForegroundColor Cyan
$d4 = New-CaseDir 'override'
Write-Conf $d4 @"
listen_ip = 127.0.0.1
listen_port = 17902
open_browser = false
log_enabled = false
backup_enabled = false
"@
$h = Start-App $d4 @('-no-open', '-addr', '127.0.0.1:17903') 17903
$p17902 = (Test-NetConnection -ComputerName 127.0.0.1 -Port 17902 -InformationLevel Quiet -WarningAction SilentlyContinue)
Check 'CFG-05' '-addr 覆盖配置文件端口' ($h.Up -and -not $p17902) "up17903=$($h.Up) up17902=$p17902"
Stop-App $h
# ---------- CFG-06:大屏鉴权开关 ----------
Write-Host "`n-- CFG-06 大屏访问控制 --" -ForegroundColor Cyan
$d5 = New-CaseDir 'screen-private'
Write-Conf $d5 @"
listen_ip = 127.0.0.1
listen_port = 17904
open_browser = false
screen_public = false
log_enabled = false
backup_enabled = false
"@
$h = Start-App $d5 @('-no-open') 17904
$anon = New-Client
$r = Get-Status $anon 'http://127.0.0.1:17904/screen'
Check 'CFG-06' 'screen_public=false 时大屏需登录' (($r.Path -like '/login*') -or ($r.Path -eq '/setup')) "path=$($r.Path)"
Stop-App $h
# ---------- CFG-08:备份目录与保留份数来自配置 ----------
Write-Host "`n-- CFG-08 备份目录与保留份数 --" -ForegroundColor Cyan
$d6 = New-CaseDir 'backup'
$bdir = Join-Path $d6 'my-backup'
Write-Conf $d6 @"
listen_ip = 127.0.0.1
listen_port = 17905
open_browser = false
log_enabled = false
backup_enabled = true
backup_dir = $bdir
backup_keep = 1
"@
$h = Start-App $d6 @('-no-open') 17905
$c6 = New-Client
Post-Form $c6 'http://127.0.0.1:17905/setup' @{ username = 'admin'; display_name = '老板'; password = 'daili123'; password2 = 'daili123' } | Out-Null
Post-Form $c6 'http://127.0.0.1:17905/packages' @{ name = '包月日常'; unit = 'day'; default_amount = '30'; color = '#2F5D50'; sort_order = '1' } | Out-Null
Post-Form $c6 'http://127.0.0.1:17905/customers' @{ tail = '3001'; name = '备份客户'; game_name = ''; game_server = ''; contact = ''; tags = ''; source = ''; note = '' } | Out-Null
Start-Sleep -Milliseconds 1200
Post-Form $c6 'http://127.0.0.1:17905/settings/backup' @{} | Out-Null
Start-Sleep -Milliseconds 1200
Post-Form $c6 'http://127.0.0.1:17905/settings/backup' @{} | Out-Null
$files = @(Get-ChildItem $bdir -Filter '*.db' -ErrorAction SilentlyContinue)
Check 'CFG-08a' '备份写入配置指定的目录' ($files.Count -ge 1) "dir=$bdir files=$($files.Count)"
Check 'CFG-08b' 'backup_keep=1 时只保留 1 份' ($files.Count -eq 1) "files=$($files.Count)"
Stop-App $h
# ---------- CFG-09:时区固定 ----------
Write-Host "`n-- CFG-09 时区固定 --" -ForegroundColor Cyan
$d7 = New-CaseDir 'timezone'
Write-Conf $d7 @"
listen_ip = 127.0.0.1
listen_port = 17906
open_browser = false
log_enabled = true
backup_enabled = false
timezone = Asia/Shanghai
"@
$h = Start-App $d7 @('-no-open') 17906
$errText = Get-Content (Join-Path $d7 'stderr.log') -Raw -Encoding UTF8
Check 'CFG-09' '时区固定为 Asia/Shanghai 生效' ($errText -match '时区已固定为 Asia/Shanghai') ""
Stop-App $h
# ---------- 汇总 ----------
$pass = @($script:results | Where-Object { $_ -like 'PASS|*' }).Count
$fail = @($script:results | Where-Object { $_ -like 'FAIL|*' }).Count
Write-Host "`n=== 汇总:通过 $pass 项,失败 $fail 项 ===" -ForegroundColor ($(if ($fail -eq 0) { 'Green' } else { 'Red' }))
New-Item -ItemType Directory -Force -Path 'F:\hupeE的代肝平台\test-evidence' | Out-Null
$script:results | Set-Content 'F:\hupeE的代肝平台\test-evidence\config-result.txt' -Encoding UTF8
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
if ($fail -gt 0) { exit 1 }
exit 0
+94
View File
@@ -0,0 +1,94 @@
"""测试辅助:直接读写测试库,用于构造 HTTP 层做不到的前置条件。
用法:
python dbtool.py backdate-pause <db> <sub_id> <days> 把暂停开始时间提前 N 天
python dbtool.py bulk <db> <count> 批量造客户+按日订阅(包月日常)
python dbtool.py exec <db> <sql> 执行一条 SQL
python dbtool.py scalar <db> <sql> 输出第一条结果的第一列
python dbtool.py integrity <db> 完整性检查
"""
import sqlite3
import sys
import datetime
def connect(path):
con = sqlite3.connect(path, timeout=10)
con.execute("PRAGMA busy_timeout=10000")
con.execute("PRAGMA foreign_keys=ON")
return con
def backdate_pause(db, sub_id, days):
con = connect(db)
now = datetime.datetime.now() - datetime.timedelta(days=int(days))
stamp = now.strftime("%Y-%m-%d %H:%M:%S")
cur = con.execute(
"UPDATE sub_pauses SET start_at=? WHERE sub_id=? AND end_at IS NULL",
(stamp, int(sub_id)))
con.commit()
print("updated rows:", cur.rowcount, "start_at:", stamp)
con.close()
def bulk(db, count):
con = connect(db)
today = datetime.date.today()
start = (today - datetime.timedelta(days=5)).isoformat()
end = (today + datetime.timedelta(days=25)).isoformat()
stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
pkg = con.execute("SELECT id FROM packages WHERE unit='day' ORDER BY id LIMIT 1").fetchone()
if not pkg:
print("FAIL: 没有按日套餐")
sys.exit(1)
pkg_id = pkg[0]
created = 0
for i in range(int(count)):
tail = "B%04d" % i
con.execute(
"INSERT OR IGNORE INTO customers(tail,name,created_at,updated_at) VALUES(?,?,?,?)",
(tail, "批量客户%d" % i, stamp, stamp))
row = con.execute("SELECT id FROM customers WHERE tail=?", (tail,)).fetchone()
cid = row[0]
con.execute(
"INSERT OR IGNORE INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,"
"total_count,used_count,created_at,updated_at) VALUES(?,?,'day','active',?,?,0,0,?,?)",
(cid, pkg_id, start, end, stamp, stamp))
created += 1
con.commit()
total = con.execute("SELECT COUNT(*) FROM subscriptions").fetchone()[0]
print("bulk customers:", created, "subscriptions total:", total)
con.close()
def main():
if len(sys.argv) < 3:
print(__doc__)
sys.exit(2)
cmd = sys.argv[1]
db = sys.argv[2]
if cmd == "backdate-pause":
backdate_pause(db, sys.argv[3], sys.argv[4])
elif cmd == "bulk":
bulk(db, sys.argv[3])
elif cmd == "exec":
con = connect(db)
cur = con.execute(sys.argv[3])
con.commit()
print("rows:", cur.rowcount)
con.close()
elif cmd == "scalar":
con = connect(db)
print(con.execute(sys.argv[3]).fetchone()[0])
con.close()
elif cmd == "integrity":
con = connect(db)
print(con.execute("PRAGMA integrity_check").fetchone()[0])
con.close()
else:
print("未知命令:", cmd)
sys.exit(2)
if __name__ == "__main__":
main()
+160
View File
@@ -0,0 +1,160 @@
# 数据可移植性测试:单文件、只拷 daili.db 无损迁移、删库即重置
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\portability-test.ps1
param(
[string]$Exe = 'F:\hupeE的代肝平台\daili.exe',
[string]$Work = 'F:\hupeE的代肝平台\portable-run',
[int]$PortA = 17781,
[int]$PortB = 17782
)
$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Add-Type -AssemblyName System.Net.Http
$script:fail = 0
$script:results = @()
function Check {
param([string]$Id, [string]$Name, $Cond, [string]$Detail = '')
if ($Cond -eq $true) {
Write-Host (" [PASS] {0} {1}" -f $Id, $Name) -ForegroundColor Green
$script:results += "PASS|$Id|$Name|$Detail"
} else {
Write-Host (" [FAIL] {0} {1} {2}" -f $Id, $Name, $Detail) -ForegroundColor Red
$script:results += "FAIL|$Id|$Name|$Detail"
$script:fail++
}
}
function New-Client {
$h = New-Object System.Net.Http.HttpClientHandler
$h.UseCookies = $true
$h.AllowAutoRedirect = $true
$c = New-Object System.Net.Http.HttpClient($h)
$c.Timeout = [TimeSpan]::FromSeconds(10)
return $c
}
function Post-Form($client, $url, $kv) {
$pairs = New-Object 'System.Collections.Generic.List[System.Collections.Generic.KeyValuePair[string,string]]'
foreach ($k in $kv.Keys) { $pairs.Add([System.Collections.Generic.KeyValuePair[string,string]]::new($k, [string]$kv[$k])) }
return $client.PostAsync($url, [System.Net.Http.FormUrlEncodedContent]::new($pairs)).Result
}
function Get-Page($client, $url) {
try {
$r = $client.GetAsync($url).Result
return @{ Code = [int]$r.StatusCode; Path = $r.RequestMessage.RequestUri.AbsolutePath; Body = $r.Content.ReadAsStringAsync().Result }
} catch { return @{ Code = 0; Path = ''; Body = '' } }
}
function Wait-Up([int]$port, [int]$seconds = 15) {
$c = New-Client
for ($i = 0; $i -lt ($seconds * 4); $i++) {
if ((Get-Page $c "http://127.0.0.1:$port/healthz").Code -eq 200) { return $true }
Start-Sleep -Milliseconds 250
}
return $false
}
function Start-App([string]$dir, [int]$port) {
$p = Start-Process -FilePath (Join-Path $dir 'daili.exe') -ArgumentList @('-addr', "127.0.0.1:$port", '-no-open') `
-WorkingDirectory $dir -WindowStyle Hidden -PassThru `
-RedirectStandardOutput (Join-Path $dir 'stdout.log') -RedirectStandardError (Join-Path $dir 'stderr.log')
return @{ Proc = $p; Up = (Wait-Up $port) }
}
function Stop-App($h) {
if ($h -and $h.Proc) { Stop-Process -Id $h.Proc.Id -Force -ErrorAction SilentlyContinue }
Start-Sleep -Milliseconds 700
}
function New-AppDir([string]$name) {
$dir = Join-Path $Work $name
Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $dir | Out-Null
Copy-Item $Exe (Join-Path $dir 'daili.exe') -Force
return $dir
}
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Work | Out-Null
$today = (Get-Date).Date.ToString('yyyy-MM-dd')
$newEnd = (Get-Date).Date.AddDays(45).ToString('yyyy-MM-dd')
Write-Host "`n=== 数据可移植性测试 ===" -ForegroundColor Cyan
# ---------- 1. A 目录造数据 ----------
Write-Host "`n-- 1. 在 A 目录造数据 --" -ForegroundColor Cyan
$dirA = New-AppDir 'A'
$hA = Start-App $dirA $PortA
Check 'P-01' 'A 目录启动成功' $hA.Up ""
$cA = New-Client
Post-Form $cA "http://127.0.0.1:$PortA/setup" @{ username = 'admin'; display_name = '老板'; password = 'daili123'; password2 = 'daili123' } | Out-Null
Post-Form $cA "http://127.0.0.1:$PortA/packages" @{ name = '包月日常'; unit = 'day'; default_amount = '30'; price = '300'; color = '#2F5D50'; note = ''; sort_order = '1' } | Out-Null
Post-Form $cA "http://127.0.0.1:$PortA/customers" @{ tail = '8001'; name = '迁移客户'; game_name = '王者荣耀'; game_server = ''; contact = 'wx:8001'; tags = '老客户'; source = ''; note = '迁移前数据' } | Out-Null
Post-Form $cA "http://127.0.0.1:$PortA/customers/1/subscriptions" @{ package_id = '1'; start_date = $today; days = '30'; note = '首单' } | Out-Null
$detail = (Get-Page $cA "http://127.0.0.1:$PortA/customers/1").Body
$subId = [regex]::Match($detail, 'id="sub-(\d+)"').Groups[1].Value
Post-Form $cA "http://127.0.0.1:$PortA/subscriptions/$subId/extend" @{ new_end_date = $newEnd; note = '迁移前续费'; back = '/customers/1' } | Out-Null
Post-Form $cA "http://127.0.0.1:$PortA/subscriptions/$subId/checkin" @{ date = $today; status = 'done'; note = '迁移前打卡'; back = '/customers/1' } | Out-Null
$detail = (Get-Page $cA "http://127.0.0.1:$PortA/customers/1").Body
Check 'P-02' '造数成功(剩 46 天 + 打卡)' ($detail -match '剩 46 天' -and $detail -match '迁移前打卡') ""
Stop-App $hA
# ---------- 2. 关闭后应当只有一个数据库文件 ----------
Write-Host "`n-- 2. 关闭后检查文件形态 --" -ForegroundColor Cyan
$dbA = Join-Path $dirA 'daili.db'
$sidecars = @()
foreach ($s in '-wal', '-shm', '-journal') {
if (Test-Path ($dbA + $s)) { $sidecars += $s }
}
Check 'P-03' '关闭后只有 daili.db(无 -wal/-shm/-journal)' ((Test-Path $dbA) -and $sidecars.Count -eq 0) "sidecars=$($sidecars -join ',')"
$sizeA = (Get-Item $dbA).Length
Check 'P-04' '主库文件包含全部数据(远大于空库 4KB)' ($sizeA -gt 20000) "size=$sizeA"
# ---------- 3. 只拷贝 daili.db 到 B 目录 ----------
Write-Host "`n-- 3. 只拷贝 daili.db 到 B 目录 --" -ForegroundColor Cyan
$dirB = New-AppDir 'B'
Copy-Item $dbA (Join-Path $dirB 'daili.db') -Force
$hB = Start-App $dirB $PortB
Check 'P-05' 'B 目录用拷贝的库启动成功' $hB.Up ""
$cB = New-Client
$login = Post-Form $cB "http://127.0.0.1:$PortB/login" @{ username = 'admin'; password = 'daili123'; next = '/customers' }
$page = (Get-Page $cB "http://127.0.0.1:$PortB/customers").Body
Check 'P-06' '原管理员账号可用(口令随库迁移)' ($page -match '共 1 位' -and $page -match '迁移客户') ""
$detailB = (Get-Page $cB "http://127.0.0.1:$PortB/customers/1").Body
Check 'P-07' '订阅与剩余天数完整' ($detailB -match '剩 46 天') ""
Check 'P-08' '打卡与追加记录完整' ($detailB -match '迁移前打卡' -and $detailB -match '迁移前续费') ""
Check 'P-09' '套餐配置完整' ((Get-Page $cB "http://127.0.0.1:$PortB/packages").Body -match '包月日常') ""
$logs = (Get-Page $cB "http://127.0.0.1:$PortB/logs").Body
Check 'P-10' '操作日志完整' ($logs -match '追加时长' -and $logs -match '打卡') ""
Stop-App $hB
for ($i = 0; $i -lt 10 -and (Test-Path ($dbA + '-wal')); $i++) { Start-Sleep -Milliseconds 200 }
$bSide = @()
foreach ($s in '-wal', '-shm', '-journal') { if (Test-Path ((Join-Path $dirB 'daili.db') + $s)) { $bSide += $s } }
Check 'P-11' 'B 目录运行后仍无旁路文件' ($bSide.Count -eq 0) "sidecars=$($bSide -join ',')"
# ---------- 4. 删除 daili.db = 重置 ----------
Write-Host "`n-- 4. 删除 daili.db 重置数据 --" -ForegroundColor Cyan
foreach ($s in '', '-wal', '-shm', '-journal') { Remove-Item ($dbA + $s) -Force -ErrorAction SilentlyContinue }
$hA2 = Start-App $dirA $PortA
Check 'P-12' '删库后可正常启动' $hA2.Up ""
$cA2 = New-Client
$root = Get-Page $cA2 "http://127.0.0.1:$PortA/"
Check 'P-13' '删库后回到初始化(数据已清空)' ($root.Path -eq '/setup' -and $root.Body -match '创建第一个管理员') "path=$($root.Path)"
Stop-App $hA2
# ---------- 汇总 ----------
$pass = @($script:results | Where-Object { $_ -like 'PASS|*' }).Count
$fail = @($script:results | Where-Object { $_ -like 'FAIL|*' }).Count
Write-Host "`n=== 汇总:通过 $pass 项,失败 $fail 项 ===" -ForegroundColor ($(if ($fail -eq 0) { 'Green' } else { 'Red' }))
New-Item -ItemType Directory -Force -Path 'F:\hupeE的代肝平台\test-evidence' | Out-Null
$script:results | Set-Content 'F:\hupeE的代肝平台\test-evidence\portability-result.txt' -Encoding UTF8
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
if ($fail -gt 0) { exit 1 }
exit 0
+95
View File
@@ -0,0 +1,95 @@
# 生成演示数据(便于看效果、试用手感;可对空库反复运行)
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\seed.ps1 -Base http://127.0.0.1:18080
param(
[string]$Base = 'http://127.0.0.1:18080',
[string]$User = 'admin',
[string]$Pass = 'daili123'
)
$ErrorActionPreference = 'Stop'
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
function Send {
param([string]$Method, [string]$Path, $Body)
$p = @{ Uri = "$Base$Path"; WebSession = $session; UseBasicParsing = $true; MaximumRedirection = 5; Method = $Method }
if ($null -ne $Body) { $p.Body = $Body }
try { Invoke-WebRequest @p } catch [System.Net.WebException] { $null }
}
$now = Get-Date
function D([int]$offset) { $now.AddDays($offset).ToString('yyyy-MM-dd') }
# 登录
$r = Send 'GET' '/setup'
if ($r -and $r.Content -match '创建第一个管理员') {
Send 'POST' '/setup' @{ username = $User; display_name = '老板'; password = $Pass; password2 = $Pass } | Out-Null
} else {
Send 'POST' '/login' @{ username = $User; password = $Pass; next = '/calendar' } | Out-Null
}
# 套餐(1、2 已存在则跳过创建)
$pkgs = @(
@{ name = '包月日常'; unit = 'day'; amount = 30; price = 300; color = '#2F5D50'; note = '每天日常 + 周常' },
@{ name = '深渊十次'; unit = 'count'; amount = 10; price = 180; color = '#6B4E9B'; note = '按次结算,打完扣次' },
@{ name = '赛季深渊'; unit = 'day'; amount = 90; price = 900; color = '#B45309'; note = '整赛季包干' },
@{ name = '每周副本'; unit = 'count'; amount = 5; price = 120; color = '#41556B'; note = '每周 5 次' }
)
foreach ($p in $pkgs) {
Send 'POST' '/packages' @{
name = $p.name; unit = $p.unit; default_amount = "$($p.amount)"
price = "$($p.price)"; color = $p.color; note = $p.note; sort_order = '1'
} | Out-Null
}
# 客户 + 订阅
$demo = @(
@{ tail = '1204'; name = '小林'; pkg = 1; start = -12; days = 30; note = '日常 + 周常' },
@{ tail = '5533'; name = '阿飞'; pkg = 1; start = -28; days = 30; note = '快到期,提醒续费' },
@{ tail = '9088'; name = '老王'; pkg = 1; start = -30; days = 30; note = '今天到期' },
@{ tail = '4321'; name = '妹妹'; pkg = 3; start = -60; days = 90; note = '整赛季' },
@{ tail = '7788'; name = '大鹏'; pkg = 2; start = -5; count = 10; end = 3; note = '按次' },
@{ tail = '6677'; name = '猫猫'; pkg = 4; start = -2; count = 5; end = 20; note = '每周副本' },
@{ tail = '3321'; name = '阿豪'; pkg = 1; start = -40; days = 30; note = '已过期两周' },
@{ tail = '9900'; name = '小雨'; pkg = 3; start = 2; days = 90; note = '还没开始' }
)
foreach ($d in $demo) {
$r = Send 'POST' '/customers' @{
tail = $d.tail; name = $d.name; game_name = '王者荣耀'; game_server = '安卓微信区'
contact = ('wx: ' + $d.tail); tags = '老客户'; source = '朋友介绍'; note = $d.note
}
$path = $r.BaseResponse.ResponseUri.AbsolutePath
if ($path -notlike '/customers/*') { continue }
$id = ($path -split '/')[-1]
if ($d.days) {
Send 'POST' "/customers/$id/subscriptions" @{
package_id = "$($d.pkg)"; start_date = (D $d.start); days = "$($d.days)"; note = $d.note
} | Out-Null
} else {
Send 'POST' "/customers/$id/subscriptions" @{
package_id = "$($d.pkg)"; start_date = (D $d.start); total_count = "$($d.count)"; end_date = (D $d.end); note = $d.note
} | Out-Null
}
}
# 今天的两条打卡
$r = Send 'GET' '/customers?q=1204'
if ($r.Content -match '/customers/(\d+)') {
$cid = $matches[1]
$r2 = Send 'GET' "/customers/$cid"
$ids = [regex]::Matches($r2.Content, 'id="sub-(\d+)"') | ForEach-Object { $_.Groups[1].Value }
if ($ids.Count -gt 0) {
Send 'POST' "/subscriptions/$($ids[0])/checkin" @{ date = (D 0); status = 'done'; note = '今日已打'; back = '/calendar' } | Out-Null
}
}
$r = Send 'GET' '/customers?q=4321'
if ($r.Content -match '/customers/(\d+)') {
$cid = $matches[1]
$r2 = Send 'GET' "/customers/$cid"
$ids = [regex]::Matches($r2.Content, 'id="sub-(\d+)"') | ForEach-Object { $_.Groups[1].Value }
if ($ids.Count -gt 0) {
Send 'POST' "/subscriptions/$($ids[0])/checkin" @{ date = (D 0); status = 'done'; note = '深渊打完'; back = '/calendar' } | Out-Null
}
}
Write-Host '演示数据已写入。' -ForegroundColor Green
+127
View File
@@ -0,0 +1,127 @@
# 用无头 Chrome 截图,做视觉验收
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\shots.ps1 -Base http://127.0.0.1:18080
param(
[string]$Base = 'http://127.0.0.1:18080',
[string]$User = 'admin',
[string]$Pass = 'daili123',
[string]$OutDir = ''
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
if (-not $OutDir) { $OutDir = Join-Path $root 'shots' }
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$chrome = 'C:\Program Files\Google\Chrome\Application\chrome.exe'
if (-not (Test-Path $chrome)) { throw '找不到 Chrome' }
# 1) 登录拿会话 cookie
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
Invoke-WebRequest -Uri "$Base/login" -WebSession $session -UseBasicParsing | Out-Null
Invoke-WebRequest -Uri "$Base/login" -Method POST -WebSession $session -UseBasicParsing -Body @{
username = $User; password = $Pass; next = '/calendar'
} | Out-Null
$cookie = ($session.Cookies.GetCookies($Base) | Where-Object { $_.Name -eq 'daili_sid' } | Select-Object -First 1)
if (-not $cookie) { throw '登录失败,拿不到会话' }
$token = $cookie.Value
# 2) 拿一个订阅 ID 用于「追加面板」截图
$custID = ''
$subID = ''
$r = Invoke-WebRequest -Uri "$Base/customers?q=7001" -WebSession $session -UseBasicParsing
if ($r.Content -match '/customers/(\d+)') {
$custID = $matches[1]
$r2 = Invoke-WebRequest -Uri "$Base/customers/$custID" -WebSession $session -UseBasicParsing
if ($r2.Content -match 'id="sub-(\d+)"') { $subID = $matches[1] }
}
# 3) 启动 Chrome
$profile = Join-Path $root ('.chrome-profile-' + (Get-Date).ToString('HHmmss'))
$chromeArgs = @(
'--headless=new', '--disable-gpu', '--hide-scrollbars', '--no-sandbox',
'--no-first-run', '--no-default-browser-check', '--disable-extensions',
'--disable-crash-reporter', '--disable-breakpad',
'--force-device-scale-factor=1', '--remote-debugging-port=9222',
"--user-data-dir=$profile", '--window-size=1440,1000', 'about:blank'
)
$chromeProc = Start-Process -FilePath $chrome -ArgumentList $chromeArgs -PassThru
$targets = $null
for ($i = 0; $i -lt 20; $i++) {
Start-Sleep -Milliseconds 500
try {
$targets = Invoke-RestMethod 'http://127.0.0.1:9222/json'
if ($targets) { break }
} catch { }
}
if (-not $targets) {
Stop-Process -Id $chromeProc.Id -Force -ErrorAction SilentlyContinue
throw 'Chrome 调试端口未就绪'
}
$page = $targets | Where-Object { $_.type -eq 'page' } | Select-Object -First 1
if (-not $page) { throw '拿不到调试目标' }
$script:ws = New-Object System.Net.WebSockets.ClientWebSocket
$script:ws.ConnectAsync([Uri]$page.webSocketDebuggerUrl, [Threading.CancellationToken]::None).Wait()
$script:msgId = 0
function Send-CDP {
param([string]$Method, $Params)
$script:msgId++
$payload = @{ id = $script:msgId; method = $Method; params = $Params } | ConvertTo-Json -Depth 12 -Compress
$bytes = [Text.Encoding]::UTF8.GetBytes($payload)
$seg = New-Object 'System.ArraySegment[byte]' -ArgumentList @(, $bytes)
$script:ws.SendAsync($seg, [Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).Wait()
while ($true) {
$ms = New-Object System.IO.MemoryStream
do {
$buf = New-Object byte[] 131072
$segR = New-Object 'System.ArraySegment[byte]' -ArgumentList @(, $buf)
$res = $script:ws.ReceiveAsync($segR, [Threading.CancellationToken]::None).Result
$ms.Write($buf, 0, $res.Count)
} while (-not $res.EndOfMessage)
$text = [Text.Encoding]::UTF8.GetString($ms.ToArray())
$msg = $text | ConvertFrom-Json
if ($msg.id -eq $script:msgId) { return $msg }
}
}
function Shoot {
param([string]$Name, [string]$Path, [int]$Width = 1440, [int]$Height = 1000, [bool]$SetCookie = $true)
Send-CDP 'Emulation.setDeviceMetricsOverride' @{
width = $Width; height = $Height; deviceScaleFactor = 1; mobile = ($Width -lt 600)
} | Out-Null
if ($SetCookie) {
Send-CDP 'Network.setCookie' @{
name = 'daili_sid'; value = $token; domain = '127.0.0.1'; path = '/'; httpOnly = $true
} | Out-Null
}
Send-CDP 'Page.navigate' @{ url = "$Base$Path" } | Out-Null
Start-Sleep -Milliseconds 900
$shot = Send-CDP 'Page.captureScreenshot' @{ format = 'png'; captureBeyondViewport = $false }
$file = Join-Path $OutDir "$Name.png"
[System.IO.File]::WriteAllBytes($file, [Convert]::FromBase64String($shot.result.data))
Write-Host (" shot {0,-16} <- {1}{2}" -f $Name, $Base, $Path) -ForegroundColor DarkGray
}
Send-CDP 'Network.enable' @{} | Out-Null
Send-CDP 'Page.enable' @{} | Out-Null
Shoot 'login' '/login' 1440 900 $false
Shoot 'calendar' '/calendar' 1440 1000
Shoot 'customers' '/customers' 1440 900
if ($custID) { Shoot 'customer-detail' "/customers/$custID" 1440 1100 }
if ($custID -and $subID) {
$today = (Get-Date).ToString('yyyy-MM-dd')
Shoot 'extend-panel' ("/customers/{0}?extend={1}#extend" -f $custID, $subID) 1440 1200
}
Shoot 'subscriptions' '/subscriptions?state=all' 1440 900
Shoot 'packages' '/packages' 1440 800
Shoot 'package-form' '/packages/new' 1200 940
Shoot 'customer-form' '/customers/new' 1000 900
Shoot 'settings' '/settings' 1440 1000
Shoot 'screen' '/screen' 1600 900
Shoot 'calendar-mobile' '/calendar' 390 844
$script:ws.Dispose()
Stop-Process -Id $chromeProc.Id -Force -ErrorAction SilentlyContinue
Write-Host '截图完成' -ForegroundColor Green
+215
View File
@@ -0,0 +1,215 @@
# 端到端冒烟测试:真实 HTTP 走一遍主要流程
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\smoke.ps1 -Base http://127.0.0.1:18080
param(
[string]$Base = 'http://127.0.0.1:18080',
[string]$User = 'admin',
[string]$Pass = 'daili123'
)
$ErrorActionPreference = 'Stop'
$script:fail = 0
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
function Send {
param([string]$Method, [string]$Path, $Body)
$p = @{
Uri = "$Base$Path"
WebSession = $session
UseBasicParsing = $true
MaximumRedirection = 5
Method = $Method
}
if ($null -ne $Body) { $p.Body = $Body }
try {
return Invoke-WebRequest @p
} catch [System.Net.WebException] {
$resp = $_.Exception.Response
if ($null -eq $resp) { throw }
$sr = New-Object System.IO.StreamReader($resp.GetResponseStream())
$body = $sr.ReadToEnd()
$sr.Close()
return [pscustomobject]@{
StatusCode = [int]$resp.StatusCode
Content = $body
BaseResponse = [pscustomobject]@{ ResponseUri = $resp.ResponseUri }
}
}
}
function Check {
param([bool]$Cond, [string]$Msg)
if ($Cond) {
Write-Host (" [ok] " + $Msg) -ForegroundColor Green
} else {
Write-Host (" [FAIL] " + $Msg) -ForegroundColor Red
$script:fail++
}
}
function Final-Path($resp) {
if ($resp.PSObject.Properties.Name -contains 'BaseResponse' -and $resp.BaseResponse -and $resp.BaseResponse.ResponseUri) {
return $resp.BaseResponse.ResponseUri.AbsolutePath
}
return ''
}
$now = Get-Date
$today = $now.ToString('yyyy-MM-dd')
$tail = Get-Random -Minimum 1000 -Maximum 9999
Write-Host "`n=== 代打台冒烟测试 @ $Base ($today / 尾号 $tail) ===" -ForegroundColor Cyan
# 1. 初始化管理员(或登录)
$r = Send 'GET' '/setup'
if ($r.Content -match '创建第一个管理员') {
$r = Send 'POST' '/setup' @{ username = $User; display_name = '老板'; password = $Pass; password2 = $Pass }
Check ($r.StatusCode -eq 200) '创建管理员并自动登录'
Check ((Final-Path $r) -like '/packages*') '初始化后跳转到套餐页'
} else {
$r = Send 'POST' '/login' @{ username = $User; password = $Pass; next = '/calendar' }
Check ($r.StatusCode -eq 200 -and (Final-Path $r) -like '/calendar*') '已存在管理员,登录成功'
}
# 2. 套餐
$r = Send 'GET' '/packages'
Check ($r.StatusCode -eq 200) '套餐页可访问'
$colors = @('#2F5D50', '#6B4E9B')
$pkgs = @(
@{ name = '包月日常'; unit = 'day'; default_amount = 30; price = 300; note = '每天日常 + 周常' },
@{ name = '深渊十次'; unit = 'count'; default_amount = 10; price = 180; note = '按次结算' }
)
for ($i = 0; $i -lt $pkgs.Count; $i++) {
$p = $pkgs[$i]
$r = Send 'POST' '/packages' @{
name = $p.name; unit = $p.unit; default_amount = "$($p.default_amount)"
price = "$($p.price)"; color = $colors[$i]; note = $p.note; sort_order = "$($i + 1)"
}
Check ($r.Content -match '套餐已保存|套餐已创建|套餐') "提交套餐 $($p.name)"
}
$r = Send 'GET' '/packages'
Check ($r.Content -match '包月日常' -and $r.Content -match '深渊十次') '套餐列表包含两个套餐'
$r = Send 'GET' '/packages/1/edit'
Check ($r.StatusCode -eq 200 -and $r.Content -match '包月日常') '套餐编辑页可打开'
# 3. 客户
$r = Send 'POST' '/customers' @{
tail = "$tail"; name = '老张'; game_name = '王者荣耀'; game_server = '微信区'
contact = 'wx: laozhang'; tags = '老客户,包月'; source = '抖音'; note = '喜欢晚上打'
}
$custPath = Final-Path $r
Check ($custPath -like '/customers/*') "新建客户成功 -> $custPath"
$custID = ($custPath -split '/')[-1]
$r = Send 'GET' "/customers?q=$tail"
Check ($r.Content -match '老张') "尾号 $tail 搜索命中老张"
$r = Send 'POST' '/customers' @{ tail = "$tail"; name = '老张' }
Check ($r.StatusCode -eq 400 -and $r.Content -match '已存在') '重复客户被拒绝并提示'
# 4. 订阅(按日 + 按次)
$r = Send 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30'; note = '首单' }
Check ($r.Content -match '已开通') '开通按日订阅'
$r = Send 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30' }
Check ($r.Content -match '追加时长') '同一套餐重复订阅被拦截'
$r = Send 'POST' "/customers/$custID/subscriptions" @{
package_id = '2'; start_date = $today; total_count = '10'; end_date = $now.AddDays(10).ToString('yyyy-MM-dd')
}
Check ($r.Content -match '已开通') '开通按次订阅'
$r = Send 'GET' "/customers/$custID"
$subIDs = [regex]::Matches($r.Content, 'id="sub-(\d+)"') | ForEach-Object { [int]$_.Groups[1].Value }
Check ($subIDs.Count -ge 2) "客户页出现 2 张订阅卡(实际 $($subIDs.Count))"
$daySub = $subIDs[0]
$cntSub = $subIDs[1]
# 5. 追加时长(日历手动选终止日:今天 +45 天)
$newEnd = $now.AddDays(45)
$oldEnd = $now.AddDays(29)
$expectDays = ($newEnd.Date - $oldEnd.Date).Days
$r = Send 'POST' "/subscriptions/$daySub/extend" @{
new_end_date = $newEnd.ToString('yyyy-MM-dd'); note = '微信转账续费'; back = "/customers/$custID"
}
Check ($r.Content -match "已追加 $expectDays 天") "追加时长反算正确(期望 $expectDays 天)"
$r = Send 'GET' "/customers/$custID"
Check ($r.Content -match '剩 46 天') '客户页剩余天数更新为 46 天'
Check ($r.Content -match '微信转账续费') '追加记录已留痕(备注)'
Check ($r.Content -match '追加天数') '追加记录标注类型'
# 6. 日历
$r = Send 'GET' "/calendar?y=$($now.Year)&m=$($now.Month)&d=$today"
Check ($r.StatusCode -eq 200) '日历页可打开'
Check ($r.Content -match '老张') '日历当天出现该客户色签'
Check ($r.Content -match '当日待打') '日历抽屉存在'
# 7. 打卡(按日)
$r = Send 'POST' "/subscriptions/$daySub/checkin" @{ date = $today; status = 'done'; note = '打完'; back = "/calendar?d=$today" }
Check ($r.Content -match '已记录') '按日打卡成功'
# 8. 按次打卡扣次
$r = Send 'POST' "/subscriptions/$cntSub/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
Check ($r.Content -match '已记录') '按次打卡成功(应扣 1 次)'
$r = Send 'GET' "/customers/$custID"
Check ($r.Content -match '剩 9 / 10 次') '按次剩余次数扣减为 9'
# 9. 暂停 / 恢复
$r = Send 'POST' "/subscriptions/$daySub/pause" @{ back = "/customers/$custID" }
Check ($r.Content -match '已暂停') '暂停订阅'
$r = Send 'GET' "/customers/$custID"
Check ($r.Content -match '暂停中') '客户页显示暂停中'
$r = Send 'POST' "/subscriptions/$daySub/resume" @{ back = "/customers/$custID" }
Check ($r.Content -match '已恢复') '恢复订阅'
# 9.5 无有效期的按次订阅:靠客户页「今日打卡」扣次
$r = Send 'POST' '/packages' @{
name = '副本五次'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; note = '无有效期'; sort_order = '3'
}
Check ($r.Content -match '套餐') '新建第三个套餐(按次、无有效期)'
$r = Send 'GET' '/packages'
$pkgIDs = [regex]::Matches($r.Content, '/packages/(\d+)/edit') | ForEach-Object { [int]$_.Groups[1].Value }
$newPkg = ($pkgIDs | Measure-Object -Maximum).Maximum
$r = Send 'POST' "/customers/$custID/subscriptions" @{ package_id = "$newPkg"; start_date = $today; total_count = '5' }
Check ($r.Content -match '已开通') '开通无有效期的按次订阅'
$r = Send 'GET' "/customers/$custID"
Check ($r.Content -match '今日打卡') '订阅卡片出现「今日打卡」按钮'
$ids3 = [regex]::Matches($r.Content, 'id="sub-(\d+)"') | ForEach-Object { [int]$_.Groups[1].Value }
$thirdSub = $ids3[-1]
$r = Send 'POST' "/subscriptions/$thirdSub/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
Check ($r.Content -match '已记录') '无有效期按次订阅打卡成功'
$r = Send 'GET' "/customers/$custID"
Check ($r.Content -match '剩 4 / 5 次') '打卡后次数扣减为 4'
# 10. 大屏
$r = Send 'GET' '/screen'
Check ($r.StatusCode -eq 200) '大屏可访问'
Check ($r.Content -match '即将到期 TOP 10') '大屏含 TOP 榜'
Check ($r.Content -match '到期热力') '大屏含热力月历'
Check ($r.Content -match "$tail") '大屏显示该客户尾号'
# 11. 日志 / 设置 / 导出
$r = Send 'GET' '/logs'
Check ($r.Content -match '追加时长') '操作日志含追加记录'
$r = Send 'GET' '/settings'
Check ($r.StatusCode -eq 200 -and $r.Content -match '基础设置') '设置页可打开'
$r = Send 'GET' '/export/customers.csv'
Check ($r.StatusCode -eq 200 -and $r.Content -match '手机尾号') '客户 CSV 导出正常'
$r = Send 'GET' '/export/subscriptions.csv'
Check ($r.StatusCode -eq 200 -and $r.Content -match '剩余') '订阅 CSV 导出正常'
$r = Send 'GET' '/export/logs.csv'
Check ($r.StatusCode -eq 200 -and $r.Content -match '操作人') '日志 CSV 导出正常'
# 12. 静态资源与健康检查
$r = Send 'GET' '/static/app.css'
Check ($r.StatusCode -eq 200 -and $r.Content -match 'ticket') '静态样式可访问'
$r = Send 'GET' '/healthz'
Check ($r.Content -match 'ok') '健康检查正常'
Write-Host ""
if ($script:fail -eq 0) {
Write-Host "全部通过" -ForegroundColor Green
exit 0
} else {
Write-Host ("失败 $script:fail 项") -ForegroundColor Red
exit 1
}
+24
View File
@@ -0,0 +1,24 @@
"""校验备份文件可被标准 SQLite 打开并含数据(Windows 自带 Python 或 Miniconda 均可运行)。"""
import glob
import os
import sqlite3
import sys
pattern = os.path.join(sys.argv[1], "*.db")
files = sorted(glob.glob(pattern), key=os.path.getmtime)
if not files:
print("FAIL: 没有找到备份文件")
sys.exit(1)
path = files[-1]
print("备份文件:", os.path.basename(path), os.path.getsize(path), "字节")
con = sqlite3.connect(path)
tables = [r[0] for r in con.execute(
"select name from sqlite_master where type='table' order by name")]
print("表:", ", ".join(tables))
for t in ("users", "packages", "customers", "subscriptions", "sub_adjustments", "checkins", "op_logs"):
if t in tables:
n = con.execute("select count(*) from %s" % t).fetchone()[0]
print(" %-16s %d 行" % (t, n))
con.close()
print("OK")
+3
View File
@@ -0,0 +1,3 @@
module modernc.org/fileutil
go 1.25.0