commit f0476621293c341fe351e1449f724d31d561263f Author: chun_qiu Date: Sun Oct 18 19:44:38 2026 +0800 首次推送 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cf9f3c8 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..cc81125 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b23ac4c --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1df6c31 --- /dev/null +++ b/README.md @@ -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 ` | 临时覆盖监听地址,如 `-addr 127.0.0.1:19000` | +| `-db <路径>` | 临时覆盖数据库路径 | +| `-backup-keep ` | 临时覆盖备份保留份数 | +| `-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) + +仓库:,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-`,内含 `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`; +- 配置文件里的数据库/备份/日志路径请自行确保目录权限,程序不会覆盖已有文件(备份重名会跳过)。 diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..01cfdf1 --- /dev/null +++ b/build.ps1 @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eeb89e1 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..027a1a3 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3ca65a1 --- /dev/null +++ b/internal/config/config.go @@ -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, + ) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..231af0e --- /dev/null +++ b/internal/config/config_test.go @@ -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("覆盖后的非法端口应被拦截") + } +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go new file mode 100644 index 0000000..674d398 --- /dev/null +++ b/internal/domain/domain.go @@ -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 +} diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go new file mode 100644 index 0000000..49854ee --- /dev/null +++ b/internal/domain/domain_test.go @@ -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) + } +} diff --git a/internal/logx/logx.go b/internal/logx/logx.go new file mode 100644 index 0000000..a0a1d47 --- /dev/null +++ b/internal/logx/logx.go @@ -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 +} diff --git a/internal/logx/logx_test.go b/internal/logx/logx_test.go new file mode 100644 index 0000000..3255d54 --- /dev/null +++ b/internal/logx/logx_test.go @@ -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)) + } +} diff --git a/internal/pw/pw.go b/internal/pw/pw.go new file mode 100644 index 0000000..e8a468a --- /dev/null +++ b/internal/pw/pw.go @@ -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] +} diff --git a/internal/store/backup.go b/internal/store/backup.go new file mode 100644 index 0000000..89ebe16 --- /dev/null +++ b/internal/store/backup.go @@ -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 +} diff --git a/internal/store/checkins.go b/internal/store/checkins.go new file mode 100644 index 0000000..90fe56c --- /dev/null +++ b/internal/store/checkins.go @@ -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 +} diff --git a/internal/store/customers.go b/internal/store/customers.go new file mode 100644 index 0000000..691ddc0 --- /dev/null +++ b/internal/store/customers.go @@ -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 +} diff --git a/internal/store/db.go b/internal/store/db.go new file mode 100644 index 0000000..3d5a61f --- /dev/null +++ b/internal/store/db.go @@ -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 +} diff --git a/internal/store/export.go b/internal/store/export.go new file mode 100644 index 0000000..0828ee5 --- /dev/null +++ b/internal/store/export.go @@ -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, ¬e, &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() +} diff --git a/internal/store/logs.go b/internal/store/logs.go new file mode 100644 index 0000000..abec89d --- /dev/null +++ b/internal/store/logs.go @@ -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 +} diff --git a/internal/store/packages.go b/internal/store/packages.go new file mode 100644 index 0000000..56a4c51 --- /dev/null +++ b/internal/store/packages.go @@ -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 +} diff --git a/internal/store/stats.go b/internal/store/stats.go new file mode 100644 index 0000000..f3010af --- /dev/null +++ b/internal/store/stats.go @@ -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) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..ebc0724 --- /dev/null +++ b/internal/store/store_test.go @@ -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) + } +} diff --git a/internal/store/subscriptions.go b/internal/store/subscriptions.go new file mode 100644 index 0000000..01984c1 --- /dev/null +++ b/internal/store/subscriptions.go @@ -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) +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..fcba725 --- /dev/null +++ b/internal/store/users.go @@ -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()) +} diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go new file mode 100644 index 0000000..ada6309 --- /dev/null +++ b/internal/web/handlers_auth.go @@ -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), + }) +} diff --git a/internal/web/handlers_calendar.go b/internal/web/handlers_calendar.go new file mode 100644 index 0000000..1945ffe --- /dev/null +++ b/internal/web/handlers_calendar.go @@ -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]) +} diff --git a/internal/web/handlers_customers.go b/internal/web/handlers_customers.go new file mode 100644 index 0000000..4984ac1 --- /dev/null +++ b/internal/web/handlers_customers.go @@ -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 + }) +} diff --git a/internal/web/handlers_misc.go b/internal/web/handlers_misc.go new file mode 100644 index 0000000..b3c5718 --- /dev/null +++ b/internal/web/handlers_misc.go @@ -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))+" 行)") +} diff --git a/internal/web/handlers_packages.go b/internal/web/handlers_packages.go new file mode 100644 index 0000000..877e9f8 --- /dev/null +++ b/internal/web/handlers_packages.go @@ -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) +} diff --git a/internal/web/handlers_screen.go b/internal/web/handlers_screen.go new file mode 100644 index 0000000..c44c7d5 --- /dev/null +++ b/internal/web/handlers_screen.go @@ -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 +} diff --git a/internal/web/handlers_subs.go b/internal/web/handlers_subs.go new file mode 100644 index 0000000..dd8dc4d --- /dev/null +++ b/internal/web/handlers_subs.go @@ -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, "已撤销打卡") +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..84cdedf --- /dev/null +++ b/internal/web/server.go @@ -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 +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css new file mode 100644 index 0000000..bfcaf9b --- /dev/null +++ b/internal/web/static/app.css @@ -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; } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js new file mode 100644 index 0000000..6e86bbb --- /dev/null +++ b/internal/web/static/app.js @@ -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(); + }); +})(); diff --git a/internal/web/static/favicon.svg b/internal/web/static/favicon.svg new file mode 100644 index 0000000..1682c6b --- /dev/null +++ b/internal/web/static/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/internal/web/static/screen.css b/internal/web/static/screen.css new file mode 100644 index 0000000..b35a604 --- /dev/null +++ b/internal/web/static/screen.css @@ -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; } +} diff --git a/internal/web/static/screen.js b/internal/web/static/screen.js new file mode 100644 index 0000000..c351133 --- /dev/null +++ b/internal/web/static/screen.js @@ -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(); + } + } + }); +})(); diff --git a/internal/web/templates/_layout.html b/internal/web/templates/_layout.html new file mode 100644 index 0000000..ce34e4a --- /dev/null +++ b/internal/web/templates/_layout.html @@ -0,0 +1,41 @@ +{{define "layout"}} + + + + +{{.Title}} · 代打台 + + + + +{{if .User}} +
+ 代代打台 + + + +
+{{end}} +{{if .Flash}}
{{.Flash}}
{{end}} +{{if .Error}}
{{.Error}}
{{end}} +{{template "content" .}} +{{if .User}}{{end}} + + +{{end}} diff --git a/internal/web/templates/_partials.html b/internal/web/templates/_partials.html new file mode 100644 index 0000000..370f5d4 --- /dev/null +++ b/internal/web/templates/_partials.html @@ -0,0 +1,135 @@ +{{define "state-chip"}}{{.StateLabel}}{{end}} + +{{define "pager"}} +{{if gt .Pages 1}} + +{{end}} +{{end}} + +{{define "empty"}} +
+
票
+

{{.Text}}

+ {{if .Action}}{{.ActionText}}{{end}} +
+{{end}} + +{{define "sub-card"}} +
+
+ +

{{.PackageName}}

+ {{template "state-chip" .}} + {{.UnitLabel}} +
+
+
{{.RemainText}}
+
{{.PeriodText}}
+ {{if .DueText}}
{{.DueText}}
{{end}} +
+
+
+ {{if ne .Status "closed"}} + {{if .CanCheckinToday}} +
+ + + +
+ {{end}} + {{if eq .Unit "day"}}追加时长{{else}}追加次数{{end}} + {{if eq .Status "active"}} +
+ +
+ {{else if eq .Status "paused"}} +
+ +
+ {{end}} +
+ +
+ {{else}} +
+ +
+ {{end}} +
+
+{{end}} + +{{define "extend-panel"}} +
+
+
+

给 {{.Sub.CustomerTail}} {{.Sub.CustomerName}} 追加{{if .IsDay}}时长{{else}}次数{{end}}

+

{{.Sub.PackageName}} · {{.View.RemainText}} · {{.View.PeriodText}}

+
+ × +
+ {{if .IsDay}} +
+ + +
+
计算起点{{.Base}}
+
原结束日{{.OldEnd}}
+
新结束日{{.Suggested}}
+
本次追加{{.SuggestedDays}} 天
+
追加后剩余— 天
+
+

在下面的日历上点选新的终止日期;未到期的订阅从原结束日次日起算,已到期的从今天起算。

+
+ {{range .Months}} +
+
{{.Title}}
+
+ 一二三四五六日 + {{range .Weeks}}{{range .}} + + {{end}}{{end}} +
+
+ {{end}} +
+ +
+ + 取消 +
+
+ {{else}} +
+ +
+
当前剩余{{.View.RemainText}}
+
已用 / 总数{{.Sub.UsedCount}} / {{.Sub.TotalCount}}
+ {{if .Sub.EndDate}}
有效期至{{.Sub.EndDate}}
{{end}} +
+
+ + +
+ +
+ + 取消 +
+
+ {{end}} +
+{{end}} diff --git a/internal/web/templates/_screen_layout.html b/internal/web/templates/_screen_layout.html new file mode 100644 index 0000000..951bc7a --- /dev/null +++ b/internal/web/templates/_screen_layout.html @@ -0,0 +1,15 @@ +{{define "screenlayout"}} + + + + + +{{.Title}} · 代打台 + + + + +{{template "content" .}} + + +{{end}} diff --git a/internal/web/templates/pages/calendar.html b/internal/web/templates/pages/calendar.html new file mode 100644 index 0000000..88d4202 --- /dev/null +++ b/internal/web/templates/pages/calendar.html @@ -0,0 +1,146 @@ +{{define "content"}} +
+
+
+ ‹ +

{{.D.Title}}

+ › + 回到今天 +
+
+ {{.D.MonthDue}}本月到期 + {{.D.MonthTasks}}本月待打 + {{.D.MonthDone}}已完成 + + 新建客户 +
+
+ + {{if .D.NoPackages}} +
还没有服务套餐,先去 新建套餐,再给客户开通。
+ {{else if .D.NoCustomers}} +
还没有客户,新建第一位客户。
+ {{end}} + +
+ + + +
+
+{{end}} diff --git a/internal/web/templates/pages/customer_detail.html b/internal/web/templates/pages/customer_detail.html new file mode 100644 index 0000000..a27a521 --- /dev/null +++ b/internal/web/templates/pages/customer_detail.html @@ -0,0 +1,152 @@ +{{define "content"}} +
+
+
+ {{.D.Customer.Tail}} +
+

{{.D.Customer.Name}}

+
+ {{range .D.Customer.TagList}}{{.}}{{end}} +
+
+
+
+ {{if .D.Customer.GameName}}
游戏
{{.D.Customer.GameName}}
{{end}} + {{if .D.Customer.GameServer}}
区服
{{.D.Customer.GameServer}}
{{end}} + {{if .D.Customer.Contact}}
联系
{{.D.Customer.Contact}}
{{end}} + {{if .D.Customer.Source}}
来源
{{.D.Customer.Source}}
{{end}} +
建档
{{.D.Customer.CreatedAt}}
+
+ +
+ + {{if .D.Customer.Note}}
{{.D.Customer.Note}}
{{end}} + +
+
+

订阅

+ 同一套餐只能有一条订阅,需要更多时长请用「追加」 +
+ {{if .D.Subs}} +
+ {{range .D.Subs}}{{template "sub-card" .}}{{end}} +
+ {{else}} +

还没有订阅。用下面的表单为他开通第一个套餐。

+ {{end}} + + {{if .D.Packages}} +
+ + + + + + +
+ {{else}} +

还没有可用套餐,先新建套餐。

+ {{end}} +
+ + {{if .D.Extend}}{{template "extend-panel" .D.Extend}}{{end}} + + {{if .D.Timeline}} +
+
+

时间轴

+ {{.D.Timeline.Start}} → {{.D.Timeline.End}} · 灰色为暂停段 +
+
+
+ {{range .D.Timeline.Ticks}}{{.Label}}{{end}} +
+ {{range .D.Timeline.Bars}} +
+ {{.Name}} +
+ + {{range .Pauses}}{{end}} + {{if .HasDue}}{{end}} +
+ {{.Label}} +
+ {{end}} +
+
+ {{end}} + +
+
+

服务时长追加记录

+ 每次追加都留痕,可作为对账依据 +
+ {{if .D.Adjustments}} +
+ + + + + + {{range .D.Adjustments}} + + + + + + + + + + + {{end}} + +
时间套餐类型计算起点结束日变化增量备注操作人
{{.CreatedAt}}{{index $.D.AdjSubName .SubID}}{{.KindLabel}}{{if .BaseDate}}{{.BaseDate}}{{else}}—{{end}}{{if .OldEndDate}}{{.OldEndDate}} → {{end}}{{if .NewEndDate}}{{.NewEndDate}}{{else}}—{{end}} + {{if gt .DaysAdded 0}}+{{.DaysAdded}} 天{{end}} + {{if gt .CountAdded 0}}+{{.CountAdded}} 次{{end}} + {{if and (le .DaysAdded 0) (le .CountAdded 0)}}—{{end}} + {{if .Note}}{{.Note}}{{else}}—{{end}}{{if .OperatorName}}{{.OperatorName}}{{else}}—{{end}}
+
+ {{else}}

还没有追加记录。

{{end}} +
+ +
+
+

打卡历史

+ 最近 30 条 +
+ {{if .D.Checkins}} +
    + {{range .D.Checkins}} +
  • + {{.ServiceDate}} + + {{.SubName}} + {{if eq .Status "done"}}完成{{else}}跳过{{end}} + {{if .Note}}{{.Note}}{{end}} + {{if .OperatorName}}{{.OperatorName}}{{end}} +
  • + {{end}} +
+ {{else}}

还没有打卡记录。

{{end}} +
+
+{{end}} diff --git a/internal/web/templates/pages/customer_form.html b/internal/web/templates/pages/customer_form.html new file mode 100644 index 0000000..ca8ba89 --- /dev/null +++ b/internal/web/templates/pages/customer_form.html @@ -0,0 +1,48 @@ +{{define "content"}} +
+
+
+

{{if .D.IsNew}}新建客户{{else}}编辑客户{{end}}

+

手机尾号 + 客户名 唯一;尾号是日常查询的主键。

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + 取消 +
+
+
+{{end}} diff --git a/internal/web/templates/pages/customers.html b/internal/web/templates/pages/customers.html new file mode 100644 index 0000000..009bfc9 --- /dev/null +++ b/internal/web/templates/pages/customers.html @@ -0,0 +1,58 @@ +{{define "content"}} +
+
+
+

客户

+

共 {{.D.Total}} 位 · 输入手机尾号可快速定位

+
+ +
+ +
+ + + {{if .D.Q}}清除{{end}} +
+ + {{if .D.List}} +
+ + + + + + {{range .D.List}} + + + + + + + + + {{end}} + +
尾号客户联系方式订阅与剩余标签
{{.Tail}} + {{.Name}} + {{if .GameName}}
{{.GameName}}{{if .GameServer}} · {{.GameServer}}{{end}}
{{end}} +
{{if .Contact}}{{.Contact}}{{else}}—{{end}} + {{.Summary}} + {{range .TagList}}{{.}}{{end}}详情
+
+ {{template "pager" .D.Pager}} + {{else}} +
+
票
+ {{if .D.Q}} +

没有匹配「{{.D.Q}}」的客户。

+ 看全部客户 + {{else}} +

还没有客户。先建一位,再给他开套餐。

+ + 新建客户 + {{end}} +
+ {{end}} +
+{{end}} diff --git a/internal/web/templates/pages/login.html b/internal/web/templates/pages/login.html new file mode 100644 index 0000000..31ba2bf --- /dev/null +++ b/internal/web/templates/pages/login.html @@ -0,0 +1,20 @@ +{{define "content"}} +
+
+
代代打台
+

登录

+

所有账户均为管理员,任何操作都会留痕。

+
+ + + + +
+

代打台 · 单文件部署 · 数据只在本机

+
+
+{{end}} diff --git a/internal/web/templates/pages/logs.html b/internal/web/templates/pages/logs.html new file mode 100644 index 0000000..be008a1 --- /dev/null +++ b/internal/web/templates/pages/logs.html @@ -0,0 +1,39 @@ +{{define "content"}} +
+
+
+

操作日志

+

共 {{.D.Total}} 条 · 谁在什么时候改了什么,都在这里

+
+
+ +
+ + + {{if .D.Q}}清除{{end}} +
+ + {{if .D.List}} +
+ + + + {{range .D.List}} + + + + + + + + + {{end}} + +
时间操作人动作对象摘要IP
{{.CreatedAt}}{{if .OperatorName}}{{.OperatorName}}{{else}}系统{{end}}{{.Action}}{{if .Entity}}{{.Entity}}{{if .EntityID}} #{{.EntityID}}{{end}}{{else}}—{{end}}{{.Summary}}{{.IP}}
+
+ {{template "pager" .D.Pager}} + {{else}} +
票

没有符合条件的日志。

+ {{end}} +
+{{end}} diff --git a/internal/web/templates/pages/package_form.html b/internal/web/templates/pages/package_form.html new file mode 100644 index 0000000..d344db5 --- /dev/null +++ b/internal/web/templates/pages/package_form.html @@ -0,0 +1,57 @@ +{{define "content"}} +
+
+
+

{{if .D.IsNew}}新增套餐{{else}}编辑套餐{{end}}

+

按日套餐用起止日期计算剩余天数;按次套餐记录剩余次数。

+
+
+ +
+ + +
+ 计费方式 * +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + + +
+ + 取消 +
+
+
+{{end}} diff --git a/internal/web/templates/pages/packages.html b/internal/web/templates/pages/packages.html new file mode 100644 index 0000000..95352a7 --- /dev/null +++ b/internal/web/templates/pages/packages.html @@ -0,0 +1,49 @@ +{{define "content"}} +
+
+
+

套餐

+

新增套餐只需加一行配置,不会影响任何已有客户与订阅

+
+ +
+ + {{if .D.List}} +
+ + + + + + {{range .D.List}} + + + + + + + + + + + {{end}} + +
名称类型默认量参考价订阅中 / 总数状态
+ {{.Name}} + {{if .Note}}
{{.Note}}
{{end}} +
{{.UnitLabel}}{{.DefaultAmount}}{{if eq .Unit "day"}} 天{{else}} 次{{end}}{{if .HasPrice}}¥{{money .PriceCents}}{{else}}—{{end}}{{.ActiveCount}} / {{.SubCount}}{{if .Archived}}已归档{{else}}启用中{{end}} + 编辑 +
+ +
+
+
+ {{else}} +
+
票
+

还没有套餐。先建「包月日常」这类常用套餐,之后客户开通就一键完成。

+ + 新增套餐 +
+ {{end}} +
+{{end}} diff --git a/internal/web/templates/pages/screen.html b/internal/web/templates/pages/screen.html new file mode 100644 index 0000000..c413ce7 --- /dev/null +++ b/internal/web/templates/pages/screen.html @@ -0,0 +1,113 @@ +{{define "content"}} +
+
+
代代打台控制台
+
--:--:--
+ +
+ +
+
+ 客户{{.D.Stats.Customers}}位 +
+
+ 活跃订阅{{.D.Stats.ActiveSubs}}条 +
+
+ 今日到期{{.D.Stats.DueToday}}条 +
+
+ 7 天内到期{{.D.Stats.Due7}}条 +
+
+ +
+
+
+

到期热力

+ {{.D.MonthTitle}} · 本月 {{.D.Stats.MonthDue}} 条到期 +
+
+
{{range .D.Weekdays}}{{.}}{{end}}
+ {{range .D.Weeks}} +
+ {{range .}} +
+ {{.Day}} + {{if .Due}}{{.Due}}{{end}} +
+ {{end}} +
+ {{end}} +
+
+ 到期数 +
+
+ +
+

即将到期 TOP 10

按剩余量排序
+ {{if .D.Top}} +
    + {{range $i, $s := .D.Top}} +
  1. + {{add $i 1}} + {{$s.CustomerTail}} + {{$s.CustomerName}} + {{$s.PackageName}} + {{$s.RemainShort}} + +
  2. + {{end}} +
+ {{else}} +

暂无订阅数据

+ {{end}} +
+
+ +
+
+
+

今日待打

+ {{.D.TodayDone}} / {{.D.TodayTasks}} 已完成 +
+ {{if .D.TodayList}} +
    + {{range .D.TodayList}} +
  • + {{.CustomerTail}}{{.CustomerName}} + {{.PackageName}} + {{.RemainShort}} +
  • + {{end}} +
+ {{else}} +

今天没有待打任务

+ {{end}} +
+ +
+

最近追加

服务时长变更流水
+ {{if .D.Recent}} +
    + {{range .D.Recent}} +
  • + {{.CreatedAt}} + {{.CustomerTail}}{{.CustomerName}} + {{.PackageName}} + {{if gt .DaysAdded 0}}+{{.DaysAdded}} 天{{end}} + {{if gt .CountAdded 0}}+{{.CountAdded}} 次{{end}} + {{.OperatorName}} +
  • + {{end}} +
+ {{else}} +

还没有追加记录

+ {{end}} +
+
+ +
代打台 v{{.D.Version}} · 数据每 {{.D.Refresh}} 秒自动刷新 · 按 F 进入全屏
+
+{{end}} diff --git a/internal/web/templates/pages/settings.html b/internal/web/templates/pages/settings.html new file mode 100644 index 0000000..3b04a52 --- /dev/null +++ b/internal/web/templates/pages/settings.html @@ -0,0 +1,102 @@ +{{define "content"}} +
+
+
+

设置

+

预警天数、备份、账户与数据导出

+
+
+ +
+

基础设置

+
+
+ + +
+

监听地址、端口、数据库、备份、日志、时区等部署参数在 daili.ini 里配置,改完重启生效。

+
+
+
+ +
+

管理员

+

所有账户权限相同,任何操作都会记在日志里。

+
+ + + + {{$me := .User}} + {{range .D.Users}} + + + + + + + + + {{end}} + +
用户名显示名称状态创建时间最近登录
{{.Username}}{{.DisplayName}}{{if .IsActive}}启用{{else}}停用{{end}}{{.CreatedAt}}{{if .LastLoginAt}}{{.LastLoginAt}}{{else}}—{{end}} + {{if eq .ID $me.ID}} + 当前账户 + {{else}} +
+ +
+ {{end}} +
+
+
+ + + + +
+
+ +
+

修改我的密码

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

数据

+
+
配置文件
{{.D.ConfigPath}} (修改后重启生效)
+
数据库
{{.D.DBPath}}
+
备份
{{.D.BackupDir}} · 保留 {{.D.BackupKeep}} 份 · {{if .D.BackupEnabled}}自动备份已开启{{else}}自动备份已关闭{{end}}
+
大屏
/screen {{if .D.ScreenPublic}}免登录{{else}}需登录{{end}}
+
局域网地址
http://{{if .D.Addrs}}{{index .D.Addrs 0}}{{else}}127.0.0.1{{end}}:{{.D.Port}}/screen
+
已运行
{{.D.Uptime}}
+
数据量
{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志
+
+ + {{if .D.Backups}} +
+ 最近备份({{len .D.Backups}} 份) +
    + {{range .D.Backups}}
  • {{.}}
  • {{end}} +
+
+ {{end}} +
+
+{{end}} diff --git a/internal/web/templates/pages/setup.html b/internal/web/templates/pages/setup.html new file mode 100644 index 0000000..7f67716 --- /dev/null +++ b/internal/web/templates/pages/setup.html @@ -0,0 +1,25 @@ +{{define "content"}} +
+
+
代代打台
+

创建第一个管理员

+

数据库里还没有账户。设置好账号后即可开始录入套餐与客户。

+
+ + + + + +
+

忘记密码只能手动改数据库,请妥善保管。

+
+
+{{end}} diff --git a/internal/web/templates/pages/subscriptions.html b/internal/web/templates/pages/subscriptions.html new file mode 100644 index 0000000..7a5d204 --- /dev/null +++ b/internal/web/templates/pages/subscriptions.html @@ -0,0 +1,76 @@ +{{define "content"}} +
+
+
+

订阅

+

共 {{.D.Total}} 条 · 剩余时长各自独立计算

+
+
+ + + +
+ + + + +
+ + {{if .D.List}} +
+ + + + + + {{range .D.List}} + + + + + + + + + {{end}} + +
客户套餐状态剩余时间操作
+ {{.CustomerTail}} + {{.CustomerName}} + {{.PackageName}} + {{.UnitLabel}}{{template "state-chip" .}}{{.RemainText}}{{if .DueText}}
{{.DueText}}
{{end}}
{{.PeriodText}} + {{if ne .Status "closed"}} + 追加 + {{if eq .Status "active"}} +
+ {{else if eq .Status "paused"}} +
+ {{end}} + {{else}} +
+ {{end}} +
+
+ {{template "pager" .D.Pager}} + {{else}} +
+
票
+

这个筛选下没有订阅。

+ 看全部 +
+ {{end}} +
+{{end}} diff --git a/internal/web/view.go b/internal/web/view.go new file mode 100644 index 0000000..ce2c5cd --- /dev/null +++ b/internal/web/view.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c038830 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/scripts/acceptance.ps1 b/scripts/acceptance.ps1 new file mode 100644 index 0000000..ef6b5c4 --- /dev/null +++ b/scripts/acceptance.ps1 @@ -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, '
.*?免登录') +$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 '今天') -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)客户\s*(\d+)').Groups[1].Value +$kpiDue = [regex]::Match($screen, '(?s)今日到期\s*(\d+)').Groups[1].Value +$kpi7 = [regex]::Match($screen, '(?s)7 天内到期\s*(\d+)').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, '(\d+) / (\d+) 已完成').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 '')) "" + +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 = '' } +$xssID = ($r.Path -split '/')[-1] +$xss = (Send $admin 'GET' "/customers/$xssID").Content +Check 'N-03' 'XSS 内容被转义' ((-not (Has $xss '')) -and (Has $xss '<script>')) "" + +$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 diff --git a/scripts/config-test.ps1 b/scripts/config-test.ps1 new file mode 100644 index 0000000..77e9496 --- /dev/null +++ b/scripts/config-test.ps1 @@ -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 diff --git a/scripts/dbtool.py b/scripts/dbtool.py new file mode 100644 index 0000000..82cbffe --- /dev/null +++ b/scripts/dbtool.py @@ -0,0 +1,94 @@ +"""测试辅助:直接读写测试库,用于构造 HTTP 层做不到的前置条件。 + +用法: + python dbtool.py backdate-pause 把暂停开始时间提前 N 天 + python dbtool.py bulk 批量造客户+按日订阅(包月日常) + python dbtool.py exec 执行一条 SQL + python dbtool.py scalar 输出第一条结果的第一列 + python dbtool.py integrity 完整性检查 +""" +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() diff --git a/scripts/portability-test.ps1 b/scripts/portability-test.ps1 new file mode 100644 index 0000000..a97a7f1 --- /dev/null +++ b/scripts/portability-test.ps1 @@ -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 diff --git a/scripts/seed.ps1 b/scripts/seed.ps1 new file mode 100644 index 0000000..8f74858 --- /dev/null +++ b/scripts/seed.ps1 @@ -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 diff --git a/scripts/shots.ps1 b/scripts/shots.ps1 new file mode 100644 index 0000000..e67d8e4 --- /dev/null +++ b/scripts/shots.ps1 @@ -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 diff --git a/scripts/smoke.ps1 b/scripts/smoke.ps1 new file mode 100644 index 0000000..34a6e80 --- /dev/null +++ b/scripts/smoke.ps1 @@ -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 +} diff --git a/scripts/verify-backup.py b/scripts/verify-backup.py new file mode 100644 index 0000000..6127832 --- /dev/null +++ b/scripts/verify-backup.py @@ -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") diff --git a/third_party/fileutil/go.mod b/third_party/fileutil/go.mod new file mode 100644 index 0000000..cb59bee --- /dev/null +++ b/third_party/fileutil/go.mod @@ -0,0 +1,3 @@ +module modernc.org/fileutil + +go 1.25.0