feat: add SproutWorkCollect apps

This commit is contained in:
2026-03-13 17:14:37 +08:00
parent 189baa3d59
commit 46afd3149f
54 changed files with 28126 additions and 4 deletions

View File

@@ -0,0 +1,420 @@
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
}
// ─── 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 {
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 {
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 loadmodifysave 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")
}