// 主程序:程序从这里启动。 // // 启动后做的事很简单: // 1. 读配置文件(端口、数据库路径等); // 2. 打开数据库(打不开也不影响启动,只是媒体库没得查); // 3. 搭好网址指路(路由),把内嵌的前端页面交给浏览器; // 4. 在浏览器里自动打开页面,然后开始招待网页请求。 // // 真正的媒体整理不会在启动时发生——都是用户在页面上点「开始扫描」才触发的。 package main import ( "fmt" "io" "io/fs" "mime" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "kenaito-media-picker/frontend" "kenaito-media-picker/internal/api" "kenaito-media-picker/internal/config" "kenaito-media-picker/internal/store" "github.com/gin-gonic/gin" ) func main() { // 读配置文件,得到端口、数据库路径等 config.LoadAppConfig() cfg := config.GetAppConfig() // 打开数据库;失败时服务照常启动,只是媒体库查不了、扫描不记信息 st, err := store.Open(cfg.Dsn) if err != nil { fmt.Printf("=== 打开数据库失败(媒体库查询不可用): %v\n", err) st = nil } else { defer st.Close() } // 搭好路由(网址 → 处理窗口) r := api.NewRouter(st) // 网页请求的访问日志写到 stderr,不占用 stdout—— // 因为扫描时 stdout 会被临时接管来采集日志,不能让访问日志混进去。 r.Use(gin.LoggerWithWriter(os.Stderr)) // 所有不是 /api 的地址,都交给内嵌的前端页面处理 r.NoRoute(staticHandler()) addr := fmt.Sprintf(":%d", cfg.Port) url := "http://localhost" + addr fmt.Printf("=== Web 服务已启动: %s\n", url) fmt.Printf("=== 数据库: %s\n", cfg.Dsn) fmt.Printf("=== 在页面上点击【开始扫描】执行媒体整理(会移动文件,注意备份)\n") // 配置了自动打开浏览器的话,等服务起来后帮用户打开 if cfg.AutoOpen { go func() { time.Sleep(600 * time.Millisecond) // 等服务端口就绪再打开 openBrowser(url) }() } if err := r.Run(addr); err != nil { fmt.Printf("=== 服务启动失败: %v\n", err) } } // openBrowser 调用系统默认浏览器打开指定网址(Windows/macOS/Linux 各自处理)。 func openBrowser(url string) { var cmd *exec.Cmd switch runtime.GOOS { case "windows": cmd = exec.Command("cmd", "/c", "start", "", url) case "darwin": cmd = exec.Command("open", url) default: cmd = exec.Command("xdg-open", url) } if err := cmd.Start(); err != nil { fmt.Printf("=== 自动打开浏览器失败: %v\n", err) } } // staticHandler 把内嵌在程序里的前端页面(编译时打包进来的)交给浏览器。 // 命中真实文件(js/css/图片)就返回那个文件,否则一律返回首页(单页应用模式)。 func staticHandler() gin.HandlerFunc { distFS, err := fs.Sub(frontend.Dist, "dist") if err != nil { // 没编译前端产物的话,给个提示 return func(c *gin.Context) { c.String(http.StatusNotFound, "前端产物缺失,请先 npm run build 后重新编译") } } return func(c *gin.Context) { // 未知的 /api 地址统一回"接口不存在" if strings.HasPrefix(c.Request.URL.Path, "/api") { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "接口不存在", "data": nil}) return } path := strings.TrimPrefix(c.Request.URL.Path, "/") if path == "" { path = "index.html" } file, err := distFS.Open(path) if err != nil { // 没找到文件就回首页(单页应用:路由由前端管理) file, err = distFS.Open("index.html") } if err != nil { c.String(http.StatusNotFound, "页面不存在") return } defer file.Close() info, statErr := file.Stat() if statErr != nil { c.String(http.StatusInternalServerError, "读取前端资源失败") return } // 尽量用 http.ServeContent 这种能断点续传的方式返回文件 if rs, ok := file.(io.ReadSeeker); ok { http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), rs) return } body, err := io.ReadAll(file) if err != nil { c.String(http.StatusInternalServerError, "读取前端资源失败") return } c.Data(http.StatusOK, mime.TypeByExtension(filepath.Ext(path)), body) } }