Files
subtrack/internal/config/config.go
T
chun_qiu cde1c84c9a feat(v1.11.5-L): lite 精简版——移除大屏/客户自查/多用户/小键盘,套餐仅保留按日
需求与确认(用户逐项确认):
- 老数据「按次 / 通行证 / 深渊」统一迁移成按日,代码里彻底删除三种模式
- 打手归属一并移除(只剩一个人,颜色与指派没有区分意义)
- 盲水印做成完全不可见:只存在于 DOM / 源码,肉眼与截图都看不到
- 拼写采用更正后的 Powered by Baka春秋
- 版本号与品牌全站统一带 -L
- screen_public / portal_public 保留解析但不起作用,保证旧 ini 能直接启动
- README 同步更新

品牌与版本(全站统一):
- const version = "1.11.5-L":<title>、启动横幅、日志前缀 [代打台-L]、
  设置页版本号、页脚版本号全部带 -L
- 左上角导航品牌 代打台 → 代打台-L(登录页、初始化页同步)
- 页脚新增可见署名 Powered by Baka春秋

盲水印:
- _layout.html 在 <body> 起始处注入 .wm-blind 容器(含注释与 data-wm 双标记),
  覆盖包括登录/初始化在内的所有页面
- app.css 用零尺寸 + 透明 + opacity:0 + clip-path:inset(50%) 四重隐藏,
  同时留同名注释;不占位、不上色、不进无障碍树、不影响打印

功能移除:
- 大屏:删除 handlers_screen.go、pages/screen.html、_screen_layout.html、
  static/screen.css、static/screen.js、store.Stats/ScreenStats;
  导航「大屏 ↗」、页脚「大屏」、设置页「大屏刷新/大屏」行、启动横幅大屏行一并删除
- 客户自查:删除 handlers_portal.go、pages/portal.html 与 .portal-*/.pcal-* 样式,
  设置页「客户自助」行删除
- 多管理员:删除 handleUserCreate/Toggle/Color/UserEditPage/UserEditSave、
  pages/user_form.html、store 侧 ActiveUsers/SetUserColor/SetUserActive/
  UpdateUserByAdmin/NormalizeColor/AssigneePalette;设置页「管理员」整块移除,
  只保留首次启动创建的那一个账户与「修改我的密码」
- 打手归属:删除 customers.assignee_id 相关读写与 UI(客户详情指派下拉、
  客户表单「归属打手」、日历格左侧打手色竖条、任务卡打手标签、套餐详情客户列)
- 登录页与初始化页的说明文案同步改为单账户口径(「只有一个管理员账户」
  「创建后不能再添加其他管理员」),字数与 main 对齐,卡片不换行、高度不变
- 触摸小键盘:删除 3×4 键盘、⌨/触控开关、localStorage 记忆、.keypad* 样式
- 套餐计费方式:只保留按日。删除按次/通行证/深渊的 Package 与 Subscription 字段、
  domain 里的 UnitCount/StateUsedUp/CycleIndex、package_form 计费方式单选与
  通行证/深渊/有效期字段、日历整段色块(has-block/cell-block/season-band)、
  追加次数分支、SeasonComplete/AddCount、CountActiveSubs 的次数条件

数据库迁移(幂等,Migrate 内自动执行):
- normalizeLegacyData:套餐一律 unit='day' 并清 season/abyss/cycle_days/
  default_validity_days/season_*;订阅清 season/abyss/cycle_days/season_done
- 通行证、深渊当年就是 unit='day' 且起止日完整,仅清标记,服务区间原样保留
- 按次按「1 次 = 1 天」折算:结束日 = 开始日 + 总次数 − 1;仍缺结束日退回开始日
- 只动数据,不删表不删列:老库的 season/abyss/cycle_days/total_count/used_count
  保留但不再使用,升级可逆、不会因为 DDL 失败卡住
- 新库 schema 干净:不再创建上述列,users.color / customers.assignee_id 也不创建
- 老库 ensureColumn 保留 calendar_separate / tag / archived 三项(真增量列)

兼容:
- config 仍解析 screen_public / portal_public(结构体字段注明已废弃),
  默认 ini 模板里改为「已废弃,写了不报错、不生效」

测试:
- go vet ./... 通过;go test ./... 全部通过
  (新增 TestNormalizeLegacyTypeData 验证三模式折算成按日;
   TestSingleUserAccount / TestSessionLifecycle 覆盖单用户与会话;
   删除按次、深渊、打手颜色、多用户等已不存在的能力的用例)
- 新增 scripts/smoke-lite.ps1:起隔离实例,19 个页面级请求全部 200,
  已移除的 /screen、/customer、/api/screen/day 均 404,
  水印/品牌/v1.11.5-L/无残留词 12 项断言通过,
  真实数据下日历出现待打任务、「剩 30 天」、今天到期徽标 4 项断言通过,
  本次 0 失败

README:改为 lite 版说明,删除大屏/客户自查/通行证/深渊/按次/小键盘/多用户章节,
新增「盲水印」与「与 main 分支的差异」两节(含老数据折算规则与列保留说明)。
2026-10-21 03:33:27 +08:00

343 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 / PortalPublic 在 lite 版已废弃:仍然解析,但不再有任何作用,
// 只为兼容旧版 daili.ini(否则旧文件会因“未知配置项”直接启动失败)。
ScreenPublic bool
PortalPublic 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,
PortalPublic: 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 "portal_public":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("portal_public %v", err)
}
c.PortalPublic = 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(`# ============================================================
# 代打台-L 配置文件(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
# 以下两项在 lite 版已废弃(本版没有大屏 /screen 与客户自助 /customer)。
# 保留是为了让旧版 daili.ini 仍能直接启动;写了也不报错、不生效。
screen_public = %t
portal_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.PortalPublic,
c.LogEnabled,
c.LogDir,
)
}