Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions backend/internal/handler/revision.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@ func (h *RevisionHandler) GetEditorContext(c *gin.Context) {
pkg.OK(c, data)
}

// GetOwnerEditorContext GET /api/v1/submissions/:id/edit-context
// 投稿者进入编辑器时拉取:原投稿 TTML + 可选音频列表
func (h *RevisionHandler) GetOwnerEditorContext(c *gin.Context) {
user := currentUser(c)
if user == nil {
pkg.Unauthorized(c)
return
}
id, ok := parseIDParam(c)
if !ok {
return
}

ctx, cancel := context.WithTimeout(c.Request.Context(), longTimeout)
defer cancel()

data, err := h.svc.GetOwnerEditorContext(ctx, user, id)
if err != nil {
writeSubmissionErr(c, err)
return
}
pkg.OK(c, data)
}

// Save POST /api/v1/submissions/:id/revision
func (h *RevisionHandler) Save(c *gin.Context) {
user := currentUser(c)
Expand Down
33 changes: 33 additions & 0 deletions backend/internal/handler/submission.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,37 @@ func (h *SubmissionHandler) UpdateFile(c *gin.Context) {
pkg.OKWithMsg(c, nil, "文件已更新")
}

// SaveContent PUT /api/v1/submissions/:id/content
// 投稿者在编辑器中修改歌词后直接保存,无需先上传文件
func (h *SubmissionHandler) SaveContent(c *gin.Context) {
user := currentUser(c)
if user == nil {
pkg.Unauthorized(c)
return
}

id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
pkg.BadRequest(c, "无效的 id")
return
}

var req service.SaveSubmitterContentInput
if err := c.ShouldBindJSON(&req); err != nil {
pkg.BadRequest(c, "参数错误")
return
}

ctx, cancel := context.WithTimeout(c.Request.Context(), longTimeout)
defer cancel()

if err := h.svc.SaveSubmitterContent(ctx, user, id, &req); err != nil {
writeSubmissionErr(c, err)
return
}
pkg.OKWithMsg(c, nil, "歌词已提交修改")
}

// Close POST /api/v1/submissions/:id/close
func (h *SubmissionHandler) Close(c *gin.Context) {
user := currentUser(c)
Expand Down Expand Up @@ -286,6 +317,8 @@ func writeSubmissionErr(c *gin.Context, err error) {
pkg.Fail(c, http.StatusForbidden, http.StatusForbidden, "无权操作该投稿")
case errors.Is(err, service.ErrMissingUnrearrangedReason):
pkg.BadRequest(c, "勾选未重排歌词时必须填写原因")
case errors.Is(err, service.ErrInvalidInput):
pkg.BadRequest(c, "请求参数非法")
case errors.Is(err, service.ErrUpstreamUnavailable):
writeUpstreamErr(c, err, "上游服务暂不可用")
default:
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ func New(deps RouterDeps) *gin.Engine {
sub.GET("/submissions/:id", submissionH.GetDetail)
sub.GET("/submissions/:id/ttml", submissionH.GetTtml)
sub.PUT("/submissions/:id/file", submissionH.UpdateFile)
// 投稿者编辑器保存(直接提交编辑器生成的 TTML)
sub.PUT("/submissions/:id/content", submissionH.SaveContent)
sub.POST("/submissions/:id/close", submissionH.Close)

// TTML 校验
Expand Down Expand Up @@ -248,6 +250,8 @@ func New(deps RouterDeps) *gin.Engine {
middleware.RequireReviewer(reviewerCache),
revisionH.GetEditorContext,
)
// 投稿者编辑器上下文(仅投稿者本人)
sub.GET("/submissions/:id/edit-context", revisionH.GetOwnerEditorContext)
sub.POST("/submissions/:id/revision",
middleware.RequireReviewer(reviewerCache),
revisionH.Save,
Expand Down
18 changes: 18 additions & 0 deletions backend/internal/service/file_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ func (s *FileService) UploadTTML(ctx context.Context, fileName string, content [
return err
}

// UploadTTMLTemp 上传到临时对象,事务成功后再 Move 到正式 key,避免事务失败覆盖正式文件
func (s *FileService) UploadTTMLTemp(ctx context.Context, fileName string, content []byte) (string, error) {
if err := validateTTMLFileName(fileName); err != nil {
return "", err
}
if len(content) > int(s.cfg.Submission.MaxTTMLSize) {
return "", fmt.Errorf("TTML 文件大小超过 %d 字节", s.cfg.Submission.MaxTTMLSize)
}
key := fmt.Sprintf("%s.tmp-%d-%s", PendingLyricsPrefix, time.Now().UnixNano(), fileName)
_, err := s.minio.PutObject(ctx, s.bucket, key, bytes.NewReader(content), int64(len(content)), minio.PutObjectOptions{
ContentType: "application/xml; charset=utf-8",
})
if err != nil {
return "", err
}
return key, nil
}

// UploadAudio 上传音频附件
func (s *FileService) UploadAudio(ctx context.Context, submissionID int64, fileName string, content []byte) (string, error) {
if len(content) > int(s.cfg.Submission.MaxAudioSize) {
Expand Down
103 changes: 102 additions & 1 deletion backend/internal/service/revision_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -482,6 +483,23 @@ func (s *RevisionService) GetRevisionContent(ctx context.Context, user *Submissi
return *sub.RevisionTTML, nil
}

// EditorReviewRequirement 最近一轮审核要求(结构化报告或文字意见)
type EditorReviewRequirement struct {
// Source report / comment
Source string `json:"source"`
// Action 审核动作或历史状态
Action string `json:"action"`
// Comment 文字审核意见
Comment string `json:"comment,omitempty"`
// ReportMD 审核报告 Markdown
ReportMD string `json:"reportMd,omitempty"`
// Structured 结构化报告,前端从中取按行条目
Structured map[string]any `json:"structured,omitempty"`
Reviewer string `json:"reviewer,omitempty"`
ReviewerInfo model.UserInfo `json:"reviewerInfo"`
CreatedAt time.Time `json:"createdAt"`
}

// EditorContext 审核员进入编辑器所需的上下文
type EditorContext struct {
// TTML 原始投稿文件内容
Expand All @@ -496,6 +514,8 @@ type EditorContext struct {
Status string `json:"status"`
// HasRevision 是否已存在待确认的修订版
HasRevision bool `json:"hasRevision"`
// Review 最近一轮审核要求
Review *EditorReviewRequirement `json:"review,omitempty"`
}

// GetEditorContext 审核员打开编辑器时拉取上下文
Expand All @@ -507,6 +527,25 @@ func (s *RevisionService) GetEditorContext(ctx context.Context, id int64) (*Edit
}
return nil, err
}
return s.buildEditorContext(ctx, sub)
}

// GetOwnerEditorContext 投稿者打开编辑器时拉取上下文
func (s *RevisionService) GetOwnerEditorContext(ctx context.Context, user *SubmissionUser, id int64) (*EditorContext, error) {
sub, err := s.subRepo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSubmissionNotFound
}
return nil, err
}
if user == nil || sub.Submitter != user.Name {
return nil, ErrForbidden
}
return s.buildEditorContext(ctx, sub)
}

func (s *RevisionService) buildEditorContext(ctx context.Context, sub *model.Submission) (*EditorContext, error) {
if sub.FileName == "" {
return nil, ErrMissingFile
}
Expand All @@ -520,20 +559,82 @@ func (s *RevisionService) GetEditorContext(ctx context.Context, id int64) (*Edit
return nil, fmt.Errorf("read ttml: %w", err)
}

audios, err := s.audioRepo.ListBySubmissionID(ctx, id)
audios, err := s.audioRepo.ListBySubmissionID(ctx, sub.ID)
if err != nil {
return nil, err
}
review, err := s.latestReviewRequirement(ctx, sub.ID)
if err != nil {
// 审核要求读取失败不影响编辑器打开
logrus.WithError(err).Warn("load editor review requirement failed")
}
return &EditorContext{
TTML: raw,
FileName: sub.FileName,
Audios: audios,
NcmID: firstNonEmpty(sub.NcmID, extractPlatformId(map[string]any(sub.Metadata), "ncm_music_id")),
Status: sub.Status,
HasRevision: sub.RevisionFileKey != "",
Review: review,
}, nil
}

// latestReviewRequirement 取最近一轮审核要求:
// 优先结构化审核报告;若之后又有带文字的新审核意见,则用文字意见。
func (s *RevisionService) latestReviewRequirement(ctx context.Context, submissionID int64) (*EditorReviewRequirement, error) {
report, err := s.reportRepo.LatestBySubmission(ctx, submissionID)
if err != nil {
return nil, err
}
history, err := s.historyRepo.ListBySubmission(ctx, submissionID)
if err != nil {
return nil, err
}
// 不依赖仓储排序实现,Service 层显式按审核时间倒序兜底
sort.SliceStable(history, func(i, j int) bool {
return history[i].ReviewedAt.After(history[j].ReviewedAt)
})
var latest *model.ReviewHistory
if len(history) > 0 {
latest = &history[0]
}

if report != nil {
hasNewerComment := latest != nil &&
strings.TrimSpace(latest.Comment) != "" &&
latest.ReviewedAt.After(report.CreatedAt)
if !hasNewerComment {
return reviewRequirementFromReport(report), nil
}
}
if latest != nil && strings.TrimSpace(latest.Comment) != "" {
return &EditorReviewRequirement{
Source: "comment",
Action: latest.Status,
Comment: latest.Comment,
Reviewer: latest.Reviewer,
ReviewerInfo: latest.ReviewerInfo,
CreatedAt: latest.ReviewedAt,
}, nil
}
if report != nil {
return reviewRequirementFromReport(report), nil
}
return nil, nil
}

func reviewRequirementFromReport(report *model.ReviewReport) *EditorReviewRequirement {
return &EditorReviewRequirement{
Source: "report",
Action: report.Action,
ReportMD: report.ReportMD,
Structured: map[string]any(report.Structured),
Reviewer: report.Reviewer,
ReviewerInfo: report.ReviewerInfo,
CreatedAt: report.CreatedAt,
}
}

// firstNonEmpty 返回首个非空字符串,用于多来源字段的兜底
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
Expand Down
Loading
Loading