feat: add versioned rule capabilities (#22)

This commit is contained in:
QiuSW
2026-08-15 15:01:19 +08:00
parent 4b38a876de
commit 9d4a0b9845
28 changed files with 1005 additions and 85 deletions
+72
View File
@@ -0,0 +1,72 @@
package device
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"go-admin/app/goauto/models"
)
var capabilityPattern = regexp.MustCompile(`^[a-z][a-z0-9.-]{0,79}$`)
func normalizeCapabilities(values []string) ([]string, error) {
if len(values) > 32 {
return nil, invalidRequest("capabilities 最多 32 项")
}
set := make(map[string]bool, len(values))
for _, raw := range values {
value := strings.ToLower(strings.TrimSpace(raw))
if !capabilityPattern.MatchString(value) {
return nil, invalidRequest(fmt.Sprintf("capability 格式无效: %s", raw))
}
set[value] = true
}
result := make([]string, 0, len(set))
for value := range set {
result = append(result, value)
}
sort.Strings(result)
return result, nil
}
func encodeCapabilities(values []string) string {
if values == nil {
values = []string{}
}
raw, _ := json.Marshal(values)
return string(raw)
}
func Capabilities(record models.AgentDevice) ([]string, error) {
var result []string
if record.CapabilitiesJSON == "" {
return []string{}, nil
}
if err := json.Unmarshal([]byte(record.CapabilitiesJSON), &result); err != nil {
return nil, internalError(fmt.Errorf("device capabilities invalid: %w", err))
}
return result, nil
}
func Supports(record models.AgentDevice, required []string) (bool, error) {
if len(required) == 0 {
return true, nil
}
values, err := Capabilities(record)
if err != nil {
return false, err
}
have := make(map[string]bool, len(values))
for _, value := range values {
have[value] = true
}
for _, value := range required {
if !have[value] {
return false, nil
}
}
return true, nil
}
+16 -7
View File
@@ -18,8 +18,9 @@ const (
)
type HeartbeatRequest struct {
RequestID string `json:"requestId"`
CurrentTaskID *uint64 `json:"currentTaskId"`
RequestID string `json:"requestId"`
CurrentTaskID *uint64 `json:"currentTaskId"`
Capabilities []string `json:"capabilities,omitempty"`
}
type HeartbeatResponse struct {
@@ -40,12 +41,16 @@ func (service *Service) Heartbeat(ctx context.Context, request HeartbeatRequest,
if presentedToken == "" {
return HeartbeatResponse{}, tokenInvalidError()
}
normalizedCapabilities, err := normalizeCapabilities(request.Capabilities)
if err != nil {
return HeartbeatResponse{}, err
}
interval := service.HeartbeatInterval
if interval <= 0 {
interval = DefaultHeartbeatIntervalSeconds
}
var response HeartbeatResponse
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err = service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var device models.AgentDevice
digest := tokenDigest(presentedToken)
if err := tx.Where("token_digest = ? AND token_revoked_at IS NULL", digest).First(&device).Error; err != nil {
@@ -72,12 +77,16 @@ func (service *Service) Heartbeat(ctx context.Context, request HeartbeatRequest,
if replayed && device.LastHeartbeatAt != nil {
now = *device.LastHeartbeatAt
} else {
updates := map[string]any{
"status": models.DeviceStatusOnline, "last_heartbeat_at": now,
"last_heartbeat_request_id": request.RequestID,
}
if request.Capabilities != nil {
updates["capabilities_json"] = encodeCapabilities(normalizedCapabilities)
}
result := tx.Model(&models.AgentDevice{}).
Where("id = ? AND token_revoked_at IS NULL AND status <> ?", device.ID, models.DeviceStatusDisabled).
Updates(map[string]any{
"status": models.DeviceStatusOnline, "last_heartbeat_at": now,
"last_heartbeat_request_id": request.RequestID,
})
Updates(updates)
if result.Error != nil {
return internalError(result.Error)
}
@@ -51,6 +51,27 @@ func TestHeartbeatAuthenticatesAndIsIdempotent(t *testing.T) {
}
}
func TestHeartbeatRefreshesCapabilities(t *testing.T) {
db := openTestDatabase(t)
service := newTestService(t, db)
_, registered := registerHeartbeatDevice(t, service)
request := HeartbeatRequest{
RequestID: uuid.NewString(),
Capabilities: []string{"rule.schema.v2", "action.swipe.v1"},
}
if _, err := service.Heartbeat(context.Background(), request, testDeviceToken); err != nil {
t.Fatalf("heartbeat: %v", err)
}
var stored models.AgentDevice
if err := db.First(&stored, registered.DeviceID).Error; err != nil {
t.Fatalf("load device: %v", err)
}
capabilities, err := Capabilities(stored)
if err != nil || len(capabilities) != 2 {
t.Fatalf("capabilities not refreshed: %v %v", capabilities, err)
}
}
func TestHeartbeatRequiresCurrentTaskToMatchServer(t *testing.T) {
db := openTestDatabase(t)
service := newTestService(t, db)
+7 -1
View File
@@ -27,6 +27,7 @@ type DeviceListItem struct {
AndroidVersion string `json:"androidVersion"`
AgentVersion string `json:"agentVersion"`
PDDVersion string `json:"pddVersion"`
Capabilities []string `json:"capabilities"`
Status string `json:"status"`
Busy bool `json:"busy"`
Selectable bool `json:"selectable"`
@@ -100,10 +101,15 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi
currentTasks[task.DeviceID] = task.ID
}
for _, record := range records {
capabilities, err := Capabilities(record)
if err != nil {
return DeviceListResponse{}, err
}
item := DeviceListItem{
ID: record.ID, Name: record.Name, Manufacturer: record.Manufacturer, Model: record.Model,
AndroidVersion: record.AndroidVersion, AgentVersion: record.AgentVersion, PDDVersion: record.PDDVersion,
Status: record.Status, LastHeartbeatAt: record.LastHeartbeatAt,
Capabilities: capabilities,
Status: record.Status, LastHeartbeatAt: record.LastHeartbeatAt,
TokenRevoked: record.TokenRevokedAt != nil,
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
}
+21 -9
View File
@@ -47,14 +47,15 @@ func (err *ServiceError) Error() string {
func (err *ServiceError) Unwrap() error { return err.Cause }
type RegisterRequest struct {
RequestID string `json:"requestId"`
InstallID string `json:"installId"`
Name string `json:"name"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
AndroidVersion string `json:"androidVersion"`
AgentVersion string `json:"agentVersion"`
PDDVersion string `json:"pddVersion"`
RequestID string `json:"requestId"`
InstallID string `json:"installId"`
Name string `json:"name"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
AndroidVersion string `json:"androidVersion"`
AgentVersion string `json:"agentVersion"`
PDDVersion string `json:"pddVersion"`
Capabilities []string `json:"capabilities,omitempty"`
}
type RegisterResponse struct {
@@ -171,6 +172,9 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
"android_version": request.AndroidVersion, "agent_version": request.AgentVersion,
"pdd_version": request.PDDVersion, "last_register_request_id": request.RequestID,
}
if request.Capabilities != nil {
updates["capabilities_json"] = encodeCapabilities(request.Capabilities)
}
result := db.Model(&models.AgentDevice{}).
Where("id = ? AND token_digest = ? AND token_revoked_at IS NULL", existing.ID, existing.TokenDigest).
Updates(updates)
@@ -197,7 +201,7 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
device := models.AgentDevice{
InstallID: request.InstallID, Name: request.Name, Manufacturer: request.Manufacturer,
Model: request.Model, AndroidVersion: request.AndroidVersion, AgentVersion: request.AgentVersion,
PDDVersion: request.PDDVersion, Status: models.DeviceStatusOnline, TokenDigest: tokenDigest(token),
PDDVersion: request.PDDVersion, CapabilitiesJSON: encodeCapabilities(request.Capabilities), Status: models.DeviceStatusOnline, TokenDigest: tokenDigest(token),
TokenIssuedAt: now, LastRegisterRequestID: &request.RequestID,
}
if err := db.Create(&device).Error; err != nil {
@@ -269,6 +273,11 @@ func normalizeRegisterRequest(request RegisterRequest) RegisterRequest {
request.AndroidVersion = strings.TrimSpace(request.AndroidVersion)
request.AgentVersion = strings.TrimSpace(request.AgentVersion)
request.PDDVersion = strings.TrimSpace(request.PDDVersion)
if request.Capabilities != nil {
if normalized, err := normalizeCapabilities(request.Capabilities); err == nil {
request.Capabilities = normalized
}
}
return request
}
@@ -293,6 +302,9 @@ func validateRegisterRequest(request RegisterRequest) error {
return invalidRequest(fmt.Sprintf("%s 必填且长度不能超过 %d", field.name, field.max))
}
}
if _, err := normalizeCapabilities(request.Capabilities); err != nil {
return err
}
return nil
}
+22
View File
@@ -80,6 +80,28 @@ func TestFirstRegistrationCreatesEnabledDeviceAndReturnsTokenOnce(t *testing.T)
}
}
func TestRegistrationNormalizesAndStoresCapabilities(t *testing.T) {
db := openTestDatabase(t)
service := newTestService(t, db)
request := validRegisterRequest()
request.Capabilities = []string{"ACTION.SWIPE.V1", "rule.schema.v2", "action.swipe.v1"}
response, err := service.Register(context.Background(), request, "")
if err != nil {
t.Fatalf("register: %v", err)
}
var stored models.AgentDevice
if err := db.First(&stored, response.DeviceID).Error; err != nil {
t.Fatalf("load device: %v", err)
}
capabilities, err := Capabilities(stored)
if err != nil {
t.Fatalf("decode capabilities: %v", err)
}
if strings.Join(capabilities, ",") != "action.swipe.v1,rule.schema.v2" {
t.Fatalf("capabilities were not normalized: %v", capabilities)
}
}
func TestSameRequestIDIsIdempotentWithoutReturningTokenAgain(t *testing.T) {
db := openTestDatabase(t)
service := newTestService(t, db)
@@ -135,6 +135,9 @@ func TestDeviceSchemaStoresOnlyTokenDigest(t *testing.T) {
if !names["token_digest"] {
t.Fatal("token_digest column missing")
}
if !names["capabilities_json"] {
t.Fatal("capabilities_json column missing")
}
for _, forbidden := range []string{"token", "device_token", "raw_token"} {
if names[forbidden] {
t.Fatalf("raw token column must not exist: %s", forbidden)
+1
View File
@@ -31,6 +31,7 @@ type AgentDevice struct {
AndroidVersion string `json:"androidVersion" gorm:"size:32;not null"`
AgentVersion string `json:"agentVersion" gorm:"size:32;not null"`
PDDVersion string `json:"pddVersion" gorm:"size:32;not null"`
CapabilitiesJSON string `json:"-" gorm:"size:4096;not null;default:'[]'"`
Status string `json:"status" gorm:"size:16;not null;index;check:ck_agent_device_status,status IN ('online','offline','disabled')"`
TokenDigest string `json:"-" gorm:"size:64;not null;uniqueIndex:ux_agent_device_token_digest"`
TokenIssuedAt time.Time `json:"tokenIssuedAt" gorm:"not null"`
+3 -3
View File
@@ -8,6 +8,7 @@ import (
"strings"
"go-admin/app/goauto/models"
"go-admin/app/goauto/rulecontract"
"github.com/google/uuid"
"gorm.io/gorm"
@@ -194,9 +195,8 @@ func validateSave(request SaveRequest) (string, string, error) {
if err := json.Unmarshal(request.Content, &object); err != nil || object == nil {
return "", "", invalidRule("content 必须是 JSON 对象")
}
var version int
if raw, ok := object["schemaVersion"]; !ok || json.Unmarshal(raw, &version) != nil || version != 1 {
return "", "", invalidRule("content.schemaVersion 必须为 1")
if err := rulecontract.Validate(request.Content); err != nil {
return "", "", invalidRule(err.Error())
}
normalized, err := json.Marshal(object)
if err != nil {
+268
View File
@@ -0,0 +1,268 @@
package rulecontract
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
)
const (
CapabilitySchemaV2 = "rule.schema.v2"
CapabilitySwipeV1 = "action.swipe.v1"
CapabilityPDDProductDetailV1 = "collector.pdd.product-detail.v1"
)
var allowedNavigationPackages = map[string]bool{
"com.xunmeng.pinduoduo": true,
"com.android.chrome": true,
"com.heytap.browser": true,
"com.android.browser": true,
"android": true,
}
var allowedOpenTargets = map[string]bool{
"打开拼多多APP": true,
"打开拼多多 App": true,
"打开": true,
}
type selector struct {
ResourceID string `json:"resourceId,omitempty"`
Text string `json:"text,omitempty"`
ContentDescription string `json:"contentDescription,omitempty"`
ClassName string `json:"className,omitempty"`
Clickable *bool `json:"clickable,omitempty"`
}
func (value selector) empty() bool {
return value.ResourceID == "" && value.Text == "" && value.ContentDescription == "" && value.ClassName == "" && value.Clickable == nil
}
type navigationStep struct {
ID string `json:"id"`
Page string `json:"page,omitempty"`
PackageName string `json:"packageName"`
ActivityName string `json:"activityName,omitempty"`
Action string `json:"action"`
Selector *selector `json:"selector,omitempty"`
TimeoutMS int `json:"timeoutMs,omitempty"`
Optional bool `json:"optional,omitempty"`
}
type hookAction struct {
Action string `json:"action"`
Target string `json:"target"`
Direction string `json:"direction"`
Count int `json:"count"`
SettleMS int `json:"settleMs,omitempty"`
}
type collectorConfig struct {
CollectorID string `json:"collectorId"`
SpecEntryStrategy string `json:"specEntryStrategy"`
PriceParser string `json:"priceParser"`
PriceGranularity string `json:"priceGranularity"`
DimensionAliases map[string][]string `json:"dimensionAliases"`
TimeoutsMS map[string]int `json:"timeoutsMs"`
Limits map[string]int `json:"limits"`
}
type v2Rule struct {
SchemaVersion int `json:"schemaVersion"`
RuleType string `json:"ruleType"`
Navigation struct {
Steps []navigationStep `json:"steps"`
} `json:"navigation"`
PageEvidence struct {
PackageName string `json:"packageName"`
ActivityName string `json:"activityName"`
Selector selector `json:"selector"`
} `json:"pageEvidence"`
Hooks map[string][]hookAction `json:"hooks,omitempty"`
Collector collectorConfig `json:"collector"`
}
// Validate accepts the legacy v1 shape and strictly validates the v2 product
// detail contract. Android remains the final enforcement point for every
// device-side action.
func Validate(raw []byte) error {
var header struct {
SchemaVersion int `json:"schemaVersion"`
}
if err := json.Unmarshal(raw, &header); err != nil {
return errors.New("content 必须是 JSON 对象")
}
switch header.SchemaVersion {
case 1:
return nil
case 2:
_, err := parseV2(raw)
return err
default:
return errors.New("content.schemaVersion 只支持 1 或 2")
}
}
// RequiredCapabilities derives scheduling requirements from an immutable rule
// snapshot. V1 deliberately has no capability requirement for compatibility
// with devices registered before capability reporting existed.
func RequiredCapabilities(raw string) ([]string, error) {
var header struct {
SchemaVersion int `json:"schemaVersion"`
}
if err := json.Unmarshal([]byte(raw), &header); err != nil {
return nil, errors.New("规则快照不是有效 JSON")
}
if header.SchemaVersion <= 1 {
return nil, nil
}
rule, err := parseV2([]byte(raw))
if err != nil {
return nil, err
}
required := map[string]bool{
CapabilitySchemaV2: true,
CapabilityPDDProductDetailV1: true,
}
for _, actions := range rule.Hooks {
if len(actions) > 0 {
required[CapabilitySwipeV1] = true
}
}
result := make([]string, 0, len(required))
for capability := range required {
result = append(result, capability)
}
sort.Strings(result)
return result, nil
}
func parseV2(raw []byte) (v2Rule, error) {
var rule v2Rule
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&rule); err != nil {
return rule, fmt.Errorf("v2 规则字段无效: %w", err)
}
if err := ensureEOF(decoder); err != nil {
return rule, err
}
if rule.SchemaVersion != 2 || rule.RuleType != "pddProductDetail" {
return rule, errors.New("v2 规则必须声明 ruleType=pddProductDetail")
}
if len(rule.Navigation.Steps) == 0 {
return rule, errors.New("navigation.steps 必须是非空数组")
}
ids := map[string]bool{}
for index, step := range rule.Navigation.Steps {
if strings.TrimSpace(step.ID) == "" || ids[step.ID] {
return rule, fmt.Errorf("navigation.steps[%d].id 为空或重复", index)
}
ids[step.ID] = true
if !allowedNavigationPackages[step.PackageName] {
return rule, fmt.Errorf("步骤 %s 的应用包不在白名单", step.ID)
}
if step.Action != "click" && step.Action != "wait" && step.Action != "back" {
return rule, fmt.Errorf("步骤 %s 的 action 不受支持", step.ID)
}
if step.Action != "back" && (step.Selector == nil || step.Selector.empty()) {
return rule, fmt.Errorf("步骤 %s 缺少 selector", step.ID)
}
if step.Action == "click" && step.PackageName == "com.xunmeng.pinduoduo" {
return rule, fmt.Errorf("v2 导航步骤 %s 不能点击 PDD 页面", step.ID)
}
if step.Action == "click" && step.PackageName != "com.xunmeng.pinduoduo" {
target := ""
if step.Selector != nil {
target = step.Selector.Text
if target == "" {
target = step.Selector.ContentDescription
}
}
if !allowedOpenTargets[target] {
return rule, fmt.Errorf("步骤 %s 不是允许的打开拼多多动作", step.ID)
}
}
if step.TimeoutMS != 0 && (step.TimeoutMS < 100 || step.TimeoutMS > 30000) {
return rule, fmt.Errorf("步骤 %s 的 timeoutMs 必须为 100..30000", step.ID)
}
}
if rule.PageEvidence.PackageName != "com.xunmeng.pinduoduo" || strings.TrimSpace(rule.PageEvidence.ActivityName) == "" || rule.PageEvidence.Selector.empty() {
return rule, errors.New("pageEvidence 必须包含 PDD 包名、精确 Activity 和非空 selector")
}
if len(rule.Hooks) > 1 {
return rule, errors.New("hooks 只允许 afterSpecPanelOpen")
}
for stage, actions := range rule.Hooks {
if stage != "afterSpecPanelOpen" {
return rule, fmt.Errorf("不支持的 hook 阶段: %s", stage)
}
if len(actions) > 8 {
return rule, errors.New("单个 hook 最多 8 个动作")
}
for _, action := range actions {
if action.Action != "swipe" || action.Target != "specPanel" {
return rule, errors.New("hook 只允许对 specPanel 执行 swipe")
}
if action.Direction != "up" && action.Direction != "down" && action.Direction != "left" && action.Direction != "right" {
return rule, errors.New("swipe.direction 不受支持")
}
if action.Count < 1 || action.Count > 5 {
return rule, errors.New("swipe.count 必须为 1..5")
}
if action.SettleMS < 0 || action.SettleMS > 2000 {
return rule, errors.New("swipe.settleMs 必须为 0..2000")
}
}
}
if rule.Collector.CollectorID != "pddProductDetailV1" || rule.Collector.SpecEntryStrategy != "safeBottomSpecEntryV1" || rule.Collector.PriceParser != "pddRmbPriceV1" || rule.Collector.PriceGranularity != "color" {
return rule, errors.New("collector 使用了 Agent 不支持的类型化能力")
}
for _, key := range []string{"color", "size"} {
aliases := rule.Collector.DimensionAliases[key]
if len(aliases) == 0 || len(aliases) > 20 {
return rule, fmt.Errorf("dimensionAliases.%s 必须包含 1..20 项", key)
}
for _, alias := range aliases {
if strings.TrimSpace(alias) == "" || len([]rune(alias)) > 30 {
return rule, fmt.Errorf("dimensionAliases.%s 含无效别名", key)
}
}
}
if err := validateIntMap(rule.Collector.TimeoutsMS, map[string][2]int{
"page": {100, 60000}, "specPanel": {100, 30000}, "selection": {100, 10000}, "price": {100, 10000}, "overall": {1000, 600000},
}); err != nil {
return rule, err
}
if err := validateIntMap(rule.Collector.Limits, map[string][2]int{
"goodsPageVerticalSwipes": {0, 10}, "specHorizontalSwipes": {0, 30}, "specVerticalSwipes": {0, 30}, "stableEdgeReads": {1, 5}, "stablePriceReads": {2, 5}, "maxSkuCount": {1, 2000},
}); err != nil {
return rule, err
}
return rule, nil
}
func validateIntMap(values map[string]int, allowed map[string][2]int) error {
if len(values) != len(allowed) {
return errors.New("规则整数配置缺失或包含未知字段")
}
for key, bounds := range allowed {
value, ok := values[key]
if !ok || value < bounds[0] || value > bounds[1] {
return fmt.Errorf("%s 必须为 %d..%d", key, bounds[0], bounds[1])
}
}
return nil
}
func ensureEOF(decoder *json.Decoder) error {
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return errors.New("规则 JSON 只能包含一个对象")
}
return nil
}
@@ -0,0 +1,57 @@
package rulecontract
import (
"strings"
"testing"
)
func validV2(hook string) string {
return `{
"schemaVersion":2,"ruleType":"pddProductDetail",
"navigation":{"steps":[{"id":"open","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":1000,"optional":true}]},
"pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content"}},
"hooks":{"afterSpecPanelOpen":` + hook + `},
"collector":{"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color",
"dimensionAliases":{"color":["颜色"],"size":["尺码"]},
"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},
"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}}
}`
}
func TestV2ContractAndCapabilities(t *testing.T) {
raw := validV2(`[{"action":"swipe","target":"specPanel","direction":"up","count":2,"settleMs":350}]`)
if err := Validate([]byte(raw)); err != nil {
t.Fatalf("valid v2 rule rejected: %v", err)
}
required, err := RequiredCapabilities(raw)
if err != nil {
t.Fatalf("requirements: %v", err)
}
joined := strings.Join(required, ",")
for _, value := range []string{CapabilitySchemaV2, CapabilitySwipeV1, CapabilityPDDProductDetailV1} {
if !strings.Contains(joined, value) {
t.Fatalf("missing capability %s in %v", value, required)
}
}
}
func TestV2RejectsUnboundedOrUnknownHookActions(t *testing.T) {
invalid := []string{
`[{"action":"tap","target":"specPanel","direction":"up","count":1}]`,
`[{"action":"swipe","target":"screen","direction":"up","count":1}]`,
`[{"action":"swipe","target":"specPanel","direction":"up","count":6}]`,
`[{"action":"swipe","target":"specPanel","direction":"diagonal","count":1}]`,
}
for _, hook := range invalid {
if err := Validate([]byte(validV2(hook))); err == nil {
t.Fatalf("invalid hook accepted: %s", hook)
}
}
}
func TestLegacyV1RequiresNoReportedCapabilities(t *testing.T) {
required, err := RequiredCapabilities(`{"schemaVersion":1,"steps":[]}`)
if err != nil || len(required) != 0 {
t.Fatalf("legacy capability behavior changed: %v %v", required, err)
}
}
+3
View File
@@ -84,6 +84,9 @@ func (service *Service) Create(ctx context.Context, request CreateRequest) (Crea
if err := tx.First(&target, *request.DeviceID).Error; err != nil || target.Status == models.DeviceStatusDisabled {
return serviceError(CodeDeviceNotFound, "设备不存在或已停用")
}
if err := ensureRuleCompatible(target, rule.ContentJSON); err != nil {
return err
}
}
var active int64
if err := tx.Model(&models.CollectionTask{}).Where("pdd_product_id = ? AND status IN ?", product.ID,
+54 -13
View File
@@ -9,6 +9,7 @@ import (
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/rulecontract"
"github.com/google/uuid"
"gorm.io/gorm"
@@ -19,13 +20,14 @@ const (
DefaultLeaseDuration = 2 * time.Minute
DefaultTaskTimeout = 120
CodeTaskNotFound = "TASK_NOT_FOUND"
CodeTaskAlreadyClaimed = "TASK_ALREADY_CLAIMED"
CodeTaskAssignedOther = "TASK_ASSIGNED_OTHER_DEVICE"
CodeDeviceBusy = "DEVICE_BUSY"
CodeDeviceOffline = "DEVICE_OFFLINE"
CodeTaskStateConflict = "TASK_STATE_CONFLICT"
CodeTaskLeaseExpired = "TASK_LEASE_EXPIRED"
CodeTaskNotFound = "TASK_NOT_FOUND"
CodeTaskAlreadyClaimed = "TASK_ALREADY_CLAIMED"
CodeTaskAssignedOther = "TASK_ASSIGNED_OTHER_DEVICE"
CodeDeviceBusy = "DEVICE_BUSY"
CodeDeviceOffline = "DEVICE_OFFLINE"
CodeTaskStateConflict = "TASK_STATE_CONFLICT"
CodeTaskLeaseExpired = "TASK_LEASE_EXPIRED"
CodeDeviceCapabilityMismatch = "DEVICE_CAPABILITY_MISMATCH"
)
type ServiceError struct {
@@ -99,23 +101,33 @@ func (service *Service) Next(ctx context.Context, token string) (*TaskPayload, e
Where("device_id = ? AND status = ? AND lease_expires_at > ?", deviceRecord.ID, models.TaskStatusPending, now).
Order("created_at ASC, id ASC").First(&record).Error
if err == nil {
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
return nil, err
}
payload, payloadErr := service.payload(record, false)
return &payload, payloadErr
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, internalError(err)
}
var candidates []models.CollectionTask
err = service.DB.WithContext(ctx).
Where("status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?) AND (device_id = ? OR device_id IS NULL)", models.TaskStatusPending, now, deviceRecord.ID).
Order("CASE WHEN device_id IS NULL THEN 1 ELSE 0 END, created_at ASC, id ASC").First(&record).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
Order("CASE WHEN device_id IS NULL THEN 1 ELSE 0 END, created_at ASC, id ASC").Limit(100).Find(&candidates).Error
if err != nil {
return nil, internalError(err)
}
payload, payloadErr := service.payload(record, false)
return &payload, payloadErr
for _, candidate := range candidates {
compatible, compatibilityErr := ruleCompatible(deviceRecord, candidate.RuleSnapshot)
if compatibilityErr != nil {
return nil, compatibilityErr
}
if compatible {
payload, payloadErr := service.payload(candidate, false)
return &payload, payloadErr
}
}
return nil, nil
}
func (service *Service) Claim(ctx context.Context, taskID uint64, request ActionRequest, token string) (TaskPayload, error) {
@@ -149,6 +161,9 @@ func (service *Service) Claim(ctx context.Context, taskID uint64, request Action
if record.Status != models.TaskStatusPending {
return serviceError(CodeTaskStateConflict, "只有待执行任务可以领取")
}
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
return err
}
now := service.Now()
if record.LeaseExpiresAt != nil && record.LeaseExpiresAt.After(now) {
return serviceError(CodeTaskAlreadyClaimed, "任务已被领取")
@@ -214,6 +229,9 @@ func (service *Service) Start(ctx context.Context, taskID uint64, request Action
if record.Status != models.TaskStatusPending {
return serviceError(CodeTaskStateConflict, "任务当前状态不能开始")
}
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
return err
}
if record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
return serviceError(CodeTaskAssignedOther, "任务不属于当前设备")
}
@@ -292,6 +310,29 @@ func validateAction(taskID uint64, request ActionRequest) error {
return nil
}
func ruleCompatible(record models.AgentDevice, snapshot string) (bool, error) {
required, err := rulecontract.RequiredCapabilities(snapshot)
if err != nil {
return false, internalError(err)
}
compatible, err := device.Supports(record, required)
if err != nil {
return false, err
}
return compatible, nil
}
func ensureRuleCompatible(record models.AgentDevice, snapshot string) error {
compatible, err := ruleCompatible(record, snapshot)
if err != nil {
return err
}
if !compatible {
return serviceError(CodeDeviceCapabilityMismatch, "设备不支持任务规则所需能力")
}
return nil
}
func serviceError(code, message string) error {
return &ServiceError{Code: code, Message: message, Retryable: false}
}
+66 -1
View File
@@ -32,6 +32,10 @@ func openTaskDatabase(t *testing.T) *gorm.DB {
}
func registerTaskDevice(t *testing.T, db *gorm.DB, name string) (models.AgentDevice, string) {
return registerTaskDeviceWithCapabilities(t, db, name, nil)
}
func registerTaskDeviceWithCapabilities(t *testing.T, db *gorm.DB, name string, capabilities []string) (models.AgentDevice, string) {
t.Helper()
token := uuid.NewString()
registration := device.NewService(db)
@@ -39,7 +43,7 @@ func registerTaskDevice(t *testing.T, db *gorm.DB, name string) (models.AgentDev
response, err := registration.Register(context.Background(), device.RegisterRequest{
RequestID: uuid.NewString(), InstallID: uuid.NewString(), Name: name,
Manufacturer: "HUAWEI", Model: "Test", AndroidVersion: "14",
AgentVersion: "0.1.0", PDDVersion: "7.72.0",
AgentVersion: "0.1.0", PDDVersion: "7.72.0", Capabilities: capabilities,
}, "")
if err != nil {
t.Fatalf("register device: %v", err)
@@ -51,6 +55,10 @@ func registerTaskDevice(t *testing.T, db *gorm.DB, name string) (models.AgentDev
return record, token
}
func v2TaskRuleSnapshot() string {
return `{"schemaVersion":2,"ruleType":"pddProductDetail","navigation":{"steps":[{"id":"open","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":1000,"optional":true}]},"pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content"}},"hooks":{"afterSpecPanelOpen":[]},"collector":{"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color","dimensionAliases":{"color":["颜色"],"size":["尺码"]},"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}}}`
}
func createTask(t *testing.T, db *gorm.DB, deviceID *uint64) models.CollectionTask {
t.Helper()
goodsID := strings.ReplaceAll(uuid.NewString(), "-", "")
@@ -166,3 +174,60 @@ func TestStartRequiresLiveLease(t *testing.T) {
t.Fatalf("expected expired lease, got %v", err)
}
}
func TestV2TaskIsOnlyOfferedToCapableDevice(t *testing.T) {
db := openTaskDatabase(t)
_, legacyToken := registerTaskDevice(t, db, "legacy-device")
capable, capableToken := registerTaskDeviceWithCapabilities(t, db, "capable-device", []string{
"rule.schema.v2", "collector.pdd.product-detail.v1",
})
goodsID := strings.ReplaceAll(uuid.NewString(), "-", "")
product := models.PDDProduct{GoodsID: goodsID, URL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID}
rule := models.CollectionRule{Name: "v2", ContentJSON: v2TaskRuleSnapshot()}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
task := models.CollectionTask{
PDDProductID: product.ID, RuleID: rule.ID, Status: models.TaskStatusPending,
URLSnapshot: product.URL, GoodsIDSnapshot: goodsID, RuleSnapshot: rule.ContentJSON,
}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
service := newTaskService(db)
if next, err := service.Next(context.Background(), legacyToken); err != nil || next != nil {
t.Fatalf("legacy device received v2 task: %+v %v", next, err)
}
if _, err := service.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, legacyToken); taskErrorCode(t, err) != CodeDeviceCapabilityMismatch {
t.Fatalf("legacy claim did not fail with capability mismatch: %v", err)
}
next, err := service.Next(context.Background(), capableToken)
if err != nil || next == nil || next.TaskID != task.ID {
t.Fatalf("capable device did not receive v2 task: %+v %v", next, err)
}
_ = capable
}
func TestAdminCreateRejectsAssignedDeviceWithoutRuleCapabilities(t *testing.T) {
db := openTaskDatabase(t)
legacy, _ := registerTaskDevice(t, db, "legacy-device")
goodsID := strings.ReplaceAll(uuid.NewString(), "-", "")
product := models.PDDProduct{GoodsID: goodsID, URL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID}
rule := models.CollectionRule{Name: "v2", ContentJSON: v2TaskRuleSnapshot()}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
_, err := newTaskService(db).Create(context.Background(), CreateRequest{
RequestID: uuid.NewString(), PDDProductID: product.ID, RuleID: rule.ID, DeviceID: &legacy.ID,
})
if taskErrorCode(t, err) != CodeDeviceCapabilityMismatch {
t.Fatalf("incompatible assigned device was accepted: %v", err)
}
}
@@ -0,0 +1,27 @@
package version_local
import (
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// This version adds the device capability snapshot used to keep old Agents on
// v1 tasks while scheduling v2 rules only to compatible Agent versions.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateDeviceCapabilities)
}
func migrateDeviceCapabilities(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}