feat: 添加媒体资源处理的异步任务及相关逻辑

This commit is contained in:
2025-12-23 12:04:30 +08:00
parent 7c2b937352
commit 1dba706022
8 changed files with 317 additions and 13 deletions

View File

@@ -0,0 +1,38 @@
package args
import (
"quyun/v2/providers/job"
. "github.com/riverqueue/river"
"go.ipao.vip/atom/contracts"
)
var _ contracts.JobArgs = MediaAssetProcessJob{}
// MediaAssetProcessJob 表示“媒体资源处理”的一次性异步任务。
//
// 设计说明:
// - 该任务用于将 media_assets 从 processing 推进到 ready/failed由 worker 执行核心处理逻辑)。
// - 幂等由 River Unique + 业务侧 DB 状态机共同保证(重复入队会被跳过或无副作用)。
type MediaAssetProcessJob struct {
// TenantID 租户ID用于多租户隔离与唯一性维度
TenantID int64 `json:"tenant_id" river:"unique"`
// AssetID 媒体资源ID用于唯一性维度
AssetID int64 `json:"asset_id" river:"unique"`
}
func (MediaAssetProcessJob) Kind() string { return "media_asset_process" }
func (a MediaAssetProcessJob) UniqueID() string { return a.Kind() }
func (MediaAssetProcessJob) InsertOpts() InsertOpts {
return InsertOpts{
Queue: job.QueueDefault,
Priority: job.PriorityDefault,
// 失败可重试;由 worker 判断不可重试的场景并 JobCancel。
MaxAttempts: 10,
UniqueOpts: UniqueOpts{
ByArgs: true,
},
}
}

View File

@@ -0,0 +1,58 @@
package jobs
import (
"context"
"time"
jobs_args "quyun/v2/app/jobs/args"
"quyun/v2/app/services"
. "github.com/riverqueue/river"
log "github.com/sirupsen/logrus"
)
var _ Worker[jobs_args.MediaAssetProcessJob] = (*MediaAssetProcessJobWorker)(nil)
// MediaAssetProcessJobWorker 负责执行媒体资源处理的异步处理(占位实现)。
//
// 当前实现为 stub不对接外部转码/截图服务,仅回写 meta 并将状态置为 ready。
//
// @provider(job)
type MediaAssetProcessJobWorker struct {
WorkerDefaults[jobs_args.MediaAssetProcessJob]
}
func (w *MediaAssetProcessJobWorker) Work(ctx context.Context, job *Job[jobs_args.MediaAssetProcessJob]) error {
args := job.Args
attempt := 0
if job != nil && job.JobRow != nil {
attempt = job.Attempt
}
logger := log.WithFields(log.Fields{
"job_kind": args.Kind(),
"tenant_id": args.TenantID,
"asset_id": args.AssetID,
"attempt": attempt,
})
logger.Info("jobs.media_asset_process.start")
_, err := services.MediaAsset.ProcessSuccess(ctx, args.TenantID, args.AssetID, map[string]any{
"process_pipeline": "stub",
"worker_attempt": attempt,
"worker_ran_at": time.Now().UTC().Format(time.RFC3339Nano),
}, time.Now().UTC())
if err != nil {
if services.IsMediaAssetProcessJobNonRetryableError(err) {
logger.WithError(err).Warn("jobs.media_asset_process.cancel")
return JobCancel(err)
}
logger.WithError(err).Warn("jobs.media_asset_process.retry")
return err
}
logger.Info("jobs.media_asset_process.ok")
return nil
}

View File

@@ -0,0 +1,85 @@
package jobs
import (
"database/sql"
"testing"
"time"
"quyun/v2/app/commands/testx"
jobs_args "quyun/v2/app/jobs/args"
"quyun/v2/app/services"
"quyun/v2/database"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/riverqueue/river"
"github.com/riverqueue/river/rivertype"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/suite"
_ "go.ipao.vip/atom"
"go.ipao.vip/atom/contracts"
"go.ipao.vip/gen/types"
"go.uber.org/dig"
)
type MediaAssetProcessJobSuiteInjectParams struct {
dig.In
DB *sql.DB
Initials []contracts.Initial `group:"initials"` // nolint:structcheck
}
type MediaAssetProcessJobSuite struct {
suite.Suite
MediaAssetProcessJobSuiteInjectParams
}
func Test_MediaAssetProcessJob(t *testing.T) {
// 注意testx.Default() 已内置一个测试用的 job worker 注册器,避免和 jobs.Provide 重复注册同 kind worker。
providers := testx.Default().With(services.Provide)
testx.Serve(providers, t, func(p MediaAssetProcessJobSuiteInjectParams) {
suite.Run(t, &MediaAssetProcessJobSuite{MediaAssetProcessJobSuiteInjectParams: p})
})
}
func (s *MediaAssetProcessJobSuite) Test_Work_ProcessingToReady() {
Convey("MediaAssetProcessJobWorker processing -> ready", s.T(), func() {
ctx := s.T().Context()
now := time.Now().UTC()
tenantID := int64(1)
userID := int64(2)
database.Truncate(ctx, s.DB, models.TableNameMediaAsset)
asset := &models.MediaAsset{
TenantID: tenantID,
UserID: userID,
Type: consts.MediaAssetTypeVideo,
Status: consts.MediaAssetStatusProcessing,
Provider: "test",
Bucket: "b",
ObjectKey: "k",
Meta: types.JSON([]byte("{}")),
CreatedAt: now,
UpdatedAt: now,
}
So(asset.Create(ctx), ShouldBeNil)
worker := &MediaAssetProcessJobWorker{}
err := worker.Work(ctx, &river.Job[jobs_args.MediaAssetProcessJob]{
JobRow: &rivertype.JobRow{Attempt: 1},
Args: jobs_args.MediaAssetProcessJob{TenantID: tenantID, AssetID: asset.ID},
})
So(err, ShouldBeNil)
tbl, query := models.MediaAssetQuery.QueryContext(ctx)
got, err := query.Where(
tbl.TenantID.Eq(tenantID),
tbl.ID.Eq(asset.ID),
).First()
So(err, ShouldBeNil)
So(got.Status, ShouldEqual, consts.MediaAssetStatusReady)
})
}

View File

@@ -11,6 +11,18 @@ import (
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func(
__job *job.Job,
) (contracts.Initial, error) {
obj := &MediaAssetProcessJobWorker{}
if err := river.AddWorkerSafely(__job.Workers, obj); err != nil {
return nil, err
}
return obj, nil
}, atom.GroupInitial); err != nil {
return err
}
if err := container.Container.Provide(func(
__job *job.Job,
) (contracts.Initial, error) {