refactor: 全面重构代码并加白话注释,移动全局并行,去掉去向/重复记录

- 重构后端与前端,全程大白话注释(不懂程序也能看懂)
- 移动阶段改为全局并行(MoveAll,工人数=CPU核心数)
- 去掉记录每个文件搬去哪了(删除去向回填,解决写库卡顿)
- 去掉重复文件标记与统计:重复判定改为 MD5+路径都相同才算,重复则原地忽略不移动;MD5 相同但路径不同仍照常搬
- 媒体库去掉归类目录列,看板去掉重复/归类分布统计
This commit is contained in:
2025-05-11 21:05:35 +08:00
parent f915a9b1ac
commit 8979c744a8
26 changed files with 1044 additions and 683 deletions
+23 -13
View File
@@ -1,4 +1,5 @@
// Package api 提供服务器文件系统浏览接口,供前端目录选择器使用(只列出目录,不列文件)。
// 文件目录浏览接口:给页面上"选择目录"用的,只列目录、不列文件。
// 前端那个"选择目录"弹窗就是一层层调这个接口往下翻文件夹的。
package api
import (
@@ -12,33 +13,38 @@ import (
"github.com/gin-gonic/gin"
)
// FsController 服务器文件系统浏览控制器
// FsController 目录浏览的"窗口"。
type FsController struct{}
// NewFsController 创建文件系统浏览控制器
// NewFsController 创建目录浏览控制器。
func NewFsController() *FsController {
return &FsController{}
}
// FsListResult 目录浏览结果
// FsListResult 一次目录浏览的结果:
// - current:当前在哪个目录(盘符层时为空)
// - parent:上一级目录(在根/盘符层时等于 current)
// - drives:Windows 的盘符列表(非 Windows 为空)
// - dirs:当前目录下的子目录(不含文件)
type FsListResult struct {
Current string `json:"current"` // 当前目录绝对路径;盘符层为空
Parent string `json:"parent"` // 父目录;根/盘符层时等于 Current
Drives []string `json:"drives"` // Windows 盘符列表(非 Windows 为空)
Dirs []FsDir `json:"dirs"` // 子目录列表(不含文件)
Current string `json:"current"`
Parent string `json:"parent"`
Drives []string `json:"drives"`
Dirs []FsDir `json:"dirs"`
}
// FsDir 子目录项
// FsDir 子目录项。
type FsDir struct {
Name string `json:"name"`
Path string `json:"path"`
}
// List GET /api/fs/list?dir= 列出指定目录的子目录(不含文件);
// dir 为空时返回 Windows 盘符列表或 Unix 根目录。
// List 处理 GET /api/fs/list?dir=xxx:列出指定目录下的子目录。
// 没传目录时,Windows 返回盘符列表,其他系统返回根目录。
func (c *FsController) List(ctx *gin.Context) {
dir := strings.TrimSpace(ctx.Query("dir"))
// 没指定目录:Windows 先让用户选盘符
if dir == "" {
if runtime.GOOS == "windows" {
Success(ctx, FsListResult{Drives: listLogicalDrives()})
@@ -52,7 +58,8 @@ func (c *FsController) List(ctx *gin.Context) {
Error(ctx, http.StatusBadRequest, "路径解析失败: "+err.Error())
return
}
// 目录不存在/不可访问时向上回退到最近的已存在父目录,避免阻塞目录选择器
// 目录不存在/打不开时,往上找最近的"确实存在"的父目录,
// 这样用户输入了不存在的地方也不会卡死,而是回到最近的可用目录。
for {
st, statErr := os.Stat(abs)
if statErr == nil && st.IsDir() {
@@ -60,7 +67,7 @@ func (c *FsController) List(ctx *gin.Context) {
}
parent := filepath.Dir(abs)
if parent == abs {
// 已到根仍不存在:回退到盘符层
// 已经退到根还是不存在:直接返回盘符层
if runtime.GOOS == "windows" {
Success(ctx, FsListResult{Drives: listLogicalDrives()})
return
@@ -77,10 +84,12 @@ func (c *FsController) List(ctx *gin.Context) {
return
}
// 上一级目录;在根目录时等于自己
parent := filepath.Dir(abs)
if parent == abs {
parent = abs
}
// 只收集子目录(跳过文件,这里只需要目录)
dirs := make([]FsDir, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
@@ -88,6 +97,7 @@ func (c *FsController) List(ctx *gin.Context) {
}
dirs = append(dirs, FsDir{Name: e.Name(), Path: filepath.Join(abs, e.Name())})
}
// 按名字排序,翻起来不凌乱
sort.Slice(dirs, func(i, j int) bool {
return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name)
})
+3 -1
View File
@@ -4,13 +4,15 @@ package api
import "golang.org/x/sys/windows"
// listLogicalDrives 返回 Windows 所有逻辑盘符(如 C:\、D:\)
// listLogicalDrives 列出 Windows 所有可用的盘符(如 C:\、D:\)。
// 只有 Windows 编译时才需要这个函数(见文件顶部的 //go:build windows)。
func listLogicalDrives() []string {
bits, err := windows.GetLogicalDrives()
if err != nil {
return nil
}
drives := make([]string, 0, 26)
// 26 个字母逐个检查,看哪个盘存在
for i := 0; i < 26; i++ {
if bits&(1<<uint(i)) != 0 {
drives = append(drives, string(rune('A'+i))+":\\")
+19 -12
View File
@@ -1,3 +1,6 @@
// 媒体库的两个接口:查列表、查统计。
// 「媒体库」页面查列表(带筛选和分页),「统计看板」页面查统计。
// 这两个接口只读数据库,不干别的活。
package api
import (
@@ -11,17 +14,17 @@ import (
"kenaito-media-picker/internal/store"
)
// MediaController 媒体库接口
// MediaController 媒体库相关接口的"窗口"。
type MediaController struct {
store *store.Store
store *store.Store // 数据库(只读)
}
// NewMediaController 创建媒体控制器,注入数据访问层
// NewMediaController 创建媒体库控制器,注入数据库。
func NewMediaController(st *store.Store) *MediaController {
return &MediaController{store: st}
}
// mediaListReq 列表查询请求
// mediaListReq 媒体库列表的查询条件(前端「媒体库」页面传过来的)。
type mediaListReq struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
@@ -35,16 +38,17 @@ type mediaListReq struct {
SortOrder string `json:"sortOrder"`
}
// mediaView 列表展示视图:补充方向/分辨率/可读大小等派生字段
// mediaView 列表里展示的一条记录:在数据库记录的基础上,
// 再补几个"现算"的字段(方向、分辨率档位、人类可读的体积和时长),方便页面直接显示。
type mediaView struct {
store.MediaInfo
Orientation string `json:"orientation"` // 横屏/竖屏/等比
Resolution string `json:"resolution"` // 分辨率档位
SizeText string `json:"sizeText"` // 人类可读体积
DurationText string `json:"durationText"` // 人类可读时长
Resolution string `json:"resolution"` // 分辨率档位(如 1080P)
SizeText string `json:"sizeText"` // 人类可读体积(如 1.5 MB)
DurationText string `json:"durationText"` // 人类可读时长(如 05:30)
}
// List POST /api/media/list 分页查询媒体库
// List 处理 POST /api/media/list:按条件分页查媒体库列表。
func (c *MediaController) List(ctx *gin.Context) {
var req mediaListReq
if err := ctx.ShouldBindJSON(&req); err != nil {
@@ -67,6 +71,7 @@ func (c *MediaController) List(ctx *gin.Context) {
SortOrder: req.SortOrder,
}
// 查这一页的数据,同时查总条数(分页条要用)
list, err := c.store.ListMedia(q)
if err != nil {
Error(ctx, http.StatusInternalServerError, "查询媒体列表失败:"+err.Error())
@@ -78,6 +83,7 @@ func (c *MediaController) List(ctx *gin.Context) {
return
}
// 每条记录补上"现算"的展示字段再返回
views := make([]interface{}, 0, len(list))
for _, m := range list {
views = append(views, toMediaView(m))
@@ -85,7 +91,7 @@ func (c *MediaController) List(ctx *gin.Context) {
Success(ctx, map[string]interface{}{"list": views, "total": total})
}
// Stats POST /api/media/stats 媒体库统计看板
// Stats 处理 POST /api/media/stats:统计看板要用的全部数据。
func (c *MediaController) Stats(ctx *gin.Context) {
st, err := c.store.Stats()
if err != nil {
@@ -95,9 +101,10 @@ func (c *MediaController) Stats(ctx *gin.Context) {
Success(ctx, st)
}
// toMediaView 补充派生字段
// toMediaView 给一条数据库记录补上展示用的派生字段。
func toMediaView(m store.MediaInfo) mediaView {
v := mediaView{MediaInfo: m}
// 宽高有效才算方向和分辨率档位
if m.Width > 0 && m.Height > 0 {
v.Orientation = classify.OrientationOf(m.Width, m.Height).String()
v.Resolution = classify.ResLabel(m.Width, m.Height)
@@ -111,7 +118,7 @@ func toMediaView(m store.MediaInfo) mediaView {
return v
}
// formatDuration 秒 → 时:分:秒
// formatDuration 把秒数转成人类可读的时:分:秒。
func formatDuration(sec float64) string {
s := int(sec)
h := s / 3600
+12 -5
View File
@@ -1,4 +1,8 @@
// Package api 提供 Web 管理接口(Gin),遵循统一响应与分页约定。
// Package api 是程序的"门面"——所有网页请求都先到这里,再转给背后的服务。
//
// 大白话理解:这是前台接待处。页面上的每个按钮、每次查询,
// 都会变成一个"请求"发到这里;这里分发给对应的办事窗口(controller),
// 办完再用统一的格式回话。
package api
import (
@@ -7,14 +11,17 @@ import (
"github.com/gin-gonic/gin"
)
// Response 统一响应结构
// Response 所有接口的统一回话格式:
// - code:成功 200,出错 400/500
// - message:一句话说明(成功就是 success,出错就是原因)
// - data:真正要返回的数据
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
// Success 成功响应
// Success 返回成功回话(code=200)。
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: http.StatusOK,
@@ -23,7 +30,7 @@ func Success(c *gin.Context, data interface{}) {
})
}
// Error 失败响应(仅允许 400 / 500)
// Error 返回失败回话。statusCode 只允许 400(参数不对)/ 500(服务器出错)。
func Error(c *gin.Context, statusCode int, message string) {
c.JSON(statusCode, Response{
Code: statusCode,
@@ -32,7 +39,7 @@ func Error(c *gin.Context, statusCode int, message string) {
})
}
// PageResult 分页响应体
// PageResult 分页列表的统一格式:list 是这一页的数据,total 是总条数。
type PageResult struct {
List []interface{} `json:"list"`
Total int64 `json:"total"`
+12 -2
View File
@@ -1,3 +1,9 @@
// Package api 是程序的"门面",所有网页请求都先进这里。
//
// 这里的 router.go 是"指路牌",负责把不同的网址指给不同的窗口(controller):
// - /api/scan/... → 扫描整理
// - /api/media/... → 媒体库与统计看板
// - /api/fs/... → 文件目录浏览(页面上"选择目录"用)
package api
import (
@@ -9,27 +15,31 @@ import (
"kenaito-media-picker/internal/store"
)
// NewRouter 组装 Gin 路由:健康检查 + API 路由组
// NewRouter 把"网址 → 窗口"的路由都登记好,返回给主程序去跑。
func NewRouter(st *store.Store) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
// 健康检查
// 健康检查:别的程序或人想知道"服务还活着吗",就访问这个
r.GET("/health/check", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// 所有 /api/ 开头的接口都归到这里
apiGroup := r.Group("/api")
{
// 媒体库 + 统计看板
mc := NewMediaController(st)
apiGroup.POST("/media/list", mc.List)
apiGroup.POST("/media/stats", mc.Stats)
// 扫描整理(开始 / 查进度 / 实时进度)
sc := NewScanController(service.NewScanService(st))
apiGroup.POST("/scan/start", sc.Start)
apiGroup.GET("/scan/status", sc.Status)
apiGroup.GET("/scan/ws", sc.Ws)
// 文件目录浏览(页面上"选择目录")
fc := NewFsController()
apiGroup.GET("/fs/list", fc.List)
}
+11 -6
View File
@@ -1,3 +1,5 @@
// 扫描整理的三个接口:开始扫描、查进度、实时进度(WebSocket)。
// 它们不干活,只负责"接话、转达",真正的活由 service 里的 ScanService 干。
package api
import (
@@ -8,23 +10,26 @@ import (
"kenaito-media-picker/internal/service"
)
// ScanController 扫描整理接口
// ScanController 扫描相关接口的"窗口"。
type ScanController struct {
service *service.ScanService
service *service.ScanService // 背后干活的管家
}
// NewScanController 创建扫描控制器
// NewScanController 创建扫描控制器,并注入干活的服务。
func NewScanController(svc *service.ScanService) *ScanController {
return &ScanController{service: svc}
}
// scanStartReq 扫描启动请求;dir 为空时使用服务当前运行目录,destDir 为空时默认输出到扫描目录下的「KMP已整理」
// scanStartReq 点击"开始扫描"时前端传过来的参数:
// - dir:要扫描哪个目录(留空就扫程序当前所在目录)
// - destDir:整理好的文件放哪(留空默认放扫描目录下的「KMP已整理」)
type scanStartReq struct {
Dir string `json:"dir"`
DestDir string `json:"destDir"`
}
// Start POST /api/scan/start 触发一次扫描整理
// Start 处理 POST /api/scan/start:触发一次扫描整理。
// 成功后立刻返回当前进度快照,好让页面马上进入"扫描中"状态。
func (c *ScanController) Start(ctx *gin.Context) {
var req scanStartReq
if err := ctx.ShouldBindJSON(&req); err != nil {
@@ -38,7 +43,7 @@ func (c *ScanController) Start(ctx *gin.Context) {
Success(ctx, c.service.Status())
}
// Status GET /api/scan/status 查询扫描状态与日志
// Status 处理 GET /api/scan/status:查询当前扫描状态与日志(轮询兜底用的)。
func (c *ScanController) Status(ctx *gin.Context) {
Success(ctx, c.service.Status())
}
+28 -14
View File
@@ -1,4 +1,8 @@
// 扫描状态 WebSocket 实时推送端点。
// 扫描状态实时推送:用 WebSocket 把进度不停推给前端页面。
//
// 大白话理解:网页和服务器之间开一条"专线",服务器一有动静(进度变了、
// 日志多了、干完了)就主动通过这条线喊一声,页面立刻更新。
// 以前是页面每隔一秒"问一次",现在是服务器"主动喊",实时又不浪费。
package api
import (
@@ -10,51 +14,58 @@ import (
"github.com/gorilla/websocket"
)
// wsUpgrader WebSocket 升级器
// wsUpgrader 负责把普通的网页请求"升级"成 WebSocket 专线。
// 同一个管理页面访问,所以允许任意来源(也方便局域网内访问)。
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 4096,
// 同源管理页,允许任意来源,便于局域网访问
CheckOrigin: func(r *http.Request) bool { return true },
CheckOrigin: func(r *http.Request) bool { return true },
}
// 心跳相关的三个时间:
const (
wsWriteWait = 10 * time.Second
wsPongWait = 60 * time.Second
wsPingPeriod = (wsPongWait * 9) / 10
wsWriteWait = 10 * time.Second // 写消息的最长等待时间
wsPongWait = 60 * time.Second // 等客户端"应答"的最长时间,超了就当它掉了
wsPingPeriod = (wsPongWait * 9) / 10 // 每隔多久主动发一次"心跳"
)
// Ws GET /api/scan/ws 建立 WebSocket 连接,持续推送扫描状态。
// 连接建立后立即推送当前状态快照;断开后前端可自动重连(服务端不做重连保证)。
// Ws 处理 GET /api/scan/ws:建立专线后,不停把扫描状态推给前端。
//
// 连接一建立,先立刻把当前状态推一份(快照),之后一有更新就推。
// 前端页面刷新、网络断了之后会自己重新连上来,这里只管服务端这一端。
func (c *ScanController) Ws(ctx *gin.Context) {
// 升级成 WebSocket 专线
conn, err := wsUpgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
return
}
// 订阅进度更新:之后有状态就送到 ch 这个管道里
ch, cancel := c.service.Subscribe()
defer func() {
cancel()
conn.Close()
}()
// 读协程:仅用于感知客户端断开/失活(配合 pong 心跳),不处理业务消息
// 读消息的协程只是用来"感知"客户端还在不在(配合心跳应答),不处理实际内容
conn.SetReadLimit(1024)
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
})
// 写协程:先推送快照,再转发订阅更新,并周期性 ping 保活
// 写消息的协程:先推一份当前状态快照,再不停转发订阅到的更新,
// 空闲时定期发心跳(ping),防止专线被路由器等中间设备掐断。
writeDone := make(chan struct{})
go func() {
defer close(writeDone)
write := func(msgType int, data []byte) bool {
if err := conn.WriteMessage(msgType, data); err != nil {
conn.Close() // 写失败即断开,让读协程退出
conn.Close() // 写失败说明专线断了,把连接关掉,让读协程收工
return false
}
return true
}
// 先推快照
if b, err := json.Marshal(c.service.Status()); err == nil {
if !write(websocket.TextMessage, b) {
return
@@ -66,7 +77,7 @@ func (c *ScanController) Ws(ctx *gin.Context) {
select {
case st, ok := <-ch:
if !ok {
return
return // 订阅被取消了(连接要关了)
}
b, err := json.Marshal(st)
if err != nil {
@@ -76,6 +87,7 @@ func (c *ScanController) Ws(ctx *gin.Context) {
return
}
case <-ticker.C:
// 定期发心跳
if !write(websocket.PingMessage, nil) {
return
}
@@ -83,11 +95,13 @@ func (c *ScanController) Ws(ctx *gin.Context) {
}
}()
// 主线程一直等着读:客户端断开或失活时 ReadMessage 会出错,这里就收工
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
}
cancel() // 先关闭订阅频道,让写协程退出,避免 <-writeDone 永久阻塞
// 先关掉订阅,让写协程退出,避免白等
cancel()
<-writeDone
}