diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md
index a489d44..7e23cbd 100644
--- a/docs/00-project-profile.md
+++ b/docs/00-project-profile.md
@@ -32,4 +32,4 @@
## 当前阶段
-当前 MVP 的 T01~T07、T09~T20 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T21 正在实现 Android v2 商品详情采集器,随后由 T22~T23 完成管理端规则模板和真机验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。
+当前 MVP 的 T01~T07、T09~T21 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T22 正在提供管理端内置 v2 规则模板和安全编辑表单,随后由 T23 完成一加真机验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。
diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md
index 187a505..c8df10e 100644
--- a/docs/08-agent-api-contract.md
+++ b/docs/08-agent-api-contract.md
@@ -27,10 +27,13 @@ POST /api/admin/v1/collection-rules
PATCH /api/admin/v1/collection-rules/{ruleId}
DELETE /api/admin/v1/collection-rules/{ruleId}
GET /api/admin/v1/collection-rules
+GET /api/admin/v1/collection-rules/templates/pdd-product-detail-v1
```
规则只有 `name` 和 `content` 等当前值,创建成功后立即可用。删除为软删除;已删除规则不能创建新任务,已有任务继续使用任务内快照。
+模板接口返回服务端内置的完整 v2 `content`,以及浏览器包名、别名数量/长度、超时、遍历上限和规格面板滑动动作的可编辑范围。管理端以安全表单修改这些参数,并显示只读 JSON 预览;模板中的采集器 ID、价格解析器、页面根证据、动作类型和语义目标不可在界面中改写。最终创建或更新请求仍由服务端按完整 v2 契约重新校验,不能依赖前端校验。
+
`content` 支持原有 `schemaVersion: 1` 线性步骤和 `schemaVersion: 2` 类型化采集器契约。v1 契约如下:
```json
diff --git a/docs/11-pdd-detail-rule-migration-analysis.md b/docs/11-pdd-detail-rule-migration-analysis.md
index 4445123..d742cd5 100644
--- a/docs/11-pdd-detail-rule-migration-analysis.md
+++ b/docs/11-pdd-detail-rule-migration-analysis.md
@@ -118,4 +118,4 @@ Agent 不限制为“只能采集”,而是注册带版本的类型化能力
## 当前状态
-T19 已验收;T20 已实现并验收 v2 规则契约、阶段钩子和设备能力协商。T21 已接入纯 Kotlin 内存节点模型、页面证据、规格入口、颜色归边与蛇形遍历、点击后刷新、选中确认、稳定价格、只读尺码、第三维部分结果和 SKU 上限,当前等待用户验收。管理端内置模板和真机验证分别属于 T22、T23;在两项完成前,规则文件仍标记为 proposed。
+T19~T21 已验收:v2 契约、能力协商和 Android 商品详情采集器已经接入。T22 已提供服务端内置模板、参数约束接口、管理端安全编辑表单、只读 JSON 预览,并在任务详情展示颜色价格和规则快照,当前等待用户验收。T23 负责一加真机验证;在 T23 完成前,文档中的规则文件仍标记为 proposed。
diff --git a/server/app/goauto/rule/handler.go b/server/app/goauto/rule/handler.go
index bf941a3..1f80d11 100644
--- a/server/app/goauto/rule/handler.go
+++ b/server/app/goauto/rule/handler.go
@@ -14,6 +14,19 @@ import (
type Handler struct{ DB *gorm.DB }
+func (h Handler) Template(c *gin.Context) {
+ s, ok := h.service(c)
+ if !ok {
+ return
+ }
+ result, e := s.Template(c.Param("templateId"))
+ if e != nil {
+ writeError(c, e)
+ return
+ }
+ c.JSON(200, gin.H{"code": 200, "data": result})
+}
+
func (h Handler) List(c *gin.Context) {
page, e := positive(c.Query("page"), 1)
if e != nil {
diff --git a/server/app/goauto/rule/router.go b/server/app/goauto/rule/router.go
index 54c7069..6f7cb8d 100644
--- a/server/app/goauto/rule/router.go
+++ b/server/app/goauto/rule/router.go
@@ -9,6 +9,7 @@ import (
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
admin := engine.Group("/api/admin/v1/collection-rules").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
+ admin.GET("/templates/:templateId", handler.Template)
admin.GET("", handler.List)
admin.POST("", handler.Create)
admin.PATCH("/:ruleId", handler.Update)
diff --git a/server/app/goauto/rule/service.go b/server/app/goauto/rule/service.go
index f33c639..3e4ad9d 100644
--- a/server/app/goauto/rule/service.go
+++ b/server/app/goauto/rule/service.go
@@ -58,10 +58,57 @@ type ListResponse struct {
Total int64 `json:"total"`
Page, PageSize int
}
+type IntRange struct {
+ Min int `json:"min"`
+ Max int `json:"max"`
+}
+type TemplateConstraints struct {
+ BrowserPackages []string `json:"browserPackages"`
+ HookDirections []string `json:"hookDirections"`
+ AliasCount IntRange `json:"aliasCount"`
+ AliasLength IntRange `json:"aliasLength"`
+ TimeoutsMS map[string]IntRange `json:"timeoutsMs"`
+ Limits map[string]IntRange `json:"limits"`
+ Swipe map[string]IntRange `json:"swipe"`
+}
+type TemplateResponse struct {
+ TemplateID string `json:"templateId"`
+ Name string `json:"name"`
+ Content json.RawMessage `json:"content"`
+ Constraints TemplateConstraints `json:"constraints"`
+}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
+func (service *Service) Template(templateID string) (TemplateResponse, error) {
+ if templateID != rulecontract.PDDProductDetailTemplateID {
+ return TemplateResponse{}, notFound()
+ }
+ return TemplateResponse{
+ TemplateID: templateID,
+ Name: "PDD 商品详情采集 v2",
+ Content: rulecontract.PDDProductDetailTemplate(),
+ Constraints: TemplateConstraints{
+ BrowserPackages: []string{"com.heytap.browser", "com.android.chrome", "com.android.browser"},
+ HookDirections: []string{"up", "down", "left", "right"},
+ AliasCount: IntRange{Min: 1, Max: 20},
+ AliasLength: IntRange{Min: 1, Max: 30},
+ TimeoutsMS: map[string]IntRange{
+ "page": {Min: 100, Max: 60000}, "specPanel": {Min: 100, Max: 30000},
+ "selection": {Min: 100, Max: 10000}, "price": {Min: 100, Max: 10000},
+ "overall": {Min: 1000, Max: 600000},
+ },
+ Limits: map[string]IntRange{
+ "goodsPageVerticalSwipes": {Min: 0, Max: 10}, "specHorizontalSwipes": {Min: 0, Max: 30},
+ "specVerticalSwipes": {Min: 0, Max: 30}, "stableEdgeReads": {Min: 1, Max: 5},
+ "stablePriceReads": {Min: 2, Max: 5}, "maxSkuCount": {Min: 1, Max: 2000},
+ },
+ Swipe: map[string]IntRange{"count": {Min: 1, Max: 5}, "settleMs": {Min: 0, Max: 2000}},
+ },
+ }, nil
+}
+
func (service *Service) Create(ctx context.Context, request SaveRequest) (SaveResponse, error) {
name, content, err := validateSave(request)
if err != nil {
diff --git a/server/app/goauto/rule/service_test.go b/server/app/goauto/rule/service_test.go
index 1cf60a9..3df5003 100644
--- a/server/app/goauto/rule/service_test.go
+++ b/server/app/goauto/rule/service_test.go
@@ -49,6 +49,40 @@ func TestRuleRequiresVersionedJSONObject(t *testing.T) {
}
}
+func TestPDDProductDetailTemplateCanBeCreated(t *testing.T) {
+ service := NewService(openRuleDatabase(t))
+ template, err := service.Template("pdd-product-detail-v1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if template.TemplateID != "pdd-product-detail-v1" || len(template.Content) == 0 {
+ t.Fatalf("bad template: %+v", template)
+ }
+ if _, err = service.Create(context.Background(), SaveRequest{RequestID: uuid.NewString(), Name: template.Name, Content: template.Content}); err != nil {
+ t.Fatalf("template could not create a rule: %v", err)
+ }
+ if _, err = service.Template("unknown"); ruleErrorCode(t, err) != CodeRuleNotFound {
+ t.Fatalf("unknown template error: %v", err)
+ }
+}
+
+func TestPDDTemplateRejectsUnknownCapabilityAndDangerousAction(t *testing.T) {
+ service := NewService(openRuleDatabase(t))
+ template, err := service.Template("pdd-product-detail-v1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ invalid := []string{
+ strings.Replace(string(template.Content), "pddProductDetailV1", "pddCreateOrderV1", 1),
+ strings.Replace(string(template.Content), "打开拼多多APP", "提交订单", 1),
+ }
+ for _, content := range invalid {
+ if _, err = service.Create(context.Background(), saveRequest("invalid v2", content)); ruleErrorCode(t, err) != CodeRuleInvalid {
+ t.Fatalf("unsafe template mutation accepted: %v", err)
+ }
+ }
+}
+
func TestRuleLifecycleIsIdempotentAndSoftDeletes(t *testing.T) {
db := openRuleDatabase(t)
service := NewService(db)
diff --git a/server/app/goauto/rulecontract/contract_test.go b/server/app/goauto/rulecontract/contract_test.go
index b6386c4..4ac35b3 100644
--- a/server/app/goauto/rulecontract/contract_test.go
+++ b/server/app/goauto/rulecontract/contract_test.go
@@ -55,3 +55,17 @@ func TestLegacyV1RequiresNoReportedCapabilities(t *testing.T) {
t.Fatalf("legacy capability behavior changed: %v %v", required, err)
}
}
+
+func TestBuiltInPDDProductDetailTemplateIsValid(t *testing.T) {
+ raw := PDDProductDetailTemplate()
+ if err := Validate(raw); err != nil {
+ t.Fatalf("built-in template invalid: %v", err)
+ }
+ required, err := RequiredCapabilities(string(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Join(required, ",") != "collector.pdd.product-detail.v1,rule.schema.v2" {
+ t.Fatalf("unexpected template capabilities: %v", required)
+ }
+}
diff --git a/server/app/goauto/rulecontract/template.go b/server/app/goauto/rulecontract/template.go
new file mode 100644
index 0000000..d5a842e
--- /dev/null
+++ b/server/app/goauto/rulecontract/template.go
@@ -0,0 +1,25 @@
+package rulecontract
+
+import "encoding/json"
+
+const PDDProductDetailTemplateID = "pdd-product-detail-v1"
+
+var pddProductDetailTemplate = json.RawMessage(`{
+ "schemaVersion":2,"ruleType":"pddProductDetail",
+ "navigation":{"steps":[
+ {"id":"browser-open-pdd","page":"browser","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":15000,"optional":true},
+ {"id":"system-open","page":"system-confirm","packageName":"android","action":"click","selector":{"text":"打开"},"timeoutMs":8000,"optional":true}
+ ]},
+ "pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content","className":"android.widget.FrameLayout"}},
+ "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 PDDProductDetailTemplate() json.RawMessage {
+ return append(json.RawMessage(nil), pddProductDetailTemplate...)
+}
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
index b0dd1cb..b83f0f2 100644
--- a/web/playwright.config.ts
+++ b/web/playwright.config.ts
@@ -8,8 +8,11 @@ export default defineConfig({
use: {
baseURL: 'http://localhost:9527',
headless: true,
+ launchOptions: {
+ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
+ },
screenshot: 'on',
- video: 'retain-on-failure',
+ video: process.env.PLAYWRIGHT_DISABLE_VIDEO === '1' ? 'off' : 'retain-on-failure',
trace: 'retain-on-failure',
},
});
diff --git a/web/src/api/goauto/collection-rules.js b/web/src/api/goauto/collection-rules.js
index bfa6047..91439dc 100644
--- a/web/src/api/goauto/collection-rules.js
+++ b/web/src/api/goauto/collection-rules.js
@@ -1,6 +1,7 @@
import request from '@/utils/request'
export function listCollectionRules(params) { return request({ url: '/api/admin/v1/collection-rules', method: 'get', params }) }
+export function getCollectionRuleTemplate(templateId = 'pdd-product-detail-v1') { return request({ url: `/api/admin/v1/collection-rules/templates/${templateId}`, method: 'get' }) }
export function createCollectionRule(data) { return request({ url: '/api/admin/v1/collection-rules', method: 'post', data }) }
export function updateCollectionRule(ruleId, data) { return request({ url: `/api/admin/v1/collection-rules/${ruleId}`, method: 'patch', data }) }
export function deleteCollectionRule(ruleId, data) { return request({ url: `/api/admin/v1/collection-rules/${ruleId}`, method: 'delete', data }) }
diff --git a/web/src/views/goauto/collection-rules/index.vue b/web/src/views/goauto/collection-rules/index.vue
index c6d122e..4949035 100644
--- a/web/src/views/goauto/collection-rules/index.vue
+++ b/web/src/views/goauto/collection-rules/index.vue
@@ -2,8 +2,8 @@
规则创建后立即可用;编辑或删除不会改变已有任务的规则快照。 通过安全表单维护 PDD 页面适配参数;规则保存后立即用于新任务。 用于浏览器跳转和 PDD 商品详情页精确校验。 回车添加别名;每组 1~20 项,每项不超过 30 个字符。 单位为毫秒;界面和服务端会同时校验范围。 所有循环都有硬上限,超出时提交已有数据并标记部分完成。 例如页面变化后需要先向上滑动两次。关闭时不执行附加动作。采集规则
采集规则
{{ compact(row.content) }}页面识别
规格维度别名
超时设置
遍历上限
规格面板打开后动作
{{ previewText }}
{{ formatRuleSnapshot(detail.data.task.ruleSnapshot) }}