- 重构后端与前端,全程大白话注释(不懂程序也能看懂) - 移动阶段改为全局并行(MoveAll,工人数=CPU核心数) - 去掉记录每个文件搬去哪了(删除去向回填,解决写库卡顿) - 去掉重复文件标记与统计:重复判定改为 MD5+路径都相同才算,重复则原地忽略不移动;MD5 相同但路径不同仍照常搬 - 媒体库去掉归类目录列,看板去掉重复/归类分布统计
136 lines
4.2 KiB
Go
136 lines
4.2 KiB
Go
// 主程序:程序从这里启动。
|
|
//
|
|
// 启动后做的事很简单:
|
|
// 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)
|
|
}
|
|
}
|