perf: 拆分规划策略
This commit is contained in:
@@ -77,6 +77,16 @@ func (s *Store) LoadLayeredOutline() ([]domain.VolumeOutline, error) {
|
||||
return volumes, nil
|
||||
}
|
||||
|
||||
// ClearLayeredOutline 清理分层大纲文件,供从长篇降级为普通大纲时使用。
|
||||
func (s *Store) ClearLayeredOutline() error {
|
||||
return s.withWriteLock(func() error {
|
||||
if err := s.removeFileUnlocked("layered_outline.json"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.removeFileUnlocked("layered_outline.md")
|
||||
})
|
||||
}
|
||||
|
||||
// GetChapterFromLayered 从分层大纲中按全局章节号查找。
|
||||
func (s *Store) GetChapterFromLayered(chapter int) (*domain.OutlineEntry, error) {
|
||||
volumes, err := s.LoadLayeredOutline()
|
||||
@@ -151,11 +161,11 @@ func (s *Store) CheckArcBoundary(chapter int) (*ArcBoundary, error) {
|
||||
for ci := range a.Chapters {
|
||||
if ch == chapter {
|
||||
cur = &chapterPos{
|
||||
volume: v.Index,
|
||||
arc: a.Index,
|
||||
volume: v.Index,
|
||||
arc: a.Index,
|
||||
indexInArc: ci,
|
||||
arcLen: len(a.Chapters),
|
||||
isLastArc: ai == len(v.Arcs)-1,
|
||||
arcLen: len(a.Chapters),
|
||||
isLastArc: ai == len(v.Arcs)-1,
|
||||
}
|
||||
} else if cur != nil && nextVol == 0 {
|
||||
// 紧跟 cur 的下一章
|
||||
|
||||
@@ -10,8 +10,14 @@ import (
|
||||
|
||||
// LoadProgress 读取 meta/progress.json。不存在时返回 nil。
|
||||
func (s *Store) LoadProgress() (*domain.Progress, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.loadProgressUnlocked()
|
||||
}
|
||||
|
||||
func (s *Store) loadProgressUnlocked() (*domain.Progress, error) {
|
||||
var p domain.Progress
|
||||
if err := s.readJSON("meta/progress.json", &p); err != nil {
|
||||
if err := s.readJSONUnlocked("meta/progress.json", &p); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -22,7 +28,13 @@ func (s *Store) LoadProgress() (*domain.Progress, error) {
|
||||
|
||||
// SaveProgress 保存进度到 meta/progress.json。
|
||||
func (s *Store) SaveProgress(p *domain.Progress) error {
|
||||
return s.writeJSON("meta/progress.json", p)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.saveProgressUnlocked(p)
|
||||
}
|
||||
|
||||
func (s *Store) saveProgressUnlocked(p *domain.Progress) error {
|
||||
return s.writeJSONUnlocked("meta/progress.json", p)
|
||||
}
|
||||
|
||||
// InitProgress 创建初始进度。
|
||||
@@ -36,84 +48,90 @@ func (s *Store) InitProgress(novelName string, totalChapters int) error {
|
||||
|
||||
// SetTotalChapters 根据大纲长度设定总章节数。
|
||||
func (s *Store) SetTotalChapters(n int) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
p = &domain.Progress{}
|
||||
}
|
||||
p.TotalChapters = n
|
||||
return s.SaveProgress(p)
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
p = &domain.Progress{}
|
||||
}
|
||||
p.TotalChapters = n
|
||||
return s.saveProgressUnlocked(p)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePhase 更新创作阶段。
|
||||
func (s *Store) UpdatePhase(phase domain.Phase) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
p = &domain.Progress{}
|
||||
}
|
||||
p.Phase = phase
|
||||
return s.SaveProgress(p)
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
p = &domain.Progress{}
|
||||
}
|
||||
p.Phase = phase
|
||||
return s.saveProgressUnlocked(p)
|
||||
})
|
||||
}
|
||||
|
||||
// MarkChapterComplete 标记章节完成,原子性更新进度。
|
||||
// 支持重写场景:如果章节已完成,先减去旧字数再加新字数。
|
||||
// hookType 和 dominantStrand 用于节奏追踪,可为空。
|
||||
func (s *Store) MarkChapterComplete(chapter, wordCount int, hookType, dominantStrand string) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return fmt.Errorf("progress not initialized, call InitProgress first")
|
||||
}
|
||||
if p.ChapterWordCounts == nil {
|
||||
p.ChapterWordCounts = make(map[int]int)
|
||||
}
|
||||
// 重写场景:减去旧字数
|
||||
if oldWC, ok := p.ChapterWordCounts[chapter]; ok {
|
||||
p.TotalWordCount -= oldWC
|
||||
}
|
||||
p.ChapterWordCounts[chapter] = wordCount
|
||||
p.TotalWordCount += wordCount
|
||||
if !slices.Contains(p.CompletedChapters, chapter) {
|
||||
p.CompletedChapters = append(p.CompletedChapters, chapter)
|
||||
}
|
||||
// 仅在正常推进时更新 CurrentChapter,重写旧章节不回退指针
|
||||
if chapter+1 > p.CurrentChapter {
|
||||
p.CurrentChapter = chapter + 1
|
||||
}
|
||||
p.InProgressChapter = 0
|
||||
p.CompletedScenes = nil
|
||||
p.Phase = domain.PhaseWriting
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return fmt.Errorf("progress not initialized, call InitProgress first")
|
||||
}
|
||||
if p.ChapterWordCounts == nil {
|
||||
p.ChapterWordCounts = make(map[int]int)
|
||||
}
|
||||
// 重写场景:减去旧字数
|
||||
if oldWC, ok := p.ChapterWordCounts[chapter]; ok {
|
||||
p.TotalWordCount -= oldWC
|
||||
}
|
||||
p.ChapterWordCounts[chapter] = wordCount
|
||||
p.TotalWordCount += wordCount
|
||||
if !slices.Contains(p.CompletedChapters, chapter) {
|
||||
p.CompletedChapters = append(p.CompletedChapters, chapter)
|
||||
}
|
||||
// 仅在正常推进时更新 CurrentChapter,重写旧章节不回退指针
|
||||
if chapter+1 > p.CurrentChapter {
|
||||
p.CurrentChapter = chapter + 1
|
||||
}
|
||||
p.InProgressChapter = 0
|
||||
p.CompletedScenes = nil
|
||||
p.Phase = domain.PhaseWriting
|
||||
|
||||
// 节奏追踪:按章节顺序填充 history(确保索引对齐)
|
||||
if dominantStrand != "" {
|
||||
for len(p.StrandHistory) < chapter-1 {
|
||||
p.StrandHistory = append(p.StrandHistory, "")
|
||||
// 节奏追踪:按章节顺序填充 history(确保索引对齐)
|
||||
if dominantStrand != "" {
|
||||
for len(p.StrandHistory) < chapter-1 {
|
||||
p.StrandHistory = append(p.StrandHistory, "")
|
||||
}
|
||||
if len(p.StrandHistory) < chapter {
|
||||
p.StrandHistory = append(p.StrandHistory, dominantStrand)
|
||||
} else {
|
||||
p.StrandHistory[chapter-1] = dominantStrand
|
||||
}
|
||||
}
|
||||
if len(p.StrandHistory) < chapter {
|
||||
p.StrandHistory = append(p.StrandHistory, dominantStrand)
|
||||
} else {
|
||||
p.StrandHistory[chapter-1] = dominantStrand
|
||||
if hookType != "" {
|
||||
for len(p.HookHistory) < chapter-1 {
|
||||
p.HookHistory = append(p.HookHistory, "")
|
||||
}
|
||||
if len(p.HookHistory) < chapter {
|
||||
p.HookHistory = append(p.HookHistory, hookType)
|
||||
} else {
|
||||
p.HookHistory[chapter-1] = hookType
|
||||
}
|
||||
}
|
||||
}
|
||||
if hookType != "" {
|
||||
for len(p.HookHistory) < chapter-1 {
|
||||
p.HookHistory = append(p.HookHistory, "")
|
||||
}
|
||||
if len(p.HookHistory) < chapter {
|
||||
p.HookHistory = append(p.HookHistory, hookType)
|
||||
} else {
|
||||
p.HookHistory[chapter-1] = hookType
|
||||
}
|
||||
}
|
||||
|
||||
return s.SaveProgress(p)
|
||||
return s.saveProgressUnlocked(p)
|
||||
})
|
||||
}
|
||||
|
||||
// MarkComplete 标记全书创作完成。
|
||||
@@ -142,36 +160,40 @@ func (s *Store) LoadLastCommit() (*domain.CommitResult, error) {
|
||||
// MarkSceneComplete 标记场景完成,用于场景级 checkpoint。
|
||||
// 切换到不同章节时自动清空旧的 CompletedScenes。
|
||||
func (s *Store) MarkSceneComplete(chapter, scene int) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return fmt.Errorf("progress not initialized, call InitProgress first")
|
||||
}
|
||||
// 章节切换:清空旧场景列表
|
||||
if p.InProgressChapter != chapter {
|
||||
p.CompletedScenes = nil
|
||||
}
|
||||
p.InProgressChapter = chapter
|
||||
if !slices.Contains(p.CompletedScenes, scene) {
|
||||
p.CompletedScenes = append(p.CompletedScenes, scene)
|
||||
}
|
||||
return s.SaveProgress(p)
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return fmt.Errorf("progress not initialized, call InitProgress first")
|
||||
}
|
||||
// 章节切换:清空旧场景列表
|
||||
if p.InProgressChapter != chapter {
|
||||
p.CompletedScenes = nil
|
||||
}
|
||||
p.InProgressChapter = chapter
|
||||
if !slices.Contains(p.CompletedScenes, scene) {
|
||||
p.CompletedScenes = append(p.CompletedScenes, scene)
|
||||
}
|
||||
return s.saveProgressUnlocked(p)
|
||||
})
|
||||
}
|
||||
|
||||
// ClearInProgress 清除场景级进度状态(章节提交后调用)。
|
||||
func (s *Store) ClearInProgress() error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.InProgressChapter = 0
|
||||
p.CompletedScenes = nil
|
||||
return s.SaveProgress(p)
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.InProgressChapter = 0
|
||||
p.CompletedScenes = nil
|
||||
return s.saveProgressUnlocked(p)
|
||||
})
|
||||
}
|
||||
|
||||
// ClearLastCommit 清除 commit 信号文件,防止重复消费。
|
||||
@@ -181,95 +203,107 @@ func (s *Store) ClearLastCommit() error {
|
||||
|
||||
// UpdateVolumeArc 更新当前卷弧位置。
|
||||
func (s *Store) UpdateVolumeArc(volume, arc int) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.CurrentVolume = volume
|
||||
p.CurrentArc = arc
|
||||
return s.SaveProgress(p)
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.CurrentVolume = volume
|
||||
p.CurrentArc = arc
|
||||
return s.saveProgressUnlocked(p)
|
||||
})
|
||||
}
|
||||
|
||||
// SetLayered 设置分层模式标志。
|
||||
func (s *Store) SetLayered(layered bool) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.Layered = layered
|
||||
return s.SaveProgress(p)
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.Layered = layered
|
||||
return s.saveProgressUnlocked(p)
|
||||
})
|
||||
}
|
||||
|
||||
// SetFlow 更新当前流程状态。
|
||||
func (s *Store) SetFlow(flow domain.FlowState) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.Flow = flow
|
||||
return s.SaveProgress(p)
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.Flow = flow
|
||||
return s.saveProgressUnlocked(p)
|
||||
})
|
||||
}
|
||||
|
||||
// SetPendingRewrites 设置待重写章节队列和原因。
|
||||
func (s *Store) SetPendingRewrites(chapters []int, reason string) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.PendingRewrites = chapters
|
||||
p.RewriteReason = reason
|
||||
return s.SaveProgress(p)
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.PendingRewrites = chapters
|
||||
p.RewriteReason = reason
|
||||
return s.saveProgressUnlocked(p)
|
||||
})
|
||||
}
|
||||
|
||||
// CompleteRewrite 从待重写队列中移除已完成的章节。
|
||||
// 队列清空时自动将 Flow 重置为 writing 并清除 RewriteReason。
|
||||
func (s *Store) CompleteRewrite(chapter int) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
var remaining []int
|
||||
for _, ch := range p.PendingRewrites {
|
||||
if ch != chapter {
|
||||
remaining = append(remaining, ch)
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
p.PendingRewrites = remaining
|
||||
if len(remaining) == 0 {
|
||||
p.Flow = domain.FlowWriting
|
||||
p.RewriteReason = ""
|
||||
}
|
||||
return s.SaveProgress(p)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
var remaining []int
|
||||
for _, ch := range p.PendingRewrites {
|
||||
if ch != chapter {
|
||||
remaining = append(remaining, ch)
|
||||
}
|
||||
}
|
||||
p.PendingRewrites = remaining
|
||||
if len(remaining) == 0 {
|
||||
p.Flow = domain.FlowWriting
|
||||
p.RewriteReason = ""
|
||||
}
|
||||
return s.saveProgressUnlocked(p)
|
||||
})
|
||||
}
|
||||
|
||||
// ClearPendingRewrites 强制清空重写队列。
|
||||
func (s *Store) ClearPendingRewrites() error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.PendingRewrites = nil
|
||||
p.RewriteReason = ""
|
||||
p.Flow = domain.FlowWriting
|
||||
return s.SaveProgress(p)
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.PendingRewrites = nil
|
||||
p.RewriteReason = ""
|
||||
p.Flow = domain.FlowWriting
|
||||
return s.saveProgressUnlocked(p)
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateChapterCommit 校验当前章节是否允许提交。
|
||||
|
||||
@@ -10,13 +10,21 @@ import (
|
||||
|
||||
// SaveRunMeta 保存运行元信息到 meta/run.json。
|
||||
func (s *Store) SaveRunMeta(meta domain.RunMeta) error {
|
||||
return s.writeJSON("meta/run.json", meta)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.saveRunMetaUnlocked(meta)
|
||||
}
|
||||
|
||||
// LoadRunMeta 读取运行元信息。
|
||||
func (s *Store) LoadRunMeta() (*domain.RunMeta, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.loadRunMetaUnlocked()
|
||||
}
|
||||
|
||||
func (s *Store) loadRunMetaUnlocked() (*domain.RunMeta, error) {
|
||||
var meta domain.RunMeta
|
||||
if err := s.readJSON("meta/run.json", &meta); err != nil {
|
||||
if err := s.readJSONUnlocked("meta/run.json", &meta); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -25,59 +33,90 @@ func (s *Store) LoadRunMeta() (*domain.RunMeta, error) {
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func (s *Store) saveRunMetaUnlocked(meta domain.RunMeta) error {
|
||||
return s.writeJSONUnlocked("meta/run.json", meta)
|
||||
}
|
||||
|
||||
// InitRunMeta 初始化或更新运行元信息,保留已有的 SteerHistory。
|
||||
func (s *Store) InitRunMeta(style, provider, model string) error {
|
||||
existing, _ := s.LoadRunMeta()
|
||||
meta := domain.RunMeta{
|
||||
StartedAt: time.Now().Format(time.RFC3339),
|
||||
Provider: provider,
|
||||
Style: style,
|
||||
Model: model,
|
||||
}
|
||||
if existing != nil {
|
||||
meta.SteerHistory = existing.SteerHistory
|
||||
meta.PendingSteer = existing.PendingSteer
|
||||
}
|
||||
return s.SaveRunMeta(meta)
|
||||
return s.withWriteLock(func() error {
|
||||
existing, err := s.loadRunMetaUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta := domain.RunMeta{
|
||||
StartedAt: time.Now().Format(time.RFC3339),
|
||||
Provider: provider,
|
||||
Style: style,
|
||||
Model: model,
|
||||
}
|
||||
if existing != nil {
|
||||
meta.SteerHistory = existing.SteerHistory
|
||||
meta.PendingSteer = existing.PendingSteer
|
||||
meta.PlanningTier = existing.PlanningTier
|
||||
}
|
||||
return s.saveRunMetaUnlocked(meta)
|
||||
})
|
||||
}
|
||||
|
||||
// AppendSteerEntry 追加用户干预记录到 meta/run.json。
|
||||
func (s *Store) AppendSteerEntry(entry domain.SteerEntry) error {
|
||||
meta, err := s.LoadRunMeta()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = &domain.RunMeta{}
|
||||
}
|
||||
meta.SteerHistory = append(meta.SteerHistory, entry)
|
||||
return s.SaveRunMeta(*meta)
|
||||
return s.withWriteLock(func() error {
|
||||
meta, err := s.loadRunMetaUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = &domain.RunMeta{}
|
||||
}
|
||||
meta.SteerHistory = append(meta.SteerHistory, entry)
|
||||
return s.saveRunMetaUnlocked(*meta)
|
||||
})
|
||||
}
|
||||
|
||||
// SetPendingSteer 记录未完成的 Steer 指令,用于中断恢复。
|
||||
func (s *Store) SetPendingSteer(input string) error {
|
||||
meta, err := s.LoadRunMeta()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = &domain.RunMeta{}
|
||||
}
|
||||
meta.PendingSteer = input
|
||||
return s.SaveRunMeta(*meta)
|
||||
return s.withWriteLock(func() error {
|
||||
meta, err := s.loadRunMetaUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = &domain.RunMeta{}
|
||||
}
|
||||
meta.PendingSteer = input
|
||||
return s.saveRunMetaUnlocked(*meta)
|
||||
})
|
||||
}
|
||||
|
||||
// ClearPendingSteer 清除已处理的 Steer 指令。
|
||||
func (s *Store) ClearPendingSteer() error {
|
||||
meta, err := s.LoadRunMeta()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil || meta.PendingSteer == "" {
|
||||
return nil
|
||||
}
|
||||
meta.PendingSteer = ""
|
||||
return s.SaveRunMeta(*meta)
|
||||
return s.withWriteLock(func() error {
|
||||
meta, err := s.loadRunMetaUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil || meta.PendingSteer == "" {
|
||||
return nil
|
||||
}
|
||||
meta.PendingSteer = ""
|
||||
return s.saveRunMetaUnlocked(*meta)
|
||||
})
|
||||
}
|
||||
|
||||
// SetPlanningTier 记录当前作品采用的规划级别。
|
||||
func (s *Store) SetPlanningTier(tier domain.PlanningTier) error {
|
||||
return s.withWriteLock(func() error {
|
||||
meta, err := s.loadRunMetaUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = &domain.RunMeta{}
|
||||
}
|
||||
meta.PlanningTier = tier
|
||||
return s.saveRunMetaUnlocked(*meta)
|
||||
})
|
||||
}
|
||||
|
||||
// SaveCheckpoint 保存当前进度快照到 meta/checkpoints/。
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
@@ -77,6 +79,52 @@ func TestAppendSteerEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSteerEntryConcurrent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
const workers = 32
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
entry := domain.SteerEntry{
|
||||
Input: fmt.Sprintf("steer-%02d", i),
|
||||
Timestamp: fmt.Sprintf("ts-%02d", i),
|
||||
}
|
||||
if err := store.AppendSteerEntry(entry); err != nil {
|
||||
t.Errorf("AppendSteerEntry(%d): %v", i, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
meta, err := store.LoadRunMeta()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRunMeta: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatal("expected run meta to exist")
|
||||
}
|
||||
if len(meta.SteerHistory) != workers {
|
||||
t.Fatalf("expected %d steer entries, got %d", workers, len(meta.SteerHistory))
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, workers)
|
||||
for _, entry := range meta.SteerHistory {
|
||||
seen[entry.Input] = struct{}{}
|
||||
}
|
||||
if len(seen) != workers {
|
||||
t.Fatalf("expected %d unique steer entries, got %d", workers, len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
@@ -165,6 +213,26 @@ func TestSetAndClearPendingSteer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPlanningTier(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
if err := store.SetPlanningTier(domain.PlanningTierLong); err != nil {
|
||||
t.Fatalf("SetPlanningTier: %v", err)
|
||||
}
|
||||
|
||||
meta, err := store.LoadRunMeta()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRunMeta: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatal("expected run meta to exist")
|
||||
}
|
||||
if meta.PlanningTier != domain.PlanningTierLong {
|
||||
t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierLong, meta.PlanningTier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearPendingSteer_Noop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Store 封装小说输出目录,提供所有状态读写操作。
|
||||
type Store struct {
|
||||
dir string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewStore 创建状态管理器,dir 为小说输出根目录。
|
||||
@@ -36,19 +38,61 @@ func (s *Store) path(rel string) string {
|
||||
}
|
||||
|
||||
func (s *Store) readFile(rel string) ([]byte, error) {
|
||||
return os.ReadFile(s.path(rel))
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.readFileUnlocked(rel)
|
||||
}
|
||||
|
||||
func (s *Store) writeFile(rel string, data []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.writeFileUnlocked(rel, data)
|
||||
}
|
||||
|
||||
func (s *Store) readFileUnlocked(rel string) ([]byte, error) {
|
||||
return os.ReadFile(s.path(rel))
|
||||
}
|
||||
|
||||
func (s *Store) writeFileUnlocked(rel string, data []byte) error {
|
||||
p := s.path(rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o644)
|
||||
tmp, err := os.CreateTemp(filepath.Dir(p), filepath.Base(p)+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() {
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(0o644); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, p)
|
||||
}
|
||||
|
||||
func (s *Store) readJSON(rel string, v any) error {
|
||||
data, err := s.readFile(rel)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.readJSONUnlocked(rel, v)
|
||||
}
|
||||
|
||||
func (s *Store) readJSONUnlocked(rel string, v any) error {
|
||||
data, err := s.readFileUnlocked(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,21 +100,41 @@ func (s *Store) readJSON(rel string, v any) error {
|
||||
}
|
||||
|
||||
func (s *Store) writeJSON(rel string, v any) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.writeJSONUnlocked(rel, v)
|
||||
}
|
||||
|
||||
func (s *Store) writeJSONUnlocked(rel string, v any) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeFile(rel, data)
|
||||
return s.writeFileUnlocked(rel, data)
|
||||
}
|
||||
|
||||
func (s *Store) writeMarkdown(rel string, content string) error {
|
||||
return s.writeFile(rel, []byte(content))
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.writeFileUnlocked(rel, []byte(content))
|
||||
}
|
||||
|
||||
func (s *Store) removeFile(rel string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.removeFileUnlocked(rel)
|
||||
}
|
||||
|
||||
func (s *Store) removeFileUnlocked(rel string) error {
|
||||
err := os.Remove(s.path(rel))
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) withWriteLock(fn func() error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return fn()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user