package service import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" "unicode" "sproutworkcollect-backend/internal/config" "sproutworkcollect-backend/internal/model" ) // WorkService manages all work data operations with concurrent-safe file I/O. type WorkService struct { cfg *config.Config mu sync.RWMutex } // NewWorkService creates a WorkService with the given config. func NewWorkService(cfg *config.Config) *WorkService { return &WorkService{cfg: cfg} } // WorksDir returns the configured works root directory. func (s *WorkService) WorksDir() string { return s.cfg.WorksDir } // WorkExists reports whether a work directory is present on disk. func (s *WorkService) WorkExists(workID string) bool { _, err := os.Stat(filepath.Join(s.cfg.WorksDir, workID)) return err == nil } func isExternalURL(value string) bool { trimmed := strings.TrimSpace(value) return strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") } // ─── Read operations ────────────────────────────────────────────────────────── // LoadWork loads and returns a single work config from disk (read-locked). func (s *WorkService) LoadWork(workID string) (*model.WorkConfig, error) { s.mu.RLock() defer s.mu.RUnlock() return s.loadWork(workID) } // loadWork is the internal (unlocked) loader; callers must hold at least an RLock. func (s *WorkService) loadWork(workID string) (*model.WorkConfig, error) { path := filepath.Join(s.cfg.WorksDir, workID, "work_config.json") data, err := os.ReadFile(path) if err != nil { return nil, err } var work model.WorkConfig if err := json.Unmarshal(data, &work); err != nil { return nil, err } work.Normalize() return &work, nil } // LoadAllWorks loads every work and returns them sorted by UpdateTime descending. func (s *WorkService) LoadAllWorks() ([]*model.WorkConfig, error) { s.mu.RLock() defer s.mu.RUnlock() entries, err := os.ReadDir(s.cfg.WorksDir) if err != nil { if os.IsNotExist(err) { return []*model.WorkConfig{}, nil } return nil, err } var works []*model.WorkConfig for _, e := range entries { if !e.IsDir() { continue } w, err := s.loadWork(e.Name()) if err != nil { continue // skip broken configs } works = append(works, w) } sort.Slice(works, func(i, j int) bool { return works[i].UpdateTime > works[j].UpdateTime }) return works, nil } // SearchWorks filters all works by keyword (name / desc / tags) and/or category. func (s *WorkService) SearchWorks(query, category string) ([]*model.WorkConfig, error) { all, err := s.LoadAllWorks() if err != nil { return nil, err } q := strings.ToLower(query) var result []*model.WorkConfig for _, w := range all { if q != "" { matched := strings.Contains(strings.ToLower(w.WorkName), q) || strings.Contains(strings.ToLower(w.WorkDesc), q) if !matched { for _, tag := range w.Tags { if strings.Contains(strings.ToLower(tag), q) { matched = true break } } } if !matched { continue } } if category != "" && w.Category != category { continue } result = append(result, w) } return result, nil } // AllCategories returns a deduplicated list of all work categories. func (s *WorkService) AllCategories() ([]string, error) { all, err := s.LoadAllWorks() if err != nil { return nil, err } seen := make(map[string]struct{}) var cats []string for _, w := range all { if w.Category != "" { if _, ok := seen[w.Category]; !ok { seen[w.Category] = struct{}{} cats = append(cats, w.Category) } } } if cats == nil { cats = []string{} } return cats, nil } // BuildResponse attaches dynamically computed link fields to a work for API responses. // These fields are never written back to disk. func (s *WorkService) BuildResponse(w *model.WorkConfig) *model.WorkResponse { resp := &model.WorkResponse{ WorkConfig: *w, DownloadLinks: make(map[string][]string), DownloadResources: make(map[string][]model.DownloadResource), } for _, platform := range w.Platforms { if files, ok := w.FileNames[platform]; ok && len(files) > 0 { links := make([]string, len(files)) for i, f := range files { rel := fmt.Sprintf("/api/download/%s/%s/%s", w.WorkID, platform, f) links[i] = rel resp.DownloadResources[platform] = append(resp.DownloadResources[platform], model.DownloadResource{ Type: "local", Alias: f, URL: rel, }) } resp.DownloadLinks[platform] = links } // 外部下载链接(带别名) if extList, ok := w.ExternalDownloads[platform]; ok && len(extList) > 0 { for _, item := range extList { if strings.TrimSpace(item.URL) == "" { continue } alias := strings.TrimSpace(item.Alias) if alias == "" { alias = "外部下载" } resp.DownloadResources[platform] = append(resp.DownloadResources[platform], model.DownloadResource{ Type: "external", Alias: alias, URL: strings.TrimSpace(item.URL), }) } } } if len(w.Screenshots) > 0 { resp.ImageLinks = make([]string, len(w.Screenshots)) for i, img := range w.Screenshots { if isExternalURL(img) { resp.ImageLinks[i] = strings.TrimSpace(img) } else { resp.ImageLinks[i] = fmt.Sprintf("/api/image/%s/%s", w.WorkID, img) } } } if len(w.VideoFiles) > 0 { resp.VideoLinks = make([]string, len(w.VideoFiles)) for i, vid := range w.VideoFiles { if isExternalURL(vid) { resp.VideoLinks[i] = strings.TrimSpace(vid) } else { resp.VideoLinks[i] = fmt.Sprintf("/api/video/%s/%s", w.WorkID, vid) } } } return resp } // ─── Write operations ───────────────────────────────────────────────────────── // SaveWork atomically persists a work config (write-locked). // It writes to a .tmp file first, then renames to guarantee atomicity. func (s *WorkService) SaveWork(work *model.WorkConfig) error { s.mu.Lock() defer s.mu.Unlock() return s.saveWork(work) } // saveWork is the internal (unlocked) writer; callers must hold the write lock. func (s *WorkService) saveWork(work *model.WorkConfig) error { configPath := filepath.Join(s.cfg.WorksDir, work.WorkID, "work_config.json") data, err := json.MarshalIndent(work, "", " ") if err != nil { return fmt.Errorf("序列化配置失败: %w", err) } tmp := configPath + ".tmp" if err := os.WriteFile(tmp, data, 0644); err != nil { return fmt.Errorf("写入临时文件失败: %w", err) } if err := os.Rename(tmp, configPath); err != nil { _ = os.Remove(tmp) return fmt.Errorf("原子写入失败: %w", err) } return nil } // ModifyWork loads a work under a write lock, applies fn, then saves the result. // Using this helper avoids the load–modify–save TOCTOU race for concurrent requests. func (s *WorkService) ModifyWork(workID string, fn func(*model.WorkConfig)) error { s.mu.Lock() defer s.mu.Unlock() work, err := s.loadWork(workID) if err != nil { return err } fn(work) work.UpdateTime = now() return s.saveWork(work) } // UpdateStats increments a statistical counter ("view" | "download" | "like"). func (s *WorkService) UpdateStats(workID, statType string) error { return s.ModifyWork(workID, func(w *model.WorkConfig) { switch statType { case "view": w.Views++ case "download": w.Downloads++ case "like": w.Likes++ } }) } // CreateWork initialises a new work directory tree and writes the first config. func (s *WorkService) CreateWork(work *model.WorkConfig) error { workDir := filepath.Join(s.cfg.WorksDir, work.WorkID) if _, err := os.Stat(workDir); err == nil { return fmt.Errorf("作品ID已存在") } dirs := []string{ workDir, filepath.Join(workDir, "image"), filepath.Join(workDir, "video"), filepath.Join(workDir, "platform"), } for _, p := range work.Platforms { dirs = append(dirs, filepath.Join(workDir, "platform", p)) } for _, d := range dirs { if err := os.MkdirAll(d, 0755); err != nil { return fmt.Errorf("创建目录失败 %s: %w", d, err) } } return s.SaveWork(work) } // UpdateWork merges incoming data into the existing config, preserving statistics // when the caller does not provide them (zero values). func (s *WorkService) UpdateWork(workID string, incoming *model.WorkConfig) error { s.mu.Lock() defer s.mu.Unlock() existing, err := s.loadWork(workID) if err != nil { return fmt.Errorf("作品不存在: %w", err) } // 兼容旧前端:如果某些字段未带上,则保留旧值,避免被清空。 if incoming.FileNames == nil { incoming.FileNames = existing.FileNames } if incoming.ExternalDownloads == nil { incoming.ExternalDownloads = existing.ExternalDownloads } if incoming.Screenshots == nil { incoming.Screenshots = existing.Screenshots } if incoming.VideoFiles == nil { incoming.VideoFiles = existing.VideoFiles } if incoming.Cover == "" { incoming.Cover = existing.Cover } if incoming.OriginalNames == nil { incoming.OriginalNames = existing.OriginalNames } if incoming.Downloads == 0 { incoming.Downloads = existing.Downloads } if incoming.Views == 0 { incoming.Views = existing.Views } if incoming.Likes == 0 { incoming.Likes = existing.Likes } if incoming.UploadTime == "" { incoming.UploadTime = existing.UploadTime } incoming.WorkID = workID incoming.UpdateTime = now() incoming.UpdateCount = existing.UpdateCount + 1 incoming.Normalize() return s.saveWork(incoming) } // DeleteWork removes the entire work directory. func (s *WorkService) DeleteWork(workID string) error { workDir := filepath.Join(s.cfg.WorksDir, workID) if _, err := os.Stat(workDir); os.IsNotExist(err) { return fmt.Errorf("作品不存在") } return os.RemoveAll(workDir) } // ─── Utility helpers ────────────────────────────────────────────────────────── // SafeFilename sanitises a filename while preserving CJK (Chinese) characters. func SafeFilename(filename string) string { if filename == "" { return "unnamed_file" } var sb strings.Builder for _, r := range filename { switch { case r >= 0x4E00 && r <= 0x9FFF: // CJK Unified Ideographs sb.WriteRune(r) case unicode.IsLetter(r) || unicode.IsDigit(r): sb.WriteRune(r) case r == '-' || r == '_' || r == '.' || r == ' ': sb.WriteRune(r) } } safe := strings.ReplaceAll(sb.String(), " ", "_") safe = strings.Trim(safe, "._") if safe == "" { return "unnamed_file" } return safe } // UniqueFilename returns a filename that does not appear in existing. // If base already conflicts, it appends _1, _2, … until unique. func UniqueFilename(base string, existing []string) string { set := make(map[string]bool, len(existing)) for _, f := range existing { set[f] = true } if !set[base] { return base } ext := filepath.Ext(base) stem := strings.TrimSuffix(base, ext) for i := 1; ; i++ { name := fmt.Sprintf("%s_%d%s", stem, i, ext) if !set[name] { return name } } } // ContainsString reports whether slice s contains value v. func ContainsString(s []string, v string) bool { for _, item := range s { if item == v { return true } } return false } // RemoveString returns a copy of s with the first occurrence of v removed. func RemoveString(s []string, v string) []string { out := make([]string, 0, len(s)) for _, item := range s { if item != v { out = append(out, item) } } return out } // now returns the current local time in Python-compatible ISO8601 format. func now() string { return time.Now().Format("2006-01-02T15:04:05.000000") }