实现七月迭代公共技术基础
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
This commit is contained in:
103
pkg/asynctask/contract.go
Normal file
103
pkg/asynctask/contract.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Package asynctask 定义业务异步任务的统一公开契约。
|
||||
package asynctask
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// StatusPending 表示任务待处理。
|
||||
StatusPending = 1
|
||||
// StatusProcessing 表示任务处理中。
|
||||
StatusProcessing = 2
|
||||
// StatusCompleted 表示任务已到达业务处理终点。
|
||||
StatusCompleted = 3
|
||||
// StatusFailed 表示任务整体无法执行到业务终点。
|
||||
StatusFailed = 4
|
||||
// StatusCancelled 表示业务明确支持且已经取消任务。
|
||||
StatusCancelled = 5
|
||||
)
|
||||
|
||||
var statusNames = map[int]string{
|
||||
StatusPending: "待处理",
|
||||
StatusProcessing: "处理中",
|
||||
StatusCompleted: "已完成",
|
||||
StatusFailed: "已失败",
|
||||
StatusCancelled: "已取消",
|
||||
}
|
||||
|
||||
// Projection 是跨业务统一的任务公开投影。
|
||||
type Projection struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status int `json:"status"`
|
||||
StatusName string `json:"status_name"`
|
||||
TotalCount int `json:"total_count"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
Progress int `json:"progress"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorSummary string `json:"error_summary,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TerminalResult 是构造终态投影所需的业务结果。
|
||||
type TerminalResult struct {
|
||||
TaskID string
|
||||
Status int
|
||||
TotalCount int
|
||||
SuccessCount int
|
||||
FailedCount int
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
StartedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// StatusName 返回固定任务状态的中文名称。
|
||||
func StatusName(status int) string {
|
||||
return statusNames[status]
|
||||
}
|
||||
|
||||
// IsTerminal 判断状态是否为统一终态。
|
||||
func IsTerminal(status int) bool {
|
||||
return status == StatusCompleted || status == StatusFailed || status == StatusCancelled
|
||||
}
|
||||
|
||||
// NewTerminalProjection 校验终态守恒并构造公开投影。
|
||||
func NewTerminalProjection(result TerminalResult) (Projection, error) {
|
||||
if !IsTerminal(result.Status) {
|
||||
return Projection{}, stderrors.New("任务状态不是终态")
|
||||
}
|
||||
if result.TotalCount < 0 || result.SuccessCount < 0 || result.FailedCount < 0 {
|
||||
return Projection{}, stderrors.New("任务结果计数不能为负数")
|
||||
}
|
||||
if result.Status == StatusCompleted && result.TotalCount != result.SuccessCount+result.FailedCount {
|
||||
return Projection{}, stderrors.New("已完成任务的结果计数不守恒")
|
||||
}
|
||||
return Projection{
|
||||
TaskID: result.TaskID, Status: result.Status, StatusName: StatusName(result.Status),
|
||||
TotalCount: result.TotalCount, SuccessCount: result.SuccessCount, FailedCount: result.FailedCount,
|
||||
Progress: 100, ErrorCode: result.ErrorCode, ErrorSummary: result.ErrorSummary,
|
||||
StartedAt: result.StartedAt, CompletedAt: result.CompletedAt, UpdatedAt: result.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidatePayload 确保统一队列客户端接收 struct 或 map,而非预序列化字节。
|
||||
func ValidatePayload(payload any) error {
|
||||
if payload == nil {
|
||||
return stderrors.New("任务载荷不能为空")
|
||||
}
|
||||
kind := reflect.TypeOf(payload).Kind()
|
||||
if kind != reflect.Struct && kind != reflect.Map && kind != reflect.Ptr {
|
||||
return stderrors.New("任务载荷必须是结构体或 map")
|
||||
}
|
||||
if kind == reflect.Ptr && reflect.TypeOf(payload).Elem().Kind() != reflect.Struct {
|
||||
return stderrors.New("任务载荷指针必须指向结构体")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
105
pkg/asynctask/contract_test.go
Normal file
105
pkg/asynctask/contract_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package asynctask_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/asynctask"
|
||||
)
|
||||
|
||||
func TestCompletedTaskUsesCountsForPartialAndAllItemFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
success int
|
||||
failed int
|
||||
}{
|
||||
{name: "部分成功", success: 7, failed: 3},
|
||||
{name: "业务项全部失败", success: 0, failed: 10},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
projection, err := asynctask.NewTerminalProjection(asynctask.TerminalResult{
|
||||
TaskID: "task-1", Status: asynctask.StatusCompleted,
|
||||
TotalCount: 10, SuccessCount: tc.success, FailedCount: tc.failed,
|
||||
StartedAt: &now, CompletedAt: &now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("构造任务投影失败:%v", err)
|
||||
}
|
||||
if projection.Status != asynctask.StatusCompleted || projection.StatusName != "已完成" {
|
||||
t.Fatalf("业务项处理到终点必须为已完成:%+v", projection)
|
||||
}
|
||||
if projection.Progress != 100 {
|
||||
t.Fatalf("终态进度必须为 100,得到:%d", projection.Progress)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalProjectionRejectsBrokenCountInvariant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := asynctask.NewTerminalProjection(asynctask.TerminalResult{
|
||||
TaskID: "task-2", Status: asynctask.StatusCompleted,
|
||||
TotalCount: 10, SuccessCount: 5, FailedCount: 4,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("终态计数不守恒时应拒绝构造公开投影")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPayloadRejectsBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := asynctask.ValidatePayload([]byte(`{"task_id":1}`)); err == nil {
|
||||
t.Fatal("预序列化 []byte 载荷必须被拒绝")
|
||||
}
|
||||
if err := asynctask.ValidatePayload(struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
}{TaskID: 1}); err != nil {
|
||||
t.Fatalf("结构化载荷应通过校验:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimRecoversOnlyExpiredProcessingTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
activeExpiry := now.Add(time.Minute)
|
||||
active, err := asynctask.Claim(asynctask.LeaseState{
|
||||
Status: asynctask.StatusProcessing, LeaseOwner: "worker-a", LeaseExpiresAt: &activeExpiry,
|
||||
}, "worker-b", now, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("检查有效租约失败:%v", err)
|
||||
}
|
||||
if active.Claimed {
|
||||
t.Fatal("其他 Worker 不得抢占有效租约")
|
||||
}
|
||||
|
||||
expiredAt := now.Add(-time.Second)
|
||||
recovered, err := asynctask.Claim(asynctask.LeaseState{
|
||||
Status: asynctask.StatusProcessing, LeaseOwner: "worker-a", LeaseExpiresAt: &expiredAt,
|
||||
}, "worker-b", now, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("恢复过期任务失败:%v", err)
|
||||
}
|
||||
if !recovered.Claimed || recovered.State.LeaseOwner != "worker-b" {
|
||||
t.Fatalf("过期任务应由新 Worker 恢复:%+v", recovered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalTaskCannotBeClaimedOrCancelledAgain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := asynctask.Claim(asynctask.LeaseState{Status: asynctask.StatusCompleted}, "worker-a", time.Now(), time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("检查终态任务失败:%v", err)
|
||||
}
|
||||
if result.Claimed || asynctask.CanCancel(asynctask.StatusCompleted) {
|
||||
t.Fatal("终态任务不得被重复消费或取消")
|
||||
}
|
||||
}
|
||||
53
pkg/asynctask/state.go
Normal file
53
pkg/asynctask/state.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package asynctask
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LeaseState 是业务任务持久化层应保存的最小状态与租约事实。
|
||||
type LeaseState struct {
|
||||
Status int
|
||||
LeaseOwner string
|
||||
LeaseExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// ClaimResult 表示条件领取后的新事实。
|
||||
type ClaimResult struct {
|
||||
State LeaseState
|
||||
Claimed bool
|
||||
}
|
||||
|
||||
// Claim 按统一语义判断待处理或租约过期任务能否被领取。
|
||||
//
|
||||
// 业务持久化 Adapter 必须把同样的条件放进 PostgreSQL UPDATE 的 WHERE 中;
|
||||
// 本函数用于跨业务共享状态判定,不能替代数据库条件更新。
|
||||
func Claim(current LeaseState, owner string, now time.Time, leaseDuration time.Duration) (ClaimResult, error) {
|
||||
if owner == "" || leaseDuration <= 0 {
|
||||
return ClaimResult{}, stderrors.New("任务租约所有者和时长不能为空")
|
||||
}
|
||||
claimable := current.Status == StatusPending
|
||||
if current.Status == StatusProcessing && current.LeaseExpiresAt != nil && !current.LeaseExpiresAt.After(now) {
|
||||
claimable = true
|
||||
}
|
||||
if !claimable {
|
||||
return ClaimResult{State: current}, nil
|
||||
}
|
||||
expiresAt := now.Add(leaseDuration)
|
||||
return ClaimResult{
|
||||
State: LeaseState{Status: StatusProcessing, LeaseOwner: owner, LeaseExpiresAt: &expiresAt},
|
||||
Claimed: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CanFinish 判断当前租约所有者能否从处理中进入终态。
|
||||
func CanFinish(current LeaseState, owner string, now time.Time) bool {
|
||||
return current.Status == StatusProcessing &&
|
||||
current.LeaseOwner == owner &&
|
||||
current.LeaseExpiresAt != nil && current.LeaseExpiresAt.After(now)
|
||||
}
|
||||
|
||||
// CanCancel 判断业务支持取消时是否可用预期状态条件更新。
|
||||
func CanCancel(status int) bool {
|
||||
return status == StatusPending || status == StatusProcessing
|
||||
}
|
||||
Reference in New Issue
Block a user