支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。

引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-29 14:09:58 +08:00
parent 962bb15298
commit 9487c8ab02
37 changed files with 2025 additions and 403 deletions

View File

@@ -5,61 +5,184 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Config 应用全局配置,通过命令行参数注入
// Config 应用全局配置:默认读工作目录下 app.ini命令行可覆盖
type Config struct {
// 工作目录(默认可执行文件所在目录)
WorkPath string
// 配置文件绝对路径
ConfigFile string
// 监听端口
Port int
// 数据目录SQLite 数据库、上传头像、日志文件均存放于此
// 数据目录SQLite、上传、日志(绝对路径)
DataDir string
// JWT 签名密钥
JWTSecret string
// 日志文件路径(相对 DataDir
// 日志文件路径
LogFile string
// 系统服务控制动作install|uninstall|start|stop|restart|status空表示正常运行
ServiceAction string
}
// Parse 解析命令行参数并初始化目录
// Parse 解析命令行与 app.ini并初始化数据目录
//
// 优先级(高 → 低):命令行显式参数 > app.ini > 内置默认值
func Parse() (*Config, error) {
port := flag.Int("port", 3000, "HTTP 监听端口")
dataDir := flag.String("data", "./data", "数据存储目录(数据库、上传、日志")
jwtSecret := flag.String("jwt-secret", "", "JWT 签名密钥(留空则自动生成并持久化")
configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini")
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录")
portFlag := flag.Int("port", 0, "HTTP 监听端口覆盖配置文件0 表示不覆盖")
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
serviceFlag := flag.String("service", "", "系统服务控制install|uninstall|start|stop|restart|status")
flag.Parse()
// 确保数据目录存在
if err := os.MkdirAll(*dataDir, 0755); err != nil {
return nil, fmt.Errorf("创建数据目录失败: %w", err)
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
if action != "" && !validServiceAction(action) {
return nil, fmt.Errorf("无效的 -service 动作 %q可选install|uninstall|start|stop|restart|status", *serviceFlag)
}
uploadDir := filepath.Join(*dataDir, "uploads", "avatars")
if err := os.MkdirAll(uploadDir, 0755); err != nil {
return nil, fmt.Errorf("创建上传目录失败: %w", err)
workPath, err := resolveWorkPath(*workFlag)
if err != nil {
return nil, err
}
postImgDir := filepath.Join(*dataDir, "uploads", "posts")
if err := os.MkdirAll(postImgDir, 0755); err != nil {
return nil, fmt.Errorf("创建帖子图片目录失败: %w", err)
configFile, err := resolveConfigPath(workPath, *configFlag)
if err != nil {
return nil, err
}
fileCfg := defaultFileSettings()
configExists := false
if st, err := os.Stat(configFile); err == nil && !st.IsDir() {
configExists = true
fileCfg, err = loadAppINI(configFile)
if err != nil {
return nil, err
}
}
port := fileCfg.Port
if *portFlag > 0 {
port = *portFlag
}
dataInput := fileCfg.DataRel
if strings.TrimSpace(*dataFlag) != "" {
dataInput = *dataFlag
}
absData, err := absPath(workPath, dataInput)
if err != nil {
return nil, fmt.Errorf("解析数据目录失败: %w", err)
}
jwtSecret := strings.TrimSpace(*jwtFlag)
if jwtSecret == "" {
jwtSecret = fileCfg.JWTSecret
}
cfg := &Config{
Port: *port,
DataDir: *dataDir,
JWTSecret: *jwtSecret,
LogFile: filepath.Join(*dataDir, "jiang13.log"),
WorkPath: workPath,
ConfigFile: configFile,
Port: port,
DataDir: absData,
JWTSecret: jwtSecret,
LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action,
}
// 处理 JWT 密钥持久化
secretFile := filepath.Join(*dataDir, ".jwt_secret")
if cfg.JWTSecret == "" {
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
cfg.JWTSecret = string(data)
} else {
cfg.JWTSecret = generateRandomSecret(32)
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600)
needDirs := action == "" || action == "install"
if needDirs {
// 首次启动自动生成 app.ini便于像 Gitea 一样改文件而不记一长串参数
if !configExists {
dataRel := resolveDataRelForINI(workPath, absData)
if err := writeAppINI(configFile, port, dataRel, ""); err != nil {
return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
}
fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
} else if action == "install" {
// 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
dataRel := resolveDataRelForINI(workPath, absData)
iniJWT := ""
if strings.TrimSpace(*jwtFlag) != "" {
iniJWT = jwtSecret
}
if err := writeAppINI(configFile, port, dataRel, iniJWT); err != nil {
return nil, fmt.Errorf("更新配置文件失败: %w", err)
}
}
if err := ensureDataDirs(absData); err != nil {
return nil, err
}
if err := cfg.resolveJWT(); err != nil {
return nil, err
}
}
return cfg, nil
}
func resolveWorkPath(flagVal string) (string, error) {
if strings.TrimSpace(flagVal) != "" {
abs, err := filepath.Abs(flagVal)
if err != nil {
return "", fmt.Errorf("解析工作目录失败: %w", err)
}
return filepath.Clean(abs), nil
}
return defaultWorkPath()
}
func resolveConfigPath(workPath, flagVal string) (string, error) {
if strings.TrimSpace(flagVal) != "" {
return absPath(workPath, flagVal)
}
return filepath.Join(workPath, defaultConfName), nil
}
func ensureDataDirs(dataDir string) error {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("创建数据目录失败: %w", err)
}
for _, sub := range []string{
filepath.Join(dataDir, "uploads", "avatars"),
filepath.Join(dataDir, "uploads", "posts"),
} {
if err := os.MkdirAll(sub, 0755); err != nil {
return fmt.Errorf("创建上传目录失败: %w", err)
}
}
return nil
}
func (c *Config) resolveJWT() error {
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
if c.JWTSecret != "" {
_ = os.WriteFile(secretFile, []byte(c.JWTSecret), 0600)
return nil
}
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
c.JWTSecret = string(data)
return nil
}
c.JWTSecret = generateRandomSecret(32)
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
}
return nil
}
func validServiceAction(action string) bool {
switch action {
case "install", "uninstall", "start", "stop", "restart", "status":
return true
default:
return false
}
}
// DBPath 返回 SQLite 数据库文件路径
func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "jiang13.db")