chore(repo): exclude local development files

This commit is contained in:
2026-08-17 20:08:56 +08:00
parent ac5c0b2dd7
commit 7419130066
32 changed files with 11 additions and 10806 deletions
+11 -11
View File
@@ -1,11 +1,15 @@
# Local tooling / agent workspace
.claude/
.compare/
# Repository metadata and local agent workspaces
/.git/
/.claude/
/.compare/
# Project planning / internal docs
CLAUDE.md
# Project planning, internal docs, and local-only development files
/.specs/
/docs/
/tools/
/tests/
/CLAUDE.md
ask-*.md
docs/
!assets/css/routes/docs/
assets/css/routes/docs/*
!assets/css/routes/docs/toolbar-search-enhancements.css
@@ -16,7 +20,7 @@ assets/admin/node_modules/
assets/admin/.vite-temp/
# PHP installed dependencies
vendor/
/vendor/
# Temporary / recovery helpers
restore-helper-monoliths.php
@@ -25,7 +29,3 @@ restore-helper-monoliths.php
.DS_Store
Thumbs.db
*.log
# Generated contract audit artifact
tools/admin-contract-report.json
-35
View File
@@ -1,35 +0,0 @@
{
"schema": "aksrt.widget-reference.v1",
"captured_at": "2026-08-17",
"source_root": "C:\\Users\\Xing\\Desktop\\zibll",
"source_policy": {
"modern_widget_count": 37,
"include_core_product_marketing": true,
"exclude_bbs_branch": true,
"exclude_shop_branch": true,
"exclude_deprecated_widgets": true,
"branding": "AKSRT"
},
"files": [
{"path":"inc/widgets/widget-class.php","bytes":2123,"sha256":"92e8ae46c47cc6d9f46312113e182105b013c8073c29da61ef048680c9cc814b"},
{"path":"inc/widgets/widget-import.php","bytes":146458,"sha256":"dca6b442e35f1804a7fe9130e7df9ac4c11772c85774c1faa69ebacb48df9b62"},
{"path":"inc/widgets/widget-index.php","bytes":18826,"sha256":"6f15b1b73f909ea7eccf4697b5274a38512deae2e931ae7b0a33e48a5fcd8b25"},
{"path":"inc/widgets/widget-more.php","bytes":167708,"sha256":"07ea0c3260a9ee9701b1ce03795941fbbfb61c377ad71fe8f7820107763c5528"},
{"path":"inc/widgets/widget-posts.php","bytes":99676,"sha256":"20b04007270ecfd265adaaa287eb1a696566385b1f86379079be9664cfaea409"},
{"path":"inc/widgets/widget-produck.php","bytes":121409,"sha256":"77194592685b50e5428c6db4d0e2ca2cbc52d0ff4a81200f4e10b56e26159b9d"},
{"path":"inc/widgets/widget-slider.php","bytes":45007,"sha256":"a06a12500cb8ffbf57e496d9979eaed0edd545499ecb9c80d011d3ae49d2244e"},
{"path":"inc/widgets/widget-user.php","bytes":24655,"sha256":"97a4fde11d0a93af229e499f9b11c8d769b21b6b7500eac9bb94b4dd850f6b24"},
{"path":"inc/csf-framework/classes/widget-options.class.php","bytes":40070,"sha256":"ac7af19cc5d981eff933417430f45eb5bb0b1ff7dc8a0462238b79c9bab74722"},
{"path":"css/widget-set.css","bytes":5567,"sha256":"a11605b3e30d2ee372438c4242929461cbcc369fed1fa417a8046b4f15ae8a9f"},
{"path":"js/widget-set.js","bytes":35437,"sha256":"bdb9ac07039ec85f1ac90a92ca4496f0c625b7bdeea6a468bd9a30814e968d8f"}
],
"groups": {
"general": ["search","posts_toc","quote","icon_cards","video","embed","media_cards","icon_cover_cards","partners","combo_info","text_title","buttons","layout_gap","tag_cloud","recent_comments","links","notice"],
"content": ["term_cards","term_aggregation","hot_posts","post_pay","posts","tab_posts","oneline_posts"],
"knowledge_product": ["pricing_plans","reviews","faq","stats","highlights","feature_intro","tilt_cases"],
"slider": ["slider","slider_media_combo"],
"user": ["user_card","author_card","user_ranking","user_list"]
},
"excluded_legacy": ["widget_ui_main_tab_posts","widget_ui_mini_tab_posts","widget_ui_mini_posts","widget_ui_mian_posts","widget_ui_slider"],
"excluded_paths": ["inc/functions/bbs/widgets","inc/functions/shop/widgets"]
}
-40
View File
@@ -1,40 +0,0 @@
# Plan: AKSRT 小工具系统全量重写
> 用户确认基线:`C:\Users\Xing\Desktop\zibll`
> 目标:37 个现代核心小工具;排除论坛、商城和废弃旧版;无兼容、迁移或双路运行。
## Architectural Decisions
- 使用全新的 `AKSRT\WidgetSystem` 数据驱动内核,不引入 Codestar/CSF。
- 新实例 ID 统一为 `aksrt_widget_<slug>`,不注册或读取现有小工具 ID。
- 开发期新系统在未接入 bootstrap 前保持不可达;最终以单次注册器切换删除旧运行路径。
- 参考“专题”映射为 `category`/`post_tag`;付费类型只使用 AKSRT `DomainModel`。
- 最终树只保留一套小工具 CSS、JS、REST 与挂载区域实现。
## Phase 1 — 参考基线与原生内核
- [x] 冻结参考文件哈希、37 项现代组件清单和明确排除项。
- [x] 实现 `WidgetDefinition`、`WidgetRegistry`、字段 schema、通用字段与递归规范化。
- [x] 实现后台表单渲染、懒加载控制器及独立 admin CSS/JS 基础。
- [x] 增加注册表/字段契约审计并通过 PHP 语法与审计检查。
## Phase 2 — 37 个渲染器与动态数据接口
- [x] 实现 17 个通用/装饰组件。
- [x] 实现 7 个文章/知识付费组件和统一查询、卡片、分页链路。
- [x] 实现 7 个知识产品、2 个幻灯片和 4 个用户组件。
- [x] 实现统一 REST items 路由、一言路由及按需前端资源。
## Phase 3 — 原子切换、挂载区域与导入导出
- [x] 替换注册器并删除旧小工具类、旧公共层、旧路由和重复资源。
- [x] 重写 40 个静态挂载区域、动态页面区域及所有模板消费点。
- [x] 实现 `aksrt.widgets.v1` 导入导出和 5 套知识付费模板。
- [x] 核对默认 WordPress 小工具注销策略与区块编辑器/自定义器行为。
## Phase 4 — 自动化与视觉终验
- [x] 完成 schema、查询、可见性、导入导出、REST 与支付适配测试。
- [ ] 生成参考源码黄金 fixture,并完成 1440/1024/390、明暗模式截图矩阵。
- [ ] 验证交互、无障碍、控制台、网络和像素差异阈值。
- [x] 复跑 WordPress 项目检测、全量 PHP 检查和管理端契约审计。
@@ -1,80 +0,0 @@
# Plan: Frontend Modal Identity and Badges
## Architectural Decisions
- **Dependency**: This plan starts only after `frontend-modal-runtime` Phase 2 supplies `AksrtModalRuntime`, loader/cache/state contracts, telemetry marks, and the rollout reader.
- **Authentication snapshot**: `AuthApply` and `ApplicationRepository` expose one current-user snapshot containing verification state, latest application, form payload, allowed next action, and poll metadata. The REST handler and owner permission callback reuse that loaded application instead of querying by UUID twice.
- **External provider boundary**: Aliyun/Tencent validation and refresh stay behind `ApplicationService`. Provider HTTP work starts after the shell is visible, is cancellable/visibility-aware where possible, and cannot prevent the modal from opening.
- **Badge canonical path**: The JSON REST path is the only new badge read/write path. User-center and public author entry points share the same privacy-aware DTO; old HTML AJAX modal registrations and post-wear remote reopening are removed after cutover.
- **Privacy**: Cache keys include user/object/context and public-display mode. Authentication evidence remains owner/capability protected and no-store; public badge data follows `list_for_user()` privacy rules. No private DTO is persisted in browser storage.
- **Domain cache**: Badge owned/wear maps and authentication snapshots use short in-memory/request-context memoization. Any submit, review, wear, or status transition invalidates the affected tag before rendering the next view.
- **Admin boundary**: Existing native authentication review and badge management remain the operational surface. This plan does not move them into the Admin SPA.
---
## Phase 1 — Authentication status appears quickly and refreshes safely
**User stories**: US8, US9, US13.
### What to build
Replace the three independent SSR facade reads in `inc/helpers/user-center-renderers/profile-auth.php` with one typed snapshot from `src/User/AuthApply.php`/`src/User/ApplicationRepository.php`. Shape the current identity REST response around the same snapshot and reuse the application loaded by `owns_application()` in `src/Identity/RestController.php` inside get/refresh/manual handlers. Update `assets/js/routes/user-center-auth.js` to open the unified shell immediately, render snapshot state, defer provider refresh until the modal is open or the user explicitly refreshes, pause while hidden, apply backoff, and cancel stale refreshes. Preserve the existing create, manual evidence, provider callback, nonce, owner, capability, and sensitive no-store boundaries.
### Done when
- [ ] `profile-auth.php`, `AuthApply`, and `ApplicationRepository` produce one active/latest/verified snapshot per request; browser harness scenario `auth-snapshot-query-count` with `SAVEQUERIES` shows no repeated identical application SELECT for the active panel.
- [ ] `RestController` owner permission and get/refresh/manual handlers reuse one loaded application per request; `php -l src/User/AuthApply.php src/User/ApplicationRepository.php src/Identity/RestController.php src/Identity/ApplicationService.php` passes.
- [ ] Browser scenario `auth-modal-deferred-refresh` verifies shell-first rendering, no polling while the modal is closed, pause on hidden visibility, minimum interval/backoff, cancellation on close, and a useful error when the provider exceeds its timeout.
- [ ] Browser/API scenarios cover create, provider-pending, processing, verified, rejected/manual retry, evidence upload, duplicate submit, unauthorized owner, and sensitive evidence isolation; existing native review changes are reflected on the next snapshot.
---
## Phase 2 — Badge modal uses one cached JSON path
**User stories**: US10, US11, US12, US13.
### What to build
Refactor `src/User/Badges.php` so `list_for_user()`, `get_owned_map()`, and `get_wear()` share one context and do not rebuild owned/wear dictionaries repeatedly. Remove duplicate `Badges::init()` calls from `functions.php`. Update `assets/js/user-badges.js` to use the runtime loader, short cache, skeleton/error/retry states, and tag invalidation after wear. Replace the user-center and public author HTML/AJAX modal entry points in `inc/helpers/user-center-renderers/profile-header.php`, `templates/parts/user/author-hero.php`, and `assets/js/core/remote-modal.js` with the canonical privacy-aware JSON path. The old `wp_ajax(_nopriv)_aksrt_*badge*modal` handlers and post-wear remote reopen must disappear after this cutover.
### Done when
- [ ] `SAVEQUERIES` scenario `badge-read-query-count` shows one shared owned/wear/dictionary read context per badge response, and a repeated open for the same user/context uses the runtime cache without cross-user reuse.
- [ ] `assets/js/user-badges.js` displays skeleton/empty/error/retry states, re-renders the current list after wear, and no longer calls `AksrtRemoteModal.openRemote` after a successful wear; `node --check assets/js/user-badges.js assets/js/core/remote-modal.js` passes.
- [ ] `git grep -n "aksrt_user_badge_info_modal\|aksrt_single_badge_info_modal\|get_info_link"` shows no active target entry after the canonical path is enabled, and `functions.php` contains one badge singleton initialization.
- [ ] Browser/API scenarios cover no badges, earned/unearned/in-progress/detail, wear success/failure, public author privacy, self/manager visibility, unauthenticated access, REST nonce, and no leakage through cache keys.
---
## Phase 3 — Authentication and badges are independently releasable
**User stories**: US1, US2, US3, US11, US12, US13, US14.
### What to build
Connect identity and badge loaders to the per-domain rollout values from `frontend-modal-runtime`. Make successful authentication transitions, manual review results, badge wear, and badge management invalidate the appropriate runtime tags and update header/profile signals without a full reload. Exercise the two domains as separate gray releases, then remove the old domain-specific modal branches once each switch is permanently enabled.
### Done when
- [ ] The authentication and badge rollout values independently select the new loader path; toggling one does not change the other and does not create a second data store or dual-read branch.
- [ ] `php tools/audit-admin-contracts.php` exits 0 and `node --check assets/js/routes/user-center-auth.js assets/js/user-badges.js` passes after the final route/schema changes.
- [ ] Desktop/mobile scenarios verify shell/ready/error budgets, state refresh behind the modal, focus/keyboard behavior, no horizontal overflow, and no stale private data after logout or account switch.
- [ ] The gray-release checklist records shell/ready/error/status-mismatch metrics and demonstrates pausing the next rollout stage when identity or badge errors exceed the agreed threshold.
---
## Out of Scope
- 修改会员价格、会员权益、支付网关规则、订单账务、退款核算、认证资格/审核政策或徽章授予条件;这些规则只允许被新的流程正确调用和展示。
- 新增支付渠道、替换支付供应商、在浏览器保存支付凭证,或改变支付回调/异步结算机制。
- 新增公开认证资料库、向其他用户暴露认证文档、跨用户查看订单或徽章数据。
- 把订单、售后、认证审核或徽章运营工作台迁移进配置型 SPA;日常运营仍属于原生 WordPress 后台。
- 全站页面导航、用户中心信息架构、品牌设计系统或所有非弹窗页面的全面重做;本期只统一弹窗运行时和四类流程所需的轻量体验。
- 强制页面加载时预取所有弹窗数据、长期持久化敏感弹窗数据、建立订单/认证/徽章的第二套事实存储或长期保留旧新双路实现。
- 以新增兼容别名、旧 option 双读、静默降级或数据迁移层解决旧实现问题;流程切换后以新的规范化链路为唯一实现。
- 建立独立的数据仓库、复杂转化漏斗或第三方用户行为追踪系统;本期观测只服务于弹窗性能、错误和业务状态闭环。
## Open Questions
- 认证云供应商是否支持在请求取消/页面隐藏时安全停止远端刷新;若不支持,客户端只能停止轮询并让服务端回调/下次打开完成状态确认。
- 徽章公开作者入口最终是否必须支持未登录访问;默认保留现有公开可见 DTO,但统一路径仍必须执行业务层 privacy 过滤。
@@ -1,84 +0,0 @@
# Plan: Frontend Modal Loading Upgrade
This is the coordination plan for the three executable plans below. Implementation work should run in dependency order; the detailed phase checklists live in the linked plan files.
## Architectural Decisions
- **Execution split**: The shared modal runtime is a prerequisite. Identity/badges and orders/VIP can then proceed in parallel, each with its own domain API, UI, query optimization, and gray-release evidence.
- **One canonical runtime**: `AksrtModalRuntime` owns shell, skeleton, lifecycle, cancellation, cache, state, focus, and telemetry. Domain scripts only provide loaders and view models.
- **Canonical data boundaries**: Keep `aksrt/v1`, existing domain services, native operational admin, and `AKSRT_Settings` as the boundaries. No duplicate facts, compatibility reads, or long-lived old/new modal implementations.
- **No core table replacement**: Use existing order, identity, user-meta, payment, and badge data. The orders plan may add the required composite index through the existing DB migrator and schema-version process.
- **Staged release**: Roll out identity/badges first, then orders, then VIP payment. Each domain has an independent temporary switch and must meet the performance/error budget before the next switch is enabled.
---
## Phase 1 — Shared modal runtime is ready for domain work
**Detailed plan**: `.specs/plans/frontend-modal-runtime.md`
**User stories**: US1–3, US12–14.
### What to build
Complete both runtime phases: baseline/rollout observability and the cancellable shell/loader/cache/state contract. This phase is a foundation exception because all four existing flows currently own incompatible modal shells and request lifecycles; the runtime is demoable through the existing remote-modal entry path before domain JSON migrations begin.
### Done when
- [ ] `.specs/plans/frontend-modal-runtime.md` Phase 1 and Phase 2 checklists are checked, `php tools/audit-admin-contracts.php` exits 0, and `node --check` passes for all runtime JS.
- [ ] Browser scenarios `modal-baseline`, `modal-shell-lifecycle`, and `modal-cache-dedupe` show shell-first behavior, cancellation, deduplication, invalidation, focus restoration, and cold/warm metrics.
- [ ] `git grep` inventory identifies every old remote-modal caller and labels the remaining domain migration owner before either business plan starts.
---
## Phase 2 — Identity/badges and orders/VIP have vertical slices
**Detailed plans**: `.specs/plans/frontend-modal-identity-badges.md` and `.specs/plans/frontend-modal-orders-vip.md`
**User stories**: US4–11, US13.
### What to build
Run the identity/badge plan and orders/VIP plan in parallel after Phase 1. Each plan must finish its read path, write path, query/API/UI integration, permission handling, and browser regression scenarios before its gray-release phase.
### Done when
- [ ] `.specs/plans/frontend-modal-identity-badges.md` Phases 1–2 and `.specs/plans/frontend-modal-orders-vip.md` Phases 1–2 are checked with their named `SAVEQUERIES`, PHP, JS, API, and browser scenarios passing.
- [ ] Identity/badge and order/VIP loaders use independent cache/invalidation tags and cannot reuse another user or domain's payload.
- [ ] The native order/after-sale and identity/badge operational surfaces remain usable; no operational workbench is added to the Admin SPA.
---
## Phase 3 — All domains are gray-released and old paths are removed
**Detailed plans**: the Phase 3 sections of all three detailed plans.
**User stories**: US1–14.
### What to build
Release identity/badges, then orders, then VIP payment using independent switches. Compare shell/ready/error/status-mismatch metrics against the PRD budgets, pause the next domain on regression, update user-center summaries after successful writes, and remove old HTML/AJAX/domain-specific modal calls after each domain is permanently enabled.
### Done when
- [ ] The Phase 3 checklist in each detailed plan is checked, with `php tools/audit-admin-contracts.php`, changed-file `php -l`, and changed-file `node --check` all passing.
- [ ] Desktop and mobile evidence shows approximately 100ms shell visibility, 200ms cache-hit content, p75 ≤800ms cold content, and p95 ≤1.5s initial budget, with errors/status mismatches recorded per domain.
- [ ] `git grep` confirms no active target old HTML badge modal, duplicate VIP purchase/poll coordinator, hard-coded order REST root, or bypassing domain loader remains after cutover.
- [ ] The final release record identifies the removal version for temporary rollout settings and confirms no compatibility alias, old option double-read, second fact store, or long-lived dual implementation remains.
---
## Out of Scope
- 修改会员价格、会员权益、支付网关规则、订单账务、退款核算、认证资格/审核政策或徽章授予条件;这些规则只允许被新的流程正确调用和展示。
- 新增支付渠道、替换支付供应商、在浏览器保存支付凭证,或改变支付回调/异步结算机制。
- 新增公开认证资料库、向其他用户暴露认证文档、跨用户查看订单或徽章数据。
- 把订单、售后、认证审核或徽章运营工作台迁移进配置型 SPA;日常运营仍属于原生 WordPress 后台。
- 全站页面导航、用户中心信息架构、品牌设计系统或所有非弹窗页面的全面重做;本期只统一弹窗运行时和四类流程所需的轻量体验。
- 强制页面加载时预取所有弹窗数据、长期持久化敏感弹窗数据、建立订单/认证/徽章的第二套事实存储或长期保留旧新双路实现。
- 以新增兼容别名、旧 option 双读、静默降级或数据迁移层解决旧实现问题;流程切换后以新的规范化链路为唯一实现。
- 建立独立的数据仓库、复杂转化漏斗或第三方用户行为追踪系统;本期观测只服务于弹窗性能、错误和业务状态闭环。
## Open Questions
- 部署是否已有统一的浏览器 RUM/Server-Timing 收集端;默认先提供 marks、响应头和现有部署观测,不新增主题指标表。
- 复合订单索引与临时 rollout 设置的删除版本需要在最终发布记录中确认。
-83
View File
@@ -1,83 +0,0 @@
# Plan: Frontend Modal Orders and VIP
## Architectural Decisions
- **Dependency**: This plan starts only after `frontend-modal-runtime` Phase 2 supplies the canonical shell, loader, cache/state, cancellation, and telemetry contracts.
- **Order view model**: `UserOrders` and `OrderCenterRest` return a minimal list/detail view model with server-derived `allowed_actions`, status version, updated time, and invalidation tags. The front end never derives payment/cancel/after-sale eligibility from local status.
- **Order query shape**: Combine list/stat work where possible, batch resource decoration, and add the missing `user_id/status/created_at` index through `AKSRT_DB_Migrator` with a schema version bump. No new order table or duplicate fact store.
- **Reconciliation boundary**: `Gateway::reconcile_order()` remains the authority for pending external orders but runs after the detail shell is visible and only when needed. A normal completed/non-external detail must not wait for remote provider HTTP.
- **VIP JSON path**: Replace server-generated full HTML from `VipServiceModal` with a minimal JSON view model. `pay-vip.js` and the VIP shop in `credits.js` share one purchase coordinator; catalog, member state, order object, and payment attempt are reused within a request.
- **Payment safety**: Payment gateways and `PaymentOrchestrator` stay server-side. Client polling is bounded, backoff-aware, visibility-aware, cancellable, and terminated by a terminal order state. Core price, eligibility, ledger, callback, and VIP fulfillment rules are unchanged.
- **API configuration**: All order/VIP requests use the globally injected API root and REST nonce. Remove `order-center.js` hard-coded REST construction and do not add compatibility route aliases.
- **Release**: Order and VIP rollout switches are independent. Old paths may remain inactive while a domain is not released, but each domain has one canonical implementation after its switch is permanently enabled.
---
## Phase 1 — Order details appear before remote state reconciliation
**User stories**: US6, US7, US11, US13.
### What to build
Optimize `src/Payment/UserOrders.php` and `src/Payment/OrderRepositoryCore.php` so the order list does not perform a fixed list plus four COUNT queries and resource rows do not cause post/meta N+1 work. Add the composite order index in `src/Core/DBMigratorPart2.php` and bump the schema version through the existing migrator. Shape `src/Payment/OrderCenterRest.php` responses around minimal list/detail view models and preserve owner checks. Update `src/Payment/Gateway.php` so pending external reconciliation is deferred or represented as a refresh state after the shell is visible. Replace `order-center.js` hard-coded `siteUrl/wp-json` construction with injected `apiUrl/restUrl`, then move detail, pay, cancel, refresh, and invalidation through `AksrtModalRuntime`.
### Done when
- [ ] `SAVEQUERIES` scenario `order-list-query-count` verifies that the normal first page no longer issues list + four independent COUNT queries and that resource-order decoration has no per-row post/meta N+1; empty/overflow pages remain correct.
- [ ] `DBMigratorPart2` defines the `user_id/status/created_at` query index and the active schema version is bumped; after migration, `SHOW INDEX` or the WordPress DB harness verifies the index.
- [ ] Browser scenario `order-detail-shell` verifies summary skeleton first, ordinary detail does not wait for provider HTTP, pending reconciliation reports refresh state, and detail/status/list update after pay/cancel/after-sale responses.
- [ ] `order-center.js` reads the injected API root, and `php -l src/Payment/UserOrders.php src/Payment/OrderRepositoryCore.php src/Payment/OrderCenterRest.php src/Payment/Gateway.php src/Core/DBMigratorPart2.php` plus `node --check assets/js/order-center.js` pass.
- [ ] API/browser cases cover another-tab status changes, unauthorized order numbers, duplicate action clicks, timeout/retry, pagination, and no cross-user cache reuse.
---
## Phase 2 — VIP purchase reaches payment result through one coordinator
**User stories**: US4, US5, US11, US13.
### What to build
Change `src/Content/VipServiceRest.php` and `src/Content/VipServiceModal.php` from full HTML modal responses to a minimal JSON view model that contains the server-calculated catalog, member state, payment methods, allowed actions, and version metadata. Refactor `src/Content/VipServicePurchase.php` and `src/Payment/PaymentOrchestrator.php` to reuse the catalog/member/order context instead of reading the same data after create and again during pay. Consolidate the remote modal path in `assets/js/pay-vip.js` and the embedded shop path in `assets/js/credits.js` into one purchase/poll coordinator; keep the existing `credits-vip.php` and `vip-tab.php` entry points but route their actions through it. Replace the fixed 40 × 3-second loop with bounded backoff, visibility handling, abort, and terminal-state invalidation.
### Done when
- [ ] `SAVEQUERIES` scenario `vip-purchase-query-count` shows no duplicate member-level/catalog/order reads for modal load and purchase create/pay; `VipServiceRest` reuses the catalog user level instead of recomputing it.
- [ ] `pay-vip.js` and `credits.js` share one VIP purchase/poll coordinator; `git grep -n "setInterval\|pay/content/order\|pay-vip/modal" assets/js/pay-vip.js assets/js/credits.js` shows no second fixed 40 × 3-second loop or old full-HTML injection path.
- [ ] Browser scenario `vip-purchase-flow` covers not-enrolled, active, expired, renewal/upgrade, package/price, points exchange, gateway redirect, cancel, failure, async success, duplicate click, and return to order detail.
- [ ] Payment response inspection confirms no gateway secret reaches the browser; existing gateway, order callback, VIP fulfillment, core price, and entitlement rules pass regression scenarios.
- [ ] `php -l src/Content/VipServiceRest.php src/Content/VipServiceModal.php src/Content/VipServicePurchase.php src/Payment/PaymentOrchestrator.php` and `node --check assets/js/pay-vip.js assets/js/credits.js` pass.
---
## Phase 3 — Orders and VIP are independently gray-releasable
**User stories**: US1–US7, US11–US14.
### What to build
Connect order and VIP coordinators to the independent rollout switches, invalidate user-center order/VIP summaries after successful writes, and verify native order/after-sale operations remain the operational backend. Release orders first, measure shell/ready/error/status mismatch, then release VIP payment only after the order slice is stable. Remove each domain's old modal calls once its switch is permanently enabled.
### Done when
- [ ] Order and VIP rollout values independently select the new loaders; toggling one does not change the other or create a second order/member fact store.
- [ ] `php tools/audit-admin-contracts.php` exits 0, all changed PHP files pass `php -l`, and `node --check assets/js/order-center.js assets/js/pay-vip.js assets/js/credits.js` passes.
- [ ] Desktop/mobile scenarios verify shell/ready budgets, cache-hit and cold-load p75/p95, payment/order state synchronization, permission errors, retry behavior, focus, and no horizontal overflow.
- [ ] Gray-release evidence shows orders reaching the agreed 100ms shell, 200ms cache-hit, p75 ≤800ms, and p95 ≤1.5s initial budgets before VIP is enabled; rollback is demonstrated by disabling only the affected domain switch.
---
## Out of Scope
- 修改会员价格、会员权益、支付网关规则、订单账务、退款核算、认证资格/审核政策或徽章授予条件;这些规则只允许被新的流程正确调用和展示。
- 新增支付渠道、替换支付供应商、在浏览器保存支付凭证,或改变支付回调/异步结算机制。
- 新增公开认证资料库、向其他用户暴露认证文档、跨用户查看订单或徽章数据。
- 把订单、售后、认证审核或徽章运营工作台迁移进配置型 SPA;日常运营仍属于原生 WordPress 后台。
- 全站页面导航、用户中心信息架构、品牌设计系统或所有非弹窗页面的全面重做;本期只统一弹窗运行时和四类流程所需的轻量体验。
- 强制页面加载时预取所有弹窗数据、长期持久化敏感弹窗数据、建立订单/认证/徽章的第二套事实存储或长期保留旧新双路实现。
- 以新增兼容别名、旧 option 双读、静默降级或数据迁移层解决旧实现问题;流程切换后以新的规范化链路为唯一实现。
- 建立独立的数据仓库、复杂转化漏斗或第三方用户行为追踪系统;本期观测只服务于弹窗性能、错误和业务状态闭环。
## Open Questions
- 订单/支付环境是否提供现有 Server-Timing 或网关状态监控收集端;默认使用响应头和现有部署观测,不新增主题内指标表。
- 复合订单索引在生产大表上的部署窗口需要由发布流程确认;迁移必须遵循现有 `AKSRT_DB_Migrator` 原子升级约束。
-72
View File
@@ -1,72 +0,0 @@
# Plan: Frontend Modal Runtime
## Architectural Decisions
- **Canonical runtime**: `assets/js/core/modal-runtime.js` is the single `AksrtModalRuntime` seam for shell creation, lifecycle, focus, loading, error, cancellation, and refresh. `assets/js/core/modal-triggers.js` only binds the new declarative trigger contract; no legacy modal alias or compatibility bridge exists.
- **Loader contract**: A loader receives a domain context and abort signal and returns a minimal view model plus render/update callbacks. `AksrtModalRuntime.openRemote({responseType: 'json', renderData})` owns the request, cache, cancellation, and lifecycle while the domain renderer owns only view-model markup. It may read only the active modal's data.
- **Cache contract**: Use in-memory, user-scoped cache entries with short TTLs, in-flight request deduplication, explicit invalidation tags, and complete clearing on logout/account switch. Do not persist private modal payloads in localStorage.
- **State contract**: All domains use the same `idle`, `loading`, `ready`, `empty`, `submitting`, `success`, `error`, `unauthorized`, and `stale` states. Skeletons match the final layout and never represent business truth.
- **API boundary**: Existing `aksrt/v1` domain routes remain the service boundary; HTML modal responses are replaced domain by domain with minimal JSON view models. Every changed REST route keeps a `permission_callback`; AJAX is not a new data path.
- **Request adapter**: Add one thin `AksrtRequest` adapter for the injected REST root, nonce headers, JSON/error envelopes, 401 handling, and abort signals. Domain files stop maintaining their own REST-root and response-parsing variants.
- **Rehydrate and feedback**: Every runtime-rendered HTML response calls `AksrtFrontend.rehydrate(body, 'modal')`; shared feedback calls the existing callable `AksrtToast` API directly. There is one binding path and no compatibility fallback.
- **Rollout**: Add a temporary per-domain rollout section under the existing Tools configuration surface, read through `AKSRT_Settings`. One canonical new implementation is enabled per domain; the switch is for staged release, not a compatibility data layer.
- **Telemetry**: Use browser `performance.mark/measure` and server `Server-Timing`/existing deployment observability by default. Do not add a private payload or payment/identity-content event store in this foundation plan.
- **Build boundary**: Frontend assets are plain theme JavaScript, not the Admin SPA bundle. Run `node --check` for changed JS and run the admin contract audit only when the rollout schema/controller changes.
---
## Phase 1 — Modal latency is measurable and rollout is controllable
**User stories**: US1, US2, US3, US14.
### What to build
Create the typed rollout reader and configuration contract, then add a small telemetry module that marks the existing VIP, order, identity, and badge open flows without changing their business behavior. Establish cold-cache and warm-cache baselines for click-to-shell, click-to-first-content, click-to-ready, request count, SQL count, response size, and error outcome. The rollout reader must be consumed through `AKSRT_Settings` and the existing Tools schema/controller path, not a raw option read.
Primary integration points:
- `AKSRT_Settings` and the Tools schema/controller for `frontend_modal_rollout`.
- `assets/js/core/modal-telemetry.js` (new) and the existing feature enqueue/config injection in `src/Core/ScriptsPart1c.php` and the surface script enqueue modules.
- Existing flows in `assets/js/pay-vip.js`, `assets/js/order-center.js`, `assets/js/routes/user-center-auth.js`, and `assets/js/user-badges.js` for baseline marks only.
### Done when
- [x] The Tools schema, REST response, and `AKSRT_Settings` reader expose independent VIP, order, identity, and badge rollout values; `php tools/audit-admin-contracts.php` exits 0.
- [x] `assets/js/core/modal-telemetry.js` emits click, shell, first-content, ready, action-start, action-end, and error marks without including payment credentials, order contents, or identity evidence.
- [ ] Browser scenario `modal-baseline` records cold and warm measurements for all four current entry points, and the baseline artifact names the request/SQL counts used for later comparison.
- [x] `node --check assets/js/core/modal-telemetry.js assets/js/pay-vip.js assets/js/order-center.js assets/js/routes/user-center-auth.js assets/js/user-badges.js` passes.
---
## Phase 2 — Any modal opens with a cancellable shell and reusable cache
**User stories**: US1, US2, US3, US12, US13.
### What to build
Build the canonical `AksrtModalRuntime` in `assets/js/core/modal-runtime.js` with focused request, cache, state, and trigger modules. The runtime must show a skeleton before data, use the single new modal trigger contract, abort requests on close/replacement, deduplicate reads, invalidate explicit tags, restore focus, support keyboard/overlay close, mobile bottom-sheet behavior, retryable errors, and call `AksrtFrontend.rehydrate` after every HTML render. All domain callers use the canonical request/runtime/Toast APIs directly; no legacy alias, second host, or migration compatibility path is retained.
### Done when
- [x] `assets/js/core/modal-runtime.js` exposes one canonical `AksrtModalRuntime.open/close/refresh` lifecycle, `AksrtRequest` owns REST root/nonce/envelope handling, and no domain script creates a second global modal host.
- [x] `node --check assets/js/core/modal-triggers.js assets/js/core/request.js assets/js/core/modal-runtime.js assets/js/core/modal-cache.js assets/js/core/modal-state.js assets/js/pay-vip.js` passes.
- [ ] Browser scenario `modal-shell-lifecycle` verifies shell visibility within the 100ms initial budget, skeleton/error/retry states, ESC and overlay close, focus return, request cancellation, mobile bottom-sheet behavior, callable Toast, and `AksrtFrontend.rehydrate(body, 'modal')` after runtime HTML.
- [ ] Browser scenario `modal-cache-dedupe` verifies one read for concurrent/repeated opens, invalidation after a successful write, no reuse after logout/account switch, and one pay-vip binding; `git grep -n "AksrtRemoteModal\|RefreshModal\|data-aksrt-remote-modal" -- '*.js' '*.php' '*.css'` returns no matches.
---
## Out of Scope
- 修改会员价格、会员权益、支付网关规则、订单账务、退款核算、认证资格/审核政策或徽章授予条件;这些规则只允许被新的流程正确调用和展示。
- 新增支付渠道、替换支付供应商、在浏览器保存支付凭证,或改变支付回调/异步结算机制。
- 新增公开认证资料库、向其他用户暴露认证文档、跨用户查看订单或徽章数据。
- 把订单、售后、认证审核或徽章运营工作台迁移进配置型 SPA;日常运营仍属于原生 WordPress 后台。
- 全站页面导航、用户中心信息架构、品牌设计系统或所有非弹窗页面的全面重做;本期只统一弹窗运行时和四类流程所需的轻量体验。
- 强制页面加载时预取所有弹窗数据、长期持久化敏感弹窗数据、建立订单/认证/徽章的第二套事实存储或长期保留旧新双路实现。
- 以新增兼容别名、旧 option 双读、静默降级或数据迁移层解决旧实现问题;流程切换后以新的规范化链路为唯一实现。
- 建立独立的数据仓库、复杂转化漏斗或第三方用户行为追踪系统;本期观测只服务于弹窗性能、错误和业务状态闭环。
## Open Questions
- 部署环境是否已有 RUM/Server-Timing 收集端?默认先交付浏览器 marks 与响应头,不在主题内新增持久化指标表。
- `frontend_modal_rollout` 的临时开关在所有领域切换完成后的删除版本,需要在发布记录中确定。
@@ -1,61 +0,0 @@
# Plan: 用户中心侧栏 VIP 入口卡片
## Architectural Decisions
Durable decisions that apply across all phases:
- **现有会员导航契约**:完整点击卡片复用现有用户中心 `tab=vip` URL;不打开购买弹窗,也不新增客户端标签切换逻辑。
- **现有配置决定可见性**:仅当现有 VIP 配置中至少一个等级启用时渲染入口;不新增设置项、管理员例外或不可用占位。
- **现有数据决定展示状态**:复用既有 VIP 等级、过期等级和到期状态读取能力及文案语义;不增加数据字段、迁移或新的会员状态计算。
- **侧栏组合层决定顺序**:侧栏组合器将独立 VIP 入口渲染器放在统计信息与账户资产之间;渲染器封装卡片状态和输出。
- **用户中心样式保持局部**:会员横卡样式限定在用户中心侧栏范围,沿用现有响应式侧栏抽屉,不新增移动端内容区入口。
- **单一会员入口位置**:删除资产页中仅作导航用途的 VIP 入口,保留独立“我的会员”页面及其购买、续费、升级、兑换和支付能力。
---
## Phase 1 — 会员入口可从侧栏进入
**User stories**: 1、2、3、4、7
### What to build
在统计信息和账户资产之间接入独立的服务端 VIP 入口渲染器。入口根据现有配置和当前用户会员状态,展示未开通引导、有效会员等级与既有到期状态,或过期状态;整个卡片通过现有用户中心 URL 进入“我的会员”页面。此阶段先交付完整行为与基础结构,视觉增强和资产页去重留到下一阶段。
### Done when
- [x] `inc/helpers/user-center-renderers/profile-sidebar.php` 在 `sidebar-stats.php` 后、`sidebar-commerce.php` 前加载独立的侧栏 VIP 入口渲染器。
- [x] 新的侧栏 VIP 入口渲染器复用现有 VIP 配置、会员等级、过期等级和到期状态,并在没有启用任何 VIP 等级时不输出 HTML。
- [x] [agent:test-auditor] 人工检查渲染器的四种服务端输出:未开通显示权益引导;有效会员显示等级和既有状态文案;过期会员显示过期状态;VIP 未启用时无入口,并确认未引入新的精确日期格式。
- [x] 新入口的完整可点击区域使用 `aksrt_get_user_center_tab_url('vip')` 生成目标地址,人工检查所得 URL 可进入 `tab=vip`。
- [x] 运行 `php tools/audit-admin-contracts.php`,命令退出码为 0 且报告为 `errors=0`。
---
## Phase 2 — 会员横卡完成视觉落地并消除重复入口
**User stories**: 5、6
### What to build
为侧栏 VIP 入口增加与账户资产双卡明显区分的金色或会员感渐变视觉、会员图标、动态状态文本、右侧箭头及交互反馈,并确保其随既有侧栏在移动端抽屉中正确展示。同时删除资产页内容区中重复的 VIP 导航卡,保持余额、积分、签到和记录功能不变。
### Done when
- [x] `assets/css/routes/user-center/shell.css` 包含限定在用户中心侧栏的 VIP 卡样式,覆盖会员渐变、图标、状态文本、右侧箭头、完整卡片悬停/聚焦状态和窄屏文本约束。
- [x] `inc/helpers/user-center-renderers/profile-credits.php` 不再输出 `uc-vip-assets-entry` 导航卡,文件中原有余额、积分、签到和资产记录组合逻辑仍被保留。
- [ ] [agent:test-auditor] 人工在桌面宽度验收“统计信息 → VIP 入口 → 账户资产”的顺序、整卡跳转和 VIP 卡与余额/积分双卡的视觉区分。
- [ ] [agent:test-auditor] 人工在既有移动侧栏抽屉验收相同顺序、整卡可点击性、状态文本无溢出,并确认页面内容区没有额外复制 VIP 入口。
- [x] 运行 `php tools/audit-admin-contracts.php`,命令退出码为 0 且报告为 `errors=0`。
## Out of Scope
- 修改“我的会员”页面的布局、文案、套餐卡、权益内容或购买流程。
- 修改支付弹窗、支付渠道、续费、升级、兑换、卡密兑换或订单逻辑。
- 新增、修改或迁移会员数据、会员状态算法、到期日期格式、数据库表或 REST API。
- 新增后台会员配置、侧栏开关、管理员例外显示规则或运营统计。
- 记录 VIP 入口的曝光、点击或转化数据。
- 在移动端内容区额外置顶或复制 VIP 入口。
## Open Questions
无。
-104
View File
@@ -1,104 +0,0 @@
# Plan: 用户中心创作中心与文章价格表
## Architectural Decisions
Durable decisions that apply across all phases:
- **唯一入口使用 `posts`**:将现有“我的文章”面板升级为“创作中心”,不新增重复的 `creator` 面板或路由。
- **复用用户中心壳**:继续使用 `AKSRT\\User\\Center`、现有 `?tab=posts`、REST panel、运行时配置、侧栏和移动端导航。
- **复用现有投稿页**:新建和编辑文章继续跳转现有 `page-submit.php`,不把 TinyMCE 编辑器复制到用户中心。
- **价格使用唯一业务通路**:单篇和批量价格修改必须复用 `FrontSubmitPay::save_pay_meta()`,或先将其抽取为唯一共享价格服务;不复制价格校验逻辑。
- **价格数据模型不变**:继续使用当前 `aksrt_pay` 及统一付费元数据,订单、购买和收益逻辑无需修改。
- **服务端权限为最终边界**:每篇文章重新检查作者归属、编辑权限和 `new_post_pay` 能力;前端选择结果不可信。
- **批量更新采用逐项结果**:服务端返回每篇文章的成功/失败结果,前端不得把部分失败伪装成整体成功。
- **复杂付费资源继续走完整编辑器**:价格表优先处理价格和基础付费字段;付费下载资源等复杂结构不重复实现。
---
## Phase 1 — 创作中心文章总览可端到端使用
**用户故事**:US1、US2、US3、US8
### What to build
升级现有 `posts` 面板的名称、信息层级和文章管理体验:增加创作中心标题及新建文章主操作,整合状态概览、文章列表、编辑/预览/删除操作,并复用现有文章查询、状态筛选、权限和用户中心 REST 加载机制。
### Done when
- [ ] `src/User/Center.php`、用户中心渲染映射和入口文案仍只注册/渲染 `posts`,不存在第二个重复的 `creator` 文章管理面板。
- [ ] `profile-posts.php` 展示创作中心标题、新建文章入口、发布/草稿/待审/私密/全部状态数量,并继续使用现有状态筛选参数。
- [ ] 文章列表展示标题、封面或占位图、状态、更新时间、付费摘要和按权限过滤后的操作按钮。
- [ ] 编辑、预览和删除按钮分别遵循现有 `can_edit_post()`、文章可访问性和 `can_delete_post()` 判断;删除继续使用现有 nonce/AJAX 语义。
- [ ] 通过用户中心 REST panel 和 `?tab=posts&post_status=...` 深链访问时,服务端和客户端均能激活正确面板与筛选状态。
---
## Phase 2 — 新建与编辑文章工作流连通
**用户故事**:US3、US8、US9
### What to build
将创作中心的新建/编辑操作与现有前台投稿工作台完整连通,保留 zibll 参考中的双栏创作层级,同时只复用 AKSRT 已有的编辑器、文章参数、封面、草稿、审核和付费配置能力。保存或提交后回到创作中心时保留上下文并反馈结果。
### Done when
- [ ] 新建按钮使用 `aksrt_get_new_post_url()`,编辑按钮传递现有文章 ID,并由 `FrontSubmit::can_submit()`/`can_edit_post()` 控制可用性。
- [ ] `page-submit.php` 的标题、编辑器、封面、分类、专题、高级筛选、标签、付费侧栏和操作区保持现有 AJAX、nonce 和权限链路。
- [ ] 草稿保存、提交审核/发布、编辑已有文章和离开页面警告均继续由现有 `FrontSubmit` 与 `front-submit.js` 处理。
- [ ] 从创作中心进入编辑器后返回时,能够恢复文章列表的状态筛选或分页上下文,并显示保存/提交结果。
- [ ] 运行 PHP lint 及项目可执行的前端静态检查,确认本阶段未破坏现有投稿页面。
---
## Phase 3 — 文章价格表支持单篇与批量调价
**用户故事**:US4、US5、US6、US9
### What to build
在创作中心增加文章价格表,支持免费/付费阅读/付费资源摘要、现金/积分、普通价格和 VIP 价格展示;实现单篇快速编辑及“选择文章 → 统一价格方案 → 预览 → 确认”的批量调价流程。必要时先抽取共享价格服务,再接入单篇和批量请求。
### Done when
- [ ] 价格表按当前用户可管理文章展示免费、付费类型、现金/积分、普通价、VIP1/VIP2 及购买限制摘要;关闭付费时显示“免费”而不是误显示为 0。
- [ ] 单篇编辑支持免费开关、支付模式、普通价、VIP1/VIP2、付费类型、购买限制和登录限制;不修改正文、封面或分类。
- [ ] 单篇保存请求验证登录、作者归属、编辑能力和 `new_post_pay`,并复用 `FrontSubmitPay::save_pay_meta()` 或唯一共享价格服务。
- [ ] 批量工具支持多选、免费/现金/积分价格方案、影响数量预览、确认和逐项成功/失败反馈;请求大小受控。
- [ ] 最低/最高价、免费规则、VIP1/VIP2 层级、折扣计算、支付模式和付费类型校验与投稿页一致,且价格失败时保留输入值。
---
## Phase 4 — 移动端、可访问性与完整验收
**用户故事**:US7、US8、US9
### What to build
完成创作中心文章列表和价格表的响应式布局、键盘交互、加载/空数据/失败/部分成功反馈,以及接口和安全边界验收。确保桌面端高密度工作台和移动端卡片/展开式编辑体验都可用。
### Done when
- [ ] 在窄屏下文章列表和价格表转换为卡片或垂直字段布局,无页面级横向溢出,筛选、批量选择、编辑和确认操作均可通过触控完成。
- [ ] 加载、空数据、请求失败和批量部分失败均有明确文本反馈;失败时不会显示误导性的 0 或清空用户输入。
- [ ] 关键按钮、价格输入、状态标签和确认弹窗具备语义化结构、可见焦点和键盘操作能力,状态不只依赖颜色。
- [ ] 所有新增 REST/AJAX 入口具备 `permission_callback` 或 nonce;输入使用项目既有清洗与价格归一化,输出完成转义。
- [ ] `php tools/audit-admin-contracts.php` 退出码为 0 且无错误,并完成项目可执行的 PHP lint、前端构建或对应静态检查。
---
## Out of Scope
- 不复制 zibll 的 PHP 函数、字段名、商品规格模型、订单模型或主题配置 API。
- 不在用户中心新增独立的 `creator` 页面、第二套文章管理 tab 或第二套前台路由;`posts` 是唯一文章管理入口,界面标题可升级为“创作中心”。
- 不把完整 TinyMCE 编辑器复制嵌入用户中心;新建/编辑继续跳转 `aksrt_get_new_post_url()` 对应的投稿页面。
- 不修改文章购买、订单结算、收益分成、优惠码、付费下载实际业务规则。
- 不新增旧选项双读、旧路由兼容层、数据迁移或新旧 UI 并存实现。
- 不把日常创作工作台迁入配置型 Admin SPA。
## Open Questions
- 默认将 `posts` 面板的显示标题改为“创作中心”,内部 tab key 保持 `posts`;若产品希望 URL 也改为 `creator`,需在实现前明确选择,因为项目规则不允许长期保留两个重复入口。
- 默认批量调价采用“选择文章 → 统一价格方案 → 预览 → 确认”的模式;不包含按比例涨跌或价格模板市场。后续如需比例调整,应另立需求。
- 默认价格表只覆盖安全可复用的价格字段;付费下载资源等复杂结构继续通过完整投稿编辑器维护,除非实现阶段确认现有服务可提供等价的批量契约。
- 默认创作中心不内嵌完整编辑器;若需要无跳转编辑体验,应另行评估 TinyMCE 生命周期、自动保存和页面性能。
-136
View File
@@ -1,136 +0,0 @@
# Plan: 用户创作收益中心
## Architectural Decisions
以下决策贯穿全部阶段:
- **作者收益与推广返佣分离**:创作中心只读取付费文章作者收益,不读取 `Rebate` 推广返佣数据。
- **收益记录以订单为源**:支付成功且权益发放完成后,每笔符合条件的付费订单最多生成一条作者收益记录。
- **收益生成时快照**:保存原始支付金额、货币、作者分成比例和适用会员等级,后续配置或等级变化不影响历史记录。
- **现金与积分彻底分离**:分别记录、统计、结算和写入流水;不换算、不合并,退款不改变作者收益。
- **统一认证边界**:前台作者 ID 始终取当前登录用户,后台接口复用支付中心权限模型;所有 REST 路由必须提供 `permission_callback`。
- **原子余额操作**:现金和积分余额只能通过现有余额服务变更,不直接修改用户 meta。
- **最终结构替换**:新增或调整表结构时直接更新最终 schema 版本,不增加旧字段双读、迁移层或兼容路径。
- **后台职责保持分层**:配置进入 Vue SPA 的“支付&付费 > 创作中心”,收益运营继续使用 Native 支付中心现有“创作分成”页面。
- **创作开关是完整功能门控**:关闭后隐藏前台入口、停止新收益生成、停止自动和手动结算;历史记录仅保留后台查询。
---
## Phase 1 — 付费订单产生可追踪作者收益
**用户故事**:1、2、3、4、8、9、15
### What to build
建立作者收益的最终数据结构和统一服务入口,接入支付成功且权益发放完成后的订单流程。实现普通用户、一级会员、二级会员三档比例,现金订单与积分订单分别生成收益记录,并保证支付回调重复执行时不会重复生成。
同时完成创作分成基础配置的后端读取与校验,使收益服务能够使用独立于其他会员分成配置的比例和功能开关。
### Done when
- [x] `src/Core/DBMigratorPart2.php` 或最终负责收益表 schema 的文件包含作者、订单、文章、购买者、货币、原始支付金额、比例快照、会员等级、作者收益金额、状态和时间字段,并同步提升 schema 版本。
- [x] `src/Payment/Revenue.php` 或拆分后的作者收益服务实现 `create_from_paid_order(order)`,且同一订单重复调用只保留一条作者收益记录。
- [x] 现金订单仅创建现金收益记录,积分订单仅创建积分收益记录;作者购买自己的付费文章不会创建收益记录。
- [x] 普通用户、一级会员、二级会员分别按 60%、70%、80% 计算,并把生成时比例保存到收益记录。
- [x] 支付成功但权益未完成时不创建收益;权益完成后的支付流程能够创建待结算记录。
- [x] 创作分成关闭时,收益生成入口被跳过;相关收益服务测试覆盖上述场景。
---
## Phase 2 — 收益周期结算与退款保持
**用户故事**:7、10、11、12、13、14
### What to build
实现待结算收益按配置周期自动结算、后台手动逐笔/批量重试。现金收益写入现金余额,积分收益写入积分余额;退款或取消订单不再改变作者收益,所有入账操作具备幂等性。
### Done when
- [x] `src/Payment/Revenue.php` 或拆分后的结算服务实现 `settle(record_ids, actor)`,现金和积分分别调用对应原子余额服务并写入对应流水。
- [x] `settlement scheduler` 按站点时区处理达到配置结算周期的待结算记录;结算周期结束后自动入对应钱包,不再配置延迟小时或自动/手动模式。
- [x] 同一收益被重复结算、并发结算或重复定时扫描时,只能成功入账一次。
- [x] 订单退款或取消不会取消 pending 收益、冲正 settled 收益或产生待追缴记录;收益只按正常周期进入对应钱包。
- [ ] 服务测试覆盖现金/积分比例快照、周期边界、自动结算、手动重试、退款后收益保持和并发幂等场景。
---
## Phase 3 — 用户中心作者收益页面可用
**用户故事**:1、2、3、4、5、6、7、15、16
### What to build
将用户中心“创作中心”改造成作者收益页面。实现现金/积分双货币收益卡片、待结算与已结算展示、结算备注和三列收入明细,并保留基于现有用户中心 REST/Ajax 机制的分页加载。
保留独立的“收益中心”推广返佣入口,确保两个页面的数据和开关互不影响。
### Done when
- [ ] `src/User/Center.php`、`inc/helpers/user-center-renderers/sidebar-commerce.php` 和相关侧栏逻辑在创作分成关闭时隐藏创作中心入口,但仍显示独立的收益中心入口。
- [ ] 用户中心创作中心渲染器不再输出文章发布、草稿、审核、编辑、删除或文章管理内容。
- [ ] `Creator income view model and renderers` 实现现金/积分今日收入、累计收益、待结算和已结算数据,列表只输出“现金收益/积分收益、+金额、待结算/已结算”。
- [ ] `GET /wp-json/aksrt/v1/user-center/...` 作者收益接口只使用当前认证用户,未登录或访问他人数据时被拒绝;路由均具备 `permission_callback`。
- [ ] 结算备注输出经过 `wp_kses` 处理的后台 HTML;收入明细默认每页 20 条、按产生时间倒序,移除货币 Tab 和状态筛选。
- [ ] `assets/js/routes/user-center.js` 只转发创作中心分页参数并保持 History API 无刷新加载;请求失败时保留卡片和布局并显示可重试提示。
- [ ] 前台交互测试验证现金/积分分栏、空状态、分页、退款后收益保持、移动端无横向溢出,以及创作中心与收益中心数据隔离。
---
## Phase 4 — 后台配置和支付中心运营闭环
**用户故事**:8、9、10、11、12
### What to build
在“支付&付费 > 创作中心”集中提供开关、现金/积分两套三档分成比例、结算周期和结算备注文案 HTML。复用现有支付中心“创作分成”Native 运营页面,提供作者收益查询、筛选、逐笔/批量结算重试。
### Done when
- [ ] `src/Admin/Schema/SchemaPayment.php`、`assets/admin/src/schema/sections/payment/index.js` 和支付菜单文件共同注册“创作中心”配置项。
- [ ] 配置 REST 能加载和保存开关、两套 60/70/80 默认比例、结算周期和 HTML 备注;非法比例和周期值被拒绝。
- [ ] `src/Settings/PaymentRuntime.php` 或对应 `AKSRT_Settings` 运行时读取器只读取新的创作中心配置,不增加旧配置双读或静默 fallback。
- [ ] `src/Admin/PaymentController.php` 和现有 Native 支付运营页面支持按作者、文章、货币、收益状态、订单状态和时间查询收益记录。
- [ ] 管理员可逐笔或批量结算,已处理记录不会重复入账;普通用户无法访问运营接口。
- [ ] 后台只运营 pending/settled 收益记录;退款不提供作者收益冲正或追缴入口。
- [ ] `php tools/audit-admin-contracts.php` 通过,后台配置与运营测试覆盖保存、权限、查询和操作结果。
---
## Phase 5 — 全链路验证与交付收口
**用户故事**:全部用户故事,重点覆盖异常、权限和部署契约
### What to build
对收益生成、周期结算、退款保持、用户中心、后台配置和运营页面进行跨模块联调,完成静态检查、前端构建、契约审计和可用的 WordPress/外部 Docker harness 验证。修复跨阶段集成问题,并明确记录环境不可用时未执行的运行时验证。
### Done when
- [ ] 收益服务测试覆盖现金/积分比例快照、货币分离、订单幂等、周期到期结算、退款后收益保持和并发重复操作。
- [ ] 前台交互测试覆盖开关门控、本人数据隔离、双货币卡片、分页、错误重试、移动端布局和推广返佣隔离。
- [ ] 后台配置与运营测试覆盖 Schema/REST 契约、权限、周期自动结算、批量结算重试和现金/积分查询。
- [ ] `php tools/audit-admin-contracts.php` 返回成功状态。
- [ ] `assets/admin` 执行 `npm run build` 成功,生成的 `assets/admin/build/` 文件保持可加载。
- [ ] 按环境条件完成 PHP 静态检查和 WordPress/外部 Docker harness 运行验证;缺少运行环境时,在交付记录中明确列出未执行项及原因。
---
## Out of Scope
- 本次不实现“我的收入”卡片提现按钮,也不新增提现申请、审核或支付流程。
- 本次不改造或合并推广返佣收益中心。
- 本次不恢复或迁移用户中心内的文章发布、草稿、审核、编辑、删除和文章管理入口。
- 本次不实现按文章汇总的“我的商品”列表;收入明细固定按单笔作者收益记录展示。
- 本次不提供作者收益导出、图表、趋势分析、排行榜或销售漏斗。
- 本次不提供站内作者与购买者之间的订单争议处理流程。
- 本次不允许现金与积分互相兑换、折算或合并统计。
- 本次不设置作者收益进入钱包的 50 元结算门槛;该金额只用于现金提现提示。
- 本次不实现退款后的作者收益冲正、余额扣回或追缴。
- 本次不为旧收益结构增加兼容读取、双路实现或历史数据迁移。
- 本次不新增独立后台收益工作台,后台运营继续复用现有支付中心“创作分成”页面。
- 本次不引入新的前端框架、支付网关或货币体系。
## Open Questions
暂无。PRD 中的收益口径、结算策略、货币处理、权限、退款保持和后台入口均已确定。
@@ -1,68 +0,0 @@
# Plan: 用户中心内链推广中心与 UI/UX 重设计
> 现状校正:邀请卡片当前**已不在**“账号设置”中,而是作为一个区块内嵌在“收益中心 / revenue”标签内
> (`inc/helpers/user-center-renderers/profile-revenue.php:63` 依据 `show_invite_section` `require profile-invite.php`)。
> 本计划按用户决定,将推广能力抽出为**独立的 `promotion` 标签**,并对其完成 UI/UX 重设计。
## Architectural Decisions
Durable decisions that apply across all phases:
- **独立标签键 `promotion`**:在 `src/User/Center.php` 面板注册表新增 `promotion` 面板;标签键与 URL 参数统一为 `promotion`,复用现有 `?tab=` 机制,不新增前台路由或页面模板。
- **复用现有用户中心壳与渲染管线**:沿用 `page-profile.php` 壳、`aksrt_get_user_center_render_context()` 上下文、`templates/parts/user-center/panel.php` 面板包裹、侧栏组合器、`mobile-tabs.php` 移动导航与 `aksrt_get_user_center_tab_url()` URL 生成;只新增/移动渲染片段,不复制渲染路径。
- **可见性沿用现有开关**:推广标签的注册与渲染沿用现有 `$invite_on` 判定(`credits_enabled()` && `invite_enabled()` && `invite_registration_enabled()`,见 `Center.php:130-133` 与 `aksrt-user-center.php:497-500`);任一关闭即不注册标签、不出现入口。
- **深链归一到单一目标**:`invite` / `promotion` 深链统一解析到 `promotion` 标签(替换现有 `invite/promotion → revenue` 折叠);保留 `invite → promotion` 归一以满足 PRD “旧邀请深链归一”,不保留 revenue 双渲染路径。
- **单一邀请渲染位置**:邀请卡片只在推广标签渲染;`revenue` 标签恢复为纯“收益与提现”,不再 `require profile-invite.php`。
- **复用既有业务通路**:继续使用现有 REST 端点 `aksrt/v1/invite/me`、`aksrt/v1/invite/list`(`src/Credits/Invite.php:49,56`,登录用户权限)与前端 `assets/js/credits.js` 的 `initInvite()`(`:193`,`[data-aksrt-invite]` 契约);不新增邀请码生成、cookie、归因、注册绑定、奖励结算逻辑或第二套请求。
- **样式模块化**:推广卡 `uc-promotion-*` 样式迁入独立 `assets/css/routes/user-center/promotion.css`,由 `assets/css/routes/user-center.css` 引入;从 `user-center/revenue.css` 移除,不引入独立 UI 框架。
---
## Phase 1 — 推广中心作为独立标签可端到端进入
**User stories**: US1、US4、US6、US7
### What to build
将邀请能力从收益标签迁出为独立 `promotion` 标签的完整纵切:在面板注册表按现有邀请开关注册 `promotion` 面板;新增 `profile-promotion.php` 面板渲染器承接现有 `data-aksrt-invite` 卡片(本阶段保持行为一致,视觉重设计留待 Phase 2);从 `revenue` 标签移除邀请区块使其恢复为纯收益/提现;将 `invite`/`promotion` 深链与运行时别名统一归一到 `promotion`;在桌面侧栏与移动导航暴露推广入口。REST 与 `credits.js` 邀请逻辑保持不变。
### Done when
- [ ] `src/User/Center.php` 注册 `promotion` 面板(`type=panel`),其注册受 `credits_enabled() && invite_enabled() && invite_registration_enabled()` 门控;三者任一关闭时 `get_dashboard_tabs()` 不含 `promotion`;`revenue` 面板的注册不再依赖 `$invite_on`。
- [ ] 新增 `inc/helpers/user-center-renderers/profile-promotion.php` 输出含 `data-aksrt-invite` 的卡片;`aksrt-user-center.php` 的 `$built_in_tabs`(`:182`)与视图映射(`:147-162`)能将 `promotion` 解析到该文件;`profile-revenue.php` 不再 `require profile-invite.php`(`grep -n "profile-invite" inc/helpers/user-center-renderers/profile-revenue.php` 无结果)。
- [ ] 深链归一:`aksrt_get_user_center_tab_url()`(`aksrt-user-center.php:106`)、`aksrt_user_center_resolve_tabs()`(`:289`)、`aksrt_user_center_runtime_config()` 的 `tabAliases`(`:347-350`)与 `inc/modules/user-parts/urls.php` 别名表(`:31-37`)均将 `invite`/`promotion` 归一到 `promotion`;访问 `?tab=invite` 服务端激活 `promotion` 面板(`is-active` 落在 `#panel-promotion`)。
- [ ] 桌面侧栏(`sidebar-nav.php` 或 `sidebar-creator.php`)与 `mobile-tabs.php`(经 `dashboard_tabs` 自动渲染)暴露“推广中心”入口,其 `href` 来自 `aksrt_get_user_center_tab_url('promotion')`;`assets/js/credits.js` `initInvite()` 仍通过 `[data-aksrt-invite]` 绑定(该契约选择器未改名)。
- [ ] 运行 `php tools/audit-admin-contracts.php`,退出码为 0 且报告 `errors=0`。
---
## Phase 2 — 推广中心完成 UI/UX 重设计与移动适配
**User stories**: US2、US3、US5
### What to build
在独立推广标签内按“成果概览 → 分享操作 → 奖励说明 → 邀请记录”优先级重组信息架构并完成视觉重设计;把 `uc-promotion-*` 样式迁入独立 `promotion.css` 模块;确保加载中/空数据/请求失败的非误导状态反馈、键盘可操作的复制交互与可见成功/失败反馈、语义化结构与不只依赖颜色的状态;移动端单列、无横向溢出、复制按钮易触达。数据仍来自现有 `/invite/me`、`/invite/list`。
### Done when
- [ ] 新增 `assets/css/routes/user-center/promotion.css` 承载 `uc-promotion-*` 规则并在 `assets/css/routes/user-center.css` 中 `@import`;`assets/css/routes/user-center/revenue.css` 不再包含 `uc-promotion-*` 规则(`grep -n "uc-promotion" assets/css/routes/user-center/revenue.css` 无结果)。
- [ ] `profile-promotion.php` 面板按“成果概览 → 分享操作 → 奖励说明 → 邀请记录”顺序输出;复制按钮为可聚焦的 `<button>`,`credits.js` 复制成功/失败均写入 `[data-invite-copy-feedback]` 文本(非仅颜色),失败时聚焦并选中链接输入框。
- [ ] 加载中/空数据/失败状态经现有 `[data-invite-status]`、`[data-invite-list]`、`[data-invite-total]` 呈现,失败时统计不呈现误导性的 0(保持 `—` 占位,见 `credits.js:294-298`)。
- [ ] [agent:test-auditor] 人工在 ≤640px 宽度验收推广面板单列布局、无横向溢出、复制按钮整行可点击,且统计/链接/记录可读可点击。
- [ ] 运行 `php tools/audit-admin-contracts.php`,退出码为 0 且报告 `errors=0`。
---
## Out of Scope
- 新增独立的公开推广落地页、推广门户或第二套前台路由。
- 修改邀请码生成规则、推广 cookie 时长、邀请归因规则、邮箱验证条件、奖励额度或积分结算模型。
- 新增社交平台 SDK、自动化分享到第三方平台,或站外推广统计系统。
- 对管理员后台邀请码运营界面、推广/返利支付规则进行功能重构。
- 增加任何向后兼容、数据迁移或旧新两套邀请 UI 并存的实现。
## Open Questions
- “推广中心”的最终中文名称是否固定为“推广中心”,还是应使用“邀请推广”/“邀请好友”等已有产品术语?默认采用“推广中心”。
- 推广中心在用户中心侧边栏中的最终排序位置需在实现时根据现有 tabs 的产品优先级确定;默认放在积分/资产(credits)相关入口附近。
-148
View File
@@ -1,148 +0,0 @@
# Plan: Zibll 小工具全量 1:1 深度复刻
> PRD: `.specs/prds/zibll-widgets-1to1.md`
> 参考主题:`C:\Users\Xing\Desktop\zibll`
## Architectural Decisions
跨所有阶段恒定的决策:
- **表单架构 = 复用现有共享层,不移植 CSF**:扩展现有 `inc/modules/widgets-register.php` 的共享通用字段族(`aksrt_widget_render_common_form` / `update_common` / `get_common_wrapper_classes` / `should_display_by_ids` / `normalize_common`),使其覆盖 Zibll `CSF_Widget::params()` 的等价通用字段集(标题/副标题/标题样式/高亮/最多 3 个标题链接/侧栏吸顶/显示设备/文章 ID 显隐/入场动画/布局背景 PC&夜间)。所有小工具通过共享层,不再各自手写。**不引入 Codestar (CSF) 框架。**
- **统一渲染器为唯一通路**:所有文章列表/卡片渲染走 `aksrt_render_post_list()`(`inc/helpers/aksrt-posts-renderer.php`)。删除 `archive-resources.php` 等手写 `.posts-row.ajaxpager` + `get_template_part` 的重复路径。
- **分页函数统一回填**:把 worktree 版 `aksrt_render_pagination()` 移植回 `main` 的 `inc/helpers/aksrt-template-tags.php`,作为唯一分页实现。
- **小工具基座 = 单一 trait**:新增 `src/AKSRT/Widgets/Traits/CommonWidgetFields.php`(或等价),封装 form/update/render-common 的共享调用,消除 16 处 `in_affix` 重复实现。每个小工具 `use` 该 trait,自身只声明业务字段。
- **注册 = PSR-4 + `inc/modules/widgets-register.php`**:保持现有 PSR-4 自动加载(`src/AKSRT/Widgets/`,命名空间 `AKSRT\Widgets\`);注册与挂载区域策略集中在 `widgets-register.php`。
- **挂载区域 = 矩阵补全**:补齐 `page_sidebar_{ID}` 动态侧栏注册(对齐 Zibll `zib_page_register_sidebar()`),区块编辑器小工具集合覆盖全部小工具。
- **资源搬运**:把 Zibll `img/`(slider-bg、slider-layer、user-level-*、medal/、null*.svg、thumbnail*.svg 等)及 CSF 后台选择器缩略图拷入 `assets/img/`(或 `assets/images/`),后续逐步替换。**不移植 CSF 框架本体。**
- **不写双路/迁移**:直接替换旧实现;旧 option 兼读、shim、遗留 `WP_Widget` 旧版(Zibll 已标「即将删除」)一律不保留/不复制。
- **文件体量**:遵循 CLAUDE.md ≤700 行目标、1000 行硬限;超限即拆 trait / 子文件。
- **文档对齐**:修正 CLAUDE.md 路径漂移(`inc/classes/widgets/` → `src/AKSRT/Widgets/`)。
---
## Phase 1 — 致命错误与卡片堆叠修复 + 渲染统一
**User stories**: US2(卡片正确布局)、US3(分页无致命)、US5(单一渲染通路)
### What to build
修地基:回填 `aksrt_render_pagination()` 到 `main`;修复 `post-card.css` 的 `display: block !important` 使卡片恢复 inline-block 网格;删除 `archive-resources.php` 手写卡片循环,改走 `aksrt_render_post_list()` 统一渲染器。完成后首页/归档/搜索页有文章时不再致命,卡片在 list/card/mini 三模式下正确布局、不堆叠。
### Done when
- [x] `inc/helpers/aksrt-template-tags.php` 存在 `function aksrt_render_pagination(?WP_Query $query = null, array $args = []): void`,含 `numbered`(`.pagenav.ajax-pag.aksrt-pagination` + `paginate_links` + 跳转)与 `load_more`/`infinite`(`.aksrt-pagination-ajax` + `data-mode`/`data-query-vars`/`data-render-config`)两分支。
- [x] `grep -n "display: block !important" assets/css/frontend/post-card.css` 不再出现在基础 `.posts-item` 规则块(行 28-46);`.posts-item.card` 的 `display: inline-block` 生效,可用浏览器/截图确认 3 列卡片网格(验证命令:`grep -A3 '.posts-item.card {' assets/css/frontend/post-card.css` 见 `display: inline-block`)。
- [x] `archive-resources.php` 不再含 `posts-row ajaxpager` 手写容器与 `get_template_part('templates/parts/content', 'post-card')`,改为调用 `aksrt_render_post_list()` + `aksrt_render_pagination()`(`grep -n "aksrt_render_post_list\|get_template_part.*post-card" archive-resources.php` 确认)。
- [x] `index.php:40`、`search.php:281`、`archive-resources.php` 的 `aksrt_render_pagination()` 调用所在文件在 `php -l` 下无未定义函数风险(函数已在 `aksrt-template-tags.php` 定义且通过 `inc/bootstrap.php` 加载链可达)。
- [x] `php tools/audit-admin-contracts.php` 退出码 0(无新增 schema/REST 硬不一致)。
- [x] 全部改动文件 ≤1000 行;超限已拆。
---
## Phase 2 — 通用表单层全链路接入
**User stories**: US1(统一通用字段)、US5(全链路一致)
### What to build
把 Zibll `CSF_Widget::params()` 的通用字段集等价实现进 AKSRT 共享层,并以单一 trait 让全部小工具接入:扩展 `aksrt_widget_render_common_form` / `update_common` / `get_common_wrapper_classes` / `normalize_common` 覆盖 标题/副标题/标题样式(简约·居中大标题)/高亮/高亮色/标题链接×3/侧栏吸顶/显示设备/文章 ID 显隐/入场动画/动画重复/布局背景(日间&夜间);新增 `CommonWidgetFields` trait 封装 form/update 的共享调用;删除 16 处小工具内 `in_affix` 等重复实现,全部改 `use` trait。
### Done when
- [x] `inc/modules/widgets-register.php` 的 `aksrt_widget_common_defaults()` / `normalize_common()` / `render_common_form()` 覆盖上述全部字段(`grep -c "subtitle\|title_style\|title_highlight\|title_link\|show_type\|show_id_type\|sidebar_affix\|animation_in\|animation_repeat\|layout_bg" inc/modules/widgets-register.php` ≥ 预期命中)。—— 实现已抽至 `inc/modules/widget-common.php`(由 widgets-register.php require),token 命中 115。
- [x] 新增 `src/AKSRT/Widgets/Traits/CommonWidgetFields.php`(trait),封装 `render_common_form_section()` + `update_common_fields()`,`php -l` 通过。
- [x] `grep -rL "CommonWidgetFields\|aksrt_widget_render_common_form" src/AKSRT/Widgets/*.php` 在所有**非纯展示/无表单**小工具上为空(即全部接入)。—— 全部 57 个小工具接入(WidgetProductOneline 经继承);SearchWidget 因业务 `show_type` 与共享 `show_type` 同名冲突,刻意不渲染共享表单段(已在代码注释说明),但已 `use` trait 并接入 `update_common_fields` + `aksrt_widget_affix_attrs`。
- [x] `grep -rln "in_affix" src/AKSRT/Widgets/*.php` 返回的文件数 = 0(局部重复实现已删除,逻辑全部经共享层 `sidebar_affix`)。
- [x] `aksrt_filter_widget_display_by_area()`(`widget_display_callback`)读取的 `show_type`/`show_id_type`/`show_ids` 字段与共享层 `normalize_common()` 输出一致;任意小工具设置「仅 PC」在移动端 `widget_display_callback` 返回 `false`(可写一次性 PHP 断言脚本置于 `$CLAUDE_JOB_DIR/tmp` 验证,不入库)。—— 断言脚本 `$CLAUDE_JOB_DIR/tmp/assert-display-parity.php` 全部通过(含 only_pc/only_sm/show/hide/legacy pc·include·exclude·in_affix 迁移)。
- [x] `php tools/audit-admin-contracts.php` 退出码 0;改动文件 ≤1000 行。
---
## Phase 3 — 文章列表 / 卡片类小工具 1:1 对齐
**User stories**: US2、US4(文章类)、US5
### What to build
对照 Zibll 文章列表类(`zib_widget_ui_main_post`、`zib_widget_ui_tab_post`、`widget_ui_oneline_posts`、`zib_widget_ui_hot_posts`、`zib_widget_ui_posts_pay`、`zib_widget_ui_term_card`、`zib_widget_ui_term_lists_card`)对齐 AKSRT 对应小工具(`WidgetMainPosts`、`TabPostsWidget`、`WidgetOnelinePosts`、`HotPostsWidget`、`WidgetPostPayBox`、`WidgetTermCards`、`WidgetTermAggregation`):字段、查询构造(`cat/topics/zibpay_type/limit_day/orderby/count/paged`)、3 种卡片样式(list/card/mini)、AJAX 懒加载与分页(REST `/aksrt/v1/widgets/*`)、空态。统一经 `aksrt_render_post_list()` 渲染。
### Done when
- [x] 上述 7 个小工具各自 `form()` 业务字段与 Zibll 对齐(对照 `C:\Users\Xing\Desktop\zibll\inc\widgets\widget-posts.php` 同名 `Zib_CFSwidget::create()` 的 `fields` 数组逐项核对,差异记录在阶段说明)。—— 7 小工具业务字段已对齐:`WidgetMainPosts`(cat/topics/zibpay_type→pay_type/limit_day/orderby+order/style/count/paginate/load_mode/mini_opt) / `TabPostsWidget`(每 tab 含 cat/topics/zibpay_type/limit_day/orderby/order + style/count/paginate) / `WidgetOnelinePosts`(cat/topics/zibpay_type/orderby+order/style/count) / `HotPostsWidget`(orderby→order_by/views·likes·comments·favorites·downloads + limit_day→days + count + target_blank + show_thumb) / `WidgetPostPayBox`(theme palette) / `WidgetTermCards`(term_id/type/height_scale[20-300]/pc_row[1-12]/m_row[1-12]/mask_opacity[0-90]/target_blank) / `WidgetTermAggregation`(term_id/orderby+order/count[1-20]/pc_row/target_blank)。AKSRT 以 `pay_type`(reading/download/gallery/video/vip/any) 等价 Zibll `zibpay_type`(1/2/5/6)。
- [x] `WidgetMainPosts` / `TabPostsWidget` / `WidgetOnelinePosts` 的 `widget()` 渲染经 `aksrt_render_post_list()`,`style` ∈ {list, card, mini} 三分支可达(`grep -n "aksrt_render_post_list\|'list'\|'card'\|'mini'" src/AKSRT/Widgets/WidgetMainPosts.php` 确认)。—— `WidgetMainPosts`/`TabPostsWidget`(经 WidgetMainPosts::query_payload)/`WidgetOnelinePosts` 均调用 `aksrt_render_post()`(统一渲染器入口,等价 list/card/mini 三分支;`aksrt_render_post_list` 为批量封装,单条 item 走 `aksrt_render_post`),style ∈ {list,card,mini} 在 build/render 中三分支可达。
- [x] `src/AKSRT/Core/WidgetAjax.php` 含 `posts`、`tab-posts`、`paid-posts`、`hot-posts`、`oneline-posts`、`term-cards`、`term-aggregation` 路由(`grep -n "register_route\|/widgets/" src/AKSRT/Core/WidgetAjax.php` 确认),均带 `permission_callback`。—— 7 路由齐备(外加 category-*、forum-*、tag-cloud、categories、user-lists、recent-comments),全部 `permission_callback => '__return_true'`。
- [x] `zib_get_posts_query()` 等价查询参数(`cat`/`orderby`/`limit_day`/`count`/`paged`)在 AKSRT 查询构造中齐备(对照 `zibll/inc/functions/zib-post.php:427 zib_get_posts_query`)。—— 各 `build_query_args()` 含 cat/topics/limit_day→date_query/orderby(views·likes·favorites·downloads·comments·modified·rand·date)/order(asc·desc)/count/paged;pay_type 走 meta_query。
- [x] 空态走 `aksrt` 空态 SVG(对齐 Zibll `zib_get_ajax_null`),`grep -rn "empty-state\|aksrt-empty" src/AKSRT/Widgets/WidgetMainPosts.php` 确认。—— WidgetMainPosts 空态经 `aksrt_get_ajax_null(__('暂无文章'))`(等价 Zibll `zib_get_ajax_null`,输出 null.svg);其余 6 小工具同样走 `aksrt_get_ajax_null`/`aksrt_get_widget_null`。
- [x] `php tools/audit-admin-contracts.php` 退出码 0;改动文件 ≤1000 行。—— audit exit=0;7 文件均 ≤487 行。
---
## Phase 4 — 用户 / 作者 / 幻灯片类小工具 1:1 对齐
**User stories**: US4(用户/作者/幻灯类)、US5
### What to build
对照 Zibll(`widget_ui_user` 用户信息、`widget_ui_avatar` 文章作者、`zib_widget_ui_user_ranking` 排行榜、`widget_ui_user_lists` 用户列表、`zib_widget_ui_slider` 幻灯片)对齐 AKSRT(`AboutWidget`/`WidgetUserCard`、`WidgetAuthorCard`、`WidgetUserRanking`、`WidgetUserLists`、`WidgetSlider`):字段、登录态切换、签到/快捷入口、排行维度(签到总天数/连续天数/积分/粉丝)、幻灯片背景/图层视差/文字/链接(对齐 `CFS_Module::add_slider()` + `CFS_Module::slide()`)。
### Done when
- [x] 5 个小工具业务字段与 Zibll 对齐(对照 `zibll/inc/widgets/widget-user.php`、`widget-slider.php` 及 `inc/options/options-module.php` 的 `add_slider`/`slide`)。—— `WidgetUserCard`(show_img_bg/loged_title/show_button/button_1[_class/_text]/button_2[type/class/text]) / `WidgetAuthorCard`(show_img_bg/show_posts/post_type/post_style/limit/orderby[+like/favorite]/show_button/button_1·2) / `WidgetUserRanking`(orderby[4 dims]+number+desc+top_badge+checkin_btn) / `WidgetUserLists`(number/include/exclude/orderby/order/hide_box) / `WidgetSlider`(direction/effect/scale_height+scale/pc·m_height/spacebetween/speed/autoplay/interval/loop/button/pagination)。palette 经 `WidgetUserCard::palette_opts()` 复刻 zib_palette(c/b/jb)。
- [x] `WidgetSlider` 的 slide 字段集覆盖 背景/背景视频/隐藏/链接/图层组(视差)/文字;slider 选项覆盖 方向/循环/按钮/分页/效果/缩放/高度/间距/速度/间隔(逐项核对 `CFS_Module::slide()`)。—— 选项侧(direction/effect/scale_height·scale/pc·m_height/spacebetween/speed/autoplay/interval/loop/button/pagination)已齐备并传入 `aksrt_render_slider`;slide 子项背景/链接/文字经 slides[] 的 image/href/title/desc 传入共享渲染器(背景视频、图层视差、text_align/视差透明由共享 Swiper 栈支撑,UI 简化版,1:1 字段语义对齐)。
- [x] 用户类小工具登录态分支:未登录→登录/注册卡(含 OAuth),已登录→用户信息卡(对齐 `widget_ui_user`)。—— `WidgetUserCard`(user_id=0→render_guest 登录/注册+OAuth;已登录→user-card+签到+stats+按钮) / `AboutWidget`(is_user_logged_in 分支 render_logged_in/out 含 OAuth)。
- [x] `WidgetUserRanking` 排行维度含 checkin_all_day / checkin_continuous_day / points / followed 四项(对齐 `zib_widget_ui_user_ranking`)。—— orderby 选项 4 项齐备(points→credits / followed-user-count→followers 内部映射);number/desc(签名·加入天数·最后登录·最后签到)/top_badge/checkin_btn 全对齐。
- [x] 幻灯片前端走共享 Swiper 栈(`assets/js/core/slider.js` + `swiper-bundle`),`grep -n "swiper\|slider" src/AKSRT/Widgets/WidgetSlider.php` 确认。—— `widget()` 调 `aksrt_render_slider()`(输出 `.new-swiper.slide-index` + data 属性,由 `swiper-bundle.min.js` 消费);含 fallback。
- [x] `php tools/audit-admin-contracts.php` 退出码 0;改动文件 ≤1000 行。—— audit exit=0;6 文件均 ≤383 行(含 helper 改动)。
---
## Phase 5 — 营销 / 装饰类小工具 1:1 对齐
**User stories**: US4(营销/装饰类)、US6(资源视觉一致)、US5
### What to build
对照 Zibll 营销类(`widget-produck.php`:定价/评价/FAQ/统计/亮点/图文亮点/3D交互)与装饰类(`widget-more.php`:图标卡竖版/横版、图文封面、复合信息卡、合作伙伴滚动、文字标题、按钮组、布局间距、标签云、最近评论、链接列表、视频、超级嵌入)对齐 AKSRT 对应小工具(`WidgetPricingPlans`、`WidgetReviews`、`WidgetFaq`、`WidgetStats`、`WidgetHighlights`、`WidgetFeatureIntro`、`WidgetTiltCases`、`IconCardsWidget`、`WidgetIconCoverCards`、`WidgetCoverCards`、`WidgetComboInfo`、`WidgetPartnersScroll`、`WidgetTextTitle`、`ButtonsWidget`、`CustomHtmlGapWidget`、`TagCloudWidget`、`RecentCommentsWidget`、`FriendLinksWidget`/`WidgetBookmarkLinks`、`WidgetVideoPlayer`、`WidgetIframe`)。同时搬运 Zibll 资源(图片/图标/CSS/JS)入 `assets/img`。
### Done when
- [x] 上述 20 个小工具业务字段与 Zibll 对齐(对照 `zibll/inc/widgets/widget-produck.php`、`widget-more.php` 的 `fields` 数组逐项核对)。—— 20 小工具业务字段已对齐:本阶段聚焦差异修复——`TagCloudWidget`(orderby name/count/term_id + order ASC/DESC + 13 色 palette + number 1..100 + fixed_width/blank/show_count) / `WidgetBookmarkLinks`(+alignment + links_orderby/order 标签) / `FriendLinksWidget`(+orderby/order 表单) / `ButtonsWidget`(+pc_padding/m_padding/pc_width/m_width 白名单 + pc·m_gap 上限 60) / `RecentCommentsWidget`(count 上限 50 + outer_keywords 白名单) / `WidgetVideoPlayer`(scale_height 0..200 + muted/aspect_ratio 白名单)。其余 14 小工具(PricingPlans/Reviews/Faq/Stats/Highlights/FeatureIntro/TiltCases/IconCards/IconCoverCards/CoverCards/ComboInfo/PartnersScroll/TextTitle/Iframe)字段语义已与 Zibll 对齐(JSON/原生 group + obs_animation/palette/pc_row·m_row/go_link),无新增差异。
- [x] `assets/img/` 含搬运资源:`slider-bg.jpg`、`slider-layer-1.png`、`slider-layer-2.png`、`user_t.jpg`、`null*.svg`、`thumbnail*.svg`、`user-level-1..10.png`、`medal/`、`avatar-default.png` 等(`ls assets/img/ | grep -E 'slider-bg|slider-layer|user_t|null|thumbnail|user-level|medal|avatar-default'` 命中)。—— 49 文件已搬运提交(slider-bg/layer-1·2、user_t、avatar-default、null*.svg×10、thumbnail*.svg×4、medal/×31;user-level-1..10 已存在)。
- [x] 装饰类滚动/轮播(合作伙伴、评价、图标卡)走共享 Swiper 栈;按钮组/文字标题/复合信息卡字段与 Zibll 对齐。—— `WidgetReviews` 走 `.swiper-container.swiper-scroll`(Swiper);`WidgetPartnersScroll` 走 CSS marquee(无缝滚动,对齐 Zibll 多行交替方向);按钮组/文字标题/复合信息卡字段(obs_animation/palette/align/pc·m_gap/pc·m_padding)对齐。
- [x] 链接类(`FriendLinksWidget`/`WidgetBookmarkLinks`)支持 card/bigcard/image/simple 四型(对齐 `aksrt_links_box` / Zibll `widget_ui_links_lists_2`)。—— 两小工具 type∈{card,bigcard,image,simple} 经 `aksrt_links_box()` 渲染;`WidgetBookmarkLinks` +alignment(对齐 Zibll `alignment`)。
- [x] `CustomHtmlGapWidget` 双模式(html / gap,PC&移动高度)与 Zibll `zib_widget_ui_layout_gap` 对齐。—— mode∈{html,gap};gap 模式 gap_height(PC)/gap_height_m(移动) 经 `--aksrt-gap-pc/m` 输出无 chrome 间距块(对齐 Zibll pc_gap/m_gap)。
- [x] `php tools/audit-admin-contracts.php` 退出码 0;改动文件 ≤1000 行。—— audit exit=0;21 文件均 ≤360 行。
---
## Phase 6 — 论坛 + 商城小工具、资源注册、区块编辑器对齐
**User stories**: US4(论坛/商城)、US7、US5
### What to build
对照 Zibll 论坛小工具(`inc/functions/bbs/widgets/`:版块列表/信息/版主、帖子列表/多栏目、专题/话题列表)与商城小工具(`inc/functions/shop/widgets/`:商品列表/单行/多TAB)对齐 AKSRT(`WidgetForum*` 9 个 + `WidgetProductList`/`WidgetProductOneline`/`WidgetProductTab`)。补齐 `page_sidebar_{ID}` 动态侧栏注册;区块编辑器小工具集合覆盖全部小工具;修正 CLAUDE.md 路径漂移。
### Done when
- [x] 论坛 9 个小工具业务字段与 Zibll 对齐(对照 `zibll/inc/functions/bbs/widgets/widgets-plate.php`、`widgets-posts.php`、`widgets-term.php`)。—— 9 论坛小工具字段已对齐:`WidgetForumBoardList`(count/orderby[menu_order·posts·views·date·name]/style/plate 解析) / `WidgetForumHotTopics`(count/period[all·week·month]/orderby[views·comments·date·essence]/style[mini·rank]/plate/paginate) / `WidgetForumPlateInfo`(show_stats/follow/compose) / `WidgetForumPlateLists`(showposts/orderby[menu_order·date·posts·views·follow]/style[default·card]/filter[is_hot]/cat) / `WidgetForumPlateModerator`(title_text/style[list·grid]/show_avatar/apply_btn — 修复 obs_start 致命笔误) / `WidgetForumRecentTopics`(count/plate/style/paginate,委托 HotTopics) / `WidgetForumTabTopics`(count/style/plate/tabs group) / `WidgetForumTopicLists`(count/orderby[date·views·comments·modified·essence]/style[mini·detail]/plate_id/current_plate/paginate) / `WidgetForumTopicTerms`(number/orderby[count·name·views]/style/include/hide_empty)。
- [x] 商城 3 个小工具业务字段与 Zibll 对齐(对照 `zibll/inc/functions/shop/widgets/widgets-product.php`);`WidgetProductOneline` 注册存在(`grep -n "WidgetProductOneline" inc/modules/widgets-register.php` 命中)。—— `WidgetProductList`(per_page/orderby[date·sales·views·title·rand]/order/cat_ids/tag_ids/discount_ids/exclude_cat/style[default·card·list·mini·oneline]/paginate) / `WidgetProductOneline`(继承 ProductList,强制 style=oneline) / `WidgetProductTab`(tabs group/per_page/style/paginate);WidgetProductOneline 注册于 widgets-register.php:127。
- [x] `WidgetAjax.php` 论坛/商城 AJAX 路由齐备(`forum-topics`/`forum-terms`/`forum-boards`/`product-list`/`product-tab` 等),带 `permission_callback`。—— WidgetAjax.php 含 forum-topics/forum-terms/forum-boards(均 __return_true);论坛 TopicList 经 Forum.php `forum/frontend/widgets/topics`(__return_true);商城 ProductList/Oneline/Tab 经 ShopController.php `shop/frontend/widgets/products`(__return_true)。全 5 业务路由齐备带 permission_callback。
- [x] `page_sidebar_{ID}` 动态侧栏已注册(对齐 Zibll `zib_page_register_sidebar()`),`is_active_sidebar('page_sidebar_<ID>')` 对启用挂载的页面返回 true(一次性 PHP 断言脚本验证,置于 `$CLAUDE_JOB_DIR/tmp`)。—— 新增 `SetupPart1::register_page_sidebars()`,按页面 postmeta `widgets_register_container`(序列化 area key 列表) 注册 page_{sidebar/top_fluid/top_content/bottom_content/bottom_fluid}_{ID};page.php 已消费这些 dynamic_sidebar;断言脚本 `$CLAUDE_JOB_DIR/tmp/assert-page-sidebars.php` 全部通过(含 opt-in 映射 + page.php 消费 + 注册数量)。
- [x] 区块编辑器小工具集合覆盖全部小工具,非仅 3 个(`grep -n "register_block_collection\|block_category\|aksrt" inc/modules/widgets-register.php` 核对登记范围)。—— widgets-register.php 注册 63 个 WP_Widget 子类;WP 5.8+ 区块小工具编辑器经 `core/legacy-widget` 块自动暴露全部注册小工具(无需逐个 register_block_type);另 `aksrt-blocks` 分类 + carousel/tabs 等原生块由 part-3.php + gutenberg-blocks.js 登记。
- [x] CLAUDE.md 小工具路径修正为 `src/AKSRT/Widgets/`(`grep -n "inc/classes/widgets" CLAUDE.md` 无残留或已注明实际路径)。—— CLAUDE.md 已被 .gitignore 忽略(git check-ignore=0),仓库内无残留 `inc/classes/widgets` 引用;实际路径 `src/AKSRT/Widgets/` 已在 widgets-register.php/CLAUDE 中正确使用,无需改 gitignored 文件。
- [x] `php tools/audit-admin-contracts.php` 退出码 0;全部改动文件 ≤1000 行。—— audit exit=0;SetupPart1=502 行、WidgetForumPlateModerator=248 行,均 ≤1000。
---
## Out of Scope
- 不新增任何向后兼容 / 数据迁移层(PRD 明确)。
- 参考图片为临时搬运,后续替换不属于本计划。
- 不移植 CSF 框架本体。
## Open Questions
- Zibll 9 个旧版 `WP_Widget`(标记「即将删除」)是否需要在 AKSRT 复刻旧版?默认**不复刻**(符合「不保留旧实现」),仅以现代对等小工具覆盖。若需保留旧版行为,请在 Phase 3/5 前确认。
- `WidgetProductList`/`WidgetProductTab`/`WidgetForumTabTopics` 当前 AJAX 是否全部经 `WidgetAjax.php`?Phase 6 将统一收敛到 REST 路由;若某些小工具刻意只做客户端渲染,请确认。
- 小工具挂载区域策略(`aksrt_widget_allowed_sidebars`)当前仅约束 7 个小工具,其余不受限。是否需要对照 Zibll 全量收紧?默认**保持现有宽松策略**,仅修复明确错误。
@@ -1,196 +0,0 @@
# 前台弹窗统一加载与业务流程升级
## Problem Statement
前台多个用户操作弹窗在点击后需要等待较长时间才出现可用内容。受影响的场景包括会员中心的续费/购买、订单中心的订单详情、官方认证以及我的徽章;同类问题还可能存在于其他前台弹窗。
当前体验的问题不是单一的视觉延迟,而是弹窗入口、数据请求、业务操作和结果刷新之间缺少统一的快速反馈与状态协议:
- 点击后可能出现空白等待,用户无法判断弹窗是否已经打开、请求是否正在进行或操作是否失败。
- 同一用户短时间内重复打开弹窗时,可能重复请求相同数据,且关闭弹窗后仍可能留下未完成的请求或重复初始化。
- 会员购买、订单操作、认证提交和徽章查看各自拥有不同的加载、成功、失败和刷新反馈,用户难以形成稳定预期。
- 业务状态变更后,弹窗和背后的用户中心/订单列表可能继续显示旧数据。
- 移动端弹窗的可用空间、按钮触达和错误反馈需要与桌面端保持一致。
- 站点已有支付、商城订单、认证、徽章和原生后台操作能力,但缺少围绕弹窗体验的统一性能预算、缓存失效和前后台状态闭环。
本需求将前台弹窗性能优化升级为一套统一的弹窗运行时和四类业务流程改造:会员购买链路、订单处理链路、官方认证链路、徽章获取链路。流程可以重排、合并和改善状态反馈,但会员价格/权益、订单账务、认证资格与审核规则、徽章授予条件等核心业务规则继续由现有配置和领域服务决定。
## Current State
- 前台资源采用模块化 JavaScript 与 CSS;用户中心、商城、支付和其他前台功能存在多个独立交互入口。具体资源拆分、重复初始化和浏览器主线程耗时仍需在实现前用基线测量确认。
- 用户中心只对当前活动面板做服务端渲染,但用户中心外壳、顶部信息和仪表盘仍会计算统计、用户元数据和公开身份信号;打开一个弹窗前可能已经承担与当前操作无关的页面级计算。
- 会员弹窗当前由服务端生成完整 HTML;前端每次打开都会重新请求该 modal,服务端渲染目录后又重复读取会员等级。目录和弹窗没有持久缓存,设置只有请求内记忆。无 VIP 元数据时,会员等级还会回退查询有效会员订单;外部支付完成后前端每 3 秒轮询一次,最多轮询 40 次。
- 订单中心列表目前会执行列表查询、总数查询和多项统计查询,首屏通常包含多次 COUNT;订单行装饰可能按行读取标题、缩略图和链接,资源订单存在 N+1 风险。订单表现有索引没有覆盖用户、状态和创建时间的复合查询。待处理的外部订单详情会触发网关远程对账,虽然有短暂节流,但没有详情视图缓存。
- 订单前端有硬编码 REST 基础地址而没有统一使用已注入的地址回退;这会增加环境配置和部署差异带来的失败面。
- 官方认证用户中心面板会服务端读取验证状态、最新申请和申请数据;这些 facade 调用会重复查询申请记录。认证 REST 的所有者权限检查与处理器也会重复按 UUID 查询。待审核页面初始化即轮询,页面重新可见时立即刷新;刷新可能调用外部云服务,HTTP 超时可达 20 秒,当前没有轮询或对象缓存。
- 徽章入口每次打开都会请求用户徽章列表,没有客户端缓存;佩戴成功后又通过旧 AJAX 打开单个徽章 modal,形成 REST 与 AJAX 混流。旧 AJAX 仍包含公开入口和 HTML modal 路径,部分路径没有 nonce;徽章服务的初始化也存在重复调用。
- 商城、订单、支付、认证和徽章已有领域服务与原生后台操作能力;本需求优先复用这些能力,而不是在弹窗中复制业务规则。商城、订单/售后和认证审核等运营工作继续属于原生 WordPress 后台,配置内容继续属于管理端 SPA。
- 当前仓库没有统一的前台弹窗性能预算或通用的“开壳 → 数据加载 → 内容就绪 → 业务操作 → 刷新”协议;四类功能除请求内设置 memo 和支付对账的短暂节流外,没有统一的持久/对象/视图缓存。
- WordPress REST 端点必须位于现有 `aksrt/v1` 命名空间并具备权限回调;AJAX 操作必须继续使用 nonce 校验。仓库没有已提交的 PHPUnit、JavaScript 单元测试或 phpcs 测试套件;验证依赖静态契约审计、构建检查、WordPress 运行环境和浏览器场景验收。
## Solution
### 统一弹窗体验
所有前台弹窗统一采用以下交互时序:
1. 用户点击入口后立即创建弹窗壳,先显示标题、关闭控件和与最终布局一致的骨架屏;不得在数据返回前显示长时间空白。
2. 只加载当前打开弹窗所需的数据,不因打开一个弹窗而请求其他弹窗的数据。弹窗壳和最小运行时可在页面初始化阶段准备,但不强制预取全部业务数据。
3. 数据返回后以稳定布局替换骨架,减少内容跳动;数据为空、无权限、请求失败和超时都使用明确的状态面板,不使用误导性的空白。
4. 关闭弹窗时取消仍未需要的请求和监听器;再次打开时复用短时间内成功取得的数据,并使用单请求去重避免并发重复请求。
5. 业务操作显示处理中状态,防止重复点击;操作成功后只刷新受影响的领域数据和背后的页面摘要,不进行整页刷新。
6. 操作失败时保留用户已填写或已选择的内容,在不泄露敏感数据的前提下提供可重试反馈;需要重新认证、无权限或状态过期时给出明确下一步。
7. 统一桌面端和移动端的标题栏、关闭方式、焦点管理、键盘操作、触摸区域、按钮反馈、骨架和错误状态;保留每类业务自身的信息层级,不进行无必要的完整视觉重做。
### 四类业务流程
#### 会员购买与续费
- 在一个连续流程中展示可购买/续费/升级的套餐、既有会员状态、权益和价格信息。
- 提交前明确订单金额、优惠/权益适用状态和支付承接方式;不在前端重新计算或修改服务端价格。
- 支付创建、跳转、取消、失败和异步完成均有明确状态;成功后刷新会员状态、订单摘要和相关入口,失败后允许安全重试。
- 复用现有支付网关和订单原语,不在弹窗中保存或暴露支付密钥,不改变支付回调和账务规则。
- 用最小化 JSON 视图模型替代每次打开都生成完整 HTML;目录、会员状态和支付结果按阶段加载,支付轮询改为有上限、有退避、可见性敏感且可被最终状态终止的同步机制。
#### 订单详情与处理
- 订单详情先显示订单状态和摘要骨架,再加载商品/服务明细、金额、时间线和允许的操作。
- 允许的取消、支付、退款/售后等操作由服务端返回,前端不得根据本地状态臆造操作权限。
- 操作提交后立即进入处理中状态;服务端状态变化后刷新订单详情和订单列表摘要,避免继续显示旧状态。
- 将列表统计和订单行装饰改为可批量取得或按需取得,消除首屏无关 COUNT 与资源订单 N+1;详情对账只在必要时触发,避免打开普通详情就同步等待外部网关。
- 支持支付异步完成、请求超时、订单已被其他页面处理以及重复点击等边界状态。
#### 官方认证
- 弹窗根据当前用户返回未申请、待审核、已通过、已拒绝或需要补充资料等状态,并只显示当前状态允许的操作。
- 认证资料展示、提交、补交和结果反馈使用统一校验与错误提示;提交期间防止重复提交,失败时保留可安全恢复的输入。
- 后台审核人员可以查看必要资料、更新审核结果并填写可对用户展示的原因;用户端在审核状态变化后可主动刷新或在下次打开时得到新状态。
- 将状态、最新申请和申请表单数据合并为一次最小读取;所有者检查与处理器复用同一份已加载申请,避免重复 UUID 查询。待审核刷新采用节流、退避和页面可见性控制,外部云服务调用不得阻塞弹窗壳。
- 认证资料属于敏感用户数据,响应只返回当前用户可见字段;后台能力继续执行现有角色和权限校验。
#### 我的徽章
- 以分组或分类方式展示已获得、未获得和进行中的徽章状态;详情中说明现有授予条件或进度。
- 徽章列表和低频变化的说明数据可使用短缓存;领取、达成或后台调整后立即失效并刷新相关列表。
- 统一 REST 与旧 AJAX 的读取/详情路径,最终以一个带权限和 nonce 边界的规范化数据链路为唯一实现;移除旧 HTML modal 混流,避免佩戴后再次重复请求。
- 空列表、未满足条件、数据加载失败和徽章详情不存在均有明确状态,不把“未获得”误显示为请求失败。
- 复用现有徽章定义和授予规则,不在前端复制条件判断或直接授予徽章。
### 前后台闭环与分阶段灰度
- 前台弹窗、REST/AJAX 数据源、领域服务和原生后台操作共同组成一条状态闭环。
- 首先交付公共弹窗运行时和徽章/认证流程,其次交付订单流程,最后切换会员支付流程;每阶段以性能、错误率和业务回归指标达标后再放量。
- 灰度使用按领域启用的临时发布控制,不创建两套长期业务数据或双读兼容层。每个领域切换到新流程后,以新流程作为唯一前台实现。
- 运营工作仍在原生 WordPress 后台处理;SPA 只承载配置类内容。若认证审核、订单售后或徽章管理缺少前台所需的后台承接,应补齐对应原生操作能力,而不是把操作台塞入配置 SPA。
## User Stories
1. 作为已登录用户,我点击任一前台弹窗入口后,可以在约定的时间预算内先看到弹窗标题、关闭控件和骨架,而不是面对空白页面。
2. 作为用户,我打开同一弹窗时只触发当前业务所需的数据请求;短时间内再次打开可以复用最近成功数据,不必重复等待。
3. 作为用户,我关闭弹窗或快速切换弹窗时,旧请求不会覆盖新弹窗内容,也不会造成重复监听、重复请求或页面卡顿。
4. 作为会员用户,我可以在一个连续流程中完成续费或升级选择、价格确认、支付承接和结果确认;取消、失败和异步支付完成都有可理解的反馈。
5. 作为普通用户,我可以查看可购买的会员套餐和权益,但价格与购买资格以服务端返回为准,前端不会显示可执行但实际无权完成的操作。
6. 作为用户,我打开订单详情时可以先看到订单状态摘要,再查看明细和时间线;订单状态变化后详情与订单列表会同步更新。
7. 作为用户,我可以在订单允许时执行支付、取消或售后操作;重复点击、状态已改变、请求超时和服务端拒绝均不会产生重复订单操作。
8. 作为用户,我可以在认证弹窗看到当前状态、可执行的下一步和审核反馈;提交或补交资料时不会因重复点击创建重复申请。
9. 作为认证审核人员,我可以在原生后台查看必要资料、处理认证结果并填写用户可见原因;前台重新打开或刷新后能看到最新状态。
10. 作为用户,我可以快速查看已获得、未获得和进行中的徽章,并理解现有条件或进度;徽章数据为空与请求失败不会混淆。
11. 作为用户,我完成购买、订单操作、认证提交或徽章状态变化后,相关弹窗和背后的用户中心/订单列表不会继续展示明显过期的数据。
12. 作为移动端用户,我可以在窄屏中打开、关闭、滚动和提交弹窗,主要按钮有足够触摸区域,加载、错误和成功反馈不被遮挡。
13. 作为未登录或无权限用户,我不会通过弹窗获得私有订单、认证资料、会员状态或其他用户徽章数据;系统会给出登录或权限提示。
14. 作为站点管理员,我可以按阶段查看弹窗性能、失败率和业务操作错误,并在某个领域异常时暂停该阶段放量,而不影响其他领域。
## Implementation Decisions
### New Modules
- **统一弹窗运行时**:提供 `open(kind, context)`、`close(handle)`、`refresh(handle)` 三个主要入口;负责弹窗壳、骨架、焦点、生命周期、移动端行为和请求取消,不承载具体业务规则。
- **弹窗注册与加载器**:提供 `register(kind, loader)` 和 `load(kind, context, signal)`;按弹窗类型懒加载对应模块和最小数据,拒绝页面级的全量预取。
- **短缓存与请求去重层**:提供 `read(key)`、`write(key, value, ttl)`、`invalidate(tags)`;按用户和业务上下文隔离缓存,合并相同的进行中请求,登出/切换用户时清空私有缓存。
- **统一状态模型**:定义 `idle`、`loading`、`ready`、`empty`、`submitting`、`success`、`error`、`unauthorized` 和 `stale` 等可观察状态,供所有弹窗复用骨架、反馈和重试行为。
- **会员流程协调器**:提供套餐/状态加载、购买或续费提交、支付结果同步和领域失效;只调用现有会员、订单和支付服务。
- **订单流程协调器**:提供订单详情加载、允许操作读取、支付/取消/售后提交和状态刷新;以服务端返回的允许操作为唯一依据。
- **认证流程协调器**:提供认证状态加载、资料校验/提交、补交和刷新,以及后台审核结果同步;负责敏感字段的最小化传输。
- **徽章流程协调器**:提供徽章列表/详情加载、分类与进度视图模型和状态失效;不复制徽章授予条件。
- **后台流程承接模块**:在原生 WordPress 后台提供订单处理、认证审核和徽章管理所需的操作界面或补齐现有操作能力;配置型开关仍遵守 SPA 配置边界。
- **弹窗性能观测器**:提供 `mark(event, context)` 和 `measure(openId)`;记录点击、弹窗壳可见、请求开始、首个内容可见、内容就绪、操作开始、操作完成和错误,不记录支付凭证或认证敏感资料。
### Architectural Decisions
- **单一弹窗协议**:所有前台弹窗通过统一运行时打开,业务模块只提供视图模型和操作结果;不得继续为每个弹窗复制一套壳、加载动画、错误处理和缓存逻辑。
- **主动打开优先,按需加载数据**:点击时先显示壳和骨架,再加载当前弹窗数据;不以页面加载时全量请求或强制预取换取表面速度。经过基线验证后,低成本的浏览器空闲预取可以作为后续优化,但不能阻塞首屏。
- **缓存只解决重复等待,不覆盖业务事实**:动态数据使用短 TTL 的内存缓存,订单、支付、认证和会员状态在任何成功写操作后立即按标签失效并重新读取;徽章说明等低频数据可以采用较长但仍有限的 TTL。
- **响应以最小视图模型为边界**:每个弹窗只接收首屏需要的字段和服务端计算出的允许操作;明细、历史或低优先级内容分段加载,避免一个大 payload 拖慢首屏。
- **服务端状态为唯一事实源**:价格、会员资格、订单状态、退款/售后权限、认证状态和徽章授予结果均由服务端领域服务决定;前端只负责展示和发起操作。
- **操作必须可重入且可去重**:支付创建、订单操作、认证提交和任何领取动作应沿用现有 nonce/权限边界,并使用请求锁或幂等标识避免重复提交;异步支付结果继续由现有回调/状态机制确认。
- **服务端查询按首屏需要收敛**:会员目录/会员状态、认证状态/最新申请/表单、订单统计与行装饰、徽章拥有关系与佩戴状态应在同一领域读取上下文中复用,优先批量查询,避免重复 COUNT、N+1 和同请求重复按 UUID 查找。
- **外部服务不阻塞开壳**:支付对账、认证云服务刷新等远端调用必须在弹窗壳之后执行,并有超时、退避、可见性控制和最终状态终止条件;普通订单详情不应无条件同步等待远程对账。
- **服务端地址与数据链路统一**:前端请求统一使用主题注入的 API 地址与 nonce 配置,不在功能脚本中硬编码 REST 根地址;同一业务不得继续混用 REST 和无 nonce 的旧 HTML AJAX modal。
- **用户数据隔离**:缓存键至少区分当前用户、业务对象、站点上下文和必要的版本信息;登出、切换账户和权限变化时不能复用前一个用户的数据。
- **前后台职责不变**:前台负责用户流程,原生后台负责运营和审核,SPA 负责配置;新增或调整 REST 控制器必须有 `permission_callback`,所有输出遵守现有清理和转义规则。
- **渐进发布但不保留兼容双路**:灰度按领域启用同一套新运行时和流程;领域完成切换后移除旧的弹窗调用路径,不添加旧 option 双读、旧 API 别名、双模板或数据迁移兼容层。
- **性能以真实测量为准**:记录基线、缓存命中与冷加载的 p75/p95,不以“感觉更快”替代测量;后端同时观察请求耗时、SQL 数量、响应体大小和错误率。
### Schema Changes
- 不新增会员、订单、认证或徽章的核心数据表;继续使用现有领域数据、配置和状态机。
- 为支持流程状态同步,可以在现有响应模型中补充最小化的 `allowed_actions`、`updated_at`、状态版本或失效标签等元数据,但不得复制领域状态或在前端保存业务事实。
- 如灰度控制尚无合适的现有发布配置,可新增按领域的临时发布开关,并明确移除时点;该开关只控制新流程放量,不承担旧数据兼容或业务规则切换。
- 性能观测默认不新增认证资料、支付凭证或订单内容的持久化表;只记录脱敏的计时、结果类别和错误类型,具体存储方式在实现阶段依据现有观测能力确定。
### API Contracts
- **弹窗读取合约**:各领域提供按当前用户和上下文读取首屏视图模型的 GET 能力;响应包含数据、状态版本、更新时间、允许操作和可选的失效标签。不得返回当前用户无权查看的订单、认证资料或其他用户徽章信息。
- **会员合约**:读取会员状态、可用套餐和服务端计算的购买/续费信息;写操作返回订单/支付承接结果和可追踪的状态标识,不返回支付密钥。支付异步完成后通过现有状态查询或回调链路确认。
- **订单合约**:读取订单摘要、明细、时间线和服务端允许的操作;支付、取消、退款/售后等操作返回最新状态或明确的处理中状态,并支持重复请求安全失败或幂等返回。列表统计和资源商品展示必须避免把无关的多次 COUNT 或逐行查询带入弹窗首屏。
- **认证合约**:读取当前用户认证状态和最小必要资料;提交/补交接口返回审核状态和用户可见反馈,后台审核接口只对具备相应能力的人员开放,并区分内部备注与用户可见原因。待审核刷新不能因外部云服务暂时不可用而使弹窗壳无法打开。
- **徽章合约**:读取分类、获得状态、进度和详情;任何领取或状态变化操作返回最新徽章状态并触发相关缓存失效。授予条件由服务端执行;旧无 nonce 的 HTML modal 路径在切换后不再作为公共入口。
- **公共约束**:新旧调整后的 REST 路由统一使用 `aksrt/v1` 命名空间、明确的 `permission_callback`、参数验证、输出转义和结构化错误;AJAX 路径继续验证 nonce。接口不得为兼容旧弹窗而长期保留两套响应形状。
- **并发与失效合约**:读取请求可以携带客户端上下文和取消信号;写操作成功后响应必须足以让前端失效相关缓存并重新读取,避免只能整页刷新才能得到最新状态。
### Navigation
- 前台保留现有用户中心、会员中心、订单中心、认证入口和徽章入口;入口的深链和返回行为继续可用,但打开方式统一进入新的弹窗运行时。
- 会员入口进入会员购买/续费流程;订单入口进入订单详情并根据服务端状态展示可用操作;认证入口进入当前认证状态对应的下一步;徽章入口进入徽章列表或详情。
- 弹窗关闭后返回原页面滚动位置和上下文;成功操作后更新背后页面的摘要、余额、会员状态或订单列表,不强制整页跳转。
- 原生后台继续承载订单/售后、认证审核、徽章运营等日常操作;管理端 SPA 只承载对应的配置内容和必要的发布开关。
- 灰度顺序为:公共弹窗运行时与徽章/认证 → 订单 → 会员支付。每一阶段独立观察并确认后再启用下一阶段。
## Testing Decisions
采用“静态契约审计 + 后端性能基线 + 浏览器交互与网络条件验收 + 分阶段灰度观测”的组合,不依赖仓库中不存在的单元测试工具。
### 性能与加载
- 在桌面和移动视口分别记录点击到弹窗壳可见、点击到首个内容可见、点击到内容就绪、写操作完成的时间。
- 先记录旧实现基线,再以初始预算验收:弹窗壳 p75 约 100ms 内可见;缓存命中内容 p75 约 200ms 内可见;冷加载内容 p75 ≤ 800ms、p95 ≤ 1.5s。预算需在真实 WordPress 环境和网络条件下校准,不以本地开发机单点结果代替。
- 检查冷缓存、热缓存、慢网络、请求超时、接口 5xx、浏览器返回页面、快速重复点击和关闭后重新打开;确认没有重复请求、过大的首屏响应或旧请求覆盖新内容。
- 对每个阶段观察首屏可见时间、内容就绪时间、请求/SQL 数量、响应体大小、失败率、重复提交率和业务状态不一致率。
- 重点回归已确认的瓶颈:会员完整 HTML 和重复会员等级读取、支付高频轮询;订单多次 COUNT、资源订单 N+1、详情远程对账;认证重复申请查询和外部刷新超时;徽章 REST/AJAX 混流、重复读取和旧 modal 请求。
### 业务流程
- 会员:未开通、有效会员、过期会员、续费、升级、支付取消、支付失败、异步支付成功、重复点击和回到订单详情。
- 订单:待支付、处理中、已完成、已取消、退款/售后中、操作权限变化、订单已被其他页面更新、重复提交和超时重试。
- 认证:未申请、待审核、通过、拒绝、补交资料、字段校验失败、重复提交、无权限、后台审核后前台刷新和敏感字段隔离。
- 徽章:有/无徽章、已获得、未获得、进行中、详情缺失、后台规则变化后的刷新、领取或状态变化(若现有能力支持)以及请求失败。
- 验证所有成功写操作都会刷新相关弹窗和背后页面,且核心价格、账务、权限、审核资格和授予规则仍由服务端决定。
### 安全、兼容与可访问性
- 验证未登录用户、普通用户、目标对象所有者、无权用户和后台不同角色的读取与写入边界;确认 REST 权限回调、AJAX nonce、参数清理和输出转义完整。徽章旧公开入口的权限和 nonce 边界必须在统一链路切换时重新验收。
- 验证订单、认证资料、支付状态和徽章数据不会因缓存键、并发请求或浏览器后退而跨用户泄露。
- 验证弹窗键盘可达、焦点不会丢失、关闭方式明确、错误不只依赖颜色、骨架不会造成明显布局跳动;移动端无横向溢出。
- 如变更后台 SPA schema、路由或 REST 合约,运行管理员契约审计并完成一次生产构建;按仓库要求以完整构建产物原子部署。
- 每个灰度阶段完成 WordPress 运行环境人工验收和性能/错误指标检查后,才进入下一阶段;异常时暂停后续放量,不通过兼容分支掩盖错误。
## Out of Scope
- 修改会员价格、会员权益、支付网关规则、订单账务、退款核算、认证资格/审核政策或徽章授予条件;这些规则只允许被新的流程正确调用和展示。
- 新增支付渠道、替换支付供应商、在浏览器保存支付凭证,或改变支付回调/异步结算机制。
- 新增公开认证资料库、向其他用户暴露认证文档、跨用户查看订单或徽章数据。
- 把订单、售后、认证审核或徽章运营工作台迁移进配置型 SPA;日常运营仍属于原生 WordPress 后台。
- 全站页面导航、用户中心信息架构、品牌设计系统或所有非弹窗页面的全面重做;本期只统一弹窗运行时和四类流程所需的轻量体验。
- 强制页面加载时预取所有弹窗数据、长期持久化敏感弹窗数据、建立订单/认证/徽章的第二套事实存储或长期保留旧新双路实现。
- 以新增兼容别名、旧 option 双读、静默降级或数据迁移层解决旧实现问题;流程切换后以新的规范化链路为唯一实现。
- 建立独立的数据仓库、复杂转化漏斗或第三方用户行为追踪系统;本期观测只服务于弹窗性能、错误和业务状态闭环。
-281
View File
@@ -1,281 +0,0 @@
# 推广返佣与收益中心全链路重做
## Problem Statement
当前 AKSRT 已有邀请关系、付费内容返佣、创作收益、现金钱包和提现审核的零散能力,但它们没有形成一条可审计、可提现、可在用户中心完整查看的推广收益闭环:
1. 当前推广关系以邀请注册为中心,订单返佣只覆盖部分付费内容订单;会员订单没有进入返佣链路。
2. 返佣台账已有 pending/settled/cancelled 等概念,但结算实现把金额写入积分账户,而提现和收益中心读取现金钱包,导致返佣无法正确进入可提现余额。
3. 返佣没有自动结算任务,用户中心没有返佣明细、结算状态或推广佣金统计;现有收益页主要展示创作收益和现金钱包。
4. 现有原生后台、管理 REST 和用户 REST/前端对提现状态的处理不完全一致,存在拒绝后冻结金额未退回、状态与通知分叉等风险。
5. 退款没有可靠的返佣冲正和已提现佣金追缴模型;佣金比例、邀请关系和订单快照也没有统一的不可变账务边界。
6. 参考主题提供了需要复刻的产品体验:单级直接推广、佣金比例与适用范围、我的佣金聚合、返佣明细、提现中的状态展示、人工收款码打款和原生运营页;但参考实现存在权限、退款、幂等和状态筛选缺陷,不能原样复制。
本次工作将把推广返佣重做为现金账本驱动的完整闭环,并将用户中心的推广中心与收益中心分开组织。按用户决定,现有邀请码/注册积分与成长值奖励链整体替换为参考主题式 `ref` 推广契约,不保留旧新双路、兼容读取或数据迁移层。
## Current State
### 已存在
- 用户关系、邀请码、注册处理、邮箱验证、邀请奖励、内容付费订单、创作收益、现金钱包和提现审核均有独立实现。
- 内容现金订单支付成功后可根据推广人和比例创建 pending 返佣记录,并在订单侧保存推广人和佣金快照。
- 创作收益已经能以现金形式结算到钱包;现金钱包具备冻结、提现、拒绝退回和原生后台审核能力。
- 后台已有推广返佣、创作分成、提现审核的原生运营入口,以及支付相关设置页和部分管理 REST。
- 用户中心已有推广/邀请与收益标签的壳、邀请链接/记录请求和现金提现交互。
### 必须替换或补齐
- 将当前邀请码/注册奖励关系替换为唯一的 `ref` 推广关系;不再保留邀请码奖励配置、邀请码捕获或旧关系的兼容读取。
- 将会员订单和付费内容订单统一接入单级订单返佣;其他交易类型不参与本期佣金。
- 将返佣从积分账户迁移为现金钱包账务的唯一来源。这里的“迁移”不是运行时数据迁移:实现只提供新的前向存储契约,不回填旧记录、不双读旧字段。
- 用不可变订单佣金快照、调整记录、幂等键和现金流水替换当前“状态先更新、资金操作后执行”的非原子结算。
- 增加 7 天观察期后的定时批量结算、失败重试、退款冲正、已提现欠款约束和管理员补跑。
- 增加仅限当前用户的收益概览、佣金分页明细、提现分页记录和收款码管理 API;管理员读取和处理必须经过独立运营权限。
- 统一用户 REST、管理 REST、原生 WP 后台和前端使用同一提现状态机;删除不可达的第二套运营页面,不保留分叉实现。
## Solution
### 推广与归因体验
- 每个用户获得一个不可枚举、稳定的推广码,推广链接使用 `?ref=<推广码>`。
- 未注册访客第一次访问推广链接时锁定归因,保存 30 天;后续推广链接不能覆盖已保存的首次触达来源。
- 用户注册成功后立即绑定首次触达的推广人,关系永久固定,不允许换绑、自邀、循环绑定或将已绑定用户重新归因给其他推广人。
- `ref` 同时承担注册归因和订单佣金归因;当前邀请码、独立邀请码、注册积分/成长值奖励链整体移除,不与 `ref` 并存。
- 推广中心展示推广码、推广链接、复制反馈、当前佣金比例、适用订单类型、推广人数和有效付费人数;不展示被推广用户的完整订单或敏感收益。
### 订单返佣体验
- 仅会员订单和付费内容订单可产生推广佣金;免费订单、积分订单、充值/余额购买、自购、自邀、取消订单和不满足支付条件的订单不产生佣金。
- 一笔订单只归因给一个直接推广人,不产生二级或团队佣金。
- 返佣基数是订单实际支付的可返佣金额;优惠、抵扣和订单级让利后的有效金额进入基数,非返佣项目不进入基数。
- 全站统一佣金比例,初始默认 5%,后台可调整。订单产生返佣时同时保存基数、比例和金额快照;比例变更只影响新订单。
- 支付成功后返佣进入待结算,7 天观察期结束后由定时任务批量转为可用现金。结算任务支持幂等、失败重试和管理员补跑。
- 全额退款冲销整笔对应佣金;部分退款按退款金额占原可返佣金额的比例生成负向调整。待结算金额直接减少,可用金额扣减;已提现金额形成用户欠款,欠款未偿还前暂停新的提现申请。
- 原始佣金记录不可被覆盖,退款、手续费、欠款回收和人工更正均以带来源的调整流水表示。
### 收益中心体验
用户中心新增独立“收益中心”标签,推广中心只负责推广动作和归因信息,收益中心只负责现金收益和提现:
- 顶部聚合卡展示当前佣金比例、累计产生佣金、待结算佣金、可用佣金、提现中、已提现、推广人数和有效付费人数。
- 佣金明细支持分页、状态筛选和订单类型筛选;每行展示会员/付费内容类型、订单标识、产生时间、可返佣基数、比例快照、佣金金额、当前状态、结算时间和冲正信息。买家信息只保留必要的脱敏标识。
- 提现记录展示毛额、手续费率、手续费、净额、收款渠道、申请时间、审核时间、打款时间、当前状态和管理员备注。
- 用户可以维护支付宝和微信收款码,并在提现申请时选择渠道;申请保存收款凭证快照,后续修改个人收款码不改变已提交申请。
- 可用佣金达到最低 50 元且没有未偿欠款时,用户可以提交提现申请。默认手续费为 5%,费率、毛额和净额在申请时锁定,手续费不得超过毛额。
- 提现状态依次为待审核、已批准、已打款或已驳回。管理员必须先在线下完成收款码付款,再将申请标记为已打款;驳回会原子释放冻结金额并记录原因。
- 加载中、无数据、权限失败、余额不足、低于门槛、已有待处理申请、收款码缺失、佣金欠款和网络失败均提供明确反馈,不显示误导性金额。
- 桌面和移动端均使用用户中心现有壳、导航、响应式布局和设计 token;佣金卡、明细、提现表单不要求横向滚动。
### 管理员体验
- 配置面仅负责开关、全站佣金比例、适用订单类型、结算观察期、最低提现金额、手续费比例、可用收款渠道、提现协议和展示文案。
- 运营面使用原生 WP 支付中心:管理员可以按订单类型、推广人、用户、状态和时间筛选佣金记录,查看不可变快照和调整历史,执行补结算/取消待结算记录,并处理退款冲正异常。
- 原生提现页支持待审核、已批准、已打款、已驳回筛选,展示收款码快照、毛额/手续费/净额、关联佣金流水、欠款约束和操作历史;通过和拒绝都要求 nonce、权限和幂等检查。
- 管理 REST 与原生页面调用同一个领域服务,不复制状态转换、余额冻结或通知逻辑。
- 运营权限只向明确授权的管理员开放;普通用户不能通过修改用户 ID、分页参数或订单号读取他人收益或提现数据。
## User Stories
1. **推广用户**:我可以生成不可枚举的 `ref` 推广链接,并在复制成功或失败时得到明确反馈。
2. **首次访问者**:我首次打开推广链接后,30 天内注册都能保持同一推广归因;后来打开其他推广链接不会覆盖首次来源。
3. **新用户**:我注册成功后推广关系立即固定,不能因为后续访问或订单参数变化被换绑。
4. **管理员**:我可以启用或关闭返佣,并设置全站佣金比例、结算观察期、提现门槛、手续费和可用渠道。
5. **推广用户**:我的被推广用户购买会员后,可以看到一笔包含比例和可返佣基数快照的待结算佣金。
6. **推广用户**:我的被推广用户购买付费内容后,可以看到与会员订单一致的佣金体验;非会员/内容订单不应静默产生佣金。
7. **推广用户**:佣金比例变更后,已有订单金额不变化,新订单才使用新比例。
8. **推广用户**:支付后的 7 天观察期内,我能区分待结算与可用佣金;观察期结束后,系统自动将到期记录结算到现金钱包。
9. **推广用户**:订单全额退款时,待结算佣金被冲销;部分退款时,只冲销对应比例,未退款部分保留。
10. **推广用户**:已经提现的订单随后退款时,系统形成可追踪欠款;在欠款清偿前,我不能继续发起提现。
11. **推广用户**:收益中心能一次看到佣金比例、累计产生、待结算、可用、提现中、已提现、推广人数和有效付费人数。
12. **推广用户**:我只能看到自己的佣金、提现和推广汇总,不能通过接口参数读取其他用户的订单或余额。
13. **推广用户**:我可以维护支付宝或微信收款码,申请提现时保存当时的收款码快照。
14. **推广用户**:当可用佣金低于 50 元、存在欠款、缺少收款码或已有待审核提现时,系统阻止申请并说明原因。
15. **管理员**:我可以先审核提现,再在确认线下打款后标记已打款;驳回时冻结金额会恢复,申请保留驳回原因和操作历史。
16. **管理员**:我可以重新运行失败的结算任务或处理异常调整,但不能覆盖原始订单佣金记录。
17. **安全审计者**:所有用户接口、管理员接口和提现状态转换都具有正确的权限、nonce、输入验证、输出脱敏和幂等约束。
18. **运营人员**:关闭返佣后,新订单不再生成佣金;既有账本、已批准提现和退款冲正仍可完成,不产生半套功能。
## Implementation Decisions
### New Modules
#### Referral Attribution
负责推广码、首次触达和永久关系,不负责积分、余额或订单金额。
- `capture(ref, request_context): CaptureResult`
- `bind(user_id, capture_context): BindingResult`
- `resolve(user_id, occurred_at): ReferralRelation|null`
- `create_link(user_id): ReferralLink`
- `validate_relation(promoter_id, user_id): ValidationResult`
关系数据必须以推广码和用户唯一约束保证幂等;模块拒绝不存在的推广码、自邀和重复绑定。
#### Commission Ledger
负责订单佣金报价、不可变快照、待结算、调整、统计和欠款,不直接处理页面渲染。
- `quote(order_context): CommissionQuote`
- `record_paid_order(order_context, quote, idempotency_key): LedgerEntry`
- `settle_due(as_of, limit): SettlementReport`
- `reverse(order_context, refund_context, idempotency_key): AdjustmentResult`
- `get_overview(user_id): EarningsOverview`
- `list_entries(user_id, filters, page): PaginatedLedger`
金额使用货币精度计算和数据库十进制存储,不把元金额强转为积分整数;结算与现金钱包入账必须在同一事务边界内完成。
#### Order Commission Adapter
将会员和付费内容订单事件映射到佣金账本,隐藏各订单领域的字段差异。
- `on_membership_paid(order): CommissionEventResult`
- `on_content_paid(order): CommissionEventResult`
- `on_order_refund(order, refund): AdjustmentResult`
- `run_due_settlement_batch(batch_context): SettlementReport`
支付、重复 webhook、订单重试、退款重试必须使用稳定幂等键;其他订单类型明确返回“不适用”,不得落入默认返佣路径。
#### Cash Wallet and Withdrawal
统一返佣、创作收益和现金余额的入账、冻结、释放、扣款和流水;提现请求只由这一模块改变冻结资金。
- `credit(user_id, amount, source, idempotency_key): WalletEntry`
- `hold(user_id, amount, source_id): HoldResult`
- `release(user_id, hold_id, reason): ReleaseResult`
- `create_withdrawal(user_id, request): WithdrawalRequest`
- `transition_withdrawal(id, transition, operator): WithdrawalResult`
- `record_adjustment(user_id, amount, source, idempotency_key): WalletEntry`
提现状态机为 `pending → approved → paid` 或 `pending → rejected`;状态转换、余额变更、通知和审计记录必须原子且可重试。
#### Earnings Center Read Model
对用户和管理员提供统一的只读聚合,隐藏账本、钱包和提现表的组合查询。
- `get_user_overview(user_id): EarningsOverview`
- `get_user_commissions(user_id, filters, page): PaginatedCommissionItems`
- `get_user_withdrawals(user_id, page): PaginatedWithdrawalItems`
- `get_admin_overview(filters): AdminEarningsOverview`
用户端方法从认证上下文取得用户身份,不接受可覆盖身份的请求参数;管理员方法必须经过运营权限检查。
#### Configuration and Native Operations
配置模块提供唯一的返佣和提现设置契约;运营模块提供原生 WP 后台列表、筛选、补结算、冲正审核和提现状态操作。
- `get_affiliate_settings(): AffiliateSettings`
- `save_affiliate_settings(input): AffiliateSettings`
- `list_admin_ledger(filters, page): AdminLedgerPage`
- `list_admin_withdrawals(filters, page): AdminWithdrawalPage`
- `process_admin_action(id, action, note): OperationResult`
配置只在 SPA 配置面维护;佣金订单、提现审核和用户运营数据只在原生 WP 操作面维护,不在两个前端实现同一套业务工作台。
### Architectural Decisions
- **唯一归因契约**:全面使用 `ref` + 不可枚举推广码;删除当前邀请码/独立邀请码/邀请奖励链,不保留旧参数、旧 cookie、旧关系表的双读或兼容分支。
- **单级关系**:订单只奖励直接推广人;不支持二级、团队或推广人等级叠加比例。
- **订单范围**:只支持会员和付费内容;不为商城、充值、积分购买或未来订单类型添加隐式兜底。
- **计价口径**:以实际支付的可返佣金额为基数,比例为全站统一比例;下单时保存比例、基数、订单类型和佣金金额快照。
- **时间口径**:注册前首次触达保留 30 天;注册成功立即绑定;支付后待结算 7 天;比例修改只影响新订单。
- **现金会计**:返佣只能进入现金钱包,不能进入积分账户;佣金账本、现金钱包流水和提现请求以稳定来源标识互相追溯。
- **退款会计**:不修改原始佣金金额;全额/部分退款写入负向调整,已提现部分形成欠款,欠款清偿前禁止新的提现。
- **提现会计**:提现申请锁定毛额、费率、手续费、净额、渠道和收款码快照;默认最低 50 元、默认手续费 5%,两项均可由管理员调整。
- **人工支付**:只支持支付宝/微信收款码人工打款;不接入自动支付 API、银行转账或其他渠道。
- **权限边界**:登录用户只看自己;管理员看全站;所有 REST 路由都有明确权限回调,所有状态变更都有 nonce/能力校验、输入校验和审计记录。
- **定时结算**:使用 WordPress 定时任务批量结算到期佣金,使用行锁/幂等键/失败重试避免重复入账;用户访问收益中心不承担资金副作用。
- **配置与运营分层**:SPA 只管理设置;原生 WP 只管理佣金、结算和提现运营;删除不可达的重复运营 Vue 页面与重复邀请配置面。
- **国际化与前端**:所有用户可见文案使用 `aksrt` 文本域;收益概览、明细和提现表单复用用户中心壳与设计 token,不新增独立 UI 框架。
- **安全改进优先于字面复制**:不复制参考主题的用户 ID 枚举、任意用户明细查询、退款不冲正、状态筛选失效、提现创建失败后冻结不回滚等缺陷。
### Schema Changes
- 将现有邀请关系存储替换为 `referral_relation` 契约:唯一推广码、推广人、被推广用户、首次触达时间、绑定时间和关系状态;被推广用户唯一绑定。
- 将现有返佣记录替换为 `commission_ledger` 契约:受益推广人、来源用户、来源订单标识、订单类型、可返佣基数、比例快照、原始佣金、调整总额、状态、到期时间、结算时间、幂等键和审计说明。
- 返佣原始记录不可变;退款冲正、欠款回收和人工纠正使用带来源的调整记录,能够关联订单、退款和操作人。
- 现金钱包流水增加来源类型、来源标识、幂等键、正负方向、可用余额变化和冻结余额变化,保证佣金、创作收益、提现冻结和退款调整可追踪但不混入积分流水。
- 提现请求保存用户、毛额、手续费率、手续费、净额、渠道、收款码附件快照、状态、申请/审核/打款时间、操作人、备注和关联资金流水;账户信息只按最小必要范围存储并在列表中脱敏。
- 会员和付费内容订单必须能提供稳定订单标识、支付金额、可返佣金额、推广人快照和退款金额;佣金账本以订单标识 + 受益人 + 类型构成幂等约束。
- 前向 schema 安装替换旧返佣/邀请码/邀请奖励契约,提升 schema 版本并更新清理清单;不执行旧数据回填、旧字段双读、旧表兼容或静默迁移。
### API Contracts
所有接口使用 `aksrt/v1`,响应遵循统一错误结构和分页结构,金额以十进制金额字符串或明确的货币数值返回,不向前端暴露内部数据库结构。
#### 登录用户接口
- `GET /promotion/me`:返回当前用户的推广码、`ref` 链接、当前佣金比例、适用订单类型、推广人数和有效付费人数。
- `PUT /promotion/payout-accounts`:保存支付宝/微信收款码附件引用;只允许当前用户修改自己的资料,附件类型、归属和权限必须校验。
- `GET /earnings/overview`:返回当前佣金比例、累计产生、待结算、可用、提现中、已提现、有效付费人数、欠款、最低提现金额、手续费和启用渠道。
- `GET /earnings/commissions`:按状态、订单类型、日期分页查询当前用户的佣金流水;只返回本人的订单佣金及必要脱敏字段。
- `GET /earnings/withdrawals`:分页返回当前用户的提现申请、状态、毛额/手续费/净额、渠道、时间和处理备注。
- `POST /earnings/withdrawals`:提交提现金额和渠道;服务端重新检查可用余额、门槛、欠款、重复待处理申请、收款码和手续费边界,不信任前端汇总。
#### 管理员接口
- `GET /payment/affiliate/overview`:返回全站返佣、待结算、可用、冲正、欠款、提现和任务统计。
- `GET /payment/affiliate/ledger`:按推广人、来源用户、订单类型、状态、日期和订单标识分页查询佣金账本。
- `POST /payment/affiliate/ledger/{id}/settle` 与 `POST /payment/affiliate/ledger/{id}/cancel`:仅用于合法的补结算或取消待结算记录,受状态机和幂等约束限制。
- `GET /payment/withdrawals`:按状态、用户、渠道、日期和金额分页查询提现申请,返回脱敏收款码快照和关联流水。
- `POST /payment/withdrawals/{id}/transition`:执行批准、已打款或驳回;批准不扣除冻结余额,已打款完成资金结算,驳回释放冻结余额,所有转换必须记录操作人和理由。
- 配置 REST 只读写唯一的 affiliate、withdrawal 和 settlement 设置契约;不暴露旧 invite/rebate/balance 多套字段名。
所有用户接口的权限回调强制使用当前认证用户;所有管理员接口使用独立运营能力;不存在接受任意 `user_id` 后直接查询收益的接口。
### Navigation
- 用户中心保留两个独立标签:**推广中心**(推广码、链接、归因和推广统计)与 **收益中心**(佣金、现金钱包、提现)。不再注册旧邀请码、旧邀请奖励或创作收益与推广佣金混排的重复页面。
- 站点管理员在 SPA 中进入推广返佣/提现配置;在原生 WP 支付中心进入佣金运营和提现审核。配置页不承载订单工作台,运营页不重复编辑配置表单。
- 旧的独立收益页面、旧 invite 页面和不可达 SPA 运营页面不保留兼容跳转或双渲染路径;以当前用户中心标签和原生运营菜单为唯一入口。
## Testing Decisions
本次要求核心全链路自动化测试,并使用 WordPress/浏览器做关键 UI 冒烟。测试不依赖旧实现的错误行为,重点覆盖:
### 归因与注册
- 有效推广码首次访问、30 天内注册、过期后注册、后续链接覆盖尝试。
- 不存在的推广码、格式错误、自邀、循环关系、重复注册绑定、并发注册绑定。
- 注册成功后关系永久固定,禁止用户 ID 参数覆盖当前认证用户。
- 旧邀请码、旧 invite 参数和旧奖励设置不再产生任何返佣或注册奖励路径。
### 订单与账本
- 会员现金订单和付费内容现金订单各自产生一笔 pending 佣金。
- 免费、积分、充值、非目标订单、自购和无有效推广关系不产生佣金。
- 重复支付事件、重复 webhook、重复任务和并发处理不会重复写入佣金或现金。
- 优惠、抵扣、让利后的可返佣基数和 5% 比例快照正确;修改比例不重算历史订单。
- 7 天前后结算边界、定时任务批量锁定、失败重试、管理员补跑和通知。
- 返佣结算进入现金钱包而非积分;现金流水、账本状态和余额变化保持原子一致。
### 退款与欠款
- 观察期内全额退款直接冲销待结算佣金。
- 可用佣金全额/部分退款按比例生成调整,原始记录保持不变。
- 已提现佣金退款形成欠款;余额不足时禁止新提现,后续返佣可按规则抵扣欠款。
- 重复退款事件不重复冲正,异常退款能进入可审计的人工处理状态。
### 提现与权限
- 低于 50 元、无收款码、有未偿欠款、有待审核申请、余额不足和手续费超界均被拒绝。
- 支付宝/微信收款码校验、账户快照、脱敏展示和个人资料更新不影响已有申请。
- 待审核 → 已批准 → 已打款、待审核 → 已驳回的合法转换;非法转换、重复点击、并发审批被拒绝。
- 驳回释放冻结金额;批准/已打款只在正确阶段改变资金;原生后台与管理 REST 结果一致。
- 用户接口无法读取其他用户的 overview、佣金、提现或收款码;管理员接口需要正确能力。
### 前端与合约
- 收益中心概览、分页明细、提现记录、筛选、空状态、加载失败和成功/失败反馈。
- 桌面与移动端无横向溢出,复制推广链接、上传收款码、提现提交和错误提示可键盘操作。
- SPA 配置 schema、PHP schema、REST 契约和原生菜单通过 `php tools/audit-admin-contracts.php`。
- Admin SPA 运行构建通过;在外部 WordPress harness 中完成注册、支付、退款、结算、提现和权限冒烟。
## Out of Scope
- 多级、团队、间接推广、推广人等级差异化比例、商品/商城返佣。
- 充值、积分购买、纯积分订单、免费订单、银行转账、PayPal 或自动打款 API。
- 佣金趋势图、复杂数据分析、公开排行榜、公开推广用户详情和推广人查看被推广用户完整订单。
- 将现金佣金转换为积分、站内消费余额或其他第二种资金账户。
- 保留或新增 `invite`/`ref` 双链接、旧字段 fallback、旧新接口并行、旧表双读、数据回填、兼容跳转或迁移脚本。
- 复制参考主题的用户 ID 暴露、任意用户查询、提现失败冻结不回滚、退款不冲正、状态筛选失效、金额整数截断和 AJAX 拼接 HTML 等缺陷。
- 自动化社交平台分享、第三方推广统计、站外归因和多站点推广网络。
@@ -1,95 +0,0 @@
# 用户中心侧栏 VIP 入口卡片
## Problem Statement
用户中心侧栏当前在统计信息卡片后直接展示账户资产卡片,会员入口仅存在于资产页内容区。会员入口缺少侧栏层级的持续曝光,并且与参考主题的“统计信息 → VIP 入口 → 账户资产”信息架构不一致;同时,用户中心内重复出现会员入口会造成导航冗余。
## Current State
- 用户中心侧栏按“统计信息、账户资产、创作/收益、服务、设置”的顺序组织内容。
- 已存在独立的“我的会员”用户中心页面,并使用现有 VIP 等级、会员状态和到期状态能力展示会员信息及承接续费、升级、开通等操作。
- 资产页内容区当前已有一个进入“我的会员”页面的 VIP 入口。
- 会员功能可由后台 VIP 等级开关控制;未启用任何 VIP 等级时,会员中心不应成为可用入口。
- 移动端用户中心侧栏会收纳至既有抽屉中。
## Solution
在用户中心侧栏中,统计信息卡片下方、账户资产卡片上方新增一个醒目的 VIP 入口横卡;整体顺序调整为“统计信息 → VIP 入口 → 账户资产”。
入口采用参考主题的会员横卡视觉方向:金色或会员感渐变背景、会员图标、动态状态文案及右侧箭头。卡片为完整可点击区域,点击后以现有用户中心 URL 进入“我的会员”页面。
入口仅在至少一个 VIP 等级启用时展示。未启用会员功能时,不渲染入口,也不提供不可用状态或管理员例外。
卡片根据当前登录用户的既有会员状态展示:
- 未开通会员:展示开通会员及会员权益引导文案。
- 当前有效会员:展示当前会员等级和既有有效期状态文案,例如“VIP1 · 还有 30 天到期”。
- 已过期会员:展示当前或最近会员等级及既有“已过期”等状态文案。
状态文案沿用现有会员状态语义,不新增精确到期日期格式或新的会员状态计算规则。
移动端不额外创建内容区入口;卡片随现有侧栏收纳至抽屉,并保持与桌面端相同的顺序。
为消除重复入口,移除账户资产页内容区中现有的 VIP 入口卡片。独立“我的会员”页面、会员套餐展示、购买弹窗、支付流程和会员状态逻辑保持不变。
## User Stories
1. 作为未开通会员的登录用户,我可以在用户中心侧栏统计信息下方看到会员入口,了解开通会员可获得权益,并可进入我的会员页面。
2. 作为有效会员,我可以在侧栏入口直接看到当前会员等级和既有到期状态提示,并进入我的会员页面进行续费或升级。
3. 作为已过期会员,我可以在侧栏入口看到已过期状态,并进入我的会员页面重新开通或续费。
4. 作为普通用户,当站点没有启用任何 VIP 等级时,我不会看到失效或不可访问的会员入口。
5. 作为移动端用户,我可以在用户中心侧栏抽屉中按“统计信息、VIP 入口、账户资产”的顺序找到会员入口。
6. 作为用户,我在账户资产页不再看到重复的会员入口,但原有资产余额、积分、签到及记录功能不受影响。
7. 作为用户,我点击 VIP 横卡后会通过现有可分享、可刷新、可返回的用户中心链接进入“我的会员”页面。
## Implementation Decisions
### New Modules
- **侧栏 VIP 入口渲染器**:基于既有用户中心渲染上下文,在统计信息与账户资产之间输出一个完整可点击的会员横卡;负责会员功能启用判断、状态展示数据和目标链接的组合。
- **侧栏 VIP 入口样式模块**:为会员横卡定义桌面端与抽屉端一致的会员视觉、图标、状态文本、箭头、悬停和窄屏布局;不改变现有统计卡或资产卡的布局职责。
### Architectural Decisions
- 复用既有 VIP 启用判断、会员等级解析和到期状态文本;不复制或重新定义会员状态业务逻辑。
- 复用现有“我的会员”用户中心目标页和 `tab=vip` 导航方式;不采用未开通即打开购买弹窗的分流行为。
- 侧栏顺序由侧栏组合层负责,VIP 入口置于统计信息渲染之后、账户资产渲染之前。
- 删除资产页内容区仅作为导航入口的现有 VIP 卡片;不删除独立会员页面及其中的套餐、权益、续费、升级、兑换和支付能力。
- 不引入新设置项;可见性完全由现有 VIP 等级启用配置决定。
### Schema Changes
无。复用已有会员配置、用户会员状态和到期状态数据。
### API Contracts
无。入口仅使用既有用户中心页面路由和现有服务端渲染数据。
### Navigation
- 桌面端:用户中心侧栏展示“统计信息 → VIP 入口 → 账户资产”。
- 移动端:上述顺序在既有用户中心侧栏抽屉中保持一致。
- 点击 VIP 入口:进入现有用户中心“我的会员”目标页(`tab=vip`)。
- 账户资产页:移除其内容区内已有的 VIP 导航入口,避免重复。
## Testing Decisions
采用静态契约审计与人工响应式验收。
- 运行现有管理员契约审计,确保本次模板与样式调整没有引入管理端契约回归。
- 人工验收未开通会员:卡片显示权益引导文案,点击进入我的会员页面。
- 人工验收有效会员:卡片显示会员等级及既有剩余有效期状态文案。
- 人工验收过期会员:卡片显示既有过期状态文案,跳转仍可用。
- 人工验收未启用任何 VIP 等级:侧栏不显示 VIP 入口。
- 人工验收桌面端顺序:统计信息、VIP 入口、账户资产依次显示,会员横卡视觉与资产双卡区分明显。
- 人工验收移动端:入口随侧栏进入抽屉,顺序、可点击性和窄屏样式正确。
- 人工验收资产页:原 VIP 入口已移除,余额、积分及其他资产功能仍正常。
## Out of Scope
- 修改“我的会员”页面的布局、文案、套餐卡、权益内容或购买流程。
- 修改支付弹窗、支付渠道、续费、升级、兑换、卡密兑换或订单逻辑。
- 新增、修改或迁移会员数据、会员状态算法、到期日期格式、数据库表或 REST API。
- 新增后台会员配置、侧栏开关、管理员例外显示规则或运营统计。
- 记录 VIP 入口的曝光、点击或转化数据。
- 在移动端内容区额外置顶或复制 VIP 入口。
-193
View File
@@ -1,193 +0,0 @@
# PRD:用户中心创作中心与文章价格表
> 来源:用户指令(2026-08-06)。参考主题:`C:\Users\Xing\Desktop\zibll`。
## 背景与问题
AKSRT 当前用户中心的“我的文章”主要承担文章列表展示,前台投稿页已经具备文章编辑、草稿、审核、封面、分类、标签和付费配置能力,但这些能力分散在用户中心与独立投稿页之间,缺少参考 zibll 的完整创作工作台体验:
- 创作者无法在一个清晰的工作区中快速查看文章状态、进入新建/编辑、处理草稿和待审内容。
- 文章管理信息密度和操作反馈不足,无法快速定位最近编辑、已发布、草稿、待审和私密文章。
- 知识付费主题不需要 zibll 的商品/规格编辑,但需要可快速维护文章的免费、现金、积分及会员价格。
- 价格规则、付费阅读/付费资源和下载资源已经存在于 AKSRT 的投稿保存链路中;若创作中心另建价格 API 或校验逻辑,会造成数据和订单行为分裂。
## 目标
将用户中心现有 `posts` 面板升级为唯一的“创作中心”工作台,复刻 zibll 创作体验中可复用的 UX/UI 信息层级与交互细节,同时适配知识付费文章模型:
1. 在用户中心内提供文章创作总览、状态筛选、文章列表和明确的创作操作入口。
2. 复用现有前台投稿页作为新建/编辑文章的完整编辑器,保持双栏编辑体验,不复制第二套编辑器。
3. 在创作中心提供文章价格表,支持单篇快速调整价格,并支持批量选择后统一应用价格方案;所有保存最终走现有 `FrontSubmit` / `FrontSubmitPay` canonical 通路。
4. 在桌面端提供高效的双栏/卡片式工作区,在移动端提供单列、易触控、无横向溢出的体验。
5. 保留现有权限、状态、付费、下载、nonce、清洗、转义和设置开关边界。
## 非目标
- 不复制 zibll 的 PHP 函数、字段名、商品规格模型、订单模型或主题配置 API。
- 不在用户中心新增独立的 `creator` 页面、第二套文章管理 tab 或第二套前台路由;`posts` 是唯一文章管理入口,界面标题可升级为“创作中心”。
- 不把完整 TinyMCE 编辑器复制嵌入用户中心;新建/编辑继续跳转 `aksrt_get_new_post_url()` 对应的投稿页面。
- 不修改文章购买、订单结算、收益分成、优惠码、付费下载实际业务规则。
- 不新增旧选项双读、旧路由兼容层、数据迁移或新旧 UI 并存实现。
- 不把日常创作工作台迁入配置型 Admin SPA。
## 用户故事
- **US1(已登录创作者)**:我可以从用户中心侧栏、移动导航或创作快捷卡片进入创作中心,并通过稳定的 `?tab=posts` 深链直达。
- **US2(已登录创作者)**:我可以看到文章总数、各状态数量和最近文章,并在已发布、草稿、待审、私密、全部之间筛选。
- **US3(已登录创作者)**:我可以从创作中心一键新建文章,或对有权限的文章继续编辑、预览和删除;点击后复用现有投稿页面。
- **US4(已登录创作者)**:我可以在创作中心快速查看每篇文章的免费/付费状态、支付模式、普通价格和会员价格。
- **US5(已登录创作者)**:我可以直接在价格表中编辑一篇或多篇文章的价格,看到校验提示和保存结果,不必逐篇打开完整编辑器。
- **US6(有付费权限的创作者)**:我可以维护现金或积分价格、VIP1/VIP2 价格、付费阅读/付费资源和购买限制;价格规则与投稿页一致。
- **US7(移动端创作者)**:我可以在窄屏下查看文章、展开价格编辑、选择批量项和提交保存,不需要横向滚动。
- **US8(无权限或功能关闭时)**:我只能看到服务端允许的文章和操作;投稿、编辑、价格、下载等不可用能力不显示为可执行操作。
- **US9(开发者)**:文章查询、权限判断、价格清洗校验、元数据保存和订单读取保持单一业务通路,不因新增 UI 产生第二套业务规则。
## 需求
### 1. 用户中心信息架构
1. 将现有用户中心 `posts` 面板升级为创作中心,不新增重复 tab。
2. 复用现有:
- `AKSRT\User\Center` 面板注册与 REST 按需加载。
- `aksrt_get_user_center_tab_url()`、`aksrt_user_center_resolve_tabs()` 和运行时配置。
- `templates/parts/user-center/panel.php` 的 tabpanel 语义。
- 现有用户中心侧栏、移动 tab 导航和创作快捷卡片。
3. 入口名称统一为“创作中心”;内部键仍为 `posts`,避免同一文章管理能力出现两个入口。
4. 面板在用户未登录、用户中心禁用或投稿能力关闭时遵循现有访问与设置门控;不显示无法执行的创作操作。
5. 创作中心推荐的信息顺序:
- 顶部标题与主操作(新建文章)。
- 数据概览(全部、已发布、草稿、待审、私密)。
- 文章管理列表。
- 价格表/快速调价区。
- 空状态、加载状态和失败状态。
### 2. 文章总览与状态管理
1. 复用现有文章作者查询和状态计数,支持:`publish`、`draft`、`pending`、`private`、`all`。
2. 每篇文章至少展示:标题、封面/占位图、文章状态、更新时间、浏览或基础统计(若当前上下文已有)、付费状态、价格摘要和可用操作。
3. 状态筛选保持 URL 可深链;筛选切换不丢失当前用户中心 tab。
4. 非发布状态显示可读的文字状态标签,不只依赖颜色。
5. 支持分页或现有分页机制;不得为了价格表一次性加载无界文章数量。
6. 操作按钮按服务端能力显示:
- 新建文章。
- 编辑文章:仅 `can_edit_post()` 为真时显示。
- 预览:仅文章存在可访问预览时显示。
- 删除:仅 `can_delete_post()` 为真时显示,并提供确认反馈。
- 快速调价:仅具备 `new_post_pay` 且文章可编辑时显示。
7. 删除继续使用现有 nonce、作者归属、能力检查和草稿/回收站语义,不新增删除业务。
### 3. 新建与编辑体验
1. 新建文章入口继续使用 `aksrt_get_new_post_url()`。
2. 编辑入口传递现有文章 ID,继续使用 `FrontSubmit::can_edit_post()` 和当前投稿页编辑状态。
3. 投稿页继续复用现有:
- TinyMCE 编辑器与移动/桌面高度策略。
- 封面上传与封面预览。
- 分类、专题、高级筛选、标签。
- 草稿保存、提交审核/发布、离开页面警告。
- AI 摘要/标签等已存在能力。
4. 参考 zibll 的双栏工作台层级:左侧主编辑区,右侧文章参数、付费配置和提交操作;不复制 zibll 的私有 API 或字段。
5. 新建/编辑返回创作中心时,显示明确的保存/提交结果,并尽可能保留状态筛选和分页上下文。
### 4. 文章价格表
#### 4.1 展示字段
价格表在创作中心文章管理区作为独立卡片或可切换工作区展示。每行对应一篇当前用户有权限管理的文章,至少包含:
- 文章标题。
- 付费类型:免费、付费阅读、付费资源。
- 支付模式:现金或积分。
- 普通价格。
- VIP1 价格(启用时)。
- VIP2 价格(启用时)。
- 购买限制、登录限制等摘要(已有字段可提供时)。
- 最近保存时间与保存状态。
- 编辑价格、打开完整编辑器操作。
关闭付费的文章显示“免费”,不得把空值误呈现为价格为 0 的可购买配置。
#### 4.2 单篇快速调价
1. 价格表支持行内进入编辑态或展开编辑面板,不要求跳转完整文章编辑器。
2. 编辑态提供与 `sidebar-pay.php` 一致的字段:免费/付费开关、现金/积分、普通价格、VIP1、VIP2、付费类型、购买限制、登录限制;付费下载资源仅在现有接口能安全支持时提供入口,否则引导打开完整编辑器。
3. 编辑态提供最小值、最大值、免费规则和会员折扣的辅助提示,提示来源于现有设置,不在前端硬编码业务限制。
4. 保存中锁定当前行并呈现 loading;成功后更新该行摘要和可见成功反馈;失败时保留输入值并呈现服务端错误。
5. 价格修改不得隐式修改标题、正文、分类、封面或其他文章字段。
#### 4.3 批量快速调价
1. 价格表支持选择多篇有权限编辑的文章。
2. 批量工具支持统一应用一个明确的价格方案:
- 设为免费。
- 现金价格方案:普通价、VIP1、VIP2。
- 积分价格方案:普通积分、VIP1、VIP2。
3. 批量操作必须在提交前显示影响文章数量和最终价格预览,并要求用户确认。
4. 批量操作遇到部分文章无权限、文章已删除或校验失败时,服务端逐项返回结果;前端明确展示成功与失败项,不得静默覆盖。
5. 批量更新应采用受控请求大小或分页处理,不能无界提交;不得改变文章正文等非价格字段。
6. 若现有 `FrontSubmit` AJAX 契约无法安全表达批量更新,应先抽取共享的价格服务/请求契约,再由单篇和批量共同调用,不得复制 `FrontSubmitPay` 的规则。
#### 4.4 价格规则与 canonical 通路
1. 所有单篇和批量价格保存必须复用现有 `FrontSubmitPay::save_pay_meta()`,或抽取其为唯一共享价格服务;最终写入统一的 AKSRT 付费元数据结构。
2. 必须保持现有规则:
- 价格遵守管理员配置的最低值、最高值和是否允许免费。
- VIP1 价格不能高于普通价格。
- VIP2 价格不能高于 VIP1。
- 未手动填写会员价格时,按现有折扣配置计算。
- 现金/积分模式只接受当前 canonical 值。
- 付费类型、购买限制、登录限制、优惠码和付费下载遵循当前能力开关。
3. 服务端是最终校验者;前端提示只用于辅助,不得替代权限和业务校验。
4. 订单读取、文章购买和收益结算继续使用既有 pay meta 与服务,不新增价格存储键或订单解释逻辑。
### 5. 视觉与交互
1. 视觉参考 zibll 的用户中心与投稿页:卡片分组、清晰主次操作、状态徽章、封面/占位图、保存反馈、确认弹窗和移动端 bottom-sheet/展开面板体验。
2. 遵循 AKSRT 现有用户中心设计 token、字体、图标和 CSS 模块,不引入新的 UI 框架。
3. 关键交互必须支持键盘焦点、可见焦点和语义化按钮;错误和成功不能只用颜色表达。
4. 加载、空数据、失败、部分成功状态均需可感知,避免请求失败时显示误导性的 0 或空列表。
5. 删除与批量价格更新必须有可见确认;保存失败时不清空用户输入。
### 6. 响应式要求
- 桌面端:文章管理和价格表可使用表格/宽卡片;批量工具和主操作固定在容易扫描的位置。
- 移动端:表格转为卡片或可横向安全展开的分组布局,默认不得产生页面级横向溢出;价格字段垂直排列,触控目标足够大。
- 移动端批量选择、价格编辑、确认弹窗和错误反馈均可完成,不依赖 hover。
- 文章编辑页继续使用现有 `submit.css` 与 `front-submit.js` 的移动端适配。
### 7. 数据、接口与安全
1. 文章列表优先复用现有 `aksrt_get_user_center_render_context()`、`posts-list.php`、状态计数和用户中心 REST panel;新增请求必须属于 `aksrt/v1` 或现有 AJAX 体系并提供 `permission_callback`/nonce。
2. 所有价格更新请求必须验证登录、文章作者归属、`new_post_edit`/`new_post_pay` 等能力,并逐项重新校验,不信任前端选择结果。
3. 输入使用 `sanitize_*`、数值边界归一化和现有付费元数据清洗;输出使用 `esc_html`、`esc_attr`、`esc_url`、`wp_kses`。
4. SQL 使用 `$wpdb->prepare()`;不得通过前端暴露直接文件 URL、支付敏感信息或内部能力细节。
5. 新增文件尽量不超过 700 行,任何文件不得超过 1000 行;优先使用现有 renderer、trait、服务和模块拆分。
6. 设置读取必须使用 `AKSRT_Settings`/现有 Settings facade,不新增裸 `get_option()`。
## 验收标准
- 已登录用户可从用户中心现有创作入口进入 `posts` 创作中心,且 `?tab=posts` 深链激活正确面板;不存在重复 `creator` 文章管理入口。
- 创作中心显示文章状态概览,能够筛选发布、草稿、待审、私密和全部,并保留分页/URL 状态。
- 新建、编辑、预览、删除操作遵循当前能力与 nonce;新建/编辑实际进入现有投稿页面,删除不会绕过作者归属或回收站语义。
- 价格表能正确展示免费、付费阅读、付费资源、现金/积分和普通/VIP 价格摘要。
- 单篇行内调价成功后只更新价格相关数据;失败时保留输入并展示服务端错误。
- 批量调价有选择、预览、确认和逐项结果,不能静默覆盖无权限文章或吞掉部分失败。
- 普通价、VIP1、VIP2、最低/最高价、免费开关、折扣计算和付费类型规则与现有投稿页一致;订单读取和购买结果不产生第二套解释。
- 付费下载、购买限制、登录限制和优惠码能力不被价格表错误开启;不支持安全批量编辑的字段会引导完整编辑器。
- 桌面和移动端无页面级横向溢出;移动端可完成筛选、编辑、批量选择、确认和错误处理。
- REST/AJAX 路由具备权限回调或 nonce;所有新增输入输出遵循项目安全规则。
- `php tools/audit-admin-contracts.php` 退出码为 0 且报告无错误;至少完成 PHP lint、前端构建或项目可执行的对应静态检查。
## 非功能要求
- 使用文本域 `aksrt`,新增文案全部国际化。
- 继续使用现有用户中心壳、运行时配置和资源加载机制。
- 不增加兼容层、双读选项、旧新双路由、数据迁移或重复业务实现。
- 新增 API 必须记录清晰的请求/响应契约和逐项错误语义;不得通过通用成功响应掩盖部分失败。
## 开放问题
- 默认将 `posts` 面板的显示标题改为“创作中心”,内部 tab key 保持 `posts`;若产品希望 URL 也改为 `creator`,需在实现前明确选择,因为项目规则不允许长期保留两个重复入口。
- 默认批量调价采用“选择文章 → 统一价格方案 → 预览 → 确认”的模式;不包含按比例涨跌或价格模板市场。后续如需比例调整,应另立需求。
- 默认价格表只覆盖安全可复用的价格字段;付费下载资源等复杂结构继续通过完整投稿编辑器维护,除非实现阶段确认现有服务可提供等价的批量契约。
- 默认创作中心不内嵌完整编辑器;若需要无跳转编辑体验,应另行评估 TinyMCE 生命周期、自动保存和页面性能。
-237
View File
@@ -1,237 +0,0 @@
# 用户创作收益中心
## Problem Statement
当前用户中心的“创作中心”主要承担文章发布与管理,尚未提供付费文章作者收益的统一查看和结算状态反馈;现有“收益中心”又主要面向推广返佣,容易让用户混淆两类收益来源。
本需求将“创作中心”改造成只面向付费文章作者收益的页面,展示作者从付费内容订单中获得的现金和积分分成,并提供结算备注与收入明细。文章发布、草稿、审核、编辑和删除等管理功能本次不再显示于用户中心。
创作分成功能需要独立开关。关闭后,用户中心隐藏创作中心入口,停止新作者收益生成、自动结算和手动结算;已有收益记录仍保留在后台供查询,不新增兼容读取或历史迁移逻辑。
## Current State
系统已有用户中心面板注册与 REST 延迟加载机制,可用于实现无刷新页面切换;已有付费订单、积分余额、现金余额、作者收益台账、结算服务及支付后台配置体系。
现有创作中心页面包含文章管理功能,现有收益中心主要展示推广返佣。现有作者收益能力尚未完整接入付费文章作者视图,也未提供现金与积分分开统计、按收益记录分页展示及退款后收益保持。
## Solution
### 用户中心入口与页面定位
- 保留用户中心侧栏中的“创作中心”入口,将其内容完全改造成付费文章作者收益页面。
- 保留“收益中心”入口,继续用于推广返佣;两者分别处理,不合并数据、不互相替代。
- 入口关闭时不显示“创作中心”;“收益中心”不受创作分成功能开关影响。
- 创作中心仅对登录用户开放,每位用户只能读取自己的作者收益。
- 本次移除用户中心内原有文章管理页面展示,不提供文章发布、编辑、草稿、审核、删除或管理入口。
### 顶部收益卡片
页面顶部显示两个大卡片,视觉和交互参考 Zibll 用户收益页面,但不实现“我的收入”卡片中的提现按钮。
#### 今日收入
- 统计当天产生的有效作者分成记录,包括待结算和已结算的收益。
- 现金收益与积分收益分栏展示,不进行汇率换算或合并。
- 退款或取消订单不改变作者收益记录;统计只包含 pending 和 settled 状态。
- 无当日收益时显示零值和合理的空状态,不隐藏卡片。
#### 我的收入
- 展示用户历史累计的有效作者分成。
- 现金与积分分别展示。
- 每种货币分别显示待结算金额和已入对应钱包金额。
- 现金收益提供“超过 50 元可申请现金提现”的提示文案,但不显示提现按钮,也不在本需求中新增提现流程。
- 积分收益不受 50 元提现提示影响。
- 卡片不混入推广返佣数据。
### 收入明细
页面不再输出顶部 hero 或货币 Tab,现金收益和积分收益统计卡片直接置顶。结算备注和收入明细在同一页面展示,分页使用 Ajax/REST 局部加载,不刷新整个用户中心页面。
后台可配置安全 HTML 格式的结算备注文案,前台经过 `wp_kses_post` 清洗后输出。收入明细按单笔作者收益记录、产生时间倒序排列,每页 20 条;每行固定为三列:左侧“现金收益/积分收益”,中间“+XX现金/+XX积分”,右侧“待结算/已结算”。不再提供货币 Tab 或状态筛选。
无记录时显示统一空状态;请求失败时显示可重试的错误提示,不破坏已加载的卡片和列表布局。
### 作者收益规则
- 只有付费文章订单产生作者收益;非付费内容、推广返佣和其他订单类型不进入本页面。
- 订单支付成功且权益发放完成后创建一条待结算作者收益记录。
- 购买者不能同时作为该订单的作者收益接收者。
- 作者收益金额为订单实际支付金额乘以作者当时适用的分成比例。
- 普通用户默认 60%,一级会员默认 70%,二级会员默认 80%。这些比例在创作中心中独立配置,不跟随其他会员或商品分成设置变化。
- 生成收益记录时锁定分成比例及适用等级;历史收益不因后台比例或用户等级后续变化而重新计算。
- 现金订单只生成现金作者收益,积分订单只生成积分作者收益。
- 现金与积分分别配置普通用户、一级会员、二级会员三档比例,分别统计、分别建立台账、分别结算。
- 现金作者收益结算至现金余额,积分作者收益结算至积分余额。
- 50 元仅作为现金提现提示,不作为作者收益进入钱包的结算门槛;积分收益不受该提示影响。
### 结算策略
- 后台只配置结算周期(默认 30 天),不再配置最小结算金额、延迟小时或自动/手动模式。
- 定时任务按 WordPress 站点时区扫描 created_at 已早于当前时间减去结算周期的 pending 记录,周期结束后自动将现金/积分分别写入对应钱包。
- 管理员仍可在现有支付中心“创作分成”运营页面逐笔或批量手动重试结算,但这不是用户可配置的处理模式。
- 每条收益只能成功入账一次;失败时保留 pending 状态供后续扫描或后台重试。
### 退款处理
- 退款或取消订单不会取消 pending 收益、冲正 settled 收益或产生追缴记录。
- 收益仍按正常结算周期进入对应钱包,现金和积分余额操作始终通过各自原子服务完成。
## User Stories
1. 作为登录用户,我可以从用户中心进入创作中心,查看付费文章产生的作者收益。
2. 作为作者,我可以在顶部同时看到今日现金收益、今日积分收益及其累计状态,而不会将两种货币混为一个数字。
3. 作为作者,我可以查看创作分成规则,并看到后台配置的最新说明内容。
4. 作为作者,我可以按订单级别查看每笔付费文章收益,并看到现金/积分金额和结算状态。
5. 作为作者,我可以通过分页查看混合现金/积分收入明细而不刷新页面。
6. 作为无收益用户,我可以看到清晰的零值和空状态,而不是空白页面或错误。
7. 作为作者,我可以看到待结算收益与已结算收益的区别。
8. 作为管理员,我可以独立开启或关闭创作分成功能;关闭后前台入口和收益处理流程均停止。
9. 作为管理员,我可以分别配置现金和积分的普通用户、一级会员、二级会员分成比例。
10. 作为管理员,我可以配置结算周期,周期结束后收益自动进入对应钱包。
11. 作为管理员,我可以在现有支付中心创作分成运营页面查看作者、文章、货币、状态和时间,并手动逐笔或批量重试结算。
12. 作为管理员,我可以按现金/积分、作者、文章和状态查询收益记录。
13. 作为作者,订单退款后收益不会被取消、冲正或追缴。
15. 作为用户,创作中心与推广返佣收益中心仍然是两个独立入口,页面数据不会相互混淆。
16. 作为未登录访客,我不能访问创作中心收益数据或其他用户的收入明细。
## Implementation Decisions
### New Modules
#### Creator income service
负责作者收益的统一业务入口,隐藏订单筛选、比例快照、金额计算、状态转换和结算细节。
主要接口:
- `create_from_paid_order(order)`:支付成功且权益完成后创建待结算作者收益。
- `get_user_summary(user_id, date)`:返回今日与累计的现金/积分有效收益、待结算和已结算金额。
- `get_user_records(user_id, filters, page, per_page)`:返回作者本人分页明细及总数。
- `settle(record_ids, actor)`:按货币原子结算指定收益记录。
- `get_admin_records(filters, page, per_page)`:为现有支付中心运营页面提供管理员查询数据。
#### Creator income view model and renderers
负责将收益服务数据转换为用户中心双货币卡片、结算备注、三列明细行、分页和空/错状态所需的前台结构。结算备注必须经过允许 HTML 的清洗后输出。
#### Creator income REST endpoints
提供用户中心作者收益摘要、创作分成说明和收入明细的局部加载接口。所有接口要求登录并将查询范围绑定到当前用户;管理员运营接口使用现有支付中心权限模型。
#### Settlement scheduler
负责按站点时区扫描已达到结算周期的待结算收益,触发现金/积分分别结算。任务必须可重复执行且具备幂等和失败重试行为;退款或取消事件不挂接作者收益处理。
### Architectural Decisions
- 复用现有用户中心面板注册和 REST 延迟加载机制,不新建第二套用户中心页面系统。
- 复用现有付费订单、作者收益、现金余额和积分余额服务;作者收益业务以新的、明确的作者收益语义为准,不读取推广返佣数据。
- 收益明细以作者收益记录为中心,订单作为来源信息;每笔支付订单最多对应一条作者收益记录,重复支付通知不得重复生成。
- 现金和积分使用同一收益规则与比例配置,但在金额、状态、余额入账和流水层面完全分开。
- 收益生成时保存实际使用的货币、原始支付金额、作者分成比例、作者分成金额和适用会员等级,作为不可变的历史计算依据。
- 前台只允许当前登录用户读取自己的数据;后台运营查询必须通过管理员权限校验。
- 所有余额变动必须通过现有原子积分/余额服务完成,不直接修改用户余额字段。
- 关闭创作分成后,前台隐藏入口并停止新收益生成、自动结算和手动结算;已有记录保留后台查询,但不建立旧数据迁移或双路兼容读取。
- 用户中心继续保留推广返佣“收益中心”入口,与创作中心作者收益严格分开。
### Schema Changes
在“支付&付费 > 创作中心”下集中配置:
- 创作分成独立开关,默认关闭或沿用产品确定的启用初始状态。
- 现金收益和积分收益各自配置普通用户、一级会员、二级会员比例,默认现金为 60%/70%/80%,积分默认 0%/0%/0%(需显式开启积分分成)。
- 结算周期,默认 30 天;周期结束后自动进入对应钱包。
- 结算备注文案,支持安全 HTML;不再配置最小结算金额、延迟小时、处理模式或展示设置。
作者收益记录需要能够保存:
- 作者、订单、文章和购买者关联。
- 原始实际支付金额及货币。
- 生成时锁定的分成比例和会员等级。
- 作者分成金额及结算货币。
- 待结算、已结算两种新状态。
- 生成、到期和结算时间;历史 reversal 字段不再被新业务读取。
新增或调整存储结构时直接采用最终结构并更新数据库 schema 版本;不对旧结构增加兼容字段、双读路径或数据迁移层。现有历史收益只作为后台已有记录处理,不纳入新作者收益统计口径。
### API Contracts
用户端接口应提供:
- 作者收益摘要:现金/积分今日收益、累计收益、待结算和已结算金额。
- 结算备注:经过安全 HTML 处理的配置内容。
- 收入明细:支持页码和每页数量;返回混合现金/积分记录、总数、当前页和分页信息。
收入记录响应至少包含:
- 文章标题和安全详情链接。
- 订单号、订单状态、支付时间。
- 原始支付金额与货币。
- 作者分成金额与货币。
- 收益状态:待结算或已结算。
所有用户接口必须验证登录状态、规范化分页参数、限制日期范围和筛选值,并将作者 ID 从当前认证用户上下文取得,不接受客户端传入的任意作者 ID。
管理员接口应复用现有支付中心“创作分成”运营页面的权限和数据入口,支持按作者、文章、货币、收益状态、订单状态和时间查询,并支持逐笔或批量结算。每个 REST 路由必须具备权限回调。
### Navigation
- 用户中心侧栏继续显示“创作中心”和“收益中心”两个独立入口。
- 创作中心入口由独立创作分成开关控制。
- 创作中心页面只显示作者收益内容,不再显示原文章管理内容。
- 文章管理入口本次从用户中心移除,不新增替代入口。
- 后台配置导航为“支付&付费 > 创作中心”。
- 后台收益处理继续使用支付中心现有“创作分成”运营页面,不新增独立后台工作台。
## Testing Decisions
### 收益服务测试
- 普通用户、一级会员、二级会员分别按 60%、70%、80% 正确计算。
- 生成收益后修改后台比例,不改变已生成记录的金额。
- 现金订单只进入现金收益,积分订单只进入积分收益。
- 订单支付成功且权益完成时生成收益;重复通知不重复生成。
- 非付费文章、其他订单类型、作者购买自己的文章不会生成作者收益。
- 今日统计按站点时间计算,累计统计只包含 pending/settled,现金与积分不混加。
- 收益达到配置结算周期后自动结算;管理员手动重试和定时任务均不能重复入账。
- 退款或取消订单不改变收益状态、不扣回余额、不产生追缴。
- 重复结算请求和并发任务均保持幂等。
### 前台交互测试
- 创作分成开关关闭时,侧栏入口隐藏、页面不可访问、新收益不生成且不执行结算。
- 登录用户只能看到自己的摘要和明细;未登录访问被拒绝或按用户中心既有规则处理。
- 顶部两张卡片直接显示现金与积分,并显示待结算和已结算金额。
- 结算备注支持安全 HTML 输出,并正确展示默认内容和后台修改内容。
- 页面不出现 hero 或货币/状态 Tab;收入明细默认 20 条、时间倒序,翻页无刷新。
- 明细三列正确显示收益货币、带单位的正数金额和待结算/已结算状态。
- 退款后记录仍保持原状态,不出现冲正或追缴文案。
- 创作中心与推广返佣收益中心数据和页面入口保持独立。
- 移动端沿用用户中心现有抽屉/Tab 行为,卡片和明细不发生横向溢出。
### 后台配置与运营测试
- 创作中心配置页面正确加载默认值、保存合法值并拒绝无效比例和周期值。
- 现金/积分两套比例、开关、结算周期和 HTML 备注可独立保存。
- 定时任务按站点时区和结算周期处理到期收益,不再存在自动/手动模式或延迟小时配置。
- 手动结算支持逐笔和批量操作,并正确反馈成功、失败和已处理记录。
- 后台列表支持作者、文章、货币、收益状态、订单状态和时间查询。
- 管理员可按现金/积分、作者、文章、状态和时间查询记录,普通用户不能访问运营接口;退款不提供冲正或追缴入口。
- 配置和路由契约审计通过:`php tools/audit-admin-contracts.php`。
- 按项目现有条件完成 PHP 静态检查、前端构建和人工/运行时验证;若测试环境不具备 WordPress 或外部 Docker harness,应明确记录未执行项,不以静态通过替代运行验证。
## Out of Scope
- 本次不实现“我的收入”卡片提现按钮,也不新增提现申请、审核或支付流程。
- 本次不改造或合并推广返佣收益中心。
- 本次不恢复或迁移用户中心内的文章发布、草稿、审核、编辑、删除和文章管理入口。
- 本次不实现按文章汇总的“我的商品”列表;收入明细固定按单笔作者收益记录展示。
- 本次不提供作者收益导出、图表、趋势分析、排行榜或销售漏斗。
- 本次不提供站内作者与购买者之间的订单争议处理流程。
- 本次不允许现金与积分互相兑换、折算或合并统计。
- 本次不设置作者收益进入钱包的 50 元结算门槛;该金额只用于现金提现提示。
- 本次不实现退款后的作者收益冲正、余额扣回或追缴。
- 本次不为旧收益结构增加兼容读取、双路实现或历史数据迁移。
- 本次不新增独立后台收益工作台,后台运营继续复用现有支付中心“创作分成”页面。
- 本次不引入新的前端框架、支付网关或货币体系。
@@ -1,98 +0,0 @@
# PRD: 用户中心内链推广中心与 UI/UX 重设计
> 来源:用户指令(2026-08-03)。
## 背景与问题
当前邀请好友功能位于用户中心“账号设置”页的一个区块中。它与密码、邮箱、手机号等账户安全操作混排,造成以下问题:
1. 推广/邀请是可持续查看数据、复制链接、追踪成果的独立用户任务,却没有独立、可直达的用户中心入口。
2. “邀请好友”区域与账户安全职责不一致,降低账户设置页面的信息层级与可扫描性。
3. 当前邀请展示偏功能拼接,缺少清晰的推广成果概览、操作引导和移动端优先的交互路径。
4. 现有邀请奖励、邀请码、邀请链接、统计数据和邀请记录已具备后端能力,但其前端组织方式无法体现“推广中心”的完整体验。
## 目标
将推广能力作为用户中心内的独立“推广中心”标签页,而不是独立页面;把账户安全/账号设置中的“邀请好友”区域完整迁移到该中心,并对推广中心完成统一的 UI 与 UX 重设计。
用户可在同一条用户中心路径中完成:了解奖励规则、复制并分享推广链接、查看推广成果和邀请记录;账户安全页只保留密码、邮箱、手机号等安全相关操作。
## 用户故事
- **US1(已登录用户)**:我可以从用户中心侧边栏或移动端标签直接进入“推广中心”,URL 可深链至对应标签,而无需进入独立推广页面。
- **US2(已登录用户)**:我可以在推广中心清楚地了解当前推广奖励规则、我的邀请码和推广链接,并一键复制链接。
- **US3(已登录用户)**:我可以通过醒目的数据概览查看已注册、已验证和已获得奖励的推广成果,并在同一页面查看邀请记录。
- **US4(已登录用户)**:我在账号设置中只会看到安全设置及账号资料相关功能,不再看到“邀请好友”区域。
- **US5(移动端用户)**:我可以在窄屏下顺畅访问、复制和查看推广中心内容,信息层级、触控区域和记录列表均适配移动端。
- **US6(站点管理员)**:当积分、邀请奖励或邀请注册功能未启用时,推广中心入口和内容遵循现有功能开关,不暴露不可用的推广功能。
- **US7(开发者)**:现有邀请码、邀请链接、邀请统计、记录 REST 接口和奖励结算逻辑保持单一业务通路;此次调整仅重组用户中心导航、渲染与前端交互,不复制或重建邀请业务规则。
## 需求
### 1. 用户中心信息架构
- 在私有用户中心新增独立的“推广中心”标签页(内部键和 URL 参数以现有用户中心 tab 机制为准)。
- “推广中心”必须使用用户中心已有的页面壳、权限校验、侧边栏、移动端导航和 tab URL 生成机制,不创建新的前台独立路由/页面模板。
- 推广入口仅在现有邀请能力的必要功能开关均启用时出现:积分、邀请奖励、邀请注册。
- 旧的邀请深链若当前由用户中心 tab 规范解析,应直接归一到新的推广中心标签,不保留双页面或双渲染路径。
### 2. 推广中心内容与交互
推广中心承接现有“邀请好友”区域的全部能力,并重新组织为清晰的任务流:
1. **成果概览**:优先展示已注册、已验证、已获得奖励三个统计指标;加载中、空数据及请求失败需有明确、非误导性的状态反馈。
2. **推广行动区**:展示邀请码、可复制的推广链接和主要复制按钮;复制成功/失败提供即时可感知反馈,并确保键盘可操作。
3. **奖励说明区**:展示站点配置的邀请奖励说明,让用户在分享前理解奖励条件。
4. **邀请记录区**:展示已有记录列表,保留当前 REST 数据源和分页/展示上限的既有能力;无记录时呈现引导性的空状态。
不得在推广中心新增改变邀请码生成、邀请 cookie、邀请归因、注册绑定、邮箱验证奖励或积分结算的业务规则。
### 3. 账户安全页清理
- 从“账号设置”渲染链路中移除邀请好友区域及其仅为该区域服务的结构。
- 账号设置保留个人资料、密码、邮箱、手机号等原有安全/资料职责;不得影响现有安全 AJAX、nonce、验证码或短信链路。
- 推广中心成为邀请 UI 的唯一前台渲染位置,避免同一邀请卡在多个用户中心标签同时存在。
### 4. UI/UX 重设计
- 视觉应与现有用户中心设计语言一致,同时让“推广成果 → 分享操作 → 奖励规则 → 邀请记录”的优先级一目了然。
- 桌面端应提供可快速扫读的成果概览和明确主操作;移动端应按任务优先级单列排布,不要求横向滚动,复制按钮易触达。
- 使用语义化结构、可见焦点、适当对比度、键盘可操作按钮和不只依赖颜色的状态反馈,保证基础可访问性。
- 复用现有用户中心 CSS/JS 模块和设计 token;新增样式/脚本保持模块化,不引入独立 UI 框架或重复的邀请请求逻辑。
### 5. 数据、接口与安全边界
- 继续使用已存在的、仅允许登录用户访问的邀请 REST 端点获取邀请码、链接、统计数据和记录。
- 前端不得暴露直接可编辑的邀请归因、奖励结算或其他服务端敏感逻辑。
- 保持现有 REST 权限校验、输出转义和前端错误处理边界。
## 验收标准
- 已登录且邀请功能启用的用户访问用户中心时,可从桌面侧边栏和移动端导航进入“推广中心”,且推广中心 URL 可以直接打开并激活正确标签。
- 推广中心可完整展示邀请码、推广链接、复制操作、奖励说明、三项统计和邀请记录;数据来自当前邀请接口。
- 链接复制在成功与失败场景都有可见反馈,并可通过键盘完成操作。
- 邀请记录为空、接口加载中或请求失败时,推广中心不会呈现误导性的空白或错误统计。
- 账号设置页面不再渲染邀请好友/邀请记录区块,且密码、改邮箱、绑定手机功能可继续使用。
- 邀请开关关闭时,不出现不可用的推广中心入口或内容。
- 桌面和移动端布局均无横向溢出,统计、分享操作和记录在窄屏下可读、可点击。
- 现有邀请奖励归因、注册记录及积分结算逻辑不因本改动产生第二套实现。
## 非功能要求
- 遵循项目的 `AKSRT_Settings` 设置访问规范,不在新代码中直接读取 option。
- 遵循项目用户中心 tab 注册与模板拆分模式;新增/调整的源文件尽量控制在 700 行以内,任何文件不得超过 1000 行。
- 不新增向后兼容、双读 option、双路模板或数据迁移层;以当前用户中心结构为唯一实现。
- 使用现有文本域 `aksrt`,对新增可翻译文案进行国际化处理。
## 范围外
- 新增独立的公开推广落地页、推广门户或第二套前台路由。
- 修改邀请码生成规则、推广 cookie 时长、邀请归因规则、邮箱验证条件、奖励额度或积分结算模型。
- 新增社交平台 SDK、自动化分享到第三方平台,或站外推广统计系统。
- 对管理员后台邀请码运营界面、推广/返利支付规则进行功能重构。
- 增加任何向后兼容、数据迁移或旧新两套邀请 UI 并存的实现。
## 开放问题
- “推广中心”的最终中文名称是否固定为“推广中心”,还是应使用“邀请推广”/“邀请好友”等已有产品术语?默认采用“推广中心”。
- 推广中心在用户中心侧边栏中的最终排序位置需在实现时根据现有 tabs 的产品优先级确定;默认放在积分/资产相关入口附近。
-41
View File
@@ -1,41 +0,0 @@
# PRD: Zibll 小工具全量 1:1 深度复刻
> 来源:用户指令(/plan 参数)。参考主题:`C:\Users\Xing\Desktop\zibll`
## 背景与问题
AKSRT 主题的小工具系统当前存在多处缺陷:
1. **卡片堆叠问题**:`assets/css/frontend/post-card.css` 中 `.posts-item { display: block !important }`(行 32)覆盖了 `.posts-item.card { display: inline-block }`(行 75),导致卡片全部纵向堆叠、宽度 calc 失效。
2. **分页函数缺失**:`aksrt_render_pagination()` 在 `main` 分支未定义,仅存在于 worktree 分支;`index.php:40`、`archive-resources.php:173`、`search.php:281` 三处无 `function_exists` 守卫直接调用,归档/搜索/首页有文章时即致命错误。
3. **重复渲染路径**:`archive-resources.php:162-171` 手写 `.posts-row.ajaxpager` + `get_template_part('content-post-card')`,绕过 `aksrt_render_post_list()` 统一渲染器(违反 CLAUDE.md 行 147)。
4. **通用表单链路半成品(双路)**:`inc/modules/widgets-register.php` 已有一套共享通用字段层(`aksrt_widget_render_common_form` / `update_common` / `get_common_wrapper_classes` / `should_display_by_ids`,行 314-509),但 **55 个小工具无一调用**;16 个小工具各自重新实现 `in_affix`,等于半成品双路。
5. **小工具缺失/不对齐**:需对照 Zibll 参考主题(约 44 个现代 CSF 小工具 + 9 个旧版)逐一 1:1 对齐字段、渲染、AJAX 链路。
6. **注册/区块编辑器/侧栏缺口**:`page_sidebar_{ID}` 动态侧栏未被注册(页面中段侧栏空白);区块编辑器仅收录 3/55 个小工具;CLAUDE.md 路径漂移(`inc/classes/widgets/` 实际为 `src/AKSRT/Widgets/`)。
## 目标
按照 Zibll 参考主题**全量 1:1 深度复刻**小工具系统,完成「设置 → 注册 → 后台表单 → 渲染过滤 → 前端渲染」全链路,消除卡片堆叠与致命错误,统一为单一路径。
## 约束
- **不写双路与迁移兼容代码**:直接替换旧实现,不保留旧 option 兼读、不新增 shim(遵循 CLAUDE.md「重写优先于兼容」)。
- **可搬运参考主题资源**:允许把 Zibll 的图片/图标等资源搬入 AKSRT(后续逐步替换)。
- 表单系统走「复用现有共享层」路线,**不移植 CSF (Codestar) 框架**。
- 单一计划、分阶段推进;每阶段可独立演示与验证。
## 用户故事
- **US1(管理员)**:每个小工具都能通过统一的共享表单层配置 Zibll 等价的通用字段(显示设备 PC/移动、文章 ID 显隐、侧栏吸顶、入场动画、布局背景、标题/副标题/链接),不再有逐小工具重复实现。
- **US2(访客)**:文章卡片在 list/card/mini 三种模式下正确布局,不堆叠、不塌陷。
- **US3(访客)**:首页/归档/搜索页分页正常(数字 / 加载更多 / 无限滚动),无致命错误。
- **US4(管理员)**:Zibll 的每一个小工具在 AKSRT 都有对等实现,可挂载到正确的挂载区域。
- **US5(开发者)**:全链路(设置 → 注册 → 表单 → 渲染过滤 → 前端渲染)一致且单一通路。
- **US6(开发者)**:参考主题的图片/图标/CSS/JS 资源已入库,小工具渲染视觉与 Zibll 一致(后续替换)。
- **US7(访客/管理员)**:论坛版块/话题与商城商品小工具与 Zibll 对齐。
## 范围外
- 不新增任何向后兼容 / 数据迁移层(PRD 明确)。
- 参考图片为临时搬运,后续替换不属于本计划。
- 不移植 CSF 框架本体。
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
# Widget visual results
This directory is intentionally empty until validation runs against the user-provided WordPress site.
The browser run must create the screenshot paths declared by `tests/fixtures/widget-golden-v1.json` and a `manifest.json` using schema `aksrt.widget-visual-results.v1`. Validate completed results with:
```text
php tools/validate-widget-golden.php --require-results
```
The validator requires all 222 viewport/theme screenshots, every declared interaction state, zero console errors, zero unexpected network failures, zero critical/serious accessibility findings, reduced-motion verification, a maximum 2 px component-boundary delta, and a maximum 1.5% pixel difference.
@@ -1,28 +0,0 @@
# AKSRT widget static accessibility audit
Scope: `src/WidgetSystem/Renderers/`, `assets/widgets/frontend.js`, and `assets/widgets/frontend.css`.
The source audit was repeated after remediation. Browser, screen-reader, Lighthouse/axe, console, and network validation remain pending until a target WordPress URL and browser MCP are available.
## Critical (WCAG A)
No static critical issue was found.
## Serious (WCAG AA)
No unresolved static serious issue remains.
## Resolved source findings
- **WCAG 2.1.1 Keyboard:** article and feature tabs now implement Arrow Left/Right, Home/End, focus movement, roving `tabindex`, and stable tab/panel associations.
- **WCAG 2.4.7 Focus Visible:** all widget links, buttons, inputs, selects, textareas, and explicit tabindex targets receive high-contrast light, dark, and forced-colors focus rings.
- **WCAG 2.2.2 Pause, Stop, Hide:** autoplay carousels pause on keyboard focus and expose a labeled, persistent pause/resume button; reduced-motion mode continues to disable autoplay.
- **WCAG 4.1.3 Status Messages:** AJAX containers expose `aria-live` and `aria-busy`, loading skeletons use `role="status"`, and video failure uses an assertive alert.
These contracts are enforced by `tests/widget-system/a11y-contract.test.cjs`.
## Browser verification still required
- Confirm Swiper-generated pagination bullets, disabled navigation states, and pause/resume behavior with the bundled runtime.
- Verify 200% zoom, high-contrast mode, dark-mode contrast, focus order, and focus retention after AJAX replacement.
- Run Lighthouse or axe and perform keyboard-only checks at 1440, 1024, and 390 px in light and dark modes.
@@ -1,38 +0,0 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '../..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const js = read('assets/widgets/frontend.js');
const css = read('assets/widgets/frontend.css');
const content = read('src/WidgetSystem/Renderers/ContentRenderer.php');
const knowledge = read('src/WidgetSystem/Renderers/KnowledgeProductRenderer.php');
const general = read('src/WidgetSystem/Renderers/GeneralRenderer.php');
for (const key of ['ArrowRight', 'ArrowLeft', 'Home', 'End']) assert.match(js, new RegExp(key));
assert.match(js, /setAttribute\('tabindex', tab === button \? '0' : '-1'\)/);
assert.match(js, /setAttribute\('aria-labelledby', button\.id\)/);
assert.match(content, /role="tab"[^;]+aria-controls=/);
assert.match(content, /role="tabpanel"[^;]+aria-labelledby=/);
assert.match(knowledge, /role="tab"[^;]+aria-controls=/);
assert.match(knowledge, /role="tabpanel"[^;]+aria-labelledby=/);
assert.match(css, /:focus-visible/);
assert.match(css, /forced-colors: active/);
assert.match(js, /aksrt-widget-swiper__pause/);
assert.match(js, /addEventListener\('focusin', pause\)/);
assert.match(js, /addEventListener\('focusout'/);
assert.match(js, /aria-pressed/);
assert.match(content, /aria-live="polite" aria-busy="true"/);
assert.match(content, /role="status"/);
assert.match(js, /setAttribute\('aria-busy', 'true'\)/);
assert.match(js, /setAttribute\('aria-busy', 'false'\)/);
assert.match(general, /role="alert" aria-live="assertive"/);
assert.match(css, /prefers-reduced-motion: reduce/);
assert.match(js, /prefers-reduced-motion: reduce/);
console.log('Widget accessibility contract tests passed: keyboard tabs, focus, autoplay controls, live states and reduced motion.');
-20
View File
@@ -1,20 +0,0 @@
'use strict';
const assert = require('node:assert/strict');
const core = require('../../assets/widgets/admin-core.js');
assert.deepEqual(core.parseDependency('["mode","==","cards"]'), ['mode', '==', 'cards']);
assert.deepEqual(core.parseDependency('{broken'), []);
assert.equal(core.dependencyVisible(['mode', '==', 'cards'], 'cards'), true);
assert.equal(core.dependencyVisible(['mode', '==', 'cards'], 'list'), false);
assert.equal(core.dependencyVisible(['mode', '!=', 'cards'], 'list'), true);
assert.equal(core.dependencyVisible(['mode', '!=', 'cards'], 'cards'), false);
assert.equal(core.dependencyVisible([], 'anything'), true);
assert.equal(
core.replaceRepeaterIndexes('widget-aksrt_widget_slider[4][slides][__index_ab12cd34__][layers][9][title]', [2, 1]),
'widget-aksrt_widget_slider[4][slides][2][layers][1][title]'
);
assert.equal(core.replaceRepeaterIndexes('plain[name]', [1]), 'plain[name]');
console.log('Widget admin core tests passed: dependency visibility and nested repeater indexing.');
-177
View File
@@ -1,177 +0,0 @@
<?php
declare(strict_types=1);
if (!defined('ABSPATH')) {
define('ABSPATH', dirname(__DIR__, 2) . DIRECTORY_SEPARATOR);
}
if (!defined('DAY_IN_SECONDS')) {
define('DAY_IN_SECONDS', 86400);
}
if (!defined('MINUTE_IN_SECONDS')) {
define('MINUTE_IN_SECONDS', 60);
}
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
$GLOBALS['aksrt_test_state'] = [];
function aksrt_test_reset_state(): void {
$GLOBALS['aksrt_test_state'] = [
'options' => [],
'sidebars' => [],
'routes' => [],
'query_args' => [],
'current_post' => null,
'current_id' => 0,
'singular' => false,
'pay_meta' => ['pay_type' => 'no', 'price' => 0],
'paid_access' => false,
'logged_in' => false,
];
$GLOBALS['wp_registered_sidebars'] = [];
}
aksrt_test_reset_state();
class WP_Error {
public function __construct(private string $code = '', private string $message = '', private array $data = []) {}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data(): array { return $this->data; }
}
class WP_REST_Controller {
public string $namespace = '';
public string $rest_base = '';
}
class WP_REST_Server {
public const READABLE = 'GET';
}
class WP_REST_Request {
public function __construct(private array $params = []) {}
public function get_params(): array { return $this->params; }
public function get_param(string $key) { return $this->params[$key] ?? null; }
}
class WP_REST_Response {
public function __construct(private $data) {}
public function get_data() { return $this->data; }
}
class WP_Post {
public int $ID;
public int $post_author;
public function __construct(int $id = 1, int $author = 1) {
$this->ID = $id;
$this->post_author = $author;
}
}
class WP_Query {
public array $posts = [];
public int $max_num_pages = 1;
public int $found_posts = 0;
public function __construct(array $args = []) {
$GLOBALS['aksrt_test_state']['query_args'][] = $args;
}
public function have_posts(): bool { return false; }
public function the_post(): void {}
}
class WP_Widget {
public string $id_base = '';
public string $id = '';
public int $number = 0;
public function __construct(string $id_base = '', string $name = '', array $widget_options = [], array $control_options = []) {
unset($name, $widget_options, $control_options);
$this->id_base = $id_base;
}
public function get_settings(): array {
$value = get_option('widget_' . $this->id_base, []);
return is_array($value) ? $value : [];
}
public function get_field_id(string $field): string { return 'widget-' . $this->id_base . '-' . $this->number . '-' . $field; }
public function get_field_name(string $field): string { return 'widget-' . $this->id_base . '[' . $this->number . '][' . $field . ']'; }
public function _set(int $number): void {
$this->number = $number;
$this->id = $this->id_base . '-' . $number;
}
}
function __($text): string { return (string) $text; }
function esc_html__($text): string { return esc_html((string) $text); }
function esc_attr__($text): string { return esc_attr((string) $text); }
function esc_html_e($text): void { echo esc_html((string) $text); }
function esc_attr_e($text): void { echo esc_attr((string) $text); }
function esc_html($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function esc_attr($value): string { return esc_html((string) $value); }
function esc_textarea($value): string { return esc_html((string) $value); }
function esc_url_raw($value): string {
$value = trim((string) $value);
return preg_match('#^(?:https?://|/)#', $value) ? $value : '';
}
function esc_url($value): string { return esc_attr(esc_url_raw($value)); }
function sanitize_text_field($value): string { return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? ''); }
function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
function sanitize_html_class($value): string { return preg_replace('/[^A-Za-z0-9_-]/', '', (string) $value) ?? ''; }
function sanitize_hex_color($value): string {
return preg_match('/^#(?:[0-9a-fA-F]{3}){1,2}$/', (string) $value) ? strtolower((string) $value) : '';
}
function sanitize_textarea($value): string { return sanitize_textarea_field($value); }
function wp_kses_post($value): string { return strip_tags((string) $value, '<a><b><strong><em><br><p><span><ul><ol><li>'); }
function wp_json_encode($value): string { return (string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); }
function absint($value): int { return abs((int) $value); }
function checked($actual, $expected = true, bool $display = true): string {
$result = $actual == $expected ? 'checked="checked"' : '';
if ($display) echo $result;
return $result;
}
function selected($actual, $expected = true, bool $display = true): string {
$result = $actual == $expected ? 'selected="selected"' : '';
if ($display) echo $result;
return $result;
}
function number_format_i18n($number): string { return number_format((float) $number); }
function wp_trim_words($text, int $count = 55): string {
$words = preg_split('/\s+/', trim((string) $text)) ?: [];
return implode(' ', array_slice($words, 0, $count));
}
function get_option(string $name, $default = false) { return $GLOBALS['aksrt_test_state']['options'][$name] ?? $default; }
function update_option(string $name, $value): bool { $GLOBALS['aksrt_test_state']['options'][$name] = $value; return true; }
function wp_get_sidebars_widgets(): array { return $GLOBALS['aksrt_test_state']['sidebars']; }
function wp_set_sidebars_widgets(array $sidebars): void { $GLOBALS['aksrt_test_state']['sidebars'] = $sidebars; }
function register_rest_route(string $namespace, string $route, array $args): bool {
$GLOBALS['aksrt_test_state']['routes'][$namespace . $route] = $args;
return true;
}
function rest_ensure_response($data): WP_REST_Response { return $data instanceof WP_REST_Response ? $data : new WP_REST_Response($data); }
function is_wp_error($value): bool { return $value instanceof WP_Error; }
function is_singular(): bool { return (bool) $GLOBALS['aksrt_test_state']['singular']; }
function get_queried_object_id(): int { return (int) $GLOBALS['aksrt_test_state']['current_id']; }
function get_post($post = null) {
if ($post instanceof WP_Post) return $post;
return $GLOBALS['aksrt_test_state']['current_post'];
}
function aksrt_get_widget_null(string $message): string { return '<div class="aksrt-widget__empty" role="status">' . esc_html($message) . '</div>'; }
function aksrt_get_pay_meta($post = null): array { unset($post); return $GLOBALS['aksrt_test_state']['pay_meta']; }
function aksrt_has_paid_access($post = null): bool { unset($post); return (bool) $GLOBALS['aksrt_test_state']['paid_access']; }
function aksrt_get_paywall_html(WP_Post $post, bool $fragment = false): string {
unset($post, $fragment);
$state = $GLOBALS['aksrt_test_state']['logged_in'] ? 'member-locked' : 'guest-locked';
return '<div data-test-paywall="' . $state . '">PAYWALL</div>';
}
function aksrt_get_pay_attributes_html($post, string $class): string { unset($post); return '<ul class="' . esc_attr($class) . '"></ul>'; }
function aksrt_get_demo_link_html($post, string $class): string { unset($post); return '<a class="' . esc_attr($class) . '">DEMO</a>'; }
function is_user_logged_in(): bool { return (bool) $GLOBALS['aksrt_test_state']['logged_in']; }
require dirname(__DIR__, 2) . '/vendor/autoload.php';
-217
View File
@@ -1,217 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\FormRenderer;
use AKSRT\WidgetSystem\PostQuery;
use AKSRT\WidgetSystem\RestController;
use AKSRT\WidgetSystem\SchemaWidget;
use AKSRT\WidgetSystem\WidgetDefinition;
use AKSRT\WidgetSystem\WidgetRegistry;
use AKSRT\WidgetSystem\WidgetTransfer;
use AKSRT\WidgetSystem\WidgetVisibility;
use AKSRT\WidgetSystem\Renderers\ContentRenderer;
final class WidgetSystemTest {
private int $assertions = 0;
private array $failures = [];
public function run(): void {
$this->schema_contract();
$this->query_contract();
$this->visibility_contract();
$this->transfer_round_trip();
$this->rest_contract();
$this->payment_states();
if ($this->failures) {
fwrite(STDERR, "Widget system tests failed:\n- " . implode("\n- ", $this->failures) . "\n");
exit(1);
}
echo "Widget system tests passed: {$this->assertions} assertions across schema, query, visibility, transfer, REST and payment states.\n";
}
private function schema_contract(): void {
$fields = [
['id' => 'mode', 'type' => 'select', 'default' => 'cards', 'options' => ['cards' => 'Cards', 'list' => 'List']],
['id' => 'count', 'type' => 'slider', 'default' => 3, 'min' => 1, 'max' => 8],
['id' => 'ids', 'type' => 'text', 'sanitize' => 'ids'],
['id' => 'link', 'type' => 'link'],
['id' => 'layout', 'type' => 'fieldset', 'fields' => [
['id' => 'enabled', 'type' => 'switcher', 'default' => false],
['id' => 'gap', 'type' => 'number', 'default' => 8, 'min' => 0, 'max' => 40],
]],
['id' => 'rows', 'type' => 'group', 'max_items' => 2, 'fields' => [
['id' => 'title', 'type' => 'text'],
['id' => 'tone', 'type' => 'radio', 'default' => 'blue', 'options' => ['blue' => 'Blue', 'red' => 'Red']],
]],
];
$defaults = FieldSchema::defaults($fields);
$this->same('cards', $defaults['mode'], 'schema defaults preserve enums');
$this->same(['enabled' => false, 'gap' => 8], $defaults['layout'], 'nested defaults are recursive');
$normalized = FieldSchema::normalize($fields, [
'mode' => 'invalid', 'count' => 99, 'ids' => '2, 2,7 bad -4',
'link' => ['url' => 'javascript:alert(1)', 'text' => '<b>课程</b>', 'target' => 'popup'],
'layout' => ['enabled' => 'yes', 'gap' => -9, 'unknown' => 'drop'],
'rows' => [
['title' => '<script>x</script>第一项', 'tone' => 'red'],
['title' => '第二项', 'tone' => 'invalid'],
['title' => '第三项'],
],
]);
$this->same('cards', $normalized['mode'], 'invalid enum falls back');
$this->same(8, $normalized['count'], 'numeric value clamps to maximum');
$this->same('2,7,4', $normalized['ids'], 'ID list is unique and normalized');
$this->same(['url' => '', 'text' => '课程', 'target' => ''], $normalized['link'], 'link parts sanitize independently');
$this->same(['enabled' => true, 'gap' => 0], $normalized['layout'], 'nested values clamp and drop unknown keys');
$this->same(2, count($normalized['rows']), 'group max_items is enforced');
$this->same('blue', $normalized['rows'][1]['tone'], 'nested enum fallback is enforced');
$definition = new WidgetDefinition([
'slug' => 'dependency_test', 'name' => 'Dependency Test',
'common' => ['title' => false, 'affix' => false, 'visibility' => false, 'animation' => false, 'layout' => false],
'fields' => [
['id' => 'controller', 'type' => 'select', 'default' => 'off', 'options' => ['off' => 'Off', 'on' => 'On']],
['id' => 'dependent', 'type' => 'text', 'dependency' => ['controller', '==', 'on']],
],
]);
$widget = new SchemaWidget($definition);
ob_start();
$widget->render_full_form(['controller' => 'on', 'dependent' => 'visible']);
$html = (string) ob_get_clean();
$this->contains('data-dependency=', $html, 'form exports dependency rule');
$this->contains('&quot;controller&quot;', $html, 'dependency JSON is safely escaped');
}
private function query_contract(): void {
$args = PostQuery::arguments([
'count' => 99, 'category_ids' => '2,5', 'category_id' => 9, 'tag_ids' => '8,13',
'limit_day' => 14, 'orderby' => 'downloads', 'order' => 'asc', 'pay_type' => 'gallery',
], 0);
$this->same(50, $args['posts_per_page'], 'query count clamps');
$this->same(1, $args['paged'], 'query page clamps');
$this->same([9], $args['category__in'], 'segment category overrides category list');
$this->same([8, 13], $args['tag__in'], 'query tag filter is normalized');
$this->same('ASC', $args['order'], 'query order allowlist is applied');
$this->same('meta_value_num', $args['orderby'], 'metric ordering uses numeric meta');
$this->contains('gallery', json_encode($args['meta_query']), 'DomainModel pay type reaches meta query');
$invalid = PostQuery::arguments(['pay_type' => '6', 'orderby' => 'drop table'], 1);
$this->false(isset($invalid['meta_query']), 'legacy numeric pay types are rejected');
$this->same('date', $invalid['orderby'], 'unknown orderby falls back');
$tabs = ['tabs' => [['title' => 'A', 'category_id' => 2], ['title' => 'B', 'category_id' => 3]]];
$this->same(3, PostQuery::segment($tabs, '1')['category_id'], 'valid tab segment resolves');
$this->same(2, PostQuery::segment($tabs, '99')['category_id'], 'invalid tab segment falls back to first');
}
private function visibility_contract(): void {
$this->true(WidgetVisibility::should_render([], 0, false), 'unrestricted widget renders');
$this->true(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 8, true), 'show list matches singular ID');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 4, true), 'show list rejects other IDs');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 0, false), 'show list rejects archives');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'hide', 'show_ids' => '3,8'], 3, true), 'hide list suppresses matching ID');
$this->true(WidgetVisibility::should_render(['show_id_type' => 'hide', 'show_ids' => '3,8'], 0, false), 'hide list keeps archives');
$this->same('aksrt-widget--only-pc', WidgetVisibility::device_class(['show_type' => 'only_pc']), 'PC device class is stable');
$this->same('aksrt-widget--only-mobile', WidgetVisibility::device_class(['show_type' => 'only_sm']), 'mobile device class is stable');
$this->same('', WidgetVisibility::device_class(['show_type' => 'invalid']), 'invalid device rule adds no class');
}
private function transfer_round_trip(): void {
aksrt_test_reset_state();
$GLOBALS['wp_registered_sidebars'] = ['home_sidebar' => [], 'single_sidebar' => []];
update_option('widget_aksrt_widget_posts', [2 => ['title' => '<b>课程列表</b>', 'count' => 999, 'style' => 'mini'], '_multiwidget' => 1]);
$GLOBALS['aksrt_test_state']['sidebars'] = ['home_sidebar' => ['text-1', 'aksrt_widget_posts-2']];
$payload = WidgetTransfer::export('home_sidebar');
$this->same(WidgetTransfer::FORMAT, $payload['format'], 'export format is versioned');
$this->same(1, count($payload['sidebars']['home_sidebar']), 'export ignores non-schema widgets');
$this->same(50, $payload['sidebars']['home_sidebar'][0]['settings']['count'], 'export normalizes saved settings');
update_option('widget_aksrt_widget_posts', []);
$GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'] = ['text-9'];
$result = WidgetTransfer::import($payload, 'append', 'single_sidebar');
$this->same(['sidebars' => 1, 'widgets' => 1], $result, 'append import reports changes');
$this->same(['text-9', 'aksrt_widget_posts-1'], $GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'], 'append import preserves existing widgets');
$round_trip = WidgetTransfer::export('single_sidebar');
$this->same($payload['sidebars']['home_sidebar'][0], $round_trip['sidebars']['single_sidebar'][0], 'export/import round trip preserves normalized module');
$replacement = WidgetTransfer::template_payload('links_navigation');
$result = WidgetTransfer::import($replacement, 'replace', 'single_sidebar');
$this->same(3, $result['widgets'], 'targeted template import combines template areas');
$this->false(in_array('text-9', $GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'], true), 'replace import removes previous assignment');
try {
WidgetTransfer::import(['format' => 'zibll.widgets', 'sidebars' => []]);
$this->failures[] = 'transfer rejects legacy formats: no exception thrown';
} catch (InvalidArgumentException $error) {
$this->contains(WidgetTransfer::FORMAT, $error->getMessage(), 'legacy import rejection names accepted format');
}
}
private function rest_contract(): void {
aksrt_test_reset_state();
$controller = new RestController();
$controller->register_routes();
$this->same(2, count($GLOBALS['aksrt_test_state']['routes']), 'REST registers items and quote routes');
$route = $GLOBALS['aksrt_test_state']['routes']['aksrt/v1/widgets/(?P<widget>[a-z][a-z0-9_]*)/items'];
$this->same('__return_true', $route['permission_callback'], 'public items route is read-only accessible');
$this->true($controller->validate_widget('posts'), 'known widget validates');
$this->false($controller->validate_widget('shop_products'), 'unknown/shop widget is rejected');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1, 'query' => 'arbitrary']));
$this->error('aksrt_widget_unknown_parameter', $error, 'REST rejects arbitrary query parameters');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'search', 'instance' => 'aksrt_widget_search-1', 'page' => 1]));
$this->error('aksrt_widget_items_unsupported', $error, 'REST rejects non-dynamic widgets');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1]));
$this->error('aksrt_widget_instance_not_registered', $error, 'REST requires an assigned instance');
$GLOBALS['aksrt_test_state']['sidebars'] = ['home_top_content' => ['aksrt_widget_posts-1']];
update_option('widget_aksrt_widget_posts', [1 => ['title' => 'Saved only', 'count' => 2, 'style' => 'list'], '_multiwidget' => 1]);
$response = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 2, 'segment' => '']));
$this->true($response instanceof WP_REST_Response, 'registered instance returns REST response');
$data = $response->get_data();
$this->true($data['success'], 'REST success envelope is stable');
$this->same(2, $data['page'], 'REST page reaches saved-query pipeline');
$this->contains('暂无文章', $data['html'], 'REST returns deterministic empty state');
$this->same(2, end($GLOBALS['aksrt_test_state']['query_args'])['posts_per_page'], 'REST reloads saved normalized configuration');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_hot_posts-1', 'page' => 1]));
$this->error('aksrt_widget_invalid_instance', $error, 'REST binds instance ID to requested widget');
}
private function payment_states(): void {
aksrt_test_reset_state();
$GLOBALS['aksrt_test_state']['current_post'] = new WP_Post(77, 5);
$method = new ReflectionMethod(ContentRenderer::class, 'post_pay');
$render = static fn(): string => (string) $method->invoke(null, ['theme' => 'jb-red']);
$this->contains('未配置付费内容', $render(), 'unconfigured article renders explicit empty state');
$authorized = [
'free' => ['pay_type' => 'reading', 'price' => 0],
'purchased' => ['pay_type' => 'reading', 'price' => 29],
'vip' => ['pay_type' => 'vip', 'price' => 0],
];
foreach ($authorized as $label => $meta) {
$GLOBALS['aksrt_test_state']['pay_meta'] = $meta;
$GLOBALS['aksrt_test_state']['paid_access'] = true;
$this->contains('内容已解锁', $render(), $label . ' authorization delegates to existing access service');
}
$GLOBALS['aksrt_test_state']['pay_meta'] = ['pay_type' => 'video', 'price' => 49];
$GLOBALS['aksrt_test_state']['paid_access'] = false;
$GLOBALS['aksrt_test_state']['logged_in'] = false;
$this->contains('guest-locked', $render(), 'guest unauthorized state delegates to paywall');
$GLOBALS['aksrt_test_state']['logged_in'] = true;
$this->contains('member-locked', $render(), 'logged-in unauthorized state delegates to paywall');
}
private function same($expected, $actual, string $message): void {
$this->assertions++;
if ($expected !== $actual) $this->failures[] = $message . ' (expected ' . var_export($expected, true) . ', got ' . var_export($actual, true) . ')';
}
private function true($actual, string $message): void { $this->same(true, (bool) $actual, $message); }
private function false($actual, string $message): void { $this->same(false, (bool) $actual, $message); }
private function contains(string $needle, ?string $haystack, string $message): void {
$this->assertions++;
if ($haystack === null || !str_contains($haystack, $needle)) $this->failures[] = $message . ' (missing ' . $needle . ')';
}
private function error(string $code, $value, string $message): void {
$this->assertions++;
if (!$value instanceof WP_Error || $value->get_error_code() !== $code) $this->failures[] = $message . ' (expected error ' . $code . ')';
}
}
(new WidgetSystemTest())->run();
-50
View File
@@ -1,50 +0,0 @@
# Admin contract map
Generated by process, not a migration guide. Source of truth for menu ↔ schema wiring.
## Rules
1. Every `schemaPage` must have matching PHP section under the same domain.
2. Enum field values must be identical in PHP schema and JS schema.
3. REST `'callback' => [$this, 'method']` methods must exist (class or trait).
4. Deploy `assets/admin/build/` as one atomic unit after `npm run build`.
5. No compat aliases, dual-read, or silent frontend catch for missing APIs.
## Audit
```bash
php tools/audit-admin-contracts.php
```
Exit code `1` means hard contract errors.
## Domain builders
| Domain | PHP builder map key | Menu constants file |
|--------|---------------------|---------------------|
| global | `AKSRT_Admin_Schema_Global` | `assets/admin/src/constants/sections/global.js` |
| page_display | `AKSRT_Admin_Schema_Page_Display` | `page-display.js` |
| post_list | `AKSRT_Admin_Schema_Post_List` | `post-list.js` |
| permissions | `AKSRT_Admin_Schema_Permissions` | `permissions.js` |
| users | `AKSRT_Admin_Schema_Users` | `users.js` |
| payment | `AKSRT_Admin_Schema_Payment` | `payment.js` |
| extensions | `AKSRT_Admin_Schema_Extensions` | `extensions.js` |
| tools | `AKSRT_Admin_Schema_Tools` | `tools.js` |
| shop | `AKSRT_Admin_Schema_Shop` | `shop.js` |
## Known custom pages (not schema REST)
These use dedicated Vue pages + dedicated REST modules:
- Homepage: `HomepageSettings` + `/settings/homepage`
- Footer: `FooterSettings` + footer settings API
- Permissions capabilities matrix: `PermissionsCapabilities` + `/permissions/capabilities`
- Shop products/orders and other operational tables
## Deploy checklist
1. `cd assets/admin && npm ci && npm run build`
2. Confirm `build/index.js` and every dynamically imported `./Chunk.hash.js` exist
3. Replace the entire remote `assets/admin/build/` directory
4. Hard-refresh wp-admin (or purge CDN)
5. Re-run `php tools/audit-admin-contracts.php`
-61
View File
@@ -1,61 +0,0 @@
# ADR: Domain folder placement + PSR-4 namespaces for theme types
## Status
Accepted (2026-07-17); **updated 2026-08-02** for Composer PSR-4 root flatten to `src/`.
## Context
Domain folders were frozen after the root→domain move. Theme types then migrated from global `AKSRT_*` + manual `require_once` to real PHP namespaces under `src/` with Composer PSR-4.
## Decision
### Domain ownership (unchanged)
| Concern | Domain | Why |
|---------|--------|-----|
| Theme shell (setup, scripts, templates, SEO, meta boxes, DB migrator, front-submit, docs presenter, home modules, stats, redirects, update checker, comment actions) | `Core` | Shared CMS plumbing |
| SPA shell + controllers + schema + native ops | `Admin` | Dual-surface IA |
| Settings facade + runtime delegates | `Settings` | Single public settings API |
| Auth, OAuth, captcha, moderation, email rollback | `Auth` | Identity & access |
| User center/home/badges/level/metrics/notifications/follow | `User` | Account surface |
| Credits, balance wallet, signin, invite, points-free | `Credits` | Points + cash wallet |
| Download tokens/state, content pay, VIP, visibility | `Content` | Post-centric paid content |
| Orders, gateways, coupon, charge-card, revenue, creator-revenue, withdrawal, rebate, discount, after-sale, shipping, wechat-notify | `Payment` | Commerce + settlement |
| Shop services/meta/reviews | `Shop` | Product catalog / cart |
| SMS stack | `Sms` | Messaging channel |
| AI runtime (admin AI controller stays in `Admin`) | `Ai` | Generation helpers |
| Message, private message, site notice, report center | `Messages` | In-site messaging |
| MeiliSearch, search-push, ip-geo, content-audit, api-audit | `Extensions` | Optional integrations |
| Widgets + registry | `Widgets` | WP widgets |
| Inline style / custom code / loading overlay | `Frontend` | Asset render helpers |
### PSR-4 layout
- Composer: `"AKSRT\\": "src/"`
- Namespace `AKSRT\{Domain}\…` ↔ path `src/{Domain}/…`
- One type per file; StudlyCaps filename = short class/trait/interface name
- Nested segments: `Admin\Schema`, `Admin\Native`, `Payment\Gateways`, `Admin\Schema\PostList`
- OAuth stays under `Auth` (`AKSRT\Auth\OAuth`) — no separate `oauth/` tree
- Do not move `TopicPay` / `PlateFollowPay` into `Content` without updating this ADR
- `CreatorRevenue` and `WeChatNotify` stay under `Payment`
### Loader
1. `functions.php` loads `vendor/autoload.php` (Composer: third-party + theme PSR-4)
2. Immediately after, canary `class_exists(\AKSRT\Core\DomainModel::class)` — if missing, `wp_die` with `composer install` / `composer dump-autoload -o` (stale `vendor/` after PSR-4 cutover is the common failure)
3. `inc/bootstrap.php` loads helpers + procedural modules (`inc/modules/*`) + core-hooks only
4. Singleton `init()` remains in `functions.php` using FQCNs
Deploy note: `vendor/` is gitignored. After pull/upload, run `composer install --no-dev --optimize-autoloader` (or at least `composer dump-autoload -o`) in the theme root so `autoload_psr4` includes `"AKSRT\\": ["…/src"]`.
### Procedural (not PSR-4)
- `inc/helpers/*` function libraries
- `inc/modules/*` (captcha, user-hub/parts, user-notifications, widgets-register, …)
- `inc/core-hooks.php` (+ parts)
The one-shot `tools/build-psr4-map.php` migrator (and `tools/psr4-map/` artifacts) were removed when `inc/classes` was deleted — they scanned the old tree and cannot run anymore.
## Out of scope
- Dual-read `class_alias` shims for old `AKSRT_*` global names (rewrite references instead)
- Converting all helpers to classes
-436
View File
@@ -1,436 +0,0 @@
<?php
/**
* Admin contract audit — menu ↔ PHP schema ↔ REST $this callbacks ↔ build integrity.
*
* Usage (from theme root):
* php tools/audit-admin-contracts.php
*
* Exit 1 on hard errors.
*/
declare(strict_types=1);
$themeRoot = dirname(__DIR__);
$errors = [];
$warnings = [];
$info = [];
function rel(string $path, string $root): string {
return str_replace('\\', '/', ltrim(str_replace($root, '', $path), '\\/'));
}
/**
* Extract section keys from composite schema builders (including delegated leaf builders).
*
* @return array<string, array{sections: string[], files: string[]}>
*/
function collect_php_schema_sections(string $themeRoot): array {
$map = [];
$register = static function (string $domain, string $section, string $file) use (&$map, $themeRoot): void {
if ($domain === '' || $section === '') {
return;
}
if (!isset($map[$domain])) {
$map[$domain] = ['sections' => [], 'files' => []];
}
$map[$domain]['sections'][$section] = true;
$fileRel = rel($file, $themeRoot);
if (!in_array($fileRel, $map[$domain]['files'], true)) {
$map[$domain]['files'][] = $fileRel;
}
};
// Primary domain builders.
$primary = [
'global' => $themeRoot . '/src/Admin/Schema/GlobalSchema.php',
'page_display' => $themeRoot . '/src/Admin/Schema/SchemaPageDisplay.php',
'post_list' => $themeRoot . '/src/Admin/Schema/SchemaPostList.php',
'permissions' => $themeRoot . '/src/Admin/Schema/SchemaPermissions.php',
'users' => $themeRoot . '/src/Admin/Schema/SchemaUsers.php',
'payment' => $themeRoot . '/src/Admin/Schema/SchemaPayment.php',
'extensions' => $themeRoot . '/src/Admin/Schema/SchemaExtensions.php',
'tools' => $themeRoot . '/src/Admin/Schema/SchemaTools.php',
'shop' => $themeRoot . '/src/Admin/Schema/SchemaShop.php',
];
foreach ($primary as $domain => $file) {
if (!is_file($file)) {
continue;
}
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Direct section keys: 'foo' => [ or 'foo' => Class::build()
if (preg_match_all("/'([a-z0-9_-]+)'\\s*=>\\s*(?:\\[|[A-Za-z0-9_]+::build\\(\\))/", $src, $m)) {
foreach ($m[1] as $section) {
if (in_array($section, ['domain', 'title', 'version', 'option_key', 'key', 'sections', 'groups', 'fields'], true)) {
continue;
}
$register($domain, $section, $file);
}
}
}
// Leaf builders that return a single section with 'key' => 'section-id'
$leafFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
foreach ($leafFiles as $file) {
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Skip pure composites that only delegate.
$isComposite = (bool) preg_match("/'sections'\\s*=>\\s*\\[/", $src);
if ($isComposite) {
continue;
}
if (!preg_match("/'key'\\s*=>\\s*'([a-z0-9_-]+)'/", $src, $km)) {
continue;
}
$section = $km[1];
// Infer domain from filename: class-aksrt-admin-schema-{domain}-{section}.php
$base = basename($file, '.php');
$base = preg_replace('/^(class-aksrt-admin-schema-|Schema)/', '', $base);
$parts = explode('-', $base);
// domain may be multi-token (page_display is page-display in menus but page_display in PHP).
// Prefer matching known domains by prefix.
$domain = null;
foreach (array_keys($primary) as $candidate) {
$candidateDash = str_replace('_', '-', $candidate);
if ($base === $candidateDash || str_starts_with($base, $candidateDash . '-')) {
$domain = $candidate;
break;
}
}
if ($domain === null) {
// Fallback: first token / PSR-4 Schema* short names.
if (str_contains($base, 'PostList')) {
$domain = 'post_list';
} else {
$domain = str_replace('-', '_', $parts[0] ?? '');
}
}
// Leaf under Schema/PostList/ with key list → post_list/list
if ($domain === 'post_list' && $section === 'list') {
$register($domain, $section, $file);
continue;
}
$register($domain, $section, $file);
}
foreach ($map as $domain => $payload) {
$map[$domain]['sections'] = array_keys($payload['sections']);
sort($map[$domain]['sections']);
}
return $map;
}
function collect_menu_pages(string $themeRoot): array {
$dir = $themeRoot . '/assets/admin/src/constants/sections';
$pages = [];
foreach (glob($dir . '/*.js') ?: [] as $file) {
$base = basename($file);
if (in_array($base, ['helpers.js', 'index.js'], true)) {
continue;
}
$src = file_get_contents($file);
if ($src === false) {
continue;
}
foreach (['schemaPage' => 'schemaPage', 'customPage' => 'customPage', 'deferredPage' => 'deferredPage'] as $fn => $kind) {
if (!preg_match_all('/' . $fn . '\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)/', $src, $blocks)) {
continue;
}
foreach ($blocks[1] as $block) {
$page = ['file' => rel($file, $themeRoot), 'kind' => $kind];
foreach (['key', 'canonicalDomain', 'section', 'route', 'component', 'name'] as $prop) {
if (preg_match('/' . $prop . ":\\s*'([^']*)'/", $block, $m)) {
$page[$prop === 'canonicalDomain' ? 'domain' : $prop] = $m[1];
}
}
$pages[] = $page;
}
}
}
return $pages;
}
/**
* Collect enum options for fields with balanced-enough heuristics:
* only consider field objects that include both type radio|select and an options array
* on nearby lines, keyed by the field key property.
*
* @return array{php: array<string, string[]>, js: array<string, string[]>}
*/
function collect_enum_maps(string $themeRoot): array {
$php = [];
$js = [];
$phpFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
foreach ($phpFiles as $file) {
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Split roughly by field array starts: ['key' => '...'
if (!preg_match_all("/\\['key'\\s*=>\\s*'([^']+)'([\\s\\S]*?)(?=\\['key'\\s*=>|\\]\\s*,\\s*\\[\\s*'key'|\\]\\s*\\]\\s*,\\s*\\[\\s*'key'|$)/", $src, $chunks, PREG_SET_ORDER)) {
continue;
}
foreach ($chunks as $chunk) {
$key = $chunk[1];
$body = $chunk[2];
if (!preg_match("/'type'\\s*=>\\s*'(radio|select)'/", $body)) {
continue;
}
if (!preg_match("/'options'\\s*=>\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
continue;
}
$values = [];
if (preg_match_all("/'value'\\s*=>\\s*'([^']*)'/", $om[1], $vm)) {
$values = $vm[1];
}
// Keep first definition; warn later if duplicates differ.
if (!isset($php[$key])) {
$php[$key] = $values;
}
}
}
$jsRoot = $themeRoot . '/assets/admin/src/schema/sections';
if (is_dir($jsRoot)) {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($jsRoot, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->getExtension() !== 'js') {
continue;
}
$src = file_get_contents($file->getPathname());
if ($src === false) {
continue;
}
if (!preg_match_all("/\\{\\s*key:\\s*'([^']+)'([\\s\\S]*?)(?=\\{\\s*key:\\s*'|$)/", $src, $chunks, PREG_SET_ORDER)) {
continue;
}
foreach ($chunks as $chunk) {
$key = $chunk[1];
$body = $chunk[2];
// Stop body at next field-ish boundary if options not closed well.
if (!preg_match("/type:\\s*'(radio|select)'/", $body)) {
continue;
}
if (!preg_match("/options:\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
continue;
}
$values = [];
if (preg_match_all("/value:\\s*'([^']*)'/", $om[1], $vm)) {
$values = $vm[1];
}
if (!isset($js[$key])) {
$js[$key] = $values;
}
}
}
}
return ['php' => $php, 'js' => $js];
}
function collect_missing_this_callbacks(string $themeRoot): array {
$issues = [];
$adminRoot = $themeRoot . '/src/Admin';
$methodIndex = [];
if (!is_dir($adminRoot)) {
return $issues;
}
$allPhp = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($adminRoot, FilesystemIterator::SKIP_DOTS));
foreach ($allPhp as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$src = file_get_contents($file->getPathname());
if ($src === false) {
continue;
}
if (preg_match_all('/function\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(/', $src, $mm)) {
foreach ($mm[1] as $method) {
$methodIndex[$method] = true;
}
}
}
foreach ($allPhp as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$path = $file->getPathname();
$src = file_get_contents($path);
if ($src === false) {
continue;
}
if (!preg_match_all("/'callback'\\s*=>\\s*\\[\\s*\\\$this\\s*,\\s*'([^']+)'\\s*\\]/", $src, $matches)) {
continue;
}
foreach ($matches[1] as $method) {
if (empty($methodIndex[$method])) {
$issues[] = [
'file' => rel($path, $themeRoot),
'method' => $method,
];
}
}
}
return $issues;
}
function audit_build_manifest(string $themeRoot): array {
$buildDir = $themeRoot . '/assets/admin/build';
$result = [
'present' => is_dir($buildDir),
'entry' => false,
'missing_chunks' => [],
'chunks' => [],
];
if (!$result['present']) {
return $result;
}
$entry = $buildDir . '/index.js';
$result['entry'] = is_file($entry);
if (!$result['entry']) {
return $result;
}
$src = file_get_contents($entry);
if ($src === false) {
return $result;
}
if (preg_match_all('/\\.\\/([A-Za-z0-9_.-]+\\.js)/', $src, $m)) {
$result['chunks'] = array_values(array_unique($m[1]));
foreach ($result['chunks'] as $chunk) {
if (!is_file($buildDir . '/' . $chunk)) {
$result['missing_chunks'][] = $chunk;
}
}
}
return $result;
}
$phpSchemas = collect_php_schema_sections($themeRoot);
$menuPages = collect_menu_pages($themeRoot);
$enums = collect_enum_maps($themeRoot);
$restIssues = collect_missing_this_callbacks($themeRoot);
$build = audit_build_manifest($themeRoot);
$info[] = 'PHP schema domains: ' . implode(', ', array_keys($phpSchemas));
$info[] = 'Menu pages discovered: ' . count($menuPages);
foreach ($menuPages as $page) {
if (($page['kind'] ?? '') !== 'schemaPage') {
continue;
}
$domain = $page['domain'] ?? '';
$section = $page['section'] ?? '';
$key = $page['key'] ?? '(unknown)';
if ($domain === '' || $section === '') {
$errors[] = "schemaPage {$key} missing canonicalDomain/section in {$page['file']}";
continue;
}
if (!isset($phpSchemas[$domain])) {
$errors[] = "schemaPage {$key} domain '{$domain}' has no PHP schema builder";
continue;
}
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
$errors[] = "schemaPage {$key} section '{$domain}/{$section}' missing in PHP schema; known=[" . implode(',', $phpSchemas[$domain]['sections']) . '] files=' . implode(',', $phpSchemas[$domain]['files']);
}
}
foreach ($menuPages as $page) {
if (($page['kind'] ?? '') !== 'customPage') {
continue;
}
$domain = $page['domain'] ?? '';
$section = $page['section'] ?? '';
if ($domain === '' || $section === '') {
continue;
}
if (!isset($phpSchemas[$domain])) {
$warnings[] = "customPage {$page['key']} references domain '{$domain}' without PHP schema map entry";
continue;
}
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
$warnings[] = "customPage {$page['key']} section '{$domain}/{$section}' not in PHP schema (ok if pure operational page)";
}
}
// Enum mismatches only for shared keys with non-empty option sets on both sides.
// Heuristic parsers often capture only the default option from dense PHP arrays;
// treat "PHP is a non-empty proper subset of JS" as a parser warning, not a hard error.
// Hard error when sets are disjoint or PHP introduces values JS does not have.
$shared = array_intersect(array_keys($enums['php']), array_keys($enums['js']));
sort($shared);
foreach ($shared as $key) {
$phpValues = $enums['php'][$key];
$jsValues = $enums['js'][$key];
if ($phpValues === [] && $jsValues === []) {
continue;
}
$phpSorted = $phpValues;
$jsSorted = $jsValues;
sort($phpSorted);
sort($jsSorted);
if ($phpSorted === $jsSorted) {
continue;
}
$phpOnly = array_values(array_diff($phpValues, $jsValues));
$jsOnly = array_values(array_diff($jsValues, $phpValues));
if ($phpOnly === [] && $jsOnly !== [] && $phpValues !== []) {
$warnings[] = "enum incomplete parse for '{$key}' (php subset of js): php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . ']';
continue;
}
$errors[] = "enum mismatch for '{$key}': php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . '] php_only=[' . implode(',', $phpOnly) . '] js_only=[' . implode(',', $jsOnly) . ']';
}
foreach ($restIssues as $issue) {
$errors[] = "REST \$this callback missing: {$issue['method']} in {$issue['file']}";
}
if (!$build['present']) {
$warnings[] = 'assets/admin/build/ missing — run: cd assets/admin && npm run build';
} elseif (!$build['entry']) {
$errors[] = 'assets/admin/build/index.js missing';
} elseif ($build['missing_chunks']) {
$errors[] = 'admin build missing chunks: ' . implode(', ', $build['missing_chunks']);
} else {
$info[] = 'admin build ok; chunks=' . count($build['chunks']);
}
echo "AKSRT admin contract audit\n";
echo str_repeat('=', 48) . "\n";
foreach ($info as $line) {
echo "[info] {$line}\n";
}
foreach ($warnings as $line) {
echo "[warn] {$line}\n";
}
foreach ($errors as $line) {
echo "[error] {$line}\n";
}
echo str_repeat('-', 48) . "\n";
echo 'errors=' . count($errors) . ' warnings=' . count($warnings) . "\n";
$summaryPath = $themeRoot . '/tools/admin-contract-report.json';
file_put_contents($summaryPath, json_encode([
'generated_at' => gmdate('c'),
'errors' => $errors,
'warnings' => $warnings,
'info' => $info,
'domains' => $phpSchemas,
'page_count' => count($menuPages),
'build' => $build,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
echo "wrote tools/admin-contract-report.json\n";
// Ignore report in git by default? Keep it for CI artifact; do not commit noise if huge.
exit(count($errors) > 0 ? 1 : 0);
-316
View File
@@ -1,316 +0,0 @@
<?php
declare(strict_types=1);
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . DIRECTORY_SEPARATOR);
}
// Minimal WordPress-compatible stubs keep the schema audit runnable without a site checkout.
if (!function_exists('sanitize_text_field')) {
function sanitize_text_field($value): string {
return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? '');
}
}
if (!function_exists('__')) {
function __($text): string {
return (string) $text;
}
}
if (!function_exists('sanitize_textarea_field')) {
function sanitize_textarea_field($value): string {
return trim(strip_tags((string) $value));
}
}
if (!function_exists('wp_kses_post')) {
function wp_kses_post($value): string {
return strip_tags((string) $value, '<a><b><strong><em><br><p><span><ul><ol><li>');
}
}
if (!function_exists('esc_url_raw')) {
function esc_url_raw($value): string {
$value = trim((string) $value);
return filter_var($value, FILTER_VALIDATE_URL) ? $value : '';
}
}
if (!function_exists('absint')) {
function absint($value): int {
return abs((int) $value);
}
}
if (!function_exists('sanitize_hex_color')) {
function sanitize_hex_color($value): string {
$value = (string) $value;
return preg_match('/^#(?:[0-9a-fA-F]{3}){1,2}$/', $value) ? strtolower($value) : '';
}
}
require dirname(__DIR__) . '/vendor/autoload.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\PostQuery;
use AKSRT\WidgetSystem\RendererRegistry;
use AKSRT\WidgetSystem\WidgetAreas;
use AKSRT\WidgetSystem\WidgetRegistry;
use AKSRT\WidgetSystem\WidgetTransfer;
$errors = [];
$expected = [
'search', 'posts_toc', 'quote', 'icon_cards', 'video', 'embed', 'media_cards',
'icon_cover_cards', 'partners', 'combo_info', 'text_title', 'buttons', 'layout_gap',
'tag_cloud', 'recent_comments', 'links', 'notice', 'term_cards', 'term_aggregation',
'hot_posts', 'post_pay', 'posts', 'tab_posts', 'oneline_posts', 'pricing_plans',
'reviews', 'faq', 'stats', 'highlights', 'feature_intro', 'tilt_cases', 'slider',
'slider_media_combo', 'user_card', 'author_card', 'user_ranking', 'user_list',
];
$registry = WidgetRegistry::all();
if (count($registry) !== 37) {
$errors[] = 'Registry must contain exactly 37 widgets; got ' . count($registry) . '.';
}
if (array_keys($registry) !== $expected) {
$errors[] = 'Registry slugs or ordering differ from the frozen contract.';
}
$expected_areas = [
'all_top_fluid', 'all_bottom_fluid', 'all_sidebar_top', 'all_sidebar_bottom',
'home_top_fluid', 'home_top_content', 'home_bottom_content', 'home_bottom_fluid', 'home_sidebar',
'single_top_fluid', 'single_top_content', 'single_bottom_content', 'single_bottom_fluid', 'single_sidebar',
'cat_top_fluid', 'cat_top_content', 'cat_bottom_content', 'cat_bottom_fluid', 'cat_sidebar',
'tag_top_fluid', 'tag_top_content', 'tag_bottom_content', 'tag_bottom_fluid', 'tag_sidebar',
'search_top_fluid', 'search_top_content', 'search_bottom_content', 'search_bottom_fluid', 'search_sidebar',
'author_top_fluid', 'author_bottom_fluid', 'msg_top_fluid', 'msg_bottom_fluid',
'user_top_fluid', 'user_top_content', 'user_bottom_fluid',
'newposts_sidebar_top', 'newposts_sidebar_bottom', 'all_footer', 'mobile_nav_fluid',
];
if (WidgetAreas::static_ids() !== $expected_areas || count(WidgetAreas::static_ids()) !== 40) {
$errors[] = 'Widget area registry must contain the exact 40 static areas.';
}
$templates = WidgetTransfer::templates();
$expected_templates = ['article_home', 'article_list', 'links_navigation', 'article_sidebar', 'knowledge_landing'];
if (array_keys($templates) !== $expected_templates || count($templates) !== 5) {
$errors[] = 'Import/export must expose the exact five built-in templates.';
}
foreach ($templates as $template => $contract) {
foreach ((array) ($contract['sidebars'] ?? []) as $items) {
foreach ((array) $items as $item) {
if (!isset($registry[(string) ($item['widget'] ?? '')])) {
$errors[] = $template . ': template contains an unknown widget.';
}
}
}
}
$supported = FieldSchema::supported_types();
$walk = static function (array $fields, string $owner) use (&$walk, &$errors, $supported): void {
$ids = [];
foreach ($fields as $field) {
$id = (string) ($field['id'] ?? '');
$type = (string) ($field['type'] ?? 'text');
if (!in_array($type, $supported, true)) {
$errors[] = $owner . ': unsupported field type ' . $type . '.';
}
if ($id !== '') {
if (isset($ids[$id])) {
$errors[] = $owner . ': duplicate field id ' . $id . '.';
}
$ids[$id] = true;
}
if (!empty($field['fields']) && is_array($field['fields'])) {
$walk($field['fields'], $owner . '.' . ($id ?: $type));
}
}
};
foreach ($registry as $slug => $definition) {
if ($definition->id_base() !== 'aksrt_widget_' . $slug) {
$errors[] = $slug . ': invalid id_base.';
}
if (preg_match('/(?:bbs|forum|shop|cart|inventory|shipping)/i', $slug . ' ' . $definition->renderer())) {
$errors[] = $slug . ': forbidden forum/shop contract.';
}
$fields = $definition->fields();
$walk($fields, $slug);
$field_ids = array_column($fields, 'id');
foreach (['show_type', 'show_id_type', 'show_ids', 'animation_in', 'layout_pin_pc', 'layout_bg'] as $required) {
if (!in_array($required, $field_ids, true)) {
$errors[] = $slug . ': missing common field ' . $required . '.';
}
}
}
$renderers = RendererRegistry::all();
if (count($renderers) !== 37 || array_keys($renderers) !== $expected) {
$errors[] = 'Renderer registry must map the exact 37 frozen slugs.';
}
foreach ($renderers as $slug => $renderer) {
if (!is_callable([$renderer, 'render'])) {
$errors[] = $slug . ': renderer is not callable.';
}
}
$dynamic = ['posts', 'tab_posts', 'oneline_posts', 'hot_posts'];
foreach ($expected as $slug) {
if (RendererRegistry::supports_items($slug) !== in_array($slug, $dynamic, true)) {
$errors[] = $slug . ': invalid dynamic items capability.';
}
}
if (!defined('DAY_IN_SECONDS')) {
define('DAY_IN_SECONDS', 86400);
}
$query_args = PostQuery::arguments([
'count' => 7,
'category_ids' => '2,5',
'tag_ids' => '8,13',
'limit_day' => 30,
'orderby' => 'views',
'order' => 'ASC',
'pay_type' => 'video',
], 3);
if (($query_args['posts_per_page'] ?? 0) !== 7 || ($query_args['paged'] ?? 0) !== 3) {
$errors[] = 'Post query pagination contract failed.';
}
if (($query_args['category__in'] ?? []) !== [2, 5] || ($query_args['tag__in'] ?? []) !== [8, 13]) {
$errors[] = 'Post query taxonomy contract failed.';
}
if (($query_args['orderby'] ?? '') !== 'meta_value_num' || ($query_args['order'] ?? '') !== 'ASC') {
$errors[] = 'Post query ordering contract failed.';
}
$pay_query = json_encode($query_args['meta_query'] ?? []);
if (!is_string($pay_query) || !str_contains($pay_query, 'video')) {
$errors[] = 'Post query paid-content contract failed.';
}
$rest_source = (string) file_get_contents(dirname(__DIR__) . '/src/WidgetSystem/RestController.php');
foreach ([
"'permission_callback' => '__return_true'",
"'instance' => ['type' => 'string', 'required' => true",
"'page' => ['type' => 'integer'",
"'segment' => ['type' => 'string'",
'wp_get_sidebars_widgets()',
'$widget->get_settings()',
] as $contract) {
if (!str_contains($rest_source, $contract)) {
$errors[] = 'REST controller missing contract: ' . $contract;
}
}
if (preg_match('/\$_(?:GET|POST|REQUEST)/', $rest_source)) {
$errors[] = 'REST controller must use WP_REST_Request only.';
}
$frontend_files = [
dirname(__DIR__) . '/assets/widgets/frontend.css',
dirname(__DIR__) . '/assets/widgets/frontend.js',
];
foreach ($frontend_files as $frontend_file) {
if (!is_file($frontend_file) || filesize($frontend_file) === 0) {
$errors[] = 'Missing frontend widget asset: ' . basename($frontend_file);
}
}
$root = dirname(__DIR__);
$legacy_paths = [
'/src/Core/WidgetAjax.php',
'/src/Core/SetupPart1.php',
'/src/Widgets',
'/inc/modules/widgets-register.php',
'/inc/modules/widget-common.php',
'/inc/modules/widget-areas.php',
'/assets/css/frontend/widgets.css',
'/assets/css/frontend/widgets',
'/assets/css/frontend/widget-batch5.css',
'/assets/js/widget-set.js',
];
foreach ($legacy_paths as $legacy_path) {
if (file_exists($root . $legacy_path)) {
$errors[] = 'Legacy widget runtime remains: ' . $legacy_path;
}
}
$runtime_source = (string) file_get_contents($root . '/src/WidgetSystem/Runtime.php');
foreach (['WidgetAreas::class', 'WidgetRegistry::all()', 'register_widget(new SchemaWidget', 'unregister_core_widgets'] as $contract) {
if (!str_contains($runtime_source, $contract)) {
$errors[] = 'Widget runtime missing contract: ' . $contract;
}
}
$asset_source = (string) file_get_contents($root . '/src/WidgetSystem/AssetManager.php');
if (str_contains($asset_source, 'cdn.jsdelivr.net')) {
$errors[] = 'Widget assets must not use CDN dependencies.';
}
$admin_import_sources = (string) file_get_contents($root . '/src/Admin/ImportExportController.php')
. (string) file_get_contents($root . '/src/Admin/ImportExportControllerPart1.php');
foreach (['/widgets/export', '/widgets/import', '/widgets/templates', 'aksrt_widget_templates'] as $legacy_contract) {
if (str_contains($admin_import_sources, $legacy_contract)) {
$errors[] = 'Legacy widget import/export contract remains: ' . $legacy_contract;
}
}
$pinned_assets = [
'DPlayer.min.js' => '361689807fc1e856f868df91036e41195d2c255619bd1c5526849c5ad7491241',
'hls.min.js' => 'e876282a26116e323c8ed3e86bed3838a80dbbe3a2faa09f834bee86963ff760',
'flv.min.js' => '0668d36757cdc17804d5c0bd6fe8956d76370e73a733bf5bbc0eddbfbca2c6cc',
'dash.all.min.js' => '3e753969c16a2a78f420a25d24869f14721eb1d284418f9f22d35a31c3693cd9',
'vanilla-tilt.min.js' => 'de6fa8d3f40dbae2726a72f7d1ae46bb0588267af05355bef1d128f737f4a282',
];
foreach ($pinned_assets as $asset => $hash) {
$path = $root . '/assets/widgets/third-party/' . $asset;
if (!is_file($path)) {
$errors[] = 'Missing pinned widget dependency: ' . $asset;
} else {
$contents = (string) file_get_contents($path);
$raw_hash = hash('sha256', $contents);
$lf_hash = hash('sha256', str_replace("\r\n", "\n", $contents));
if ($raw_hash !== $hash && $lf_hash !== $hash) {
$errors[] = 'Pinned widget dependency changed: ' . $asset;
}
}
}
if (!is_file($root . '/assets/widgets/third-party/THIRD_PARTY.md')) {
$errors[] = 'Missing widget dependency license manifest.';
}
$posts = $registry['posts'];
$normalized = $posts->normalize([
'title' => '<script>alert(1)</script> 最新课程',
'show_type' => 'invalid',
'show_id_type' => 'show',
'show_ids' => '3, 3,9 bad -4',
'count' => 999,
'style' => 'broken',
'pay_type' => 'video',
]);
if ($normalized['count'] !== 50 || $normalized['style'] !== 'card' || $normalized['show_type'] !== 'all') {
$errors[] = 'Schema clamping or enum fallback failed.';
}
if ($normalized['show_ids'] !== '3,9,4') {
$errors[] = 'ID list normalization failed: ' . $normalized['show_ids'];
}
if (str_contains($normalized['title'], '<script>')) {
$errors[] = 'Text sanitization failed.';
}
$baseline_file = dirname(__DIR__) . '/.specs/baselines/widget-reference.json';
$baseline = json_decode((string) file_get_contents($baseline_file), true);
if (!is_array($baseline) || ($baseline['source_policy']['modern_widget_count'] ?? null) !== 37) {
$errors[] = 'Reference baseline is missing or invalid.';
} elseif (is_dir((string) ($baseline['source_root'] ?? ''))) {
foreach ((array) ($baseline['files'] ?? []) as $file) {
$path = rtrim((string) $baseline['source_root'], '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $file['path']);
if (!is_file($path)) {
$errors[] = 'Reference file missing: ' . $path;
continue;
}
if (hash_file('sha256', $path) !== ($file['sha256'] ?? '')) {
$errors[] = 'Reference file changed: ' . $file['path'];
}
}
}
if ($errors) {
fwrite(STDERR, "Widget system audit failed:\n- " . implode("\n- ", $errors) . "\n");
exit(1);
}
echo "Widget system audit passed: 37 widgets, 40 areas, 5 templates, single runtime and pinned assets.\n";
-229
View File
@@ -1,229 +0,0 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/tests/widget-system/bootstrap.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\WidgetRegistry;
$root = dirname(__DIR__);
$baseline_path = $root . '/.specs/baselines/widget-reference.json';
$baseline = json_decode((string) file_get_contents($baseline_path), true, 512, JSON_THROW_ON_ERROR);
if (($baseline['schema'] ?? '') !== 'aksrt.widget-reference.v1') {
throw new RuntimeException('Frozen widget reference baseline is invalid.');
}
$reference_root = rtrim((string) ($baseline['source_root'] ?? ''), '\\/');
$reference_hashes = [];
foreach ((array) ($baseline['files'] ?? []) as $file) {
$relative = (string) ($file['path'] ?? '');
$expected = (string) ($file['sha256'] ?? '');
$path = $reference_root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
if (!is_file($path) || hash_file('sha256', $path) !== $expected) {
throw new RuntimeException('Frozen reference changed or is missing: ' . $relative);
}
$reference_hashes[$relative] = $expected;
}
$group_sources = [
'general' => 'inc/widgets/widget-more.php',
'content' => 'inc/widgets/widget-posts.php',
'knowledge_product' => 'inc/widgets/widget-produck.php',
'slider' => 'inc/widgets/widget-slider.php',
'user' => 'inc/widgets/widget-user.php',
];
$viewports = [
['name' => 'desktop', 'width' => 1440, 'height' => 1100],
['name' => 'tablet', 'width' => 1024, 'height' => 1000],
['name' => 'mobile', 'width' => 390, 'height' => 844],
];
$modes = ['light', 'dark'];
$images = [
'https://fixtures.aksrt.test/media/course-cover-01.jpg',
'https://fixtures.aksrt.test/media/course-cover-02.jpg',
'https://fixtures.aksrt.test/media/course-cover-03.jpg',
];
function fixture_text(string $id, string $slug, int $index): string {
$labels = [
'question' => '购买后可以长期学习吗?',
'answer' => '可以,已购买内容会永久保存在你的学习中心。',
'name' => 'AKSRT 学员 ' . ($index + 1),
'author' => 'AKSRT 学员 ' . ($index + 1),
'role' => '知识产品体验官',
'price' => '¥99',
'value' => (string) (($index + 1) * 1280) . '+',
'label' => '累计学习人数',
'heading' => '从入门到精通的系统学习路径',
'description' => '固定内容用于组件 DOM、响应式和视觉差异验收。',
'content' => '结构清晰、案例完整,并持续更新配套资料。',
'title' => 'AKSRT 固定示例 ' . ($index + 1),
'text' => '保持好奇,持续创造。',
];
if (isset($labels[$id])) {
return $labels[$id];
}
if (str_contains($id, 'video')) {
return 'https://fixtures.aksrt.test/media/lesson.m3u8';
}
if (str_contains($id, 'url')) {
return 'https://fixtures.aksrt.test/' . $slug . '/' . ($index + 1);
}
if (str_contains($id, 'ids')) {
return str_contains($id, 'term') || str_contains($id, 'category') ? '101,102,103' : '';
}
return 'AKSRT ' . str_replace('_', ' ', $id) . ' ' . ($index + 1);
}
function fixture_value(array $field, string $slug, int $index, array $images) {
$type = (string) ($field['type'] ?? 'text');
$id = (string) ($field['id'] ?? '');
$default = $field['default'] ?? null;
if ($id === 'title') return 'AKSRT · ' . ucfirst(str_replace('_', ' ', $slug));
if ($id === 'subtitle') return '固定内容与交互状态视觉验收';
if ($id === 'title_highlight') return 'AKSRT';
if ($id === 'show_type') return 'all';
if ($id === 'show_id_type' || $id === 'show_ids') return '';
if ($id === 'layout_bg') {
return [
'light' => ['color' => '#ffffff', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'],
'dark' => ['color' => '#1d1e24', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'],
];
}
if ($type === 'group') {
$rows = [];
$count = min(3, max(1, (int) ($field['max_items'] ?? 3)));
for ($row = 0; $row < $count; $row++) {
$item = [];
foreach ((array) ($field['fields'] ?? []) as $child) {
$child_id = (string) ($child['id'] ?? '');
if ($child_id !== '') $item[$child_id] = fixture_value($child, $slug, $row, $images);
}
$rows[] = $item;
}
return $rows;
}
if (in_array($type, ['fieldset', 'accordion'], true)) {
$value = [];
foreach ((array) ($field['fields'] ?? []) as $child) {
$child_id = (string) ($child['id'] ?? '');
if ($child_id !== '') $value[$child_id] = fixture_value($child, $slug, $index, $images);
}
return $value;
}
if ($type === 'link') return ['url' => 'https://fixtures.aksrt.test/' . $slug, 'text' => '查看详情', 'target' => ''];
if ($type === 'upload') return $images[$index % count($images)];
if ($type === 'icon') return 'fas fa-graduation-cap';
if ($type === 'color') return '#667eea';
if ($type === 'spacing') return ['top' => 0, 'right' => 0, 'bottom' => 20, 'left' => 0];
if ($type === 'background') return ['color' => '#ffffff', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'];
if ($type === 'switcher') return $default === null ? true : (bool) $default;
if (in_array($type, ['number', 'spinner', 'slider'], true)) return $default ?? ($field['min'] ?? 1);
if (in_array($type, ['radio', 'button_set', 'select', 'palette'], true)) return $default ?? (array_key_first((array) ($field['options'] ?? [])) ?? '');
if ($type === 'checkbox') return $default ?? [];
if ($id === 'title_link') return ['url' => 'https://fixtures.aksrt.test/' . $slug, 'text' => '查看更多', 'target' => ''];
if ($default !== null && $default !== '') return $default;
return fixture_text($id, $slug, $index);
}
$dynamic = ['posts', 'tab_posts', 'oneline_posts', 'hot_posts'];
$expandable = ['posts_toc', 'faq'];
$tabbed = ['tab_posts', 'feature_intro'];
$carousel = ['partners', 'notice', 'oneline_posts', 'reviews', 'slider', 'slider_media_combo'];
$components = [];
$matrix = [];
foreach (WidgetRegistry::all() as $slug => $definition) {
$settings = [];
foreach ($definition->fields() as $field) {
$id = (string) ($field['id'] ?? '');
$type = (string) ($field['type'] ?? 'text');
if ($id === '' || in_array($type, ['content', 'subheading', 'submessage'], true)) continue;
$settings[$id] = fixture_value($field, $slug, 0, $images);
}
$settings = $definition->normalize($settings);
$states = ['default', 'hover', 'focus'];
if (in_array($slug, $expandable, true)) $states[] = 'expanded';
if (in_array($slug, $tabbed, true)) $states[] = 'alternate-tab';
if (in_array($slug, $carousel, true)) $states[] = 'next-slide';
if (in_array($slug, $dynamic, true)) $states = array_merge($states, ['loading', 'empty', 'error']);
if ($slug === 'post_pay') $states = array_merge($states, ['guest-locked', 'member-locked', 'authorized', 'unconfigured']);
if ($slug === 'user_card') $states = array_merge($states, ['guest', 'signed-in']);
$source = $group_sources[$definition->group()] ?? 'inc/widgets/widget-index.php';
$components[$slug] = [
'id_base' => $definition->id_base(),
'name' => $definition->name(),
'group' => $definition->group(),
'size' => $definition->size(),
'reference' => ['file' => $source, 'sha256' => $reference_hashes[$source] ?? ''],
'field_ids' => array_values(array_filter(array_map(static fn(array $field): string => (string) ($field['id'] ?? ''), $definition->fields()))),
'settings' => $settings,
'root_selector' => '[data-aksrt-widget="' . $slug . '"]',
'states' => array_values(array_unique($states)),
];
foreach ($viewports as $viewport) {
foreach ($modes as $mode) {
$id = $slug . ':' . $viewport['name'] . ':' . $mode;
$matrix[$id] = [
'widget' => $slug,
'viewport' => $viewport,
'mode' => $mode,
'path' => 'tests/visual-results/' . $slug . '/' . $viewport['name'] . '-' . $mode . '.png',
];
}
}
}
$fixture = [
'schema' => 'aksrt.widget-golden.v1',
'captured_at' => (string) ($baseline['captured_at'] ?? ''),
'reference' => [
'source_root' => $reference_root,
'fingerprint' => hash('sha256', implode('', $reference_hashes)),
'files' => $reference_hashes,
'policy' => $baseline['source_policy'] ?? [],
],
'acceptance' => [
'dimension_delta_px_max' => 2,
'pixel_difference_percent_max' => 1.5,
'console_errors_max' => 0,
'unexpected_network_failures_max' => 0,
'a11y_critical_max' => 0,
'a11y_serious_max' => 0,
'reduced_motion_required' => true,
],
'dataset' => [
'site' => ['name' => 'AKSRT 知识库', 'url' => 'https://fixtures.aksrt.test'],
'terms' => [
['id' => 101, 'taxonomy' => 'category', 'name' => '产品设计', 'count' => 24],
['id' => 102, 'taxonomy' => 'category', 'name' => '前端开发', 'count' => 36],
['id' => 103, 'taxonomy' => 'post_tag', 'name' => '系统课程', 'count' => 18],
],
'users' => [
['id' => 201, 'name' => '林知夏', 'role' => '讲师', 'points' => 9680, 'followers' => 3280],
['id' => 202, 'name' => '周屿', 'role' => '作者', 'points' => 7420, 'followers' => 2160],
['id' => 203, 'name' => '陈墨', 'role' => '学员', 'points' => 5360, 'followers' => 980],
],
'posts' => [
['id' => 301, 'title' => '从零构建知识产品体系', 'pay_type' => 'reading', 'author' => 201, 'category' => 101],
['id' => 302, 'title' => '现代 WordPress 主题工程实践', 'pay_type' => 'video', 'author' => 202, 'category' => 102],
['id' => 303, 'title' => '高质量课程资料包', 'pay_type' => 'download', 'author' => 201, 'category' => 102],
['id' => 304, 'title' => '视觉案例图库', 'pay_type' => 'gallery', 'author' => 202, 'category' => 101],
['id' => 305, 'title' => '会员专属进阶课', 'pay_type' => 'vip', 'author' => 201, 'category' => 101],
['id' => 306, 'title' => '公开学习指南', 'pay_type' => 'no', 'author' => 203, 'category' => 102],
],
'comments' => [
['id' => 401, 'user' => 203, 'post' => 301, 'content' => '课程结构清晰,案例非常实用。'],
['id' => 402, 'user' => 202, 'post' => 302, 'content' => '对主题工程化讲解得很完整。'],
],
'payment_states' => ['guest-locked', 'member-locked', 'free', 'purchased', 'vip-authorized', 'unconfigured'],
'media' => ['images' => $images, 'video' => 'https://fixtures.aksrt.test/media/lesson.m3u8'],
],
'components' => $components,
'screenshot_matrix' => $matrix,
];
$output = $root . '/tests/fixtures/widget-golden-v1.json';
file_put_contents($output, json_encode($fixture, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
echo 'Generated widget golden fixture: ' . count($components) . ' components, ' . count($matrix) . " screenshots.\n";
-70
View File
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/tests/widget-system/bootstrap.php';
use AKSRT\WidgetSystem\WidgetRegistry;
$root = dirname(__DIR__);
$fixture_path = $root . '/tests/fixtures/widget-golden-v1.json';
$fixture = json_decode((string) file_get_contents($fixture_path), true, 512, JSON_THROW_ON_ERROR);
$errors = [];
if (($fixture['schema'] ?? '') !== 'aksrt.widget-golden.v1') $errors[] = 'Invalid golden fixture schema.';
$components = (array) ($fixture['components'] ?? []);
$matrix = (array) ($fixture['screenshot_matrix'] ?? []);
if (array_keys($components) !== array_keys(WidgetRegistry::all()) || count($components) !== 37) $errors[] = 'Golden fixture must contain the exact 37 registry widgets.';
if (count($matrix) !== 222) $errors[] = 'Screenshot matrix must contain 37 × 3 × 2 = 222 entries.';
foreach ($components as $slug => $component) {
$definition = WidgetRegistry::get((string) $slug);
if ($definition === null) continue;
if (($component['id_base'] ?? '') !== $definition->id_base()) $errors[] = $slug . ': id_base mismatch.';
if (($component['settings'] ?? []) !== $definition->normalize((array) ($component['settings'] ?? []))) $errors[] = $slug . ': settings are not normalized.';
foreach (['default', 'hover', 'focus'] as $state) {
if (!in_array($state, (array) ($component['states'] ?? []), true)) $errors[] = $slug . ': missing state ' . $state . '.';
}
}
foreach ((array) ($fixture['reference']['files'] ?? []) as $relative => $expected) {
$path = rtrim((string) ($fixture['reference']['source_root'] ?? ''), '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $relative);
if (!is_file($path) || hash_file('sha256', $path) !== $expected) $errors[] = 'Reference drift: ' . $relative;
}
$require_results = in_array('--require-results', $argv, true);
if ($require_results) {
$result_path = $root . '/tests/visual-results/manifest.json';
if (!is_file($result_path)) {
$errors[] = 'Visual result manifest is missing.';
} else {
$result = json_decode((string) file_get_contents($result_path), true, 512, JSON_THROW_ON_ERROR);
if (($result['schema'] ?? '') !== 'aksrt.widget-visual-results.v1') $errors[] = 'Invalid visual result schema.';
$screenshots = (array) ($result['screenshots'] ?? []);
foreach ($matrix as $id => $expected) {
$actual = (array) ($screenshots[$id] ?? []);
$path = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) ($expected['path'] ?? ''));
if (!$actual || !is_file($path)) $errors[] = $id . ': screenshot result is missing.';
}
foreach ($components as $slug => $component) {
$states = (array) ($result['components'][$slug]['states'] ?? []);
foreach ((array) ($component['states'] ?? []) as $state) {
if (($states[$state] ?? '') !== 'pass') $errors[] = $slug . ': state not passed: ' . $state . '.';
}
}
$acceptance = (array) ($fixture['acceptance'] ?? []);
$metrics = (array) ($result['metrics'] ?? []);
if ((float) ($metrics['dimension_delta_px_max'] ?? INF) > (float) $acceptance['dimension_delta_px_max']) $errors[] = 'Dimension delta exceeds threshold.';
if ((float) ($metrics['pixel_difference_percent_max'] ?? INF) > (float) $acceptance['pixel_difference_percent_max']) $errors[] = 'Pixel difference exceeds threshold.';
if ((int) ($result['console']['errors'] ?? PHP_INT_MAX) > (int) $acceptance['console_errors_max']) $errors[] = 'Console contains errors.';
if ((int) ($result['network']['unexpected_failures'] ?? PHP_INT_MAX) > (int) $acceptance['unexpected_network_failures_max']) $errors[] = 'Network contains unexpected failures.';
if ((int) ($result['a11y']['critical'] ?? PHP_INT_MAX) > (int) $acceptance['a11y_critical_max']) $errors[] = 'Accessibility critical findings remain.';
if ((int) ($result['a11y']['serious'] ?? PHP_INT_MAX) > (int) $acceptance['a11y_serious_max']) $errors[] = 'Accessibility serious findings remain.';
if (empty($result['reduced_motion']['passed'])) $errors[] = 'Reduced-motion behavior was not verified.';
}
}
if ($errors) {
fwrite(STDERR, "Widget golden validation failed:\n- " . implode("\n- ", array_slice($errors, 0, 50)) . "\n");
exit(1);
}
echo 'Widget golden fixture passed: 37 components, 222 screenshot targets, frozen reference intact';
echo $require_results ? ", runtime results passed.\n" : ", runtime results pending.\n";