From affa6b3f8c787100489dd78c3b37426d6b1effb7 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:36:58 +0800 Subject: [PATCH] feat: add safe PDD rule template editor (#24) --- docs/00-project-profile.md | 2 +- docs/08-agent-api-contract.md | 3 + docs/11-pdd-detail-rule-migration-analysis.md | 2 +- server/app/goauto/rule/handler.go | 13 ++ server/app/goauto/rule/router.go | 1 + server/app/goauto/rule/service.go | 47 ++++++ server/app/goauto/rule/service_test.go | 34 ++++ .../app/goauto/rulecontract/contract_test.go | 14 ++ server/app/goauto/rulecontract/template.go | 25 +++ web/playwright.config.ts | 5 +- web/src/api/goauto/collection-rules.js | 1 + .../views/goauto/collection-rules/index.vue | 156 +++++++++++++----- .../views/goauto/collection-tasks/index.vue | 5 +- web/tests/e2e/collection-rule-editor.spec.ts | 58 +++++++ 14 files changed, 321 insertions(+), 45 deletions(-) create mode 100644 server/app/goauto/rulecontract/template.go create mode 100644 web/tests/e2e/collection-rule-editor.spec.ts 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 @@ @@ -33,51 +81,77 @@ diff --git a/web/src/views/goauto/collection-tasks/index.vue b/web/src/views/goauto/collection-tasks/index.vue index 4ea8089..9df6a19 100644 --- a/web/src/views/goauto/collection-tasks/index.vue +++ b/web/src/views/goauto/collection-tasks/index.vue @@ -46,8 +46,10 @@ {{ detail.data.task.reviewCount ?? '未采集' }}{{ detail.data.task.errorCode ? `${detail.data.task.errorCode}:${detail.data.task.errorMessage}` : '无' }}

规格维度

+

颜色价格

SKU

+
{{ formatRuleSnapshot(detail.data.task.ruleSnapshot) }}
@@ -71,6 +73,7 @@ export default { handleQuery() { this.query.page = 1; this.getList() }, resetQuery() { this.query = { page: 1, pageSize: 20, goodsId: '', status: '' }; this.getList() }, statusLabel(value) { return this.statuses.find(item => item.value === value)?.label || value }, statusType(value) { return { pending: 'info', running: 'warning', completed: 'success', completed_partial: 'warning', failed: 'danger' }[value] || 'info' }, isTerminal(value) { return ['completed', 'completed_partial', 'failed'].includes(value) }, specsText(specs) { return Object.entries(specs || {}).map(([key, value]) => `${key}: ${value}`).join(' / ') }, + formatRuleSnapshot(value) { try { return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2) } catch (_) { return String(value || '') } }, async openCreate() { this.form = { pddProductId: null, ruleId: null, deviceId: null }; this.createDialog.open = true; const [p, r, d] = await Promise.all([listPddProducts({ page: 1, pageSize: 100 }), listCollectionRules({ page: 1, pageSize: 100 }), listDevices({ page: 1, pageSize: 100 })]); this.options = { products: p.data.items, rules: r.data.items, devices: d.data.items }; this.$nextTick(() => this.$refs.taskForm?.clearValidate()) }, async createTask() { if (!await this.$refs.taskForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { await createCollectionTask({ requestId: window.crypto.randomUUID(), pddProductId: this.form.pddProductId, ruleId: this.form.ruleId, deviceId: this.form.deviceId || null }); ElMessage.success('任务已创建'); this.createDialog.open = false; await this.getList() } finally { this.createDialog.saving = false } }, async showDetail(row) { this.detail = { open: true, loading: true, data: null }; try { const r = await getCollectionTask(row.id); this.detail.data = r.data } finally { this.detail.loading = false } }, @@ -81,5 +84,5 @@ export default { diff --git a/web/tests/e2e/collection-rule-editor.spec.ts b/web/tests/e2e/collection-rule-editor.spec.ts new file mode 100644 index 0000000..8a1d738 --- /dev/null +++ b/web/tests/e2e/collection-rule-editor.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from '@playwright/test' + +const template = { + 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 }, + }, +} + +test('v2 rule uses safe form and read-only JSON preview', async ({ page, context }) => { + await context.addCookies([{ name: 'Admin-Token', value: 'test-token', domain: 'localhost', path: '/' }]) + await page.route('**/api/v1/getinfo', route => route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: 'admin', avatar: '', introduction: '', permissions: [] } } })) + await page.route('**/api/v1/menurole', route => route.fulfill({ json: { code: 200, data: [] } })) + await page.route('**/api/admin/v1/collection-rules/templates/pdd-product-detail-v1', route => route.fulfill({ json: { code: 200, data: { templateId: 'pdd-product-detail-v1', name: 'PDD 商品详情采集 v2', content: template } } })) + await page.route(/\/api\/admin\/v1\/collection-rules\?(?!.*templates).*/, route => route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 20 } } })) + + await page.goto('/#/collection-rules/index') + await expect(page.getByRole('heading', { name: '采集规则' })).toBeVisible() + await page.getByRole('button', { name: '创建 PDD 采集规则' }).click() + + await expect(page.getByRole('heading', { name: '页面识别' })).toBeVisible() + await expect(page.getByRole('heading', { name: '规格维度别名' })).toBeVisible() + await expect(page.getByRole('heading', { name: '超时设置' })).toBeVisible() + await expect(page.getByRole('heading', { name: '遍历上限' })).toBeVisible() + await expect(page.getByText('固定能力 v1')).toBeVisible() + await expect(page.locator('.json-editor')).toHaveCount(0) + await page.getByText('查看只读 JSON 预览').click() + await expect(page.locator('.json-preview-block')).toContainText('pddProductDetailV1') + await expect(page.locator('.json-preview-block')).not.toContainText('提交订单') + await expect(page.getByRole('button', { name: '保存并立即生效' })).toBeEnabled() +}) + +test('task detail shows structured color prices and v2 snapshot', async ({ page, context }) => { + await context.addCookies([{ name: 'Admin-Token', value: 'test-token', domain: 'localhost', path: '/' }]) + await page.route('**/api/v1/getinfo', route => route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: 'admin', avatar: '', introduction: '', permissions: [] } } })) + await page.route('**/api/v1/menurole', route => route.fulfill({ json: { code: 200, data: [] } })) + const task = { id: 88, goodsIdSnapshot: '719834019024', ruleName: 'PDD 商品详情采集', deviceName: 'OPPO-PKG110', status: 'completed_partial', createdAt: '2026-08-15T00:00:00Z', title: '测试商品', shopName: '测试旗舰店', salesText: '已拼100件', reviewCount: 20, ruleSnapshot: JSON.stringify(template) } + await page.route(/\/api\/admin\/v1\/collection-tasks\?.*/, route => route.fulfill({ json: { code: 200, data: { items: [task], total: 1, page: 1, pageSize: 20 } } })) + await page.route('**/api/admin/v1/collection-tasks/88', route => route.fulfill({ json: { code: 200, data: { task, dimensions: [{ key: 'color', name: '颜色', values: ['红色'] }], colorPrices: [{ color: '红色', priceCent: 1099 }], skus: [{ specs: { color: '红色' }, priceCent: 1099, complete: false }], missing: ['size'] } } })) + + await page.goto('/#/collection-tasks/index') + await expect(page.getByText('719834019024')).toBeVisible() + await page.getByRole('button', { name: '详情' }).click() + await expect(page.getByRole('heading', { name: '颜色价格' })).toBeVisible() + await expect(page.getByText('¥10.99').first()).toBeVisible() + await page.getByText('查看本任务规则快照').click() + await expect(page.locator('.snapshot-json')).toContainText('pddProductDetailV1') +})