Initial UNC file watcher web console
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.gocache/
|
||||||
|
.gotmp/
|
||||||
|
unc_tar_watcher.exe
|
||||||
|
unc_tar_watcher.exe~
|
||||||
|
unc_tar_watcher_settings.json
|
||||||
|
*.part
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# UNC File Watcher
|
||||||
|
|
||||||
|
This project is a Go-based local web console for watching files in UNC paths and copying or moving them to local directories.
|
||||||
|
|
||||||
|
After build, the program is a single `exe`. The target machine does not need Go installed.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
The program supports two rule types at the same time:
|
||||||
|
|
||||||
|
- Archive rule
|
||||||
|
Watches `.tar.gz`
|
||||||
|
Copies or moves files to a `zip` directory
|
||||||
|
Skips when the `zip` directory already has a file with the same name
|
||||||
|
Skips when the `unzip` directory already has an extracted folder with the same name
|
||||||
|
|
||||||
|
- Generic file rule
|
||||||
|
Watches other file types by extension list
|
||||||
|
Only needs `UNC source path + local target path`
|
||||||
|
Does not use `unzip`
|
||||||
|
Supports two dedupe modes
|
||||||
|
|
||||||
|
## Generic File Dedupe
|
||||||
|
|
||||||
|
The generic file rule supports these dedupe modes:
|
||||||
|
|
||||||
|
- `Same name and same size`
|
||||||
|
This is the default mode
|
||||||
|
If the target directory already has a file with the same name and the same size, the file is skipped
|
||||||
|
If the target directory already has a file with the same name but a different size, it is treated as a conflict and skipped without overwrite
|
||||||
|
|
||||||
|
- `Same name and same hash`
|
||||||
|
When the target directory already has a file with the same name, the program computes SHA-256 for source and target
|
||||||
|
If the hashes match, the file is skipped
|
||||||
|
If the hashes do not match, it is treated as a conflict and skipped without overwrite
|
||||||
|
|
||||||
|
## Stable File Detection
|
||||||
|
|
||||||
|
To avoid processing a file while another service is still writing it in the UNC path, the program checks both conditions:
|
||||||
|
|
||||||
|
- `size + modified time` stay unchanged across multiple scans
|
||||||
|
- the last modified time is older than `stable_for`
|
||||||
|
|
||||||
|
Suggested starting values:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scan interval: 15s
|
||||||
|
stable_for: 1m
|
||||||
|
stable_scans: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go build -o unc_tar_watcher.exe .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\unc_tar_watcher.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Default listen address:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:18080
|
||||||
|
```
|
||||||
|
|
||||||
|
To change the port:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\unc_tar_watcher.exe -listen 127.0.0.1:19090
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web UI Settings
|
||||||
|
|
||||||
|
### Archive Rule
|
||||||
|
|
||||||
|
- Enable archive rule
|
||||||
|
- Archive UNC source path
|
||||||
|
- Zip path
|
||||||
|
- Unzip path
|
||||||
|
|
||||||
|
### Generic File Rule
|
||||||
|
|
||||||
|
- Enable generic file rule
|
||||||
|
- Generic file UNC source path
|
||||||
|
- Local target path
|
||||||
|
- Extension list, for example `.csv,.txt,.pdf`
|
||||||
|
- Dedupe mode
|
||||||
|
|
||||||
|
### Shared Settings
|
||||||
|
|
||||||
|
- Scan interval
|
||||||
|
- Stable duration
|
||||||
|
- Stable scan count
|
||||||
|
- Transfer mode: `copy` or `move`
|
||||||
|
- Recursive scan
|
||||||
|
|
||||||
|
## Local Settings File
|
||||||
|
|
||||||
|
The web UI saves settings automatically next to the executable:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unc_tar_watcher_settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
When you open the page again, the last saved settings are loaded automatically.
|
||||||
|
|
||||||
|
## Runtime Notes
|
||||||
|
|
||||||
|
- In `copy` mode, source files remain in the UNC path
|
||||||
|
- In `move` mode, the program copies first and then removes the source file
|
||||||
|
- The generic file rule never overwrites an existing target file when a conflict is detected
|
||||||
|
- The archive rule only handles `.tar.gz`
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newApp() *app {
|
||||||
|
defaultCfg := defaultConfig()
|
||||||
|
defaultForm := formFromConfig(defaultCfg)
|
||||||
|
settingsPath := resolveSettingsPath()
|
||||||
|
|
||||||
|
savedForm, err := loadSettings(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("load settings failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &app{
|
||||||
|
cfg: defaultCfg,
|
||||||
|
form: mergeForm(defaultForm, savedForm),
|
||||||
|
settingsPath: settingsPath,
|
||||||
|
lastError: loadSettingsErrorMessage(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderPage(w, formValues{}, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form := formFromRequest(r)
|
||||||
|
cfg, err := parseConfig(form)
|
||||||
|
if err != nil {
|
||||||
|
a.renderPage(w, form, "", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedForm := formFromConfig(cfg)
|
||||||
|
saveErr := a.persistForm(normalizedForm)
|
||||||
|
|
||||||
|
if err := a.startWatcher(cfg); err != nil {
|
||||||
|
a.renderPage(w, normalizedForm, "", joinMessages(err.Error(), saveErrorMessage(saveErr)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderPage(w, normalizedForm, "监听任务已启动,配置已保存到本地", saveErrorMessage(saveErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleStop(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := "当前没有正在运行的监听任务"
|
||||||
|
if a.stopWatcher() {
|
||||||
|
message = "已发送停止请求"
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderPage(w, formValues{}, message, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) persistForm(form formValues) error {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.form = form
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if err := saveSettings(a.settingsPath, form); err != nil {
|
||||||
|
a.addLogf("save settings failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
if strings.HasPrefix(a.lastError, "读取本地配置失败") || strings.HasPrefix(a.lastError, "配置保存失败") {
|
||||||
|
a.lastError = ""
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) startWatcher(cfg config) error {
|
||||||
|
cfg, err := validateConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.archive.enabled {
|
||||||
|
if err := ensureExistingDir(cfg.archive.sourceDir); err != nil {
|
||||||
|
return fmt.Errorf("压缩包规则源路径错误: %w", err)
|
||||||
|
}
|
||||||
|
if err := ensureDir(cfg.archive.zipDir); err != nil {
|
||||||
|
return fmt.Errorf("压缩包规则 zip 路径错误: %w", err)
|
||||||
|
}
|
||||||
|
if err := ensureDir(cfg.archive.unzipDir); err != nil {
|
||||||
|
return fmt.Errorf("压缩包规则 unzip 路径错误: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.generic.enabled {
|
||||||
|
if err := ensureExistingDir(cfg.generic.sourceDir); err != nil {
|
||||||
|
return fmt.Errorf("普通文件规则源路径错误: %w", err)
|
||||||
|
}
|
||||||
|
if err := ensureDir(cfg.generic.targetDir); err != nil {
|
||||||
|
return fmt.Errorf("普通文件规则目标路径错误: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
if a.cancel != nil {
|
||||||
|
a.cancel()
|
||||||
|
}
|
||||||
|
a.runID++
|
||||||
|
runID := a.runID
|
||||||
|
a.cancel = cancel
|
||||||
|
a.running = true
|
||||||
|
a.startedAt = time.Now()
|
||||||
|
a.lastError = ""
|
||||||
|
a.lastMessage = "监听任务运行中"
|
||||||
|
a.cfg = cfg
|
||||||
|
a.form = formFromConfig(cfg)
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if cfg.archive.enabled {
|
||||||
|
a.addLogf("archive watcher enabled: source=%q zip=%q unzip=%q", cfg.archive.sourceDir, cfg.archive.zipDir, cfg.archive.unzipDir)
|
||||||
|
}
|
||||||
|
if cfg.generic.enabled {
|
||||||
|
a.addLogf(
|
||||||
|
"generic watcher enabled: source=%q target=%q extensions=%s dedupe=%s",
|
||||||
|
cfg.generic.sourceDir,
|
||||||
|
cfg.generic.targetDir,
|
||||||
|
strings.Join(cfg.generic.extensions, ", "),
|
||||||
|
cfg.generic.dedupeMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
a.addLogf("common settings: interval=%s stable_for=%s stable_scans=%d mode=%s recursive=%t",
|
||||||
|
cfg.interval, cfg.stableFor, cfg.stableScans, cfg.mode, cfg.recursive)
|
||||||
|
|
||||||
|
go a.runWatcher(ctx, runID, cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) stopWatcher() bool {
|
||||||
|
a.mu.RLock()
|
||||||
|
cancel := a.cancel
|
||||||
|
running := a.running
|
||||||
|
a.mu.RUnlock()
|
||||||
|
|
||||||
|
if cancel == nil || !running {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
a.addLogf("stop requested from web UI")
|
||||||
|
cancel()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) runWatcher(ctx context.Context, runID int64, cfg config) {
|
||||||
|
defer a.finishRun(runID)
|
||||||
|
|
||||||
|
archiveState := make(map[string]fileState)
|
||||||
|
genericState := make(map[string]fileState)
|
||||||
|
|
||||||
|
runScan := func() {
|
||||||
|
var errorsFound []string
|
||||||
|
|
||||||
|
if cfg.archive.enabled {
|
||||||
|
err := scanRule(
|
||||||
|
"压缩包规则",
|
||||||
|
cfg.archive.sourceDir,
|
||||||
|
cfg.recursive,
|
||||||
|
func(name string) bool {
|
||||||
|
return strings.HasSuffix(strings.ToLower(name), tarGzSuffix)
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
archiveState,
|
||||||
|
func(sourcePath string) (string, bool, error) {
|
||||||
|
return processArchiveCandidate(cfg, sourcePath)
|
||||||
|
},
|
||||||
|
a.addLogf,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
errorsFound = append(errorsFound, "压缩包规则扫描失败: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.generic.enabled {
|
||||||
|
err := scanRule(
|
||||||
|
"普通文件规则",
|
||||||
|
cfg.generic.sourceDir,
|
||||||
|
cfg.recursive,
|
||||||
|
func(name string) bool {
|
||||||
|
return matchesExtensions(name, cfg.generic.extensions)
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
genericState,
|
||||||
|
func(sourcePath string) (string, bool, error) {
|
||||||
|
return processGenericCandidate(cfg, sourcePath)
|
||||||
|
},
|
||||||
|
a.addLogf,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
errorsFound = append(errorsFound, "普通文件规则扫描失败: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errorsFound) > 0 {
|
||||||
|
message := strings.Join(errorsFound, ";")
|
||||||
|
a.setLastError(message)
|
||||||
|
a.addLogf("%s", message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.setLastError("")
|
||||||
|
}
|
||||||
|
|
||||||
|
runScan()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(cfg.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
a.addLogf("watcher stopped")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runScan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) finishRun(runID int64) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
if a.runID != runID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.running = false
|
||||||
|
a.cancel = nil
|
||||||
|
a.lastMessage = "监听任务已停止"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) setLastError(message string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.lastError = message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) addLogf(format string, args ...any) {
|
||||||
|
msg := fmt.Sprintf(format, args...)
|
||||||
|
log.Print(msg)
|
||||||
|
|
||||||
|
line := fmt.Sprintf("%s %s", time.Now().Format("2006-01-02 15:04:05"), msg)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
a.logs = append(a.logs, line)
|
||||||
|
if len(a.logs) > maxLogLines {
|
||||||
|
a.logs = a.logs[len(a.logs)-maxLogLines:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) renderPage(w http.ResponseWriter, override formValues, submitMessage, submitError string) {
|
||||||
|
page := a.snapshot()
|
||||||
|
if hasFormInput(override) {
|
||||||
|
page.Form = override
|
||||||
|
}
|
||||||
|
page.SubmitMessage = submitMessage
|
||||||
|
page.SubmitError = submitError
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := pageTemplate.Execute(w, page); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) snapshot() pageData {
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
|
||||||
|
logs := append([]string(nil), a.logs...)
|
||||||
|
startedAt := ""
|
||||||
|
if !a.startedAt.IsZero() {
|
||||||
|
startedAt = a.startedAt.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageData{
|
||||||
|
Running: a.running,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
LastMessage: a.lastMessage,
|
||||||
|
LastError: a.lastError,
|
||||||
|
SettingsPath: a.settingsPath,
|
||||||
|
Form: a.form,
|
||||||
|
Logs: logs,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||||
|
|
||||||
|
listenAddr := flag.String("listen", defaultListen, "web UI listen address, for example 127.0.0.1:18080")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
application := newApp()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", application.handleIndex)
|
||||||
|
mux.HandleFunc("/start", application.handleStart)
|
||||||
|
mux.HandleFunc("/stop", application.handleStop)
|
||||||
|
|
||||||
|
log.Printf("web UI listening at http://%s", *listenAddr)
|
||||||
|
log.Printf("open the address in your browser and configure the watcher there")
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(*listenAddr, mux); err != nil {
|
||||||
|
log.Fatalf("web server stopped: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractedDirName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "plain", in: "demo.tar.gz", want: "demo"},
|
||||||
|
{name: "mixed case suffix", in: "Demo.TAR.GZ", want: "Demo"},
|
||||||
|
{name: "other name", in: "demo.zip", want: "demo.zip"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := extractedDirName(tt.in)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("extractedDirName(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsStableEnough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC)
|
||||||
|
cfg := config{
|
||||||
|
stableFor: 30 * time.Second,
|
||||||
|
stableScans: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
stableCount int
|
||||||
|
modTime time.Time
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "not enough scans", stableCount: 2, modTime: now.Add(-1 * time.Minute), want: false},
|
||||||
|
{name: "not enough age", stableCount: 3, modTime: now.Add(-20 * time.Second), want: false},
|
||||||
|
{name: "enough scans and age", stableCount: 3, modTime: now.Add(-45 * time.Second), want: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := isStableEnough(cfg, tt.stableCount, fileSnapshot{modTime: tt.modTime}, now)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("isStableEnough(...) = %t, want %t", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseExtensions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := parseExtensions(".csv, txt;PDF \n tar.gz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseExtensions() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{".csv", ".txt", ".pdf", ".tar.gz"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("parseExtensions() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateGenericDuplicateNameSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
sourcePath := filepath.Join(dir, "source.csv")
|
||||||
|
targetPath := filepath.Join(dir, "target.csv")
|
||||||
|
|
||||||
|
if err := os.WriteFile(sourcePath, []byte("abcd"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write source error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(targetPath, []byte("wxyz"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write target error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, duplicate, err := evaluateGenericDuplicate(sourcePath, targetPath, genericDedupeNameSize)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateGenericDuplicate() error = %v", err)
|
||||||
|
}
|
||||||
|
if !duplicate {
|
||||||
|
t.Fatalf("expected same-size file to be treated as duplicate")
|
||||||
|
}
|
||||||
|
if decision == "" {
|
||||||
|
t.Fatalf("expected non-empty decision")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateGenericDuplicateNameHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
sourcePath := filepath.Join(dir, "source.csv")
|
||||||
|
targetPath := filepath.Join(dir, "target.csv")
|
||||||
|
|
||||||
|
if err := os.WriteFile(sourcePath, []byte("same-content"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write source error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(targetPath, []byte("same-content"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write target error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, duplicate, err := evaluateGenericDuplicate(sourcePath, targetPath, genericDedupeNameHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateGenericDuplicate() error = %v", err)
|
||||||
|
}
|
||||||
|
if !duplicate {
|
||||||
|
t.Fatalf("expected same-hash file to be treated as duplicate")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(targetPath, []byte("DIFF-content"), 0o644); err != nil {
|
||||||
|
t.Fatalf("rewrite target error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, duplicate, err = evaluateGenericDuplicate(sourcePath, targetPath, genericDedupeNameHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateGenericDuplicate() error = %v", err)
|
||||||
|
}
|
||||||
|
if duplicate {
|
||||||
|
t.Fatalf("expected different-hash file not to be treated as duplicate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAndLoadSettings(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
settingsPath := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
want := formValues{
|
||||||
|
ArchiveEnabled: true,
|
||||||
|
ArchiveSourceDir: `\\server\share\incoming`,
|
||||||
|
ArchiveZipDir: `D:\zip`,
|
||||||
|
ArchiveUnzipDir: `D:\unzip`,
|
||||||
|
GenericEnabled: true,
|
||||||
|
GenericSourceDir: `\\server\share\others`,
|
||||||
|
GenericTargetDir: `D:\files`,
|
||||||
|
GenericExts: ".csv,.txt",
|
||||||
|
GenericDedupe: genericDedupeNameHash,
|
||||||
|
Interval: "15s",
|
||||||
|
StableFor: "1m",
|
||||||
|
StableScans: "3",
|
||||||
|
Mode: "move",
|
||||||
|
Recursive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveSettings(settingsPath, want); err != nil {
|
||||||
|
t.Fatalf("saveSettings() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadSettings(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadSettings() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("loadSettings() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadLegacySettings(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
settingsPath := filepath.Join(t.TempDir(), "legacy.json")
|
||||||
|
legacyJSON := `{
|
||||||
|
"source_dir": "\\\\server\\share\\incoming",
|
||||||
|
"zip_dir": "D:\\zip",
|
||||||
|
"unzip_dir": "D:\\unzip",
|
||||||
|
"interval": "15s",
|
||||||
|
"stable_for": "1m",
|
||||||
|
"stable_scans": "3",
|
||||||
|
"mode": "copy",
|
||||||
|
"recursive": true
|
||||||
|
}`
|
||||||
|
|
||||||
|
if err := os.WriteFile(settingsPath, []byte(legacyJSON), 0o644); err != nil {
|
||||||
|
t.Fatalf("write legacy settings error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadSettings(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadSettings() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !got.ArchiveEnabled {
|
||||||
|
t.Fatalf("expected archive rule to be enabled for legacy settings")
|
||||||
|
}
|
||||||
|
if got.ArchiveSourceDir != `\\server\share\incoming` {
|
||||||
|
t.Fatalf("unexpected archive source = %q", got.ArchiveSourceDir)
|
||||||
|
}
|
||||||
|
if got.GenericEnabled {
|
||||||
|
t.Fatalf("did not expect generic rule to be enabled for legacy settings")
|
||||||
|
}
|
||||||
|
if got.GenericDedupe != genericDedupeNameSize {
|
||||||
|
t.Fatalf("expected legacy settings to default to name_size dedupe")
|
||||||
|
}
|
||||||
|
}
|
||||||
+408
@@ -0,0 +1,408 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultConfig() config {
|
||||||
|
return config{
|
||||||
|
interval: defaultInterval,
|
||||||
|
stableFor: defaultStableFor,
|
||||||
|
stableScans: 2,
|
||||||
|
mode: "copy",
|
||||||
|
generic: genericRule{
|
||||||
|
dedupeMode: genericDedupeNameSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSettingsPath() string {
|
||||||
|
executablePath, err := os.Executable()
|
||||||
|
if err == nil {
|
||||||
|
return filepath.Join(filepath.Dir(executablePath), settingsFileName)
|
||||||
|
}
|
||||||
|
return settingsFileName
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSettingsErrorMessage(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("读取本地配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSettings(path string) (formValues, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return formValues{}, nil
|
||||||
|
}
|
||||||
|
return formValues{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return formValues{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasAnyLegacyKeys(raw) {
|
||||||
|
var legacy legacyFormValues
|
||||||
|
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||||
|
return formValues{}, err
|
||||||
|
}
|
||||||
|
return formValues{
|
||||||
|
ArchiveEnabled: legacy.SourceDir != "" || legacy.ZipDir != "" || legacy.UnzipDir != "",
|
||||||
|
ArchiveSourceDir: legacy.SourceDir,
|
||||||
|
ArchiveZipDir: legacy.ZipDir,
|
||||||
|
ArchiveUnzipDir: legacy.UnzipDir,
|
||||||
|
GenericDedupe: genericDedupeNameSize,
|
||||||
|
Interval: legacy.Interval,
|
||||||
|
StableFor: legacy.StableFor,
|
||||||
|
StableScans: legacy.StableScans,
|
||||||
|
Mode: legacy.Mode,
|
||||||
|
Recursive: legacy.Recursive,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var form formValues
|
||||||
|
if err := json.Unmarshal(data, &form); err != nil {
|
||||||
|
return formValues{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := raw["archive_enabled"]; !ok && hasArchiveFields(form) {
|
||||||
|
form.ArchiveEnabled = true
|
||||||
|
}
|
||||||
|
if _, ok := raw["generic_enabled"]; !ok && hasGenericFields(form) {
|
||||||
|
form.GenericEnabled = true
|
||||||
|
}
|
||||||
|
if form.GenericDedupe == "" {
|
||||||
|
form.GenericDedupe = genericDedupeNameSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return form, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAnyLegacyKeys(raw map[string]json.RawMessage) bool {
|
||||||
|
_, sourceOK := raw["source_dir"]
|
||||||
|
_, zipOK := raw["zip_dir"]
|
||||||
|
_, unzipOK := raw["unzip_dir"]
|
||||||
|
return sourceOK || zipOK || unzipOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveSettings(path string, form formValues) error {
|
||||||
|
data, err := json.MarshalIndent(form, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeForm(base, saved formValues) formValues {
|
||||||
|
if saved.ArchiveSourceDir != "" {
|
||||||
|
base.ArchiveSourceDir = saved.ArchiveSourceDir
|
||||||
|
}
|
||||||
|
if saved.ArchiveZipDir != "" {
|
||||||
|
base.ArchiveZipDir = saved.ArchiveZipDir
|
||||||
|
}
|
||||||
|
if saved.ArchiveUnzipDir != "" {
|
||||||
|
base.ArchiveUnzipDir = saved.ArchiveUnzipDir
|
||||||
|
}
|
||||||
|
if saved.GenericSourceDir != "" {
|
||||||
|
base.GenericSourceDir = saved.GenericSourceDir
|
||||||
|
}
|
||||||
|
if saved.GenericTargetDir != "" {
|
||||||
|
base.GenericTargetDir = saved.GenericTargetDir
|
||||||
|
}
|
||||||
|
if saved.GenericExts != "" {
|
||||||
|
base.GenericExts = saved.GenericExts
|
||||||
|
}
|
||||||
|
if saved.GenericDedupe != "" {
|
||||||
|
base.GenericDedupe = saved.GenericDedupe
|
||||||
|
}
|
||||||
|
if saved.Interval != "" {
|
||||||
|
base.Interval = saved.Interval
|
||||||
|
}
|
||||||
|
if saved.StableFor != "" {
|
||||||
|
base.StableFor = saved.StableFor
|
||||||
|
}
|
||||||
|
if saved.StableScans != "" {
|
||||||
|
base.StableScans = saved.StableScans
|
||||||
|
}
|
||||||
|
if saved.Mode != "" {
|
||||||
|
base.Mode = saved.Mode
|
||||||
|
}
|
||||||
|
base.ArchiveEnabled = saved.ArchiveEnabled
|
||||||
|
base.GenericEnabled = saved.GenericEnabled
|
||||||
|
base.Recursive = saved.Recursive
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func formFromRequest(r *http.Request) formValues {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
return formValues{
|
||||||
|
ArchiveEnabled: r.FormValue("archive_enabled") != "",
|
||||||
|
ArchiveSourceDir: strings.TrimSpace(r.FormValue("archive_source")),
|
||||||
|
ArchiveZipDir: strings.TrimSpace(r.FormValue("archive_zip")),
|
||||||
|
ArchiveUnzipDir: strings.TrimSpace(r.FormValue("archive_unzip")),
|
||||||
|
GenericEnabled: r.FormValue("generic_enabled") != "",
|
||||||
|
GenericSourceDir: strings.TrimSpace(r.FormValue("generic_source")),
|
||||||
|
GenericTargetDir: strings.TrimSpace(r.FormValue("generic_target")),
|
||||||
|
GenericExts: strings.TrimSpace(r.FormValue("generic_exts")),
|
||||||
|
GenericDedupe: strings.TrimSpace(r.FormValue("generic_dedupe")),
|
||||||
|
Interval: strings.TrimSpace(r.FormValue("interval")),
|
||||||
|
StableFor: strings.TrimSpace(r.FormValue("stable_for")),
|
||||||
|
StableScans: strings.TrimSpace(r.FormValue("stable_scans")),
|
||||||
|
Mode: strings.TrimSpace(r.FormValue("mode")),
|
||||||
|
Recursive: r.FormValue("recursive") != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formFromConfig(cfg config) formValues {
|
||||||
|
return formValues{
|
||||||
|
ArchiveEnabled: cfg.archive.enabled,
|
||||||
|
ArchiveSourceDir: cfg.archive.sourceDir,
|
||||||
|
ArchiveZipDir: cfg.archive.zipDir,
|
||||||
|
ArchiveUnzipDir: cfg.archive.unzipDir,
|
||||||
|
GenericEnabled: cfg.generic.enabled,
|
||||||
|
GenericSourceDir: cfg.generic.sourceDir,
|
||||||
|
GenericTargetDir: cfg.generic.targetDir,
|
||||||
|
GenericExts: strings.Join(cfg.generic.extensions, ","),
|
||||||
|
GenericDedupe: cfg.generic.dedupeMode,
|
||||||
|
Interval: cfg.interval.String(),
|
||||||
|
StableFor: cfg.stableFor.String(),
|
||||||
|
StableScans: strconv.Itoa(cfg.stableScans),
|
||||||
|
Mode: cfg.mode,
|
||||||
|
Recursive: cfg.recursive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFormInput(form formValues) bool {
|
||||||
|
return form.ArchiveEnabled ||
|
||||||
|
form.ArchiveSourceDir != "" ||
|
||||||
|
form.ArchiveZipDir != "" ||
|
||||||
|
form.ArchiveUnzipDir != "" ||
|
||||||
|
form.GenericEnabled ||
|
||||||
|
form.GenericSourceDir != "" ||
|
||||||
|
form.GenericTargetDir != "" ||
|
||||||
|
form.GenericExts != "" ||
|
||||||
|
form.GenericDedupe != "" ||
|
||||||
|
form.Interval != "" ||
|
||||||
|
form.StableFor != "" ||
|
||||||
|
form.StableScans != "" ||
|
||||||
|
form.Mode != "" ||
|
||||||
|
form.Recursive
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasArchiveFields(form formValues) bool {
|
||||||
|
return form.ArchiveSourceDir != "" || form.ArchiveZipDir != "" || form.ArchiveUnzipDir != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasGenericFields(form formValues) bool {
|
||||||
|
return form.GenericSourceDir != "" || form.GenericTargetDir != "" || form.GenericExts != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConfig(form formValues) (config, error) {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
|
||||||
|
cfg.archive = archiveRule{
|
||||||
|
enabled: form.ArchiveEnabled,
|
||||||
|
sourceDir: normalizePath(form.ArchiveSourceDir),
|
||||||
|
zipDir: normalizePath(form.ArchiveZipDir),
|
||||||
|
unzipDir: normalizePath(form.ArchiveUnzipDir),
|
||||||
|
}
|
||||||
|
cfg.generic = genericRule{
|
||||||
|
enabled: form.GenericEnabled,
|
||||||
|
sourceDir: normalizePath(form.GenericSourceDir),
|
||||||
|
targetDir: normalizePath(form.GenericTargetDir),
|
||||||
|
dedupeMode: strings.ToLower(strings.TrimSpace(form.GenericDedupe)),
|
||||||
|
}
|
||||||
|
cfg.mode = strings.ToLower(strings.TrimSpace(form.Mode))
|
||||||
|
cfg.recursive = form.Recursive
|
||||||
|
|
||||||
|
if form.Interval != "" {
|
||||||
|
duration, err := time.ParseDuration(form.Interval)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("扫描间隔格式不正确: %w", err)
|
||||||
|
}
|
||||||
|
cfg.interval = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.StableFor != "" {
|
||||||
|
duration, err := time.ParseDuration(form.StableFor)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("稳定时长格式不正确: %w", err)
|
||||||
|
}
|
||||||
|
cfg.stableFor = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.StableScans != "" {
|
||||||
|
stableScans, err := strconv.Atoi(form.StableScans)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, errors.New("稳定扫描次数必须是整数")
|
||||||
|
}
|
||||||
|
cfg.stableScans = stableScans
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.GenericExts != "" {
|
||||||
|
extensions, err := parseExtensions(form.GenericExts)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.generic.extensions = extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConfig(cfg config) (config, error) {
|
||||||
|
if cfg.mode == "" {
|
||||||
|
cfg.mode = "copy"
|
||||||
|
}
|
||||||
|
if cfg.generic.dedupeMode == "" {
|
||||||
|
cfg.generic.dedupeMode = genericDedupeNameSize
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cfg.mode {
|
||||||
|
case "copy", "move":
|
||||||
|
default:
|
||||||
|
return cfg, fmt.Errorf("不支持的处理方式 %q", cfg.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cfg.generic.dedupeMode {
|
||||||
|
case genericDedupeNameSize, genericDedupeNameHash:
|
||||||
|
default:
|
||||||
|
return cfg, fmt.Errorf("不支持的普通文件去重方式 %q", cfg.generic.dedupeMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.interval <= 0 {
|
||||||
|
return cfg, errors.New("扫描间隔必须大于 0")
|
||||||
|
}
|
||||||
|
if cfg.stableFor < 0 {
|
||||||
|
return cfg, errors.New("稳定时长不能小于 0")
|
||||||
|
}
|
||||||
|
if cfg.stableScans < 1 {
|
||||||
|
return cfg, errors.New("稳定扫描次数必须至少为 1")
|
||||||
|
}
|
||||||
|
if !cfg.archive.enabled && !cfg.generic.enabled {
|
||||||
|
return cfg, errors.New("至少启用一条监听规则")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.archive.enabled {
|
||||||
|
if cfg.archive.sourceDir == "" {
|
||||||
|
return cfg, errors.New("压缩包规则的 UNC 源路径不能为空")
|
||||||
|
}
|
||||||
|
if cfg.archive.zipDir == "" {
|
||||||
|
return cfg, errors.New("压缩包规则的 zip 路径不能为空")
|
||||||
|
}
|
||||||
|
if cfg.archive.unzipDir == "" {
|
||||||
|
return cfg, errors.New("压缩包规则的 unzip 路径不能为空")
|
||||||
|
}
|
||||||
|
if cfg.archive.sourceDir == cfg.archive.zipDir {
|
||||||
|
return cfg, errors.New("压缩包规则的源路径和 zip 路径不能相同")
|
||||||
|
}
|
||||||
|
if cfg.archive.sourceDir == cfg.archive.unzipDir {
|
||||||
|
return cfg, errors.New("压缩包规则的源路径和 unzip 路径不能相同")
|
||||||
|
}
|
||||||
|
if cfg.archive.zipDir == cfg.archive.unzipDir {
|
||||||
|
return cfg, errors.New("压缩包规则的 zip 路径和 unzip 路径不能相同")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.generic.enabled {
|
||||||
|
if cfg.generic.sourceDir == "" {
|
||||||
|
return cfg, errors.New("普通文件规则的 UNC 源路径不能为空")
|
||||||
|
}
|
||||||
|
if cfg.generic.targetDir == "" {
|
||||||
|
return cfg, errors.New("普通文件规则的目标路径不能为空")
|
||||||
|
}
|
||||||
|
if cfg.generic.sourceDir == cfg.generic.targetDir {
|
||||||
|
return cfg, errors.New("普通文件规则的源路径和目标路径不能相同")
|
||||||
|
}
|
||||||
|
if len(cfg.generic.extensions) == 0 {
|
||||||
|
return cfg, errors.New("普通文件规则的扩展名列表不能为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExtensions(raw string) ([]string, error) {
|
||||||
|
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case ',', ';', '\n', '\r', '\t', ' ':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
extensions := make([]string, 0, len(parts))
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
item := strings.ToLower(strings.TrimSpace(part))
|
||||||
|
item = strings.TrimPrefix(item, "*")
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(item, ".") {
|
||||||
|
item = "." + item
|
||||||
|
}
|
||||||
|
if item == "." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[item]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item] = struct{}{}
|
||||||
|
extensions = append(extensions, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(extensions) == 0 {
|
||||||
|
return nil, errors.New("普通文件规则的扩展名列表不能为空")
|
||||||
|
}
|
||||||
|
return extensions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesExtensions(name string, extensions []string) bool {
|
||||||
|
lowerName := strings.ToLower(name)
|
||||||
|
for _, ext := range extensions {
|
||||||
|
if strings.HasSuffix(lowerName, ext) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePath(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Clean(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinMessages(primary, secondary string) string {
|
||||||
|
if primary == "" {
|
||||||
|
return secondary
|
||||||
|
}
|
||||||
|
if secondary == "" {
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
return primary + ";" + secondary
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveErrorMessage(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("配置保存失败: %v", err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tarGzSuffix = ".tar.gz"
|
||||||
|
defaultListen = "127.0.0.1:18080"
|
||||||
|
defaultInterval = 15 * time.Second
|
||||||
|
defaultStableFor = 30 * time.Second
|
||||||
|
maxLogLines = 200
|
||||||
|
settingsFileName = "unc_tar_watcher_settings.json"
|
||||||
|
genericDedupeNameSize = "name_size"
|
||||||
|
genericDedupeNameHash = "name_hash"
|
||||||
|
)
|
||||||
|
|
||||||
|
type archiveRule struct {
|
||||||
|
enabled bool
|
||||||
|
sourceDir string
|
||||||
|
zipDir string
|
||||||
|
unzipDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type genericRule struct {
|
||||||
|
enabled bool
|
||||||
|
sourceDir string
|
||||||
|
targetDir string
|
||||||
|
extensions []string
|
||||||
|
dedupeMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
interval time.Duration
|
||||||
|
stableFor time.Duration
|
||||||
|
stableScans int
|
||||||
|
mode string
|
||||||
|
recursive bool
|
||||||
|
archive archiveRule
|
||||||
|
generic genericRule
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileSnapshot struct {
|
||||||
|
size int64
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileState struct {
|
||||||
|
lastObserved fileSnapshot
|
||||||
|
stableCount int
|
||||||
|
handledKey string
|
||||||
|
lastDecision string
|
||||||
|
}
|
||||||
|
|
||||||
|
type candidate struct {
|
||||||
|
path string
|
||||||
|
info os.FileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type formValues struct {
|
||||||
|
ArchiveEnabled bool `json:"archive_enabled"`
|
||||||
|
ArchiveSourceDir string `json:"archive_source_dir"`
|
||||||
|
ArchiveZipDir string `json:"archive_zip_dir"`
|
||||||
|
ArchiveUnzipDir string `json:"archive_unzip_dir"`
|
||||||
|
GenericEnabled bool `json:"generic_enabled"`
|
||||||
|
GenericSourceDir string `json:"generic_source_dir"`
|
||||||
|
GenericTargetDir string `json:"generic_target_dir"`
|
||||||
|
GenericExts string `json:"generic_exts"`
|
||||||
|
GenericDedupe string `json:"generic_dedupe"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
StableFor string `json:"stable_for"`
|
||||||
|
StableScans string `json:"stable_scans"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Recursive bool `json:"recursive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyFormValues struct {
|
||||||
|
SourceDir string `json:"source_dir"`
|
||||||
|
ZipDir string `json:"zip_dir"`
|
||||||
|
UnzipDir string `json:"unzip_dir"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
StableFor string `json:"stable_for"`
|
||||||
|
StableScans string `json:"stable_scans"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Recursive bool `json:"recursive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pageData struct {
|
||||||
|
Running bool
|
||||||
|
StartedAt string
|
||||||
|
LastMessage string
|
||||||
|
LastError string
|
||||||
|
SubmitMessage string
|
||||||
|
SubmitError string
|
||||||
|
SettingsPath string
|
||||||
|
Form formValues
|
||||||
|
Logs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type app struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cancel context.CancelFunc
|
||||||
|
runID int64
|
||||||
|
running bool
|
||||||
|
startedAt time.Time
|
||||||
|
lastMessage string
|
||||||
|
lastError string
|
||||||
|
cfg config
|
||||||
|
form formValues
|
||||||
|
logs []string
|
||||||
|
settingsPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type processFunc func(string) (string, bool, error)
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "html/template"
|
||||||
|
|
||||||
|
var pageTemplate = template.Must(template.New("page").Parse(`<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>UNC 文件监听控制台</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f3efe7;
|
||||||
|
--panel: #fffaf2;
|
||||||
|
--panel-strong: #f7efe1;
|
||||||
|
--ink: #1f2520;
|
||||||
|
--muted: #6b716a;
|
||||||
|
--accent: #205c43;
|
||||||
|
--accent-soft: #dcebdd;
|
||||||
|
--warn: #8a3b2f;
|
||||||
|
--warn-soft: #f7ddd8;
|
||||||
|
--line: #d8cfbf;
|
||||||
|
--shadow: rgba(31, 37, 32, 0.08);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(32, 92, 67, 0.10), transparent 28%),
|
||||||
|
linear-gradient(180deg, #f8f4ec 0%, var(--bg) 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
width: min(1180px, calc(100% - 32px));
|
||||||
|
margin: 24px auto 40px;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, #214d3a 0%, #2f6a54 100%);
|
||||||
|
color: #f8f5ee;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: 0 20px 40px var(--shadow);
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: clamp(28px, 5vw, 42px);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.hero p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 860px;
|
||||||
|
color: rgba(248, 245, 238, 0.85);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.35fr 0.65fr;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 22px;
|
||||||
|
box-shadow: 0 14px 30px var(--shadow);
|
||||||
|
}
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fffdf8;
|
||||||
|
}
|
||||||
|
.section + .section {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.section h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.status-line {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pill.running {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: rgba(32, 92, 67, 0.2);
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.flash {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.flash.ok {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: rgba(32, 92, 67, 0.18);
|
||||||
|
}
|
||||||
|
.flash.err {
|
||||||
|
background: var(--warn-soft);
|
||||||
|
color: var(--warn);
|
||||||
|
border-color: rgba(138, 59, 47, 0.18);
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.fields .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
label span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fffdf8;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.checkbox input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #f7f3ec;
|
||||||
|
}
|
||||||
|
.secondary {
|
||||||
|
background: #e7dfd0;
|
||||||
|
color: #493c2c;
|
||||||
|
}
|
||||||
|
.logs {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.logbox {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #1f2520;
|
||||||
|
color: #dbe5dc;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 320px;
|
||||||
|
max-height: 560px;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: #9cab9e;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<h1>UNC 文件监听控制台</h1>
|
||||||
|
<p>支持两类规则同时运行。压缩包规则专门处理 <code>.tar.gz</code> 并结合 zip / unzip 目录去重;普通文件规则按扩展名列表把文件从 UNC 路径复制或剪切到本机目录,并支持按“同名+大小”或“同名+哈希”去重。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>监听配置</h2>
|
||||||
|
{{if .SubmitMessage}}<div class="flash ok">{{.SubmitMessage}}</div>{{end}}
|
||||||
|
{{if .SubmitError}}<div class="flash err">{{.SubmitError}}</div>{{end}}
|
||||||
|
{{if .LastError}}<div class="flash err">{{.LastError}}</div>{{end}}
|
||||||
|
<form method="post" action="/start">
|
||||||
|
<div class="section">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="archive_enabled" {{if .Form.ArchiveEnabled}}checked{{end}}>
|
||||||
|
启用压缩包规则
|
||||||
|
</label>
|
||||||
|
<div class="fields">
|
||||||
|
<label class="wide">压缩包 UNC 源路径
|
||||||
|
<span>例如 \\server\share\incoming,仅处理 .tar.gz</span>
|
||||||
|
<input name="archive_source" value="{{.Form.ArchiveSourceDir}}" placeholder="\\server\share\incoming">
|
||||||
|
</label>
|
||||||
|
<label>zip 路径
|
||||||
|
<span>需要复制或剪切到这里</span>
|
||||||
|
<input name="archive_zip" value="{{.Form.ArchiveZipDir}}" placeholder="D:\zip">
|
||||||
|
</label>
|
||||||
|
<label>unzip 路径
|
||||||
|
<span>已有同名解压目录则跳过</span>
|
||||||
|
<input name="archive_unzip" value="{{.Form.ArchiveUnzipDir}}" placeholder="D:\unzip">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="generic_enabled" {{if .Form.GenericEnabled}}checked{{end}}>
|
||||||
|
启用普通文件规则
|
||||||
|
</label>
|
||||||
|
<div class="fields">
|
||||||
|
<label class="wide">普通文件 UNC 源路径
|
||||||
|
<span>例如 \\server\share\others</span>
|
||||||
|
<input name="generic_source" value="{{.Form.GenericSourceDir}}" placeholder="\\server\share\others">
|
||||||
|
</label>
|
||||||
|
<label>本机目标路径
|
||||||
|
<span>只处理到这个目录,不检查 unzip</span>
|
||||||
|
<input name="generic_target" value="{{.Form.GenericTargetDir}}" placeholder="D:\other_files">
|
||||||
|
</label>
|
||||||
|
<label>扩展名列表
|
||||||
|
<span>例如 .csv,.txt,.pdf 或 xlsx,docx</span>
|
||||||
|
<input name="generic_exts" value="{{.Form.GenericExts}}" placeholder=".csv,.txt,.pdf">
|
||||||
|
</label>
|
||||||
|
<label>去重方式
|
||||||
|
<span>目标目录已有同名文件时使用</span>
|
||||||
|
<select name="generic_dedupe">
|
||||||
|
<option value="name_size" {{if eq .Form.GenericDedupe "name_size"}}selected{{end}}>同名且大小相同才跳过</option>
|
||||||
|
<option value="name_hash" {{if eq .Form.GenericDedupe "name_hash"}}selected{{end}}>同名且哈希相同才跳过</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>公共设置</h3>
|
||||||
|
<div class="fields">
|
||||||
|
<label>扫描间隔
|
||||||
|
<span>支持 Go duration,如 15s、1m</span>
|
||||||
|
<input name="interval" value="{{.Form.Interval}}" placeholder="15s">
|
||||||
|
</label>
|
||||||
|
<label>稳定时长
|
||||||
|
<span>最后修改时间至少早于当前多久</span>
|
||||||
|
<input name="stable_for" value="{{.Form.StableFor}}" placeholder="30s">
|
||||||
|
</label>
|
||||||
|
<label>稳定扫描次数
|
||||||
|
<span>连续几次扫描未变化才处理</span>
|
||||||
|
<input name="stable_scans" value="{{.Form.StableScans}}" placeholder="2">
|
||||||
|
</label>
|
||||||
|
<label>处理方式
|
||||||
|
<span>copy 复制,move 剪切</span>
|
||||||
|
<select name="mode">
|
||||||
|
<option value="copy" {{if eq .Form.Mode "copy"}}selected{{end}}>copy</option>
|
||||||
|
<option value="move" {{if eq .Form.Mode "move"}}selected{{end}}>move</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="checkbox" style="margin-top:12px;">
|
||||||
|
<input type="checkbox" name="recursive" {{if .Form.Recursive}}checked{{end}}>
|
||||||
|
递归扫描源目录
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="note">页面配置会自动保存到 <code>{{.SettingsPath}}</code>,下次打开页面会自动带出。</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" type="submit">启动或重启监听</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>运行状态</h2>
|
||||||
|
<div class="status-line">
|
||||||
|
<div class="pill {{if .Running}}running{{end}}">
|
||||||
|
{{if .Running}}运行中{{else}}未运行{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .StartedAt}}
|
||||||
|
<div class="pill">启动时间 {{.StartedAt}}</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .LastMessage}}
|
||||||
|
<p class="note">{{.LastMessage}}</p>
|
||||||
|
{{end}}
|
||||||
|
<p class="note">如果 UNC 目录中的文件由后台服务持续写入,建议把“稳定时长”和“稳定扫描次数”设得更保守一些,例如 <code>stable_for=1m</code>、<code>stable_scans=3</code>。</p>
|
||||||
|
<p class="note">普通文件规则里如果目标目录出现同名但不同大小,或者同名但哈希不同,程序会记录为冲突并跳过,不会覆盖已有文件。</p>
|
||||||
|
<form method="post" action="/stop">
|
||||||
|
<div class="actions">
|
||||||
|
<button class="secondary" type="submit">停止监听</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel logs">
|
||||||
|
<h2>最近日志</h2>
|
||||||
|
<div class="logbox">{{if .Logs}}{{range .Logs}}{{.}}
|
||||||
|
{{end}}{{else}}<span class="empty">暂无日志。启动监听后,这里会显示文件发现、跳过、复制、剪切和错误信息。</span>{{end}}</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
+360
@@ -0,0 +1,360 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ensureExistingDir(path string) error {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("%q 不是目录", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDir(path string) error {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("%q 不是目录", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.MkdirAll(path, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRule(
|
||||||
|
ruleName string,
|
||||||
|
root string,
|
||||||
|
recursive bool,
|
||||||
|
matches func(string) bool,
|
||||||
|
cfg config,
|
||||||
|
state map[string]fileState,
|
||||||
|
process processFunc,
|
||||||
|
logf func(string, ...any),
|
||||||
|
) error {
|
||||||
|
entries, err := collectCandidates(root, recursive, matches)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
seen := make(map[string]struct{}, len(entries))
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
sourcePath := entry.path
|
||||||
|
seen[sourcePath] = struct{}{}
|
||||||
|
|
||||||
|
snap := fileSnapshot{
|
||||||
|
size: entry.info.Size(),
|
||||||
|
modTime: entry.info.ModTime(),
|
||||||
|
}
|
||||||
|
key := snapshotKey(snap)
|
||||||
|
|
||||||
|
current := state[sourcePath]
|
||||||
|
if current.lastObserved != snap {
|
||||||
|
state[sourcePath] = fileState{
|
||||||
|
lastObserved: snap,
|
||||||
|
stableCount: 1,
|
||||||
|
}
|
||||||
|
logf("[%s] 发现新文件或文件有变化: %s", ruleName, sourcePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.handledKey == key {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current.stableCount++
|
||||||
|
current.lastObserved = snap
|
||||||
|
state[sourcePath] = current
|
||||||
|
|
||||||
|
if !isStableEnough(cfg, current.stableCount, snap, now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, handled, err := process(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
logf("[%s] 处理失败: %s, err=%v", ruleName, sourcePath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if handled {
|
||||||
|
current.handledKey = key
|
||||||
|
} else {
|
||||||
|
current.handledKey = ""
|
||||||
|
}
|
||||||
|
if decision != current.lastDecision {
|
||||||
|
logf("[%s] %s: %s", ruleName, decision, sourcePath)
|
||||||
|
}
|
||||||
|
current.lastDecision = decision
|
||||||
|
state[sourcePath] = current
|
||||||
|
}
|
||||||
|
|
||||||
|
for path := range state {
|
||||||
|
if _, ok := seen[path]; !ok {
|
||||||
|
delete(state, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectCandidates(root string, recursive bool, matches func(string) bool) ([]candidate, error) {
|
||||||
|
if recursive {
|
||||||
|
var items []candidate
|
||||||
|
err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !matches(info.Name()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
items = append(items, candidate{path: path, info: info})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]candidate, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !matches(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, candidate{
|
||||||
|
path: filepath.Join(root, name),
|
||||||
|
info: info,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processArchiveCandidate(cfg config, sourcePath string) (string, bool, error) {
|
||||||
|
fileName := filepath.Base(sourcePath)
|
||||||
|
zipTarget := filepath.Join(cfg.archive.zipDir, fileName)
|
||||||
|
|
||||||
|
if pathExists(zipTarget) {
|
||||||
|
return "已跳过,zip 目录中存在同名压缩包", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
unzipTarget := filepath.Join(cfg.archive.unzipDir, extractedDirName(fileName))
|
||||||
|
if isDir(unzipTarget) {
|
||||||
|
return "已跳过,unzip 目录中存在同名解压目录", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := transferFile(sourcePath, zipTarget, cfg.mode); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.mode == "move" {
|
||||||
|
return "已剪切到 zip 目录", true, nil
|
||||||
|
}
|
||||||
|
return "已复制到 zip 目录", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processGenericCandidate(cfg config, sourcePath string) (string, bool, error) {
|
||||||
|
fileName := filepath.Base(sourcePath)
|
||||||
|
targetPath := filepath.Join(cfg.generic.targetDir, fileName)
|
||||||
|
|
||||||
|
if pathExists(targetPath) {
|
||||||
|
decision, duplicate, err := evaluateGenericDuplicate(sourcePath, targetPath, cfg.generic.dedupeMode)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if duplicate {
|
||||||
|
return decision, false, nil
|
||||||
|
}
|
||||||
|
return decision, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := transferFile(sourcePath, targetPath, cfg.mode); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.mode == "move" {
|
||||||
|
return "已剪切到普通文件目标目录", true, nil
|
||||||
|
}
|
||||||
|
return "已复制到普通文件目标目录", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateGenericDuplicate(sourcePath, targetPath, dedupeMode string) (string, bool, error) {
|
||||||
|
sourceInfo, err := os.Stat(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("stat source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetInfo, err := os.Stat(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("stat target: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch dedupeMode {
|
||||||
|
case genericDedupeNameHash:
|
||||||
|
if sourceInfo.Size() != targetInfo.Size() {
|
||||||
|
return "已跳过,同名文件已存在但大小不同", false, nil
|
||||||
|
}
|
||||||
|
same, err := filesHaveSameHash(sourcePath, targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if same {
|
||||||
|
return "已跳过,同名文件已存在且哈希相同", true, nil
|
||||||
|
}
|
||||||
|
return "已跳过,同名文件已存在但哈希不同", false, nil
|
||||||
|
|
||||||
|
case genericDedupeNameSize:
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
if sourceInfo.Size() == targetInfo.Size() {
|
||||||
|
return "已跳过,同名文件已存在且大小相同", true, nil
|
||||||
|
}
|
||||||
|
return "已跳过,同名文件已存在但大小不同", false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filesHaveSameHash(sourcePath, targetPath string) (bool, error) {
|
||||||
|
sourceHash, err := fileSHA256(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("hash source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetHash, err := fileSHA256(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("hash target: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceHash == targetHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSHA256(path string) ([32]byte, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return [32]byte{}, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
if _, err := io.Copy(hasher, file); err != nil {
|
||||||
|
return [32]byte{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sumBytes := hasher.Sum(nil)
|
||||||
|
var sum [32]byte
|
||||||
|
copy(sum[:], sumBytes)
|
||||||
|
return sum, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferFile(sourcePath, targetPath, mode string) error {
|
||||||
|
if pathExists(targetPath) {
|
||||||
|
return fmt.Errorf("target already exists: %s", targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
tempPath := targetPath + ".part"
|
||||||
|
if pathExists(tempPath) {
|
||||||
|
if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove stale temp file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source, err := os.Open(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open source: %w", err)
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
target, err := os.OpenFile(tempPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp target: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
copyErr := copyFileContents(target, source)
|
||||||
|
closeErr := target.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return fmt.Errorf("close temp target: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tempPath, targetPath); err != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return fmt.Errorf("rename temp target: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == "move" {
|
||||||
|
if err := os.Remove(sourcePath); err != nil {
|
||||||
|
return fmt.Errorf("remove source after move: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFileContents(target *os.File, source *os.File) error {
|
||||||
|
if _, err := io.Copy(target, source); err != nil {
|
||||||
|
return fmt.Errorf("copy data: %w", err)
|
||||||
|
}
|
||||||
|
if err := target.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("flush target: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractedDirName(fileName string) string {
|
||||||
|
lowerName := strings.ToLower(fileName)
|
||||||
|
if strings.HasSuffix(lowerName, tarGzSuffix) {
|
||||||
|
return fileName[:len(fileName)-len(tarGzSuffix)]
|
||||||
|
}
|
||||||
|
return fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotKey(s fileSnapshot) string {
|
||||||
|
return fmt.Sprintf("%d:%d", s.size, s.modTime.UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStableEnough(cfg config, stableCount int, snap fileSnapshot, now time.Time) bool {
|
||||||
|
if stableCount < cfg.stableScans {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return now.Sub(snap.modTime) >= cfg.stableFor
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDir(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && info.IsDir()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user