From 7776fa0fd068eac150232899480a366b94686910 Mon Sep 17 00:00:00 2001 From: cybaka520 Date: Sun, 18 Oct 2026 14:04:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E5=8D=A0=E7=94=A8=E5=BF=83=E8=B7=B3=E7=A7=9F=E7=BA=A6=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=E5=B9=B6=E5=A2=9E=E5=8A=A0=E7=A8=BF=E4=BB=B6=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E8=AF=AD=E8=A8=80=E6=A0=87=E7=AD=BE=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8F=8A=E4=BF=AE=E6=AD=A3=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=93=81?= =?UTF-8?q?=E7=89=8C=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../amll/hub/android/ui/screen/HomeScreen.kt | 2 +- .../amll/hub/android/ui/screen/LoginScreen.kt | 2 +- .../amll/hub/android/ui/screen/ScanScreen.kt | 4 +- .../hub/android/ui/screen/ScanViewModel.kt | 2 +- android/app/src/main/res/values/strings.xml | 2 +- android/settings.gradle.kts | 2 +- backend/cmd/server/server.go | 7 + backend/internal/config/config.go | 18 +- backend/internal/handler/auth_qrcode.go | 1 + backend/internal/handler/review.go | 35 +++- backend/internal/job/review_lease.go | 83 ++++++++ backend/internal/model/submission.go | 2 + .../internal/repository/submission_repo.go | 100 ++++++++-- backend/internal/router/router.go | 5 + backend/internal/service/review_service.go | 63 +++++- .../internal/service/submission_service.go | 2 + backend/internal/service/viewer_service.go | 13 +- backend/migrations/001_init.up.sql | 10 + frontend/src/atoms/reviewCenter.ts | 4 +- frontend/src/hooks/useSubmissionListSync.ts | 2 + frontend/src/lib/api.ts | 23 ++- frontend/src/lib/types.ts | 2 + frontend/src/pages/ReviewCenter.tsx | 43 ++++- frontend/src/pages/SubmissionDetailPage.tsx | 142 +++++++++----- worker/src/migration/consumer.rs | 4 +- worker/src/migration/mapper.rs | 179 +++++++++++++++++- 26 files changed, 654 insertions(+), 98 deletions(-) create mode 100644 backend/internal/job/review_lease.go diff --git a/android/app/src/main/java/dev/amll/hub/android/ui/screen/HomeScreen.kt b/android/app/src/main/java/dev/amll/hub/android/ui/screen/HomeScreen.kt index 861cf2a..7975248 100644 --- a/android/app/src/main/java/dev/amll/hub/android/ui/screen/HomeScreen.kt +++ b/android/app/src/main/java/dev/amll/hub/android/ui/screen/HomeScreen.kt @@ -51,7 +51,7 @@ fun HomeScreen( ) { Spacer(Modifier.height(16.dp)) Text( - text = "AMLl Hub", + text = "AMLL Hub", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary, ) diff --git a/android/app/src/main/java/dev/amll/hub/android/ui/screen/LoginScreen.kt b/android/app/src/main/java/dev/amll/hub/android/ui/screen/LoginScreen.kt index 75b3d95..7845f1a 100644 --- a/android/app/src/main/java/dev/amll/hub/android/ui/screen/LoginScreen.kt +++ b/android/app/src/main/java/dev/amll/hub/android/ui/screen/LoginScreen.kt @@ -62,7 +62,7 @@ fun LoginScreen( ) { Spacer(Modifier.height(24.dp)) Text( - text = "AMLl Hub", + text = "AMLL Hub", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.primary, ) diff --git a/android/app/src/main/java/dev/amll/hub/android/ui/screen/ScanScreen.kt b/android/app/src/main/java/dev/amll/hub/android/ui/screen/ScanScreen.kt index 0db4fd3..66ebcfd 100644 --- a/android/app/src/main/java/dev/amll/hub/android/ui/screen/ScanScreen.kt +++ b/android/app/src/main/java/dev/amll/hub/android/ui/screen/ScanScreen.kt @@ -103,7 +103,7 @@ fun ScanScreen( AlertDialog( onDismissRequest = viewModel::cancel, title = { Text("确认授权") }, - text = { Text("是否允许当前账号登录 AMLl Hub 网页端?确认后网页会自动完成登录。") }, + text = { Text("是否允许当前账号登录 AMLL Hub 网页端?确认后网页会自动完成登录。") }, confirmButton = { TextButton(onClick = viewModel::confirm) { Text("确认登录") } }, @@ -187,7 +187,7 @@ private fun ScannerPane( .padding(24.dp), ) { Text( - text = "将 AMLl Hub 网页端登录弹窗的二维码放入框内", + text = "将 AMLL Hub 网页端登录弹窗的二维码放入框内", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, diff --git a/android/app/src/main/java/dev/amll/hub/android/ui/screen/ScanViewModel.kt b/android/app/src/main/java/dev/amll/hub/android/ui/screen/ScanViewModel.kt index a709cfc..f7202ab 100644 --- a/android/app/src/main/java/dev/amll/hub/android/ui/screen/ScanViewModel.kt +++ b/android/app/src/main/java/dev/amll/hub/android/ui/screen/ScanViewModel.kt @@ -48,7 +48,7 @@ class ScanViewModel @Inject constructor( fun onScanned(raw: String) { val ticket = extractTicket(raw) if (ticket.isBlank()) { - _state.value = ScanState.Error("这不是 AMLl Hub 的登录二维码") + _state.value = ScanState.Error("这不是 AMLL Hub 的登录二维码") return } _state.value = ScanState.AwaitingConfirm(ticket) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a3aee90..322162d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - AMLl Hub + AMLL Hub diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 81d38ec..91f40ae 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,5 +20,5 @@ dependencyResolutionManagement { } } -rootProject.name = "AMLlHub-Android" +rootProject.name = "AMLLHub-Android" include(":app") diff --git a/backend/cmd/server/server.go b/backend/cmd/server/server.go index 55cb812..a034e96 100644 --- a/backend/cmd/server/server.go +++ b/backend/cmd/server/server.go @@ -227,6 +227,13 @@ func Run() { autoRejectJob.SetNotifier(notifSvc) // T4 触发点 go autoRejectJob.Run(appCtx) + // 4.3.1 启动审核占用租约清理任务(心跳超时 → 回退待审核) + reviewLeaseJob := job.NewReviewLeaseJob( + subRepo, viewerSvc, + cfg.Submission.ReviewLeaseTTL, cfg.Submission.ReviewLeaseInterval, + ) + go reviewLeaseJob.Run(appCtx) + // 4.4 审核员/超级管理员缓存 reviewerCache := middleware.NewReviewerCache(reviewerRepo, cfg.Submission.ReviewerCacheTTL) adminCache := middleware.NewAdminCache(adminRepo, cfg.Submission.ReviewerCacheTTL) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 14095dc..2141031 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -168,6 +168,10 @@ type SubmissionConfig struct { AutoRejectAfter time.Duration // ReviewerCacheTTL 审核员名单内存缓存 TTL ReviewerCacheTTL time.Duration + // ReviewLeaseTTL 审核占用租约时长:超时未收到心跳则回退为待审核 + ReviewLeaseTTL time.Duration + // ReviewLeaseInterval 审核占用租约清理任务执行间隔 + ReviewLeaseInterval time.Duration // MaxTTMLSize 单个 TTML 文件最大字节数 MaxTTMLSize int64 // MaxAudioSize 单个音频文件最大字节数 @@ -419,12 +423,14 @@ func Load() (*Config, error) { RepoName: v.GetString("GITHUB_APP_REPO_NAME"), }, Submission: SubmissionConfig{ - AutoRejectInterval: v.GetDuration("SUBMISSION_AUTO_REJECT_INTERVAL"), - AutoRejectAfter: v.GetDuration("SUBMISSION_AUTO_REJECT_AFTER"), - ReviewerCacheTTL: v.GetDuration("SUBMISSION_REVIEWER_CACHE_TTL"), - MaxTTMLSize: v.GetInt64("SUBMISSION_MAX_TTML_SIZE"), - MaxAudioSize: v.GetInt64("SUBMISSION_MAX_AUDIO_SIZE"), - MaxImageSize: v.GetInt64("SUBMISSION_MAX_IMAGE_SIZE"), + AutoRejectInterval: v.GetDuration("SUBMISSION_AUTO_REJECT_INTERVAL"), + AutoRejectAfter: v.GetDuration("SUBMISSION_AUTO_REJECT_AFTER"), + ReviewerCacheTTL: v.GetDuration("SUBMISSION_REVIEWER_CACHE_TTL"), + ReviewLeaseTTL: v.GetDuration("SUBMISSION_REVIEW_LEASE_TTL"), + ReviewLeaseInterval: v.GetDuration("SUBMISSION_REVIEW_LEASE_INTERVAL"), + MaxTTMLSize: v.GetInt64("SUBMISSION_MAX_TTML_SIZE"), + MaxAudioSize: v.GetInt64("SUBMISSION_MAX_AUDIO_SIZE"), + MaxImageSize: v.GetInt64("SUBMISSION_MAX_IMAGE_SIZE"), }, Worker: WorkerConfig{ BaseURL: v.GetString("WORKER_BASE_URL"), diff --git a/backend/internal/handler/auth_qrcode.go b/backend/internal/handler/auth_qrcode.go index 81b92c8..5d4fd8b 100644 --- a/backend/internal/handler/auth_qrcode.go +++ b/backend/internal/handler/auth_qrcode.go @@ -41,6 +41,7 @@ func (h *AuthHandler) QRCodeStatus(c *gin.Context) { writeAuthErr(c, err) return } + h.enrichReviewer(c, result.User) pkg.OK(c, result) } diff --git a/backend/internal/handler/review.go b/backend/internal/handler/review.go index 3285c38..2f87ca7 100644 --- a/backend/internal/handler/review.go +++ b/backend/internal/handler/review.go @@ -66,11 +66,42 @@ func (h *ReviewHandler) MarkReviewing(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), longTimeout) defer cancel() - if err := h.svc.MarkReviewing(ctx, user, id); err != nil { + held, err := h.svc.MarkReviewing(ctx, user, id) + if err != nil { writeSubmissionErr(c, err) return } - pkg.OKWithMsg(c, nil, "已标记为审核中") + msg := "已标记为审核中" + if !held { + msg = "该稿件正由其他审核员处理" + } + pkg.OKWithMsg(c, gin.H{"held": held}, msg) +} + +// Heartbeat POST /api/v1/submissions/:id/review-heartbeat +// 审核员停留在详情页期间定时刷新审核占用租约 +func (h *ReviewHandler) Heartbeat(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 + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), longTimeout) + defer cancel() + + held, status, err := h.svc.Heartbeat(ctx, user, id) + if err != nil { + writeSubmissionErr(c, err) + return + } + pkg.OK(c, gin.H{"held": held, "status": status}) } // ReleaseReview POST /api/v1/submissions/:id/release-review diff --git a/backend/internal/job/review_lease.go b/backend/internal/job/review_lease.go new file mode 100644 index 0000000..df9f98f --- /dev/null +++ b/backend/internal/job/review_lease.go @@ -0,0 +1,83 @@ +package job + +import ( + "context" + "time" + + "github.com/sirupsen/logrus" + + "github.com/amll-dev/amll-hub/backend/internal/repository" + "github.com/amll-dev/amll-hub/backend/internal/service" +) + +// ReviewLeaseJob 审核占用租约清理任务。 +// 审核员停留在详情页期间由前端定时心跳刷新 review_lease_at, +// 超过 ttl 未刷新(关标签页、崩溃、断网等无法发释放请求的情况)则回退为 pending。 +type ReviewLeaseJob struct { + subRepo *repository.SubmissionRepo + viewers *service.ViewerService + ttl time.Duration + interval time.Duration +} + +// NewReviewLeaseJob 创建审核占用租约清理任务 +func NewReviewLeaseJob( + subRepo *repository.SubmissionRepo, + viewers *service.ViewerService, + ttl, interval time.Duration, +) *ReviewLeaseJob { + if ttl <= 0 { + ttl = 3 * time.Minute + } + if interval <= 0 { + interval = 30 * time.Second + } + return &ReviewLeaseJob{ + subRepo: subRepo, + viewers: viewers, + ttl: ttl, + interval: interval, + } +} + +// Run 阻塞执行,每 interval 扫描一次,直到 ctx 取消 +func (j *ReviewLeaseJob) Run(ctx context.Context) { + logrus.WithFields(logrus.Fields{ + "interval": j.interval.String(), + "ttl": j.ttl.String(), + }).Info("review-lease job started") + + ticker := time.NewTicker(j.interval) + defer ticker.Stop() + + j.runOnce(ctx) + + for { + select { + case <-ctx.Done(): + logrus.Info("review-lease job stopped") + return + case <-ticker.C: + j.runOnce(ctx) + } + } +} + +func (j *ReviewLeaseJob) runOnce(ctx context.Context) { + cutoff := time.Now().Add(-j.ttl) + released, err := j.subRepo.ReleaseStaleReviewing(ctx, cutoff) + if err != nil { + logrus.WithError(err).Error("review-lease job failed") + return + } + if len(released) == 0 { + return + } + + logrus.WithField("count", len(released)).Info("review-lease: released stale reviewing submissions") + for i := range released { + if j.viewers != nil { + _ = j.viewers.NotifySubmissionChanged(ctx, released[i].ID) + } + } +} diff --git a/backend/internal/model/submission.go b/backend/internal/model/submission.go index d8931bc..111bff6 100644 --- a/backend/internal/model/submission.go +++ b/backend/internal/model/submission.go @@ -139,7 +139,9 @@ type Submission struct { ClosedBy string `gorm:"column:closed_by;type:varchar(100)" json:"closedBy,omitempty"` ClosedByInfo *UserInfo `gorm:"column:closed_by_info;type:jsonb" json:"closedByInfo,omitempty"` Reviewer string `gorm:"type:varchar(100)" json:"reviewer,omitempty"` + ReviewerInfo *UserInfo `gorm:"column:reviewer_info;type:jsonb" json:"reviewerInfo,omitempty"` ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewedAt,omitempty"` + ReviewLeaseAt *time.Time `gorm:"column:review_lease_at;type:timestamptz" json:"reviewLeaseAt,omitempty"` ReviewComment string `gorm:"column:review_comment;type:text" json:"reviewComment,omitempty"` // 审核员修订版:编辑器保存时勾选(上传修改后的 TTML)才会写入 diff --git a/backend/internal/repository/submission_repo.go b/backend/internal/repository/submission_repo.go index ed95106..a5b8a94 100644 --- a/backend/internal/repository/submission_repo.go +++ b/backend/internal/repository/submission_repo.go @@ -80,7 +80,9 @@ func (r *SubmissionRepo) UpdateStatusWhere(ctx context.Context, tx *gorm.DB, s * Updates(map[string]any{ "status": s.Status, "reviewer": s.Reviewer, + "reviewer_info": s.ReviewerInfo, "reviewed_at": s.ReviewedAt, + "review_lease_at": s.ReviewLeaseAt, "review_comment": s.ReviewComment, "revision_requested_at": s.RevisionRequestedAt, "closed_at": s.ClosedAt, @@ -102,6 +104,57 @@ func (r *SubmissionRepo) UpdateStatusWhere(ctx context.Context, tx *gorm.DB, s * return res.RowsAffected == 1, nil } +// RefreshReviewLease 刷新审核占用心跳时间,仅当该投稿仍由 reviewer 持有时生效 +func (r *SubmissionRepo) RefreshReviewLease(ctx context.Context, id int64, reviewer string, at time.Time) (bool, error) { + res := r.db.WithContext(ctx).Model(&model.Submission{}). + Where("id = ? AND status = ? AND reviewer = ?", id, model.StatusReviewing, reviewer). + // UpdateColumn:只刷心跳时间,不改 updated_at(避免列表“更新时间”被心跳刷屏) + UpdateColumn("review_lease_at", at) + if res.Error != nil { + return false, res.Error + } + return res.RowsAffected == 1, nil +} + +// ReleaseStaleReviewing 释放超时未心跳的审核占用(reviewing → pending) +// cutoff 之前(或从未写入租约时间)的记录视为审核员已离开 +func (r *SubmissionRepo) ReleaseStaleReviewing(ctx context.Context, cutoff time.Time) ([]model.Submission, error) { + const batch = 200 + + var candidates []model.Submission + if err := r.db.WithContext(ctx). + Select("id"). + Where("status = ? AND (review_lease_at IS NULL OR review_lease_at < ?)", model.StatusReviewing, cutoff). + Order("id"). + Limit(batch). + Find(&candidates).Error; err != nil { + return nil, err + } + if len(candidates) == 0 { + return nil, nil + } + + ids := make([]int64, 0, len(candidates)) + for i := range candidates { + ids = append(ids, candidates[i].ID) + } + + // 条件更新:仅释放此刻仍然超时的行,避免误释放刚刷新过心跳的占用 + err := r.db.WithContext(ctx).Model(&model.Submission{}). + Where("id IN ?", ids). + Where("status = ? AND (review_lease_at IS NULL OR review_lease_at < ?)", model.StatusReviewing, cutoff). + Updates(map[string]any{ + "status": model.StatusPending, + "reviewer": "", + "reviewer_info": nil, + "review_lease_at": nil, + }).Error + if err != nil { + return nil, err + } + return candidates, nil +} + // UpdateFile 更新投稿文件 func (r *SubmissionRepo) UpdateFile(ctx context.Context, tx *gorm.DB, id int64, fileName string, metadata *model.Submission, newStatus string) error { if tx == nil { @@ -323,20 +376,45 @@ func (r *SubmissionRepo) AutoReject(ctx context.Context, tx *gorm.DB, cutoff tim if tx == nil { tx = r.db.WithContext(ctx) } + const batch = 500 + + // FOR UPDATE SKIP LOCKED:多实例并发时各自处理不同批次, + // 避免重复写审核历史、重复删文件与重复通知 var items []model.Submission - // 使用 RETURNING 拿到被更新的记录,便于后续插入审核历史与删除文件 - err := tx.WithContext(ctx). - Table("submissions"). + if err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). Where("status = ? AND revision_requested_at < ?", model.StatusNeedRevision, cutoff). + Order("revision_requested_at ASC"). + Limit(batch). + Find(&items).Error; err != nil { + return nil, err + } + if len(items) == 0 { + return nil, nil + } + + ids := make([]int64, 0, len(items)) + for i := range items { + ids = append(ids, items[i].ID) + } + + // 条件更新:只更新仍处于 need_revision 的记录,避免覆盖并发的人工审核结果。 + // 注意:不要用 Table+Clauses(Returning)+Updates,GORM 在没有 Model 时会 panic。 + err := tx.WithContext(ctx).Model(&model.Submission{}). + Where("id IN ?", ids). + Where("status = ?", model.StatusNeedRevision). Updates(map[string]any{ - "status": model.StatusRejected, - "reviewer": "system", - "reviewed_at": time.Now(), - "review_comment": gorm.Expr("COALESCE(review_comment,'') || E'\n[系统自动处理] 超过4天未修改,自动拒绝。'"), - }). - Clauses(clause.Returning{}). - Scan(&items).Error - return items, err + "status": model.StatusRejected, + "reviewer": "system", + "reviewer_info": &model.UserInfo{Username: "system", DisplayName: "系统"}, + "reviewed_at": time.Now(), + "review_lease_at": nil, + "review_comment": gorm.Expr("COALESCE(review_comment,'') || E'\n[系统自动处理] 超过4天未修改,自动拒绝。'"), + }).Error + if err != nil { + return nil, err + } + return items, nil } // ReviewHistoryRepo 审核历史 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index df571c7..6ca9f8f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -232,6 +232,11 @@ func New(deps RouterDeps) *gin.Engine { middleware.RequireReviewer(reviewerCache), reviewH.MarkReviewing, ) + // 审核占用心跳(进入详情页期间定时刷新) + sub.POST("/submissions/:id/review-heartbeat", + middleware.RequireReviewer(reviewerCache), + reviewH.Heartbeat, + ) // 释放审核占用 sub.POST("/submissions/:id/release-review", middleware.RequireReviewer(reviewerCache), diff --git a/backend/internal/service/review_service.go b/backend/internal/service/review_service.go index 99f6302..2d7a06e 100644 --- a/backend/internal/service/review_service.go +++ b/backend/internal/service/review_service.go @@ -129,33 +129,70 @@ func (s *ReviewService) Review(ctx context.Context, user *SubmissionUser, id int return nil } -// MarkReviewing 审核员进入详情页时将状态标记为审核中 -func (s *ReviewService) MarkReviewing(ctx context.Context, user *SubmissionUser, id int64) error { +// MarkReviewing 审核员进入详情页时占用该投稿(status → reviewing)。 +// 返回是否由 user 持有:他人已占用或状态不允许时返回 false,且不做任何抢占。 +func (s *ReviewService) MarkReviewing(ctx context.Context, user *SubmissionUser, id int64) (bool, error) { sub, err := s.subRepo.GetByID(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrSubmissionNotFound + return false, ErrSubmissionNotFound } - return err + return false, err + } + // 已由本人占用:只刷新心跳时间,避免重复变更状态 + if sub.Status == model.StatusReviewing && sub.Reviewer == user.Name { + return s.subRepo.RefreshReviewLease(ctx, id, user.Name, time.Now()) } - // 仅 pending 状态需要变更,避免重复标记 + // 仅 pending 状态可被占用;他人已占用或已到终态时保持原状 if sub.Status != model.StatusPending { - return nil + return false, nil } + now := time.Now() sub.Status = model.StatusReviewing sub.Reviewer = user.Name + sub.ReviewerInfo = &model.UserInfo{ + Username: user.Name, + DisplayName: user.DisplayName, + Avatar: user.Avatar, + } + sub.ReviewLeaseAt = &now ok, err := s.subRepo.UpdateStatusWhere(ctx, nil, sub, []string{model.StatusPending}) if err != nil { - return err + return false, err } if !ok { - // 其他审核员已抢先标记,幂等返回成功 - return nil + // 并发下被其他审核员抢先占用 + return false, nil } if s.viewers != nil { _ = s.viewers.NotifySubmissionChanged(ctx, sub.ID) } - return nil + return true, nil +} + +// Heartbeat 刷新审核占用租约,返回当前是否仍由 user 持有以及投稿最新状态。 +// 租约已超时被后台释放(或已被他人接管)时会尝试重新占用。 +func (s *ReviewService) Heartbeat(ctx context.Context, user *SubmissionUser, id int64) (bool, string, error) { + ok, err := s.subRepo.RefreshReviewLease(ctx, id, user.Name, time.Now()) + if err != nil { + return false, "", err + } + if ok { + return true, model.StatusReviewing, nil + } + // 租约失效:若投稿已回到 pending 则重新占用 + held, err := s.MarkReviewing(ctx, user, id) + if err != nil { + return false, "", err + } + sub, err := s.subRepo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, "", ErrSubmissionNotFound + } + return false, "", err + } + return held, sub.Status, nil } // ReleaseReview 审核员离开详情页时释放审核占用: @@ -173,6 +210,8 @@ func (s *ReviewService) ReleaseReview(ctx context.Context, user *SubmissionUser, } sub.Status = model.StatusPending sub.Reviewer = "" + sub.ReviewerInfo = nil + sub.ReviewLeaseAt = nil ok, err := s.subRepo.UpdateStatusWhere(ctx, nil, sub, []string{model.StatusReviewing}) if err != nil { return err @@ -190,7 +229,10 @@ func (s *ReviewService) ReleaseReview(ctx context.Context, user *SubmissionUser, func (s *ReviewService) claimAndRecord(ctx context.Context, sub *model.Submission, newStatus, comment string, reviewerInfo model.UserInfo, now time.Time, fromStatuses []string) error { sub.Status = newStatus sub.Reviewer = reviewerInfo.Username + sub.ReviewerInfo = &reviewerInfo sub.ReviewedAt = &now + // 审核结论已落定,占用租约不再需要 + sub.ReviewLeaseAt = nil if comment != "" { sub.ReviewComment = comment } @@ -216,6 +258,7 @@ func (s *ReviewService) claimAndRecord(ctx context.Context, sub *model.Submissio func (s *ReviewService) revertClaim(ctx context.Context, sub *model.Submission, fromStatus string) { sub.Status = fromStatus sub.Reviewer = "" + sub.ReviewerInfo = nil sub.ReviewedAt = nil sub.ReviewComment = "" ok, err := s.subRepo.UpdateStatusWhere(ctx, nil, sub, []string{model.StatusApproved, model.StatusRejected}) diff --git a/backend/internal/service/submission_service.go b/backend/internal/service/submission_service.go index f9c2b06..f98d8f2 100644 --- a/backend/internal/service/submission_service.go +++ b/backend/internal/service/submission_service.go @@ -70,6 +70,7 @@ type Submission struct { ClosedBy string `json:"closedBy,omitempty"` ClosedByInfo *UserInfo `json:"closedByInfo,omitempty"` Reviewer string `json:"reviewer,omitempty"` + ReviewerInfo *UserInfo `json:"reviewerInfo,omitempty"` ReviewedAt *time.Time `json:"reviewedAt,omitempty"` ReviewComment string `json:"reviewComment,omitempty"` HasRevision bool `json:"hasRevision"` @@ -825,6 +826,7 @@ func convertSubmission(m model.Submission) Submission { ClosedBy: m.ClosedBy, ClosedByInfo: convertUserInfoPtr(m.ClosedByInfo), Reviewer: m.Reviewer, + ReviewerInfo: convertUserInfoPtr(m.ReviewerInfo), ReviewedAt: m.ReviewedAt, ReviewComment: m.ReviewComment, diff --git a/backend/internal/service/viewer_service.go b/backend/internal/service/viewer_service.go index 3ee1948..bd58401 100644 --- a/backend/internal/service/viewer_service.go +++ b/backend/internal/service/viewer_service.go @@ -44,11 +44,12 @@ func (s *ViewerService) NotifySubmissionChanged(ctx context.Context, submissionI return fmt.Errorf("load submission for notify: %w", err) } return s.hub.PublishChanged(ctx, submissionID, map[string]any{ - "id": sub.ID, - "status": sub.Status, - "title": sub.Title, - "submitter": sub.Submitter, - "reviewer": sub.Reviewer, - "updatedAt": sub.UpdatedAt, + "id": sub.ID, + "status": sub.Status, + "title": sub.Title, + "submitter": sub.Submitter, + "reviewer": sub.Reviewer, + "reviewerInfo": sub.ReviewerInfo, + "updatedAt": sub.UpdatedAt, }) } diff --git a/backend/migrations/001_init.up.sql b/backend/migrations/001_init.up.sql index 7b01698..9714cd6 100644 --- a/backend/migrations/001_init.up.sql +++ b/backend/migrations/001_init.up.sql @@ -210,8 +210,14 @@ CREATE TABLE submissions ( closed_by VARCHAR(100), closed_by_info JSONB, reviewer VARCHAR(100), + -- reviewer_info:当前/最近一位审核员的信息(username/displayName/avatar), + -- 审核占用(reviewing)时写入,供详情页与审核中心展示“谁在审核”。 + reviewer_info JSONB, reviewed_at TIMESTAMPTZ, review_comment TEXT, + -- review_lease_at:审核占用心跳时间。reviewing 状态下由审核员详情页定时刷新, + -- 超过租约时长(默认 3 分钟)未刷新视为已离开,由后台任务回退为 pending。 + review_lease_at TIMESTAMPTZ, revision_file_key VARCHAR(500) NOT NULL DEFAULT '', revision_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, revision_ttml TEXT, @@ -228,6 +234,10 @@ COMMENT ON COLUMN submissions.revision_ttml IS '审核员修订版 TTML 原文,用于详情页对比展示与导出'; COMMENT ON COLUMN submissions.revision_metadata IS '审核员修正后的 TTML metadata(snake_case 序列化格式)'; +COMMENT ON COLUMN submissions.review_lease_at IS + '审核占用心跳时间;reviewing 状态下由审核员详情页定时刷新,超时未刷新由后台任务回退为 pending'; +COMMENT ON COLUMN submissions.reviewer_info IS + '审核员信息(username/displayName/avatar),审核占用或审核结论时写入'; CREATE INDEX idx_submissions_submitter ON submissions(submitter); CREATE INDEX idx_submissions_status ON submissions(status); diff --git a/frontend/src/atoms/reviewCenter.ts b/frontend/src/atoms/reviewCenter.ts index da7ebd4..0878a2b 100644 --- a/frontend/src/atoms/reviewCenter.ts +++ b/frontend/src/atoms/reviewCenter.ts @@ -23,7 +23,7 @@ type View = 'home' | 'content' | 'search-ip'; /** 审核中心当前视图(首页 / 歌词审核 / IP显示投稿) */ export const reviewViewAtom = atom('home'); /** 歌词审核列表的状态筛选 */ -export const reviewTabAtom = atom('all'); +export const reviewTabAtom = atom('pending'); /** 歌词审核列表的搜索关键词 */ export const reviewSearchAtom = atom(''); /** 搜索IP投稿列表的状态筛选 */ @@ -35,7 +35,7 @@ export const searchIpDetailIdAtom = atom(null); export function resetReviewCenterState() { const store = getDefaultStore(); store.set(reviewViewAtom, 'home'); - store.set(reviewTabAtom, 'all'); + store.set(reviewTabAtom, 'pending'); store.set(reviewSearchAtom, ''); store.set(searchIpTabAtom, 'all'); store.set(searchIpDetailIdAtom, null); diff --git a/frontend/src/hooks/useSubmissionListSync.ts b/frontend/src/hooks/useSubmissionListSync.ts index c8bad0d..97870ec 100644 --- a/frontend/src/hooks/useSubmissionListSync.ts +++ b/frontend/src/hooks/useSubmissionListSync.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { getToken } from '@/lib/auth'; import { wsUrl } from '@/lib/ws'; +import type { UserInfo } from '@/lib/types'; /** 投稿状态变更推送负载 */ export interface SubmissionChangePayload { @@ -9,6 +10,7 @@ export interface SubmissionChangePayload { status?: string; title?: string; reviewer?: string; + reviewerInfo?: UserInfo; updatedAt?: string; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4d4f5ab..2d593c9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -689,16 +689,31 @@ export const api = { }); }, - /** 标记为审核中 POST /api/v1/submissions/:id/mark-reviewing */ - markReviewing(id: number): Promise { - return request(`/api/v1/submissions/${id}/mark-reviewing`, { + /** + * 标记为审核中 POST /api/v1/submissions/:id/mark-reviewing + * 返回 held:是否真的由自己持有(他人已占用时 held=false,且不会抢占) + */ + markReviewing(id: number): Promise<{ held: boolean }> { + return request<{ held: boolean }>(`/api/v1/submissions/${id}/mark-reviewing`, { method: 'POST', }); }, + /** + * 审核占用心跳 POST /api/v1/submissions/:id/review-heartbeat + * held 表示该投稿当前是否仍由自己持有(租约超时被释放时 held=false); + * status 为投稿最新状态,用于他人审核完成后同步本页状态 + */ + reviewHeartbeat(id: number): Promise<{ held: boolean; status: string }> { + return request<{ held: boolean; status: string }>( + `/api/v1/submissions/${id}/review-heartbeat`, + { method: 'POST' } + ); + }, + /** * 释放审核占用(恢复待审核)POST /api/v1/submissions/:id/release-review - * @param keepalive 页面卸载(beforeunload)期间调用时置 true,保证请求发出 + * @param keepalive 页面卸载(pagehide/beforeunload)期间调用时置 true,保证请求发出 */ releaseReview(id: number, keepalive = false): Promise { return request(`/api/v1/submissions/${id}/release-review`, { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0d767d5..87cfd94 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -223,6 +223,8 @@ export interface SubmissionListItem { closedBy?: string; closedByInfo?: UserInfo; reviewer?: string; + /** 审核员信息(占用时写入,用于展示“谁在审核”) */ + reviewerInfo?: UserInfo; reviewedAt?: string; reviewComment?: string; } diff --git a/frontend/src/pages/ReviewCenter.tsx b/frontend/src/pages/ReviewCenter.tsx index 15af84c..a41539c 100644 --- a/frontend/src/pages/ReviewCenter.tsx +++ b/frontend/src/pages/ReviewCenter.tsx @@ -33,6 +33,8 @@ import { ListSkeleton } from '@/components/ui/Skeleton'; import { EmptyState } from '@/components/ui/EmptyState'; import { SearchIpDetail } from '@/components/creator/SearchIpDetail'; import type { SubmissionListItem, SubmissionListResult } from '@/lib/types'; +import { UserAvatar } from '@/components/submission/UserAvatar'; +import { UserDisplayName } from '@/components/submission/UserDisplayName'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; @@ -146,7 +148,12 @@ function ReviewList() { ...p, items: (p.items ?? []).map((it) => it.id === id - ? { ...it, status: payload.status as SubmissionListItem['status'] } + ? { + ...it, + status: payload.status as SubmissionListItem['status'], + reviewer: payload.reviewer ?? it.reviewer, + reviewerInfo: payload.reviewerInfo ?? it.reviewerInfo, + } : it ), })), @@ -233,6 +240,40 @@ function ReviewList() { {meta.label} | + {/* 投稿人:审核时先看是谁投的 */} + + + 投稿人 + + + + + {/* 审核中:显示当前占用者,避免多个审核员重复劳动 */} + {item.status === 'reviewing' && item.reviewer && ( + <> + | + + + 审核人 + + + + )} + | 语言:{langText(item.language)} diff --git a/frontend/src/pages/SubmissionDetailPage.tsx b/frontend/src/pages/SubmissionDetailPage.tsx index be928ea..1b4eb7a 100644 --- a/frontend/src/pages/SubmissionDetailPage.tsx +++ b/frontend/src/pages/SubmissionDetailPage.tsx @@ -67,6 +67,9 @@ import { AlertDialogTrigger, } from '@/components/ui/alert-dialog'; +/** 审核占用心跳间隔:需小于后端租约时长(默认 3 分钟)才能持续续约 */ +const REVIEW_HEARTBEAT_MS = 60_000; + /** 详情页顶栏:左侧 logo + 标题 + 返回,右侧在线观看者指示器 */ function DetailTopBar({ pageTitle, @@ -213,8 +216,8 @@ function SubmissionDetailContent({ }) { const navigate = useNavigate(); const queryClient = useQueryClient(); - // 用 ref 跟踪最新状态,供组件卸载时判断是否需要释放审核占用 - const statusRef = useRef(''); + // 是否持有该投稿的审核占用(进入页面即占用,离开/卸载时释放) + const claimedRef = useRef(false); // 页面状态存全局 atoms(卸载时复位,语义同原 useState) const [tab, setTab] = useAtom(detailTabAtom); const [fileView, setFileView] = useAtom(fileViewAtom); @@ -251,31 +254,84 @@ function SubmissionDetailContent({ ? '加载失败' : ''; - useEffect(() => { - statusRef.current = detail?.status ?? ''; - }, [detail?.status]); + // 审核占用租约:进入即占用 + const claimGenRef = useRef(0); - // 审核员进入 pending 状态投稿时自动标记为审核中,并广播给列表页实时同步 - const markedDetailRef = useRef(null); useEffect(() => { - if (!detail || !isReviewer || detail.status !== 'pending') return; - if (markedDetailRef.current === detail) return; - markedDetailRef.current = detail; - // 乐观更新 statusRef,防止 markReviewing 返回前离开页面 - // 导致 release 因 statusRef 仍为 "pending" 而不执行,状态卡在审核中 - statusRef.current = 'reviewing'; - api - .markReviewing(id) - .then(() => { + if (!isReviewer) return; + const gen = ++claimGenRef.current; + const isCurrent = () => gen === claimGenRef.current; + + const release = (keepalive = false) => { + if (!claimedRef.current) return; + claimedRef.current = false; + // keepalive:页面卸载期间也能把请求发出 + api.releaseReview(id, keepalive).catch(() => {}); + }; + + const claim = async () => { + try { + const { held } = await api.markReviewing(id); + // 过期实例(StrictMode 的第一轮 / 已切换稿件):结果直接丢弃,不写状态也不释放 + if (!isCurrent()) return; + claimedRef.current = held; + // 他人已占用(或已是终态):不抢占,详情以服务端状态为准 + if (!held) { + void queryClient.invalidateQueries({ queryKey: queryKeys.submission(id) }); + return; + } + // 乐观更新:pending 时立即显示“审核中”,最终以后端广播为准 queryClient.setQueryData(queryKeys.submission(id), (prev) => - prev ? { ...prev, status: 'reviewing' as SubmissionDetail['status'] } : prev + prev && prev.status === 'pending' + ? { ...prev, status: 'reviewing' as SubmissionDetail['status'] } + : prev ); - }) - .catch(() => { - // 标记失败:回退 statusRef,避免离开时误发 release 请求 - statusRef.current = 'pending'; - }); - }, [detail, isReviewer, id, queryClient]); + // 首次进入时详情 GET 可能早于占用请求返回(拿到的是 pending), + // 且 WS 未必已连上收不到广播,这里主动拉一次保证详情展示最新状态 + void queryClient.invalidateQueries({ queryKey: queryKeys.submission(id) }); + } catch { + // 占用失败不影响阅读,交给后端租约兜底 + } + }; + + const heartbeat = async () => { + // 页面不可见时不再续约:超时后自动释放,回来时重新占用 + if (!isCurrent() || document.visibilityState !== 'visible') return; + try { + const { held, status } = await api.reviewHeartbeat(id); + if (!isCurrent()) return; + claimedRef.current = held; + if (!held) await claim(); + // 他人审核完成/状态被后台释放时,把最新状态同步进本页 + if (status) { + queryClient.setQueryData(queryKeys.submission(id), (prev) => + prev && prev.status !== status + ? { ...prev, status: status as SubmissionDetail['status'] } + : prev + ); + } + } catch { + // 心跳失败静默,交给后端租约超时释放 + } + }; + + const claimTimer = setTimeout(() => void claim(), 0); + const timer = setInterval(() => void heartbeat(), REVIEW_HEARTBEAT_MS); + // pagehide 比 beforeunload 更可靠:覆盖关标签页、刷新、前进后退、bfcache + const onPageHide = () => release(true); + window.addEventListener('pagehide', onPageHide); + + return () => { + clearTimeout(claimTimer); + clearInterval(timer); + window.removeEventListener('pagehide', onPageHide); + // 只有最新一代实例才负责释放;被 StrictMode 替换掉的旧实例不在这里释放 + if (isCurrent()) { + release(); + claimGenRef.current += 1; + } + }; + }, [id, isReviewer, queryClient]); // WS 状态更新:直接补丁缓存里的详情 useEffect(() => { @@ -294,6 +350,8 @@ function SubmissionDetailContent({ ? { title: (obj.title as string) ?? prev.title, reviewer: (obj.reviewer as string) ?? prev.reviewer, + reviewerInfo: + (obj.reviewerInfo as SubmissionDetail['reviewerInfo']) ?? prev.reviewerInfo, updatedAt: (obj.updatedAt as string) ?? prev.updatedAt, } : {}), @@ -305,27 +363,6 @@ function SubmissionDetailContent({ return () => window.removeEventListener('submission:status-update', handler); }, [id, queryClient]); - // 审核员离开详情页时,若状态仍为自己标记的“审核中”,释放占用恢复为“待审核”。 - // 通过 beforeunload + useEffect cleanup - useEffect(() => { - if (!isReviewer) return; - - const release = () => { - // 仅当状态为 reviewing 时释放;后端会校验是否为本人标记,非本人则 no-op, - // 这样页面刷新/重进后也能正确释放(避免审核中状态卡住) - if (statusRef.current !== 'reviewing') return; - // keepalive:页面卸载期间也能把请求发出;错误静默(卸载期间无需提示) - api.releaseReview(id, true).catch(() => {}); - }; - - // 页面直接关闭/刷新时触发(React cleanup 在 unload 时不保证执行) - window.addEventListener('beforeunload', release); - return () => { - window.removeEventListener('beforeunload', release); - release(); - }; - }, [id, isReviewer]); - // 详情刷新(审核操作/更新歌词/上传音频成功后调用) const loadDetail = useCallback(() => { void detailQuery.refetch(); @@ -419,6 +456,23 @@ function SubmissionDetailContent({ #{detail.id}
+ {detail.status === 'reviewing' && detail.reviewer && ( + + + + 审核人 + + + + + )} ) -> (&'static str, Vec) { (primary, vec![primary.to_string()]) } +/// 语言标签前缀 +const LANG_LABEL_PREFIX: &str = "语言"; + +const LANGUAGE_CN_NAMES: &[(&str, &str)] = &[ + ("日语", "ja"), + ("汉语", "zh"), + ("英语", "en"), + ("韩语", "ko"), + ("其它", "others"), + ("其他", "others"), +]; + +fn label_to_language(label: &str) -> Option<&'static str> { + let normalized = label.trim().replace(':', ":"); + let mut segments = normalized + .split(':') + .map(str::trim) + .filter(|s| !s.is_empty()); + + if segments.next()? != LANG_LABEL_PREFIX { + return None; + } + let name = segments.next()?; + LANGUAGE_CN_NAMES + .iter() + .find(|(alias, _)| *alias == name) + .map(|(_, code)| *code) +} + +/// 从 PR 标签提取语言代码:按标签顺序去重;命中具体语言时丢弃 `others` +pub fn labels_language(labels: &[String]) -> Vec<&'static str> { + let mut codes: Vec<&'static str> = Vec::new(); + for label in labels { + if let Some(code) = label_to_language(label) + && !codes.contains(&code) + { + codes.push(code); + } + } + if codes.iter().any(|code| *code != "others") { + codes.retain(|code| *code != "others"); + } + codes +} + +/// 过滤掉语言标签,剩下的作为投稿 tags +pub fn non_language_labels(labels: &[String]) -> Vec { + labels + .iter() + .filter(|label| label_to_language(label).is_none()) + .map(|label| label.trim().to_string()) + .filter(|label| !label.is_empty()) + .collect() +} + +/// 判定投稿语言 +pub fn resolve_language(ttml: Option<&str>, labels: &[String]) -> (String, Vec) { + let from_labels = labels_language(labels); + if let Some(primary) = from_labels.first() { + let languages = from_labels.iter().map(|code| code.to_string()).collect(); + return (primary.to_string(), languages); + } + let (primary, languages) = map_language(ttml); + (primary.to_string(), languages) +} + /// 解析 TTML,返回站点 submissions 需要的元数据 pub struct ParsedTtmlMeta { pub title: String, @@ -202,7 +268,7 @@ pub struct ParsedTtmlMeta { } /// 解析 TTML 字节流(复用 ttml_processor,与 /validate 口径一致) -pub fn parse_ttml_meta(bytes: &[u8]) -> Result { +pub fn parse_ttml_meta(bytes: &[u8], labels: &[String]) -> Result { use ttml_processor::model::PlatformId; let text = String::from_utf8_lossy(bytes); @@ -229,7 +295,7 @@ pub fn parse_ttml_meta(bytes: &[u8]) -> Result { } } - let (language, languages) = map_language(meta.language.as_deref()); + let (language, languages) = resolve_language(meta.language.as_deref(), labels); Ok(ParsedTtmlMeta { title: join(meta.title).chars().take(200).collect(), @@ -239,7 +305,7 @@ pub fn parse_ttml_meta(bytes: &[u8]) -> Result { qq_id, am_id, spotify_id, - language: language.to_string(), + language, languages, ttml_author_github: meta .author_ids @@ -458,4 +524,111 @@ mod tests { assert_eq!(map_language(None).0, "others"); assert_eq!(map_language(Some("fr")).0, "others"); } + + #[test] + fn reads_language_from_pr_label() { + let labels = vec!["语言:日语".to_string()]; + // 标签优先于 TTML metadata + let (primary, languages) = resolve_language(Some("zh-Hans"), &labels); + assert_eq!(primary, "ja"); + assert_eq!(languages, vec!["ja".to_string()]); + } + + #[test] + fn maps_all_current_language_labels() { + for (label, code) in [ + ("语言:其它", "others"), + ("语言:日语", "ja"), + ("语言:汉语", "zh"), + ("语言:英语", "en"), + ("语言:韩语", "ko"), + // 半角冒号同样识别 + ("语言:韩语", "ko"), + ] { + assert_eq!( + resolve_language(None, &[label.to_string()]).0, + code, + "标签 {} 应识别为 {}", + label, + code + ); + } + } + + #[test] + fn collects_multiple_label_languages_deduped() { + let labels = vec![ + "语言:日语".to_string(), + "待更新".to_string(), + "语言:英语".to_string(), + "语言:日语".to_string(), + ]; + let (primary, languages) = resolve_language(None, &labels); + assert_eq!(primary, "ja"); + assert_eq!(languages, vec!["ja".to_string(), "en".to_string()]); + } + + #[test] + fn explicit_others_label_wins_over_ttml() { + let labels = vec!["语言:其它".to_string()]; + let (primary, languages) = resolve_language(Some("ja"), &labels); + assert_eq!(primary, "others"); + assert_eq!(languages, vec!["others".to_string()]); + } + + #[test] + fn drops_others_when_concrete_language_present() { + let labels = vec!["语言:其它".to_string(), "语言:韩语".to_string()]; + let (primary, languages) = resolve_language(None, &labels); + assert_eq!(primary, "ko"); + assert_eq!(languages, vec!["ko".to_string()]); + } + + #[test] + fn falls_back_to_ttml_without_language_label() { + let labels = vec!["待更新".to_string(), "符合规范".to_string()]; + let (primary, languages) = resolve_language(Some("en-US"), &labels); + assert_eq!(primary, "en"); + assert_eq!(languages, vec!["en".to_string()]); + // 都没有 → others + assert_eq!(resolve_language(None, &labels).0, "others"); + } + + #[test] + fn strips_language_labels_from_tags() { + let labels = vec![ + "语言:英语".to_string(), + "首次投稿".to_string(), + "语言:其它".to_string(), + "已完成".to_string(), + ]; + assert_eq!( + non_language_labels(&labels), + vec!["首次投稿".to_string(), "已完成".to_string()] + ); + // 非语言标签全部保留 + let only_others = vec!["待更新".to_string(), "MSR".to_string()]; + assert_eq!(non_language_labels(&only_others), only_others); + } + + #[test] + fn ignores_labels_outside_whitelist() { + // 严格匹配:白名单外的标签一律忽略,不做中文名或子串推断 + let ignored = [ + "日语", + "语言", + "语言:法语", + "语言:日语(简体)", + "lang|ja|日语", + "lang|fr|法语", + ]; + for label in ignored { + assert_eq!( + resolve_language(None, &[label.to_string()]).0, + "others", + "标签 {} 不应被识别", + label + ); + } + } }