Files
chun_qiu ddc7988f34
build / build-and-test (push) Successful in 3m18s
fix(v1.11.3): 客户自助查询 /customer 全量修复
- 路由:从 screen_public 拆出 portal_public(默认 true),关掉大屏不再连带 404;
  设置页显示客户自助状态;/customer 与 /screen 各自独立。
- 查询:尾号只接受 4–8 位数字,服务端精确匹配,去掉 LIKE 模糊回退;按 IP
  40 次/分钟限流;页面 noindex。
- 语义:修正「今日待完成」把已完成数当待完成数的问题;0/0 改为「今日无排单」;
  无订阅走空态;删掉进度条和重复的今日文案,只留一个状态。
- 订阅:复用管理端 SubView / calSub;通行证 1/1、深渊 x/y 周期;过期、暂停、
  按次用尽、跳过都有独立口径;「最近到期」只取未来日期。
- 日历:与主日历一致,只在本月格显示任务;通行证/深渊整段色块(深渊按周期
  减淡),日常任务用空心/实心点;每格带读屏描述;today 加「今天」文字标记。
- 错误:只渲染一次并放回内容容器,字段加 aria-invalid;删掉顶部重复横幅。
- 多客户:显示掩码姓名 + 卡内补姓名表单;姓名回填;翻月用 url.Values 编码;
  1 位尾号不触发查询;只有空态/补姓名时 autofocus。
- UI:品牌头、可折叠「查询其他尾号」、订阅全宽列表、状态统一用现有 chip、
  月份按钮 44px、去掉卡中卡 / opacity / 满屏 0/1。
- 版本 1.11.3;README 增加 4.4 与 portal_public 配置说明。

验证:gofmt / go vet / go test 全绿;portal_public=true 时 /customer 200、
false 时 404;screen_public=false + portal_public=true 时 /customer 200、
/screen 302;尾号 2 与 % 被拒;2222 多客户掩码;9900 日常点、3321 通行证
1/1、6677 深渊 1/4 + 已到期订阅;明暗 1440 与 390 截图无对比度/溢出问题。
2026-10-20 03:39:41 +08:00

341 lines
9.4 KiB
Go
Raw Permalink 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 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(`# ============================================================
# 代打台 配置文件(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
# 客户自助查询 /customer 是否免登录(默认 true;不需要对外时可改成 false)
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,
)
}