Files
2026-03-18 22:09:43 +08:00

434 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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")
}