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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("确认登录") }
},
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">AMLl Hub</string>
<string name="app_name">AMLL Hub</string>
</resources>
2 changes: 1 addition & 1 deletion android/settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ dependencyResolutionManagement {
}
}

rootProject.name = "AMLlHub-Android"
rootProject.name = "AMLLHub-Android"
include(":app")
7 changes: 7 additions & 0 deletions backend/cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 12 additions & 6 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 单个音频文件最大字节数
Expand Down Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions backend/internal/handler/auth_qrcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func (h *AuthHandler) QRCodeStatus(c *gin.Context) {
writeAuthErr(c, err)
return
}
h.enrichReviewer(c, result.User)
pkg.OK(c, result)
}

Expand Down
35 changes: 33 additions & 2 deletions backend/internal/handler/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions backend/internal/job/review_lease.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 2 additions & 0 deletions backend/internal/model/submission.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)才会写入
Expand Down
100 changes: 89 additions & 11 deletions backend/internal/repository/submission_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 审核历史
Expand Down
5 changes: 5 additions & 0 deletions backend/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading